鸿蒙原生应用 ArkUI 实战:手把手实现社交发现页的兴趣多选与实时预览卡
App 18「校园社交匹配」发现 Tab(Func1Tab),是智能匹配偏好设置页——用户设置昵称/简介/兴趣/范围,实时预览卡片同步更新。整页用 Header + 基本信息表单(昵称/简介)+10 个兴趣标签多选(已选计数)+ 匹配范围设置(学院/年龄范围/匹配范围/匿名 Toggle)+实时预览卡片(昵称/学院/匿名状态/简介/已选标签)+ 4 个快捷模板 + 开始匹配按钮。本篇基于
18-social-match/entry/src/main/ets/pages/Func1Tab.ets(约 271 行)逐段拆解,附 4 张实机截图。
一、整体结构:7 个 @Builder + 2 个辅助方法的偏好表单
Func1Tab 是本系列交互联动最丰富的表单页——7 个 @Builder +toggleInterest()/selectedCount()2 个辅助方法:
build() { Column() { this.Header() Scroll() { Column({ space: 14 }) { this.FormSection() this.InterestSection() this.SelectorSection() this.PreviewCard() this.TemplateSection() this.SubmitBtn() } .width('100%') .padding({ left: D.pad, right: D.pad, top: 14, bottom: D.pad + this.safeBottom + 20 }) } .layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top) } .width('100%').height('100%').backgroundColor(C.bg) }7 个块按"填信息 → 选兴趣 → 设范围 → 看预览 → 用模板 → 开始匹配"漏斗展开:
- FormSection— 昵称 + 个人简介(输入)
- InterestSection— 10 个兴趣标签多选(本页核心交互)
- SelectorSection— 学院/年龄范围/匹配范围/匿名开关
- PreviewCard— 实时预览(本页最大亮点)
- TemplateSection— 4 个快捷模板
- SubmitBtn— 开始智能匹配
与 App 13/17 表单的差异:App 13/17 是"单选类型/优先级",App 18 是"多选兴趣"——社交匹配需要多标签(一个人可以有多个兴趣),这是"多选 vs 单选"的业务差异。
项目源码开源:https://gitee.com/codenestFlow/HarmonyOSHub
二、FormSection:昵称 + 个人简介
FormSection 是基本信息输入(昵称 TextInput + 简介 TextArea):
@Builder FormSection() { Column({ space: 14 }) { Text('基本信息').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text).width('100%') Column({ space: 8 }) { Text('昵称').fontSize(12).fontColor(C.textDim).width('100%') TextInput({ placeholder: '请输入你的昵称', text: this.nickname }) .onChange((val: string) => { this.nickname = val; }) .height(44).borderRadius(D.rMd).backgroundColor(C.bg) .border({ width: 1, color: C.stroke }).placeholderColor(C.textDim).placeholderFont({ size: 13 }) }.width('100%') Column({ space: 8 }) { Text('个人简介').fontSize(12).fontColor(C.textDim).width('100%') TextArea({ placeholder: '介绍一下自己,让大家更了解你...', text: this.bio }) .onChange((val: string) => { this.bio = val; }) .height(80).borderRadius(D.rMd).backgroundColor(C.bg) .border({ width: 1, color: C.stroke }).placeholderColor(C.textDim).placeholderFont({ size: 13 }) }.width('100%') } .width('100%').padding(14).backgroundColor(C.card).borderRadius(D.rMd).border({ width: 1, color: C.stroke }) }与 App 13/17 的差异:
- 背景
C.bg(浅灰)而非C.cardSoft——输入框用页面背景色,配 1vp 描边(C.stroke)——"描边输入框"风格(区别于 App 13/17 的"纯色块输入框") - 标签在上(
Text('昵称')在 TextInput 上方)而非左侧固定宽度——"上标签"布局(更宽松,适合移动端表单)
@State nickname: string = ''+@State bio: string = ''双向绑定——输入内容实时存入状态,供 PreviewCard 读取。
三、InterestSection:10 个兴趣标签多选(本页核心)
InterestSection 是 10 个兴趣标签的多选胶囊,右上角实时显示已选数量:
@Builder InterestSection() { Column({ space: 12 }) { Row() { Text('兴趣标签').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text) Blank() Text('已选 ' + this.selectedCount() + ' 个').fontSize(12).fontColor(C.primary) }.width('100%') Flex({ wrap: FlexWrap.Wrap }) { ForEach(this.interests, (item: InterestTag) => { Text(item.label) .fontSize(13) .fontColor(item.selected ? '#FFFFFF' : C.textSub) .backgroundColor(item.selected ? C.primary : C.cardSoft) .borderRadius(16) .padding({ left: 14, right: 14, top: 8, bottom: 8 }) .margin({ right: 8, bottom: 8 }) .border({ width: 1, color: item.selected ? C.primary : C.stroke }) .onClick(() => { this.toggleInterest(item.id); }) }, (item: InterestTag) => item.id.toString()) }.width('100%') } .width('100%').padding(14).backgroundColor(C.card).borderRadius(D.rMd).border({ width: 1, color: C.stroke }) }3.1 Flex 换行布局(本页特色)
Flex({ wrap: FlexWrap.Wrap })是 Flex 布局的换行模式:
Flex({ wrap: FlexWrap.Wrap }) { ForEach(this.interests, (item: InterestTag) => { Text(item.label) .fontSize(13) ... .margin({ right: 8, bottom: 8 }) ... }, (item: InterestTag) => item.id.toString()) }.width('100%')FlexWrap.Wrap让标签自动换行——10 个标签不会挤在一行,而是自动流式排列(每行放不下的换到下一行)。每个标签用margin({ right: 8, bottom: 8 })留出间距(而不是 space,因为 Flex 换行模式不支持 space)。
对比系列其他选择器:App 13 类型用 Row 一行、App 16 商品用 Grid 3 列、App 18 兴趣用Flex 自动换行——"标签数量多 + 长短不一 → Flex 换行最佳"(10 个标签长短不一,Grid 固定列会留白,Row 一行放不下)。
3.2 选中态三件套 + toggleInterest()
.fontColor(item.selected ? '#FFFFFF' : C.textSub) .backgroundColor(item.selected ? C.primary : C.cardSoft) .borderRadius(16) ... .border({ width: 1, color: item.selected ? C.primary : C.stroke })- 选中:粉红底 + 白字 + 粉红描边
- 未选:浅灰底 + 灰字 + 浅灰描边
点击调用toggleInterest(item.id):
toggleInterest(id: number): void { for (let i = 0; i < this.interests.length; i++) { if (this.interests[i].id === id) { this.interests[i] = { id: this.interests[i].id, label: this.interests[i].label, selected: !this.interests[i].selected }; } } }toggleInterest是"翻转选中"逻辑——找到 id 匹配的标签,selected: !selected取反。关键写法:
this.interests[i] = { id: ..., label: ..., selected: !this.interests[i].selected };创建新对象赋值而非原地改字段——this.interests[i].selected = !selected直接改会触发 ArkUI 的"响应式不感知"问题(数组元素属性变更不触发重渲染),"整体替换元素"才能触发 ForEach 重渲染。这是 ArkTS 响应式数组的经典写法(@State 数组里改元素要重建对象)。
3.3 selectedCount():已选计数
selectedCount(): number { let count: number = 0; for (let i = 0; i < this.interests.length; i++) { if (this.interests[i].selected) { count++; } } return count; }线性遍历统计已选数量——右上角"已选 4 个"(初始 4 个:编程/篮球/阅读/电影)。@State interests一变 →selectedCount()重算 → 文字更新。
10 个兴趣数据:编程(选)/动漫/篮球(选)/音乐/摄影/阅读(选)/旅行/美食/游戏/电影(选)——初始 4 个选中,覆盖"技术/运动/文艺/生活"多类兴趣。
四、SelectorSection:匹配范围四件套
SelectorSection 是"学院/年龄范围/匹配范围/匿名开关"四组设置:
@Builder SelectorSection() { Column({ space: 14 }) { Text('匹配范围').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text).width('100%') Column({ space: 10 }) { Text('选择学院').fontSize(12).fontColor(C.textDim).width('100%') Row({ space: 8 }) { ForEach(this.depts, (d: DeptItem) => { Row({ space: 6 }) { Text(d.icon).fontSize(14) Text(d.name).fontSize(12) } .padding({ left: 10, right: 10, top: 8, bottom: 8 }) .borderRadius(D.rMd) .backgroundColor(this.selectedDept === d.id ? C.primarySoft : C.cardSoft) .border({ width: 1, color: this.selectedDept === d.id ? C.primary : C.stroke }) .onClick(() => { this.selectedDept = d.id; promptAction.showToast({ message: d.name }); }) }, (d: DeptItem) => d.id.toString()) }.width('100%') }.width('100%') Column({ space: 10 }) { Text('年龄范围').fontSize(12).fontColor(C.textDim).width('100%') Row({ space: 8 }) { ForEach(this.ageRanges, (r: string, idx: number) => { Text(r) .fontSize(12).fontColor(this.ageRange === idx ? '#FFFFFF' : C.textSub) .backgroundColor(this.ageRange === idx ? C.primary : C.cardSoft) .borderRadius(D.rMd) .padding({ left: 12, right: 12, top: 8, bottom: 8 }) .border({ width: 1, color: this.ageRange === idx ? C.primary : C.stroke }) .onClick(() => { this.ageRange = idx; }) }, (r: string, idx: number) => r + idx) }.width('100%') }.width('100%') Column({ space: 10 }) { Text('匹配范围').fontSize(12).fontColor(C.textDim).width('100%') Row({ space: 8 }) { ForEach(this.scopes, (s: string, idx: number) => { Text(s) .fontSize(12).fontColor(this.matchScope === idx ? '#FFFFFF' : C.textSub) .backgroundColor(this.matchScope === idx ? C.primary : C.cardSoft) .borderRadius(D.rMd) .padding({ left: 12, right: 12, top: 8, bottom: 8 }) .border({ width: 1, color: this.matchScope === idx ? C.primary : C.stroke }) .onClick(() => { this.matchScope = idx; }) }, (s: string, idx: number) => s + idx) }.width('100%') }.width('100%') Row() { Text('匿名匹配').fontSize(13).fontColor(C.text).layoutWeight(1) Toggle({ type: ToggleType.Switch, isOn: this.anonymous }) .selectedColor(C.primary) .onChange((on: boolean) => { this.anonymous = on; }) }.width('100%') } .width('100%').padding(14).backgroundColor(C.card).borderRadius(D.rMd).border({ width: 1, color: C.stroke }) }4 组设置的 3 种交互形态:
- 学院选择(emoji + 文字胶囊)——5 个学院(不限/计算机/艺术/文/理),
selectedDept单选。点击还弹 Toast(d.name)——"选择反馈" - 年龄范围(文字胶囊)——3 档(同龄±1岁/同年级/不限年龄),
ageRange单选 - 匹配范围(文字胶囊)——3 档(同学院/同校区/全校),
matchScope单选 - 匿名匹配(Toggle 开关)——
anonymous布尔
3 个单选 + 1 个开关 = 4 个 @State(selectedDept/ageRange/matchScope/anonymous)——每个设置一个 @State,互不干扰。
"匿名匹配"是社交 App 的特色功能——匿名状态让用户"不敢打招呼"时也能参与匹配(Soul 的匿名匹配同款)。
五、PreviewCard:实时预览(本页最大亮点)
PreviewCard 是实时预览卡——昵称/学院/匿名状态/简介/已选标签全部实时联动:
@Builder PreviewCard() { Column({ space: 12 }) { Row() { Text('预览卡片').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text) Blank() Text('他人将看到').fontSize(11).fontColor(C.textDim) }.width('100%') Row({ space: 14 }) { Row() { Text('👤').fontSize(40) } .width(64).height(64).backgroundColor(C.primarySoft).borderRadius(32).justifyContent(FlexAlign.Center) Column({ space: 4 }) { Text(this.nickname.length > 0 ? this.nickname : '你的昵称').fontSize(16).fontWeight(FontWeight.Bold).fontColor(C.text) Text(this.depts[this.selectedDept].name).fontSize(12).fontColor(C.textDim) Text(this.anonymous ? '🔒 匿名模式' : '🌐 公开模式').fontSize(11).fontColor(this.anonymous ? C.warn : C.ok) }.alignItems(HorizontalAlign.Start).layoutWeight(1) }.width('100%') if (this.bio.length > 0) { Text(this.bio).fontSize(12).fontColor(C.textSub).width('100%').maxLines(3).textOverflow({ overflow: TextOverflow.Ellipsis }) } Row({ space: 6 }) { ForEach(this.interests, (item: InterestTag) => { if (item.selected) { Text(item.label).fontSize(11).fontColor(C.primary) .backgroundColor(C.primarySoft).borderRadius(4) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) } }, (item: InterestTag) => item.id.toString()) }.width('100%') } .width('100%').padding(14).backgroundColor(C.card).borderRadius(D.rLg).border({ width: 1, color: C.stroke }) }5.1 昵称实时联动
Text(this.nickname.length > 0 ? this.nickname : '你的昵称').fontSize(16).fontWeight(FontWeight.Bold).fontColor(C.text)昵称输入 →@State nickname→ 预览卡实时更新(没输入时显示占位"你的昵称")。"输入即预览"——用户边打字边看到"别人将看到的我的卡片"。
5.2 学院 + 匿名状态联动
Text(this.depts[this.selectedDept].name).fontSize(12).fontColor(C.textDim) Text(this.anonymous ? '🔒 匿名模式' : '🌐 公开模式').fontSize(11).fontColor(this.anonymous ? C.warn : C.ok)学院:depts[selectedDept].name从数组取当前选中学院名(id=1 → "不限学院")——"下标取数"联动。
匿名状态:anonymous ? '🔒 匿名模式' : '🌐 公开模式'——🔒 橙(warn)+ "匿名模式" vs 🌐 绿(ok)+ "公开模式"——"🔒/🌐 图标 + 文字 + 颜色"三重表达。Toggle 一开,预览卡立刻显示"🔒 匿名模式"。
5.3 简介条件渲染
if (this.bio.length > 0) { Text(this.bio).fontSize(12).fontColor(C.textSub).width('100%').maxLines(3).textOverflow({ overflow: TextOverflow.Ellipsis }) }if (this.bio.length > 0)有简介才显示(空简介不占位)。maxLines(3)+TextOverflow.Ellipsis超 3 行省略——"简介截断"。
5.4 已选标签联动
Row({ space: 6 }) { ForEach(this.interests, (item: InterestTag) => { if (item.selected) { Text(item.label).fontSize(11).fontColor(C.primary) .backgroundColor(C.primarySoft).borderRadius(4) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) } }, (item: InterestTag) => item.id.toString()) }.width('100%')预览卡只显示已选标签(if (item.selected)过滤)——点选/取消兴趣,预览卡的标签列表实时增删。"多选联动预览"是本页联动链的核心闭环:
点击兴趣标签 → toggleInterest() 翻转 selected → InterestSection 胶囊高亮变化 → selectedCount() 数字变化 → PreviewCard 标签列表增删(if selected 过滤)一个 @State(interests)驱动 4 处 UI 同步更新——"状态 → 多视图联动"是 ArkUI 响应式的精髓。
六、TemplateSection + SubmitBtn
TemplateSection 是 4 个快捷模板(学习搭子/运动伙伴/美食探店/文艺同好):
@Builder TemplateSection() { Column({ space: 12 }) { Text('快捷模板').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text).width('100%') Row({ space: 12 }) { ForEach(this.templates, (t: TemplateItem) => { Column({ space: 6 }) { Row() { Text(t.icon).fontSize(24) } .width(44).height(44).backgroundColor(C.primarySoft).borderRadius(22).justifyContent(FlexAlign.Center) Text(t.title).fontSize(12).fontWeight(FontWeight.Bold).fontColor(C.text) Text(t.desc).fontSize(10).fontColor(C.textDim).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) } .layoutWeight(1).padding({ top: 10, bottom: 10 }) .backgroundColor(C.card).borderRadius(D.rMd).border({ width: 1, color: C.stroke }) .alignItems(HorizontalAlign.Center) .onClick(() => { promptAction.showToast({ message: '应用模板: ' + t.title }); }) }, (t: TemplateItem) => t.id.toString()) }.width('100%') } .width('100%').padding(14).backgroundColor(C.card).borderRadius(D.rMd).border({ width: 1, color: C.stroke }) }4 个模板 = 4 种社交目的:学习搭子📚(一起自习)、运动伙伴⚽(约球跑步)、美食探店🍜(发现美食)、文艺同好🎬(电影音乐)——"模板即匹配场景"。点击弹 Toast "应用模板: 学习搭子"——真实项目应自动填充兴趣/范围。
SubmitBtn:"开始智能匹配" 50vp 高粉红按钮——点击弹 Toast "正在为你匹配同好..."(匹配流程占位)。
七、@State 的 7 状态设计
Func1Tab 有 7 个 @State(与 App 15/16 并列系列最多):
| @State | 类型 | 用途 | 联动 |
|---|---|---|---|
nickname | string | 昵称输入 | 预览卡昵称 |
bio | string | 简介输入 | 预览卡简介 |
interests | InterestTag[] | 10 标签选中 | 胶囊高亮/计数/预览标签 |
selectedDept | number | 学院(1-5) | 学院胶囊 + 预览卡学院名 |
ageRange | number | 年龄范围(0-2) | 年龄胶囊 |
matchScope | number | 匹配范围(0-2) | 范围胶囊 |
anonymous | boolean | 匿名开关 | Toggle + 预览卡匿名状态 |
7 个 @State(含 anonymous)覆盖 7 个交互点——"@State 数量 = 交互点数"规律。
interests是"对象数组 @State"——修改要整体替换元素(toggleInterest 的新对象写法)——ArkTS 响应式数组的关键知识点(直接改字段不触发重渲染)。
八、与 App 13/17 表单的对比
| 维度 | App 13/17 表单 | App 18 发现页 |
|---|---|---|
| 选择类型 | 单选(类型/优先级) | 多选(10 兴趣标签) |
| 布局 | Row/Grid 固定列 | Flex 自动换行 |
| 设置项 | 开关+入口 | 3 单选 + 1 开关 |
| 预览 | 无 | 实时预览卡(核心) |
| 联动 | 单选联动按钮 | 多选联动预览+计数 |
| 辅助方法 | getProduct | toggleInterest/selectedCount |
App 18 的进步:① 多选交互(toggleInterest 翻转);②实时预览卡(输入/选择/点标签全部实时反馈);③ Flex 换行布局。"偏好设置 + 实时预览"模板可复用到:婚恋交友、招聘求职(技能多选)、游戏匹配、租房筛选——任何"设置偏好 → 看效果"的场景。
九、匹配范围的"三组单选"设计逻辑
SelectorSection 的三组单选(学院/年龄范围/匹配范围)不是随意堆叠,而是**"匹配精准度的三层控制"**:
| 维度 | 选项 | 控制什么 |
|---|---|---|
| 学院 | 不限/计算机/艺术/文/理 | 领域匹配(找同学院?) |
| 年龄 | 同龄±1岁/同年级/不限 | 代际匹配(找同龄人?) |
| 范围 | 同学院/同校区/全校 | 空间匹配(找多远的人?) |
三个维度组合 = 完整的"匹配漏斗":
范围(同学院/同校区/全校)→ 领域(不限/计算机/…)→ 年龄(同龄/同年级/不限)"范围最外、年龄最内"——先限定空间(全校/同校区),再限定领域(学院),再限定代际(年龄)——"漏斗式筛选"是匹配引擎的经典设计(第一层粗筛、第二层中筛、第三层精筛)。
默认值的设计:
- 学院默认"不限学院"(selectedDept=1,id=1)
- 年龄默认"同年级"(ageRange=1)
- 范围默认"同学院"(matchScope=0)
- 匿名默认关(anonymous=false)
"默认值 = 保守起步"——默认范围尽量宽(不限学院)、隐私尽量公开(非匿名)——"先广撒网再收窄"是社交 App 的推荐策略(Soul/探探默认推荐全量)。
真实项目的进阶:
- 滑块选择(年龄 18-25 滑动条)比"三档胶囊"更精细
- 位置权限(匹配范围联动定位)
- 多维标签(兴趣 + 性格 + 作息)
**"三组单选 = 三层匹配控制"**是本页 SelectorSection 的设计逻辑。
十、兴趣标签的"多选交互"设计细节
App 18 的兴趣多选(10 个标签)有几个值得细读的交互细节:
1. 为什么是 10 个?——"10 个兴趣"覆盖校园主流兴趣(编程/动漫/篮球/音乐/摄影/阅读/旅行/美食/游戏/电影),"10 选多"既不多到选择困难、也不少到不够个性化。真实项目通常是 20-30 个(分类更细)。
2. 为什么初始选 4 个?——"编程/篮球/阅读/电影"初始选中——"预选"降低首次使用门槛(用户不用从零选起,改改就行)——"默认值降低门槛"。
3. 已选计数的反馈——右上角"已选 4 个"实时更新——"计数反馈"让用户知道当前选择量(选太多/太少都有数)。
4. 点击的"翻转"语义——toggleInterest的"选中↔未选"翻转——"胶囊即开关"(点一下选中、再点取消)——"多选"的正确交互(单选用"点别的换选中",多选用"点击翻转")。
5. 选中的"实时预览"——预览卡标签列表同步增删——"选择即所见"(用户选什么,预览卡就显示什么)。
"多选标签"的完整交互闭环:点击 → toggleInterest 翻转 → 胶囊高亮变 → 计数变 → 预览卡变——"一次点击、四处联动"。这是 ArkUI 响应式状态管理的最佳实践——"状态驱动多视图"。
十一、匿名模式的隐私设计
App 18 的"匿名匹配"开关是本系列第一个"隐私功能":
Row() { Text('匿名匹配').fontSize(13).fontColor(C.text).layoutWeight(1) Toggle({ type: ToggleType.Switch, isOn: this.anonymous }) .selectedColor(C.primary) .onChange((on: boolean) => { this.anonymous = on; }) }.width('100%')匿名模式的业务价值:
- 降低社交压力——不暴露身份也能参与匹配(内向用户的"保护壳")
- 保护隐私——不想被同学知道"我在用匹配 App"
- 自由表达——匿名状态下更敢表达真实兴趣
预览卡联动:
Text(this.anonymous ? '🔒 匿名模式' : '🌐 公开模式').fontSize(11).fontColor(this.anonymous ? C.warn : C.ok)"🔒 匿名模式"(橙)+ "🌐 公开模式"(绿)——"锁=隐私、地球=公开"的隐喻 + 颜色区分。Toggle 一开,预览卡立刻从"🌐 公开"变"🔒 匿名"——"隐私状态的实时反馈"。
真实项目的匿名实现:
- 头像模糊(匿名时头像打码/换默认)
- 昵称隐藏(显示"匿名用户")
- 真实身份不可搜索(匿名用户的资料不出现在搜索结果)
- 举报保护(匿名不意味着无责,平台仍可追溯)
"匿名开关 + 预览反馈"是隐私功能的标准交互——用户打开开关立刻看到效果,信任感强。
十二、总结
App 18 发现匹配页解析完毕。10 标签多选(toggleInterest 翻转)+ 实时预览卡(5 处联动)+ Flex 换行 + 匿名开关是四大亮点。**"输入即预览"**让用户完整掌控"别人将看到什么"——这是社交匹配类 App 最核心的信任设计。