鸿蒙原生应用 ArkUI 声明式:兴趣匹配首页环形 Progress 与推荐卡片流

App 39「校园兴趣匹配」首页(HomeTab),主题色 #FF6B9D 粉色(pink),4 个 Tab 分别为首页(💖)、兴趣(🎯)、匹配(🔗)、我的(👤)。首页采用"Header + 今日匹配 Banner + 推荐列表"三区布局——白色双行 Header("兴趣匹配"22 号加粗 + 副标题"遇见志同道合的 TA 💫") + 粉色渐变今日最佳匹配卡(环形 Progress 95% + "音乐小铃 · 95% 契合" + "查看 >") + SectionTitle(为你推荐)+ 4 条推荐用户列表(🎧 音乐小铃 95% 传媒学院 / 🎮 游戏阿杰 88% 计算机学院 / 📚 书虫小雅 82% 文学院 / 🏀 运动大伟 76% 体育学院,每条含 52×52 浅粉圆形 emoji 头像 + 姓名 + 学院 + 粉色大号匹配度百分比 + 3 个 #兴趣标签)。本篇基于 39-interest-match/entry/src/main/ets/pages/HomeTab.ets(共 111 行)逐段拆解,附 4 张实机截图。

一、整体结构:三区"Header + 今日匹配 + 推荐列表"布局

App 39 首页是"品牌头部 + 今日最佳匹配宣传卡 + 用户推荐列表"的三区布局——3 个 @Builder 块:

build() {
  Column() {
    this.Header()
    Scroll() {
      Column({ space: 14 }) {
        this.TodayMatch()
        this.SectionTitle('为你推荐')
        this.UserList()
      }
      .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)
}

3 块结构:

  1. Header — 双行品牌头("兴趣匹配" + 副标题),白底、占满整宽,属于固定在顶部的非滚动区
  2. TodayMatch — 粉色渐变"今日最佳匹配"宣传卡,含环形进度与推荐对象名,直接呼应 App 的核心卖点"每天帮你发现最合拍的那个人"
  3. UserList — "为你推荐"标题 + 4 条用户卡片,每条卡片展示匹配度百分比与兴趣标签,是首页的信息主体

整个列表包在 Scroll 中,设置 layoutWeight(1) 让滚动区自动占满剩余高度,scrollBar(BarState.Off) 隐藏滚动条、align(Alignment.Top) 保证内容从顶部对齐;外层容器 padding 的 bottom 还预留了 safeBottom + 20,规避了底部导航栏与安全区的遮挡问题。

项目源码开源:https://gitee.com/codenestFlow/HarmonyOSHub

二、Header:双行品牌头,白色卡片式收尾

首页顶部没有沿用前几个 App 的单行纯标题,而是做成了"主标题 + 副标题"的双行结构:

Header() {
  Column() {
    Text('兴趣匹配').fontSize(22).fontWeight(FontWeight.Bold).fontColor(C.text)
      .width('100%').padding({ top: this.safeTop + 10, left: D.pad, right: D.pad, bottom: 4 })
    Text('遇见志同道合的 TA 💫').fontSize(12).fontColor(C.textDim).width('100%')
      .padding({ left: D.pad, right: D.pad, bottom: 12 })
  }.width('100%').backgroundColor(C.card)
}

几个细节值得注意:

  • 双行错位排版:第一行 22 号加粗正文字色,第二行 12 号浅灰,行间有 bottom: 4 与 bottom: 12 的间距控制,视觉上"大标题 + 轻说明",层级分明。
  • 安全区适配:第一行顶部 padding({ top: this.safeTop + 10 }),safeTop 通过 @StorageProp('safeTop') 从全局存储中读取状态栏高度,适配了不同机型的刘海/挖孔屏。
  • 全宽白底:backgroundColor(C.card) 让头部形成一块白色背景板,与下方 C.bg 的浅蓝灰底形成干净的层次过渡。
  • emoji 点睛:副标题末尾的 💫 用小符号传达"相遇、缘分"的氛围,与前两个 App 的风格一脉相承。

39 首页首屏

三、TodayMatch:粉色渐变 + 环形进度,今日最佳匹配卡

这是首页最醒目的视觉焦点,也是整个 App 的"门面":

TodayMatch() {
  Row({ space: 14 }) {
    Stack({ alignContent: Alignment.Center }) {
      Progress({ value: 95, total: 100, type: ProgressType.Ring })
        .width(64).height(64).color('#FFFFFF')
        .style({ strokeWidth: 6 }).backgroundColor('#44FFFFFF')
      Text('95%').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
    }
    Column({ space: 4 }) {
      Text('今日最佳匹配').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
      Text('音乐小铃 · 95% 契合').fontSize(12).fontColor('#FFFFFF').opacity(0.9)
    }.alignItems(HorizontalAlign.Start).layoutWeight(1)
    Text('查看 >').fontSize(12).fontColor('#FFFFFF')
  }
  .width('100%').padding(18).borderRadius(D.rLg)
  .linearGradient({ angle: 135, colors: [[C.primary, 0.0], [C.accent, 1.0]] })
  .onClick(() => { promptAction.showToast({ message: '查看匹配' }); })
}

技术拆解:

  1. Progress Ring 环形进度:Progress({ value: 95, total: 100, type: ProgressType.Ring }) 声明一个总量 100、当前值 95 的圆环;strokeWidth: 6 控制环粗,backgroundColor('#44FFFFFF') 是半透明底环,color('#FFFFFF') 是白色进度环。
  2. Stack 居中叠加:外层 Stack({ alignContent: Alignment.Center }) 把环形进度和正中的"95%"文字叠加在一起,实现"环包字"的经典数据可视化效果。
  3. 渐变背景:linearGradient({ angle: 135, colors: [[C.primary, 0.0], [C.accent, 1.0]] }) 从主题粉 #FF6B9D 135° 渐变到浅粉 #FF9DBA,让整张卡片自带柔和暖色过渡,视觉上非常"甜"。
  4. 主次信息:右侧 Column 内 16 号加粗"今日最佳匹配"为主标题,12 号 90% 透明度"音乐小铃 · 95% 契合"为副信息;右侧尾部的"查看 >"暗示可点击,整卡 onClick 弹出"查看匹配"Toast。
  5. 布局弹性:中间 Column 加 layoutWeight(1) 抢占弹性宽度,把左右两端固定元素推开,保证任何屏幕宽度下排版都稳定。

四、SectionTitle 与 UserList:为你推荐 + 4 条用户卡片

标题部分极其精简:

SectionTitle(title: string) {
  Row() { Text(title).fontSize(16).fontWeight(FontWeight.Bold).fontColor(C.text) }.width('100%')
}

16 号加粗,宽度占满,仅此而已。真正的重点是下方推荐用户列表:

UserList() {
  Column({ space: 12 }) {
    ForEach(this.users, (u: User) => {
      Column({ space: 10 }) {
        Row({ space: 12 }) {
          Row() { Text(u.emoji).fontSize(28) }
          .width(52).height(52).backgroundColor(C.primarySoft).borderRadius(26).justifyContent(FlexAlign.Center)
          Column({ space: 4 }) {
            Text(u.name).fontSize(15).fontWeight(FontWeight.Medium).fontColor(C.text)
            Text(u.major).fontSize(12).fontColor(C.textDim)
          }.alignItems(HorizontalAlign.Start).layoutWeight(1)
          Column({ space: 2 }) {
            Text(u.match + '%').fontSize(18).fontWeight(FontWeight.Bold).fontColor(C.primary)
            Text('匹配度').fontSize(10).fontColor(C.textDim)
          }
        }.width('100%')
        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach(u.tags, (t: string) => {
            Text('#' + t).fontSize(11).fontColor(C.accent)
              .padding({ left: 8, right: 8, top: 3, bottom: 3 }).backgroundColor(C.cardSoft).borderRadius(10)
              .margin({ right: 8 })
          }, (t: string) => t)
        }.width('100%')
      }
      .width('100%').padding(14).backgroundColor(C.card).borderRadius(D.rMd).border({ width: 1, color: C.stroke })
      .onClick(() => { promptAction.showToast({ message: u.name }); })
    }, (u: User) => u.id.toString())
  }.width('100%')
}

用户数据由 private users: User[] 静态数组提供(数据源在 21-26 行):

用户学院匹配度兴趣标签
🎧 音乐小铃传媒学院95%民谣 / 摄影 / 旅行
🎮 游戏阿杰计算机学院88%电竞 / 编程 / 动漫
📚 书虫小雅文学院82%阅读 / 写作 / 咖啡
🏀 运动大伟体育学院76%篮球 / 健身 / 跑步

卡片细节:

  • emoji 头像:52×52 的浅粉圆形(primarySoft)居中放大 28 号 emoji,替代真实头像,成本低且氛围轻松——注意这是"兴趣匹配"App,用兴趣 emoji 当头像正是产品定位的体现。
  • 主信息列:姓名 15 号 Medium 字重 + 学院 12 号浅灰,两行叠放,layoutWeight(1) 抢占中间弹性空间。
  • 匹配度:右侧 18 号粉色加粗百分比 + 10 号"匹配度"小字,是整张卡最重要的数据,放在最显眼的右端。
  • 标签行:独立 Flex({ wrap: FlexWrap.Wrap }) 自动换行排布 #民谣 #摄影 #旅行 等标签,11 号 accent 粉色文字 + cardSoft 浅底 + 圆角 10,向右留 8 的 margin,可容纳任意数量标签。
  • 整体卡片:白底圆角 14 + 1 号 stroke 描边 + 14 内边距,点击弹 Toast 显示用户名。
  • key 策略:外层 ForEach 以 u.id.toString() 为 key,保证列表项唯一稳定。

五、跨页数据自洽:音乐小铃 95% 贯穿全 App

首页不是孤立页面,其数据与另外三个 Tab 严格自洽:

  • 今日最佳匹配"音乐小铃 · 95% 契合",正是下方推荐列表第一位的"🎧 音乐小铃 95%"——Banner 与列表互相印证。
  • 匹配页(Func2Tab) 中匹配结果第一位同样是"🎧 音乐小铃 95% 共同兴趣:民谣、摄影",与首页"民谣、摄影、旅行"标签高度重合,共同兴趣 = 双方标签交集,逻辑闭环。
  • 我的页(ProfileTab) 的统计"5 兴趣标签、24 匹配历史、18 好友"与匹配页"24 匹配的人、8 高契合度、12 已打招呼"数字一致,说明整套产品有一套统一的"用户画像 + 匹配记录"数据模型。

这种跨页面的一致性让 Demo 在演示时观感极其真实,也是本系列文章反复强调的"数据自洽"设计原则。

六、扩展思考:兴趣匹配的推荐算法雏形

虽然本 Demo 的推荐数据是写死的静态数组,但其页面结构已经隐含了真实产品的算法思路:

  1. 标签画像:每位用户维护一组兴趣标签(如音乐小铃的民谣/摄影/旅行),这是匹配算法的最基本输入。
  2. 交集计算:匹配度本质可以理解为"共同标签数 / 加权标签数"的量化,匹配页展示的"共同兴趣"标签正是交集的可视化。
  3. 每日刷新:"今日最佳匹配"暗示按天轮换推荐对象,对应真实产品中的"每日一荐"机制,能提升用户打开率。
  4. 可扩展方向:若把 users 数组换成网络数据 + 分页加载,把匹配度改成实时计算的函数,再配上滑动左/右表示"感兴趣/跳过",就是一款完整的"校园版 Soul / 探探"。

七、彩蛋细节:藏在布局里的设计巧思

  1. 副标题文案:"遇见志同道合的 TA 💫"里的"TA"刻意不写"他/她",兼顾性别中立,是校园产品常见的小心思。
  2. 头像 emoji 即兴趣:音乐小铃的 🎧、游戏阿杰的 🎮、书虫小雅的 📚、运动大伟的 🏀,四个 emoji 直接对应四类兴趣,不用任何文字说明用户就已"人设"清晰。
  3. 匹配度排序:列表严格按 95→88→82→76 降序排列,是"推荐按契合度排序"的最朴素实现,暗示数据层已具备排序能力。
  4. 渐变方向统一:首页渐变卡、我的页 UserCard 全部使用 135° 且同为 primary→accent,品牌色在全 App 内保持一致的方向感。
  5. Progress 环的 44 透明度:底环 #44FFFFFF 的 alpha 值 0x44 约等于 26.7% 透明度,是特意调出来的半透明效果,让白色进度环在粉色渐变上既清晰又不生硬。

八、实机截图与交互演示

本节结合 4 张实机截图,逐张还原首页的视觉效果与交互过程。

1. 首页首屏:渐变卡 + 推荐列表

第一张截图是进入 App 39 后的默认首屏。自上而下依次是:白色双行 Header("兴趣匹配" 22 号加粗 + 副标题"遇见志同道合的 TA 💫")、粉色渐变"今日最佳匹配"卡(左侧环形进度 95% 与白色数字叠加、中央 16 号"今日最佳匹配"、右侧"查看 >"箭头)、"为你推荐"分区标题与第一条用户卡片(🎧 音乐小铃 95% 传媒学院,头像下方三个粉色 # 标签)。整页以浅蓝灰 #F2F5FB 为背景,白色卡片与粉色渐变卡交替排布,节奏明快、信息层级一目了然。

2. 点击"查看 >":弹出匹配 Toast

第二张截图演示了点击今日最佳匹配卡右侧"查看 >"后的即时反馈:屏幕下方弹出"查看匹配"系统 Toast。整张渐变卡只挂了一个 onClick,文案与卡片语义完全一致,让用户清楚知道"点击此处会进入匹配详情流程",是引导转化的关键一步。

3. 点击用户卡片:弹出姓名 Toast

第三张截图是点击推荐列表中任一张用户卡片后的反馈——Toast 显示该用户的名字。由于每张卡片的 onClick 都绑定 promptAction.showToast({ message: u.name }),点击"音乐小铃"即弹出"音乐小铃",点击其他用户同理,交互反馈精确到人,验证了 ForEach 列表的点击事件参数传递正确。

4. 列表滚动到底:第四位用户完整可见

第四张截图是向上滑动列表后的底部视图。第四位用户"🏀 运动大伟 76% 体育学院"及其三个兴趣标签(#篮球 #健身 #跑步)完整露出,证明 Scroll 滚动正常、四条用户卡按 95→88→82→76 的匹配度降序完整排列;页面底部预留了 safeBottom + 20 的安全区内边距,底部 Tab 栏始终可点,滚动与导航互不干扰。

九、扩展细节:主题系统与常量提取的工程价值

首页代码中大量直接引用了 C(主题色)与 D(圆角/内边距)两个静态常量类,这背后是值得展开的工程思想:

  1. 单一事实来源:Theme.ets 中 C.primary = '#FF6B9D' 定义一次,首页渐变卡、用户卡片头像、匹配度数字、标签文字四处引用同一常量——如果产品要换品牌色(比如从粉色改成紫色),只需改一行,全 App 同步生效,杜绝"改漏一处"的配色灾难。
  2. 语义化命名:常量不叫 pink 而叫 primary,把"颜色值"抽象为"语义角色"——primary(主色)、accent(强调色)、textDim(次要文字)、cardSoft(浅底)。组件只需表达"我要主色",不必关心具体色值,主题更换时零改动。
  3. 圆角与间距统一:D.rLg = 20 / rMd = 14 / rSm = 10 / pad = 16 四档尺度被全 App 复用,保证每个页面的圆角、留白视觉节奏一致,这是"设计系统"在代码层面的最小落地。
  4. 可扩展为深色模式:若未来引入深色模式,只需把 C 的静态常量改为 @StorageProp 驱动的动态主题(或用资源文件 resources 的深色限定符),页面代码无需任何改动——常量集中化带来的最大红利就是"主题可替换"。

39 首页查看匹配 Toast

十、扩展细节:页面状态与数据流向分析

从数据流视角回看首页,可以提炼出一套清晰的 MVVM 思想:

  1. View 与 Model 分离:users 静态数组是"模型层",@Builder 的 Header/TodayMatch/UserList 是"视图层",页面中没有业务逻辑函数——点击事件只做 Toast 展示,模型与视图解耦。
  2. @State 的唯一状态:首页仅声明 @StorageProp('safeTop') 与 safeBottom 两个状态,其余全部是静态私有属性,说明本页是"纯展示页",没有需要持久化的交互状态,状态面极窄、容易测试。
  3. ForEach 的 key 设计:(u: User) => u.id.toString() 用唯一 id 作 key,即使将来 users 数组增删或排序,ArkUI 也能精准 diff、复用节点,避免整列表重建的性能损耗。
  4. 演化路径:把 private users 换成 @State users 并从网络接口拉取(配合 onAppear 生命周期、下拉刷新 Refresh 组件、骨架屏占位),首页立刻具备真实产品形态——当前静态实现恰好是"先跑通 UI、再接入数据"的最小可验证原型。

39 首页游戏阿杰 Toast

十一、补充说明与终评

首页是整套 App 的"门面担当",承担着"一眼看懂产品在做什么"的职责。本页用三个区块完成了完整叙事:Header 讲品牌、渐变卡讲"今日最佳"、列表讲"为你推荐"。技术实现上,Progress 环形进度、linearGradient 渐变、Stack 叠加、Flex 标签换行都是 ArkUI 的高频能力,代码量虽小但组合出的视觉效果相当完整。粉色系 #FF6B9D → #FF9DBA 的渐变风格甜美清爽,与校园社交的调性高度契合,非常适合作为兴趣社交类产品首页的参考范本。

十二、常见问题与开发避坑

开发这类"渐变卡 + 列表"首页时,有几个高频坑值得记录:

  1. Progress 环形进度不显示:Progress({ value, total, type: ProgressType.Ring }) 必须同时设置 width/height 才能撑出圆环,否则默认尺寸可能过小;同时注意 style({ strokeWidth }) 要在 Progress 上链式调用,写错位置会失效。
  2. linearGradient 的 angle 单位:angle: 135 的单位是度(deg),从 12 点钟方向顺时针计算,135 度即"左上 → 右下"。如果想实现"左下 → 右上"需要改用 315 度或调换 colors 顺序。
  3. @StorageProp 未初始化:@StorageProp('safeTop') safeTop: number = 0 的默认值 0 很重要——如果 AppStorage 里还没有该键,读取到的是默认值而非 undefined,页面不会因布局错乱而崩溃。全局安全区通常在 EntryAbility 的 onCreate 中通过 windowStage.getMainWindowSync() 获取后写入 AppStorage。
  4. ForEach 的 key 警告:若 key 是重复字符串(如所有用户 id 都是 1),控制台会刷"duplicate key"警告且 diff 错乱——务必保证 key 全局唯一,本页用 u.id.toString() 是正确示范。
  5. Toast 连点重复弹出:快速多次点击渐变卡会连续弹出多个 Toast,体验略差。工程化时可加节流(如 let last = 0; if (Date.now() - last < 800) return; last = Date.now();)合并重复反馈。

39 首页用户卡 Toast

十三、系列横向对比:兴趣匹配 vs 校园社交类 App

把 App 39 放入本系列横向审视,它的定位与差异化非常清晰:

维度App 39 兴趣匹配App 35 笔记交换App 38 技能交换
主题色粉色 #FF6B9D紫罗兰 #9B51E0青蓝 #2D9CDB
核心对象人(兴趣相投者)物(笔记/文档)技能(可交换能力)
首页结构今日匹配卡 + 推荐列表分类 + 笔记列表分类胶囊 + Banner + 技能列表
匹配机制共同兴趣标签交集无想学 ↔ 会
核心操作打招呼浏览/收藏交换

可以清晰看到三者的演进脉络:笔记交换解决"资料怎么共享"、技能交换解决"能力怎么交换"、兴趣匹配解决"人怎么认识"——层层递进,App 39 站在了社交链路的顶端。若把三者的数据打通(笔记作者 = 技能达人也出现在兴趣推荐里),就是一个完整的校园人脉生态。这种"系列化产品"的视角,正是理解单页价值之外的额外收获。

十四、FAQ:关于首页的常见疑问

Q1:为什么"今日最佳匹配"用的是环形进度而不是普通数字? 环形进度把"95%"包装成可视化的完成度圆环,视觉冲击力远强于纯数字,同时 6 号粗环在渐变卡上能形成稳定的视觉锚点,让整张卡"活"起来。这也是数据可视化"图表优先于数字"的典型体现。

Q2:匹配度 95% 是怎么来的? Demo 中 users 是静态数组,匹配度是写死的值。真实产品里它由"共同标签数 / 标签并集"等算法实时计算(如兴趣页选中的标签与对方标签的 Jaccard 相似度),并在每次用户更新兴趣后重算——首页只是这个计算结果的展示层。

Q3:为什么推荐列表只有 4 个人? 4 条是"一屏能完整看到 + 有滚动余地"的黄金数量:太多会稀释注意力、太少显得内容单薄。真实产品则用分页加载(每页 10-20 条)+ 下拉刷新替换,但首屏展示逻辑与本 Demo 一致。

Q4:渐变卡可以直接替换成图片 Banner 吗? 可以。把 linearGradient 换成 Image 组件并铺满卡片即可,其余布局代码无需改动——这正是"背景可替换"的架构红利。不过渐变方案在加载速度与文件体积上优于图片,适合 Demo 与轻量场景。

Q5:点击卡片弹出 Toast 是不是太简陋? Toast 是原型阶段的合法反馈手段,胜在零依赖、即时可见。产品化时按"卡片 → 用户详情页"跳转即可,点击事件与跳转参数(用户 id)的传递方式完全一致,无需重构。

Q6:首页没有"搜索"入口,会不会不好用? 本 Demo 定位是"系统主动推荐",搜索属于进阶能力。真实产品可在 Header 右侧加放大镜图标,跳转搜索页按标签/学院过滤用户——与本页的数据结构天然兼容(过滤即对 users 数组做条件筛选)。

十五、一句话总结

首页用三个区块讲完一个故事:Header 亮品牌、渐变卡说"今天的最佳"、列表给"更多选择"。粉色渐变与环形进度是视觉记忆点,跨页数据自洽是产品可信度,而 Progress/linearGradient/Stack/Flex 四个 ArkUI 组件的高频组合,则是这篇解析最值得带走的工程价值。

值得一提的是,本页还隐含了一处容易被忽略的设计:safeTop 与 safeBottom 两个安全区变量通过 @StorageProp 从全局注入,首页 Header、列表底边距、底部 Tab 栏高度(barHeight(60 + this.safeBottom))全部消费了它们——这意味着同一个页面代码在全面屏、带手势条的机型、甚至 2in1 平板形态上都能自动适配,无需为每种机型写专门布局。这也解释了为什么模拟器 1280×2800 的竖屏下,16 张截图全部干净无遮挡。对于想把这套代码移植到真机的开发者,这一层安全区适配体系是最值得直接抄走的"隐形基建"。另外,本页推荐列表的姓名、学院、标签三行信息与 App 38 技能列表的布局节奏几乎一致(52×52 头像 + 两行文本 + 右侧数据),印证了这套"头像 + 信息 + 动作"的卡片模板在本系列中的高复用性——它既保证了开发效率,也让用户在 32-39 号 App 之间切换时保持一致的阅读习惯,品牌识别度无形中得以强化。

配图

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐