HarmonyOS运动健康——社区交互与用户主页
2026/7/12 6:42:26 网站建设 项目流程

社区页面的第一版是"静态展示"——五条帖子、三个按钮、点了没反应。这一版给社区加上了完整的动态交互:点赞有动画、评论能输入、关注会变色、顶部能发帖、点击用户名能跳转到用户主页。从"看起来像真的"到"用起来像真的",这是原型走向产品的关键一步。
完整效果

数据模型的扩展

第一版Post接口只有5个字段,这一版扩展到了10个:

interface Post { user: string; avatar: string; content: string; likes: number; time: string liked: boolean; followed: boolean; comment: string; showComment: boolean comments: string[] }

新增的5个字段全部服务于交互:

字段类型作用
likedboolean当前用户是否已点赞
followedboolean当前用户是否已关注此人
commentstring评论输入框的当前内容
showCommentboolean是否展开评论输入框
commentsstring[]该帖子的评论列表

初始数据中,营养师阿杰的liked: true(默认已点赞),游泳健将的followed: true(默认已关注)。这样页面加载后就能看到两种状态的视觉差异——已点赞的❤️红色和未点赞的🤍灰色,已关注的灰色按钮和未关注的红色按钮。

发帖功能

顶部输入区

Row() { Text('🏃').fontSize(28).width(40).height(40).borderRadius(20) .backgroundColor('#F5F5F7').textAlign(TextAlign.Center) TextInput({ placeholder: '分享你的运动瞬间...', text: this.newPost }) .layoutWeight(1).height(40).fontSize(14).backgroundColor('#F5F5F7') .borderRadius(20).padding({ left: 14 }).margin({ left: 10 }) .onChange((v: string) => { this.newPost = v }) }.width('100%').padding({ left: 16, right: 16, top: 4, bottom: 10 })

输入区在导航栏和帖子列表之间。左边是固定的用户头像emoji,右边是TextInput。placeholder是灰色提示文字"分享你的运动瞬间…",用户开始输入后自动消失。onChange把输入内容同步到@State newPost变量。

发布按钮

Text('发布').fontSize(14).fontColor(A).fontWeight(FontWeight.Bold) .onClick(() => { if (this.newPost.trim().length > 0) { const copy: Post[] = [{ user: '我', avatar: '🏃', content: this.newPost, likes: 0, time: '刚刚', liked: false, followed: false, comment: '', showComment: false, comments: [] }] for (let c: number = 0; c < this.posts.length; c++) { copy.push(this.posts[c]) } this.posts = copy this.newPost = '' } })

发布按钮在导航栏右侧,红色文字。点击时做了三件事:

  1. 检查输入不为空(trim后长度大于0)
  2. 创建新帖子对象,插入新数组的头部
  3. 清空输入框

新帖子的用户名写死为"我",头像写死为🏃,时间写死为"刚刚"。这三样应该从登录状态获取——但原型阶段够用。

关键操作是this.posts = copy——没有用pushunshift,而是创建新数组再赋值给@State变量。这是ArkUI状态管理的核心要求:@State检测的是变量引用的变化,不是对象内部的变化。直接this.posts.unshift(newPost)虽然修改了数组内容,但this.posts仍然指向同一个数组引用,框架不会触发重新渲染。

点赞交互

private toggleLike(idx: number): void { if (this.posts[idx].liked) { this.posts[idx].likes-- } else { this.posts[idx].likes++ } this.posts[idx].liked = !this.posts[idx].liked this.posts = this.posts.slice() }

点赞逻辑:如果已点赞就减1,未点赞就加1,然后切换liked状态。最后用this.posts.slice()创建新数组——和发帖时的copy数组效果一样,都是让@State检测到引用变化。

点赞按钮的动画

Row() { Text(p.liked ? '❤️' : '🤍').fontSize(14) .scale({ x: p.liked ? 1.2 : 1, y: p.liked ? 1.2 : 1 }) .animation({ duration: 200, curve: Curve.EaseOut }) Text(' ' + p.likes.toString()).fontSize(12).fontColor(p.liked ? A : T3) }.margin({ right: 24 }).onClick(() => { this.toggleLike(idx) })

点赞时❤️图标会从1倍放大到1.2倍,200毫秒完成。.animation()修饰符让scale变化产生过渡效果——没有这个修饰符,图标会瞬间从1倍跳到1.2倍,没有动画感。

Curve.EaseOut是缓动曲线:先快后慢。点击的瞬间图标迅速放大,然后慢慢减速到1.2倍。这种曲线符合人的直觉——动作开始时有力,结束时柔和。

点赞数字的颜色也会变化:已点赞时数字变红色(A),未点赞时是灰色(T3)。颜色变化不需要animation——它是瞬间切换的,和图标的动画形成对比。

关注按钮的双态切换

Text(p.followed ? '已关注' : '+ 关注').fontSize(11) .fontWeight(FontWeight.Medium) .fontColor(p.followed ? T2 : A) .padding({ left: 10, right: 10, top: 4, bottom: 4 }) .backgroundColor(p.followed ? '#F5F5F7' : '#FFF0F0') .borderRadius(10) .onClick(() => { this.toggleFollow(idx) })

关注按钮有两种状态:

状态文字文字颜色背景色
未关注+ 关注A(红色)#FFF0F0(浅红)
已关注已关注T2(灰色)#F5F5F7(浅灰)

未关注时红色醒目,吸引点击;已关注后变灰色低调,减少视觉干扰。这种"主操作→已操作"的颜色变化是社交产品的标准设计。

toggleFollow的实现和toggleLike一样——修改字段、创建新数组:

private toggleFollow(idx: number): void { this.posts[idx].followed = !this.posts[idx].followed this.posts = this.posts.slice() }

评论系统

展开/收起评论输入框

private toggleComment(idx: number): void { this.posts[idx].showComment = !this.posts[idx].showComment this.posts = this.posts.slice() }

点击"评论"按钮切换showComment的值。为true时显示输入框,为false时隐藏。

评论输入框

if (p.showComment) { Row() { TextInput({ placeholder: '写评论...', text: p.comment }) .layoutWeight(1).height(36).fontSize(13).backgroundColor('#F8F7FC') .borderRadius(18).padding({ left: 14 }) .onChange((v: string) => { p.comment = v }) Text('发送').fontSize(12).fontWeight(FontWeight.Bold).fontColor(A) .padding({ left: 10, right: 10, top: 6, bottom: 6 }) .backgroundColor('#FFF0F0').borderRadius(12).margin({ left: 8 }) .onClick(() => { this.sendComment(idx) }) }.width('100%').padding({ top: 6, bottom: 4 }) .backgroundColor('#FCFCFE').borderRadius(12) }

输入框在帖子卡片底部展开,和帖子内容在同一张白色卡片内。TextInput绑定p.comment,用户输入时同步更新。发送按钮在右边,红色文字+浅红背景。

发送评论

private sendComment(idx: number): void { if (this.posts[idx].comment.trim().length === 0) return this.posts[idx].comments.push(this.posts[idx].comment) this.posts[idx].showComment = false this.posts[idx].comment = '' const copy: Post[] = [] for (let c: number = 0; c < this.posts.length; c++) { copy.push(this.posts[c]) } this.posts = copy }

四个步骤:空内容检查→推入评论数组→收起输入框→清空内容→创建新数组触发渲染。最后的for循环创建新数组——和toggleLikeslice()效果一样,但写法更啰嗦。两种方式都对,slice()更简洁。

已有评论的展示

if (p.comments.length > 0) { Column() { ForEach(p.comments, (c: string) => { Row() { Text('💬').fontSize(11).margin({ right: 6 }) Text(c).fontSize(12).fontColor(T2).layoutWeight(1) }.width('100%').padding({ top: 4, bottom: 4 }) }) }.width('100%').padding({ top: 4, bottom: 8, left: 4, right: 4 }) .backgroundColor('#FCFCFE').borderRadius(10).margin({ bottom: 10 }) }

已有评论在一个浅灰背景(#FCFCFE)的圆角区域内,每条评论用💬图标+文字展示。评论文字12px灰色(T2),比帖子正文(14px黑色)层级更低——视觉上区分"帖子内容"和"评论内容"。

初始数据中,健身达人小王有2条评论,瑜伽教练Lily有1条,营养师阿杰有2条。跑步爱好者和游泳健将没有评论——评论区域不显示。

用户主页跳转

社区页面的跳转入口

Column() { Text(p.user).fontSize(14).fontWeight(FontWeight.Bold).fontColor(T1) Text(p.time).fontSize(11).fontColor(T3) }.alignItems(HorizontalAlign.Start).margin({ left: 10 }).layoutWeight(1) .onClick(() => { router.pushUrl({ url: 'pages/UserProfile', params: { 'name': p.user, 'avatar': p.avatar } as Record<string, Object> }) })

点击用户名或头像区域,跳转到UserProfile页面,传递两个参数:用户名和头像emoji。router.pushUrl会把参数序列化,目标页面通过router.getParams()接收。

参数传递用as Record<string, Object>类型断言——这是ArkUI路由参数的标准写法。如果不加断言,TypeScript会报类型错误。

UserProfile页面的参数接收

@State name: string = '用户' @State avatar: string = '🏃' @State followed: boolean = false aboutToAppear(): void { const p = router.getParams() as Record<string, Object> if (p) { if (p['name']) this.name = p['name'] as string if (p['avatar']) this.avatar = p['avatar'] as string } }

aboutToAppear在组件创建时执行,从路由参数中读取name和avatar。如果参数不存在(用户直接打开UserProfile页面),使用默认值"用户"和🏃。

这种"有参数用参数,没参数用默认值"的模式在整个App中反复出现——WorkoutPage的planId、ProgressPage的返回逻辑,都是同样的思路。防御性编程:不假设调用方一定传了正确的参数。

用户主页的结构

用户主页分四部分:

导航栏:返回按钮 + 用户名 + 关注按钮。关注按钮和社区页面的逻辑一样——点击切换关注状态。但这里有个问题:UserProfile的followed是独立的@State,和CommunityPage的posts[idx].followed没有同步。在社区页面关注某人后跳转到用户主页,主页的关注按钮仍然是未关注状态。

头像和标签

Text(this.avatar).fontSize(64).width(100).height(100).borderRadius(50) .backgroundColor('#F5F5F7').textAlign(TextAlign.Center) Text(this.name).fontSize(22).fontWeight(FontWeight.Bold).fontColor(T1).margin({ top: 16 }) Text('运动达人 · 连续打卡 15 天').fontSize(13).fontColor(T3).margin({ top: 4 })

头像从社区帖子的44×44放大到100×100——用户主页是"个人空间",头像应该更醒目。标签"运动达人 · 连续打卡 15 天"是硬编码的,实际项目中应该从用户数据获取。

统计数据行

Row({ space: 0 }) { this.Stat('156', '动态') Row() {}.width(1).height(30).backgroundColor('#F0EEF4') this.Stat('2.3k', '粉丝') Row() {}.width(1).height(30).backgroundColor('#F0EEF4') this.Stat('89', '关注') }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(16)

三个统计数字:156动态、2.3k粉丝、89关注。数字之间用1px宽的灰色竖线分隔——不是用Divider组件,而是用一个空的Row设置宽度1px和背景色。这种"假分割线"在ArkUI中很常见——Divider组件的样式控制不够灵活,手动造一个更可控。

StatBuilder把数字和标签组合成一个Column,layoutWeight(1)让三个统计项均分宽度。

最近动态

Text('最近动态').fontSize(16).fontWeight(FontWeight.Bold).fontColor(T1) Column({ space: 10 }) { Text('今天完成了5组波比跳,燃脂效果太棒了!坚持就是胜利 🔥') .fontSize(14).fontColor(T1).lineHeight(22).width('100%') .padding(14).backgroundColor('#FFFFFF').borderRadius(14) Text('晨跑打卡第15天!配速又提升了 🏃‍♂️') .fontSize(14).fontColor(T1).lineHeight(22).width('100%') .padding(14).backgroundColor('#FFFFFF').borderRadius(14) Text('分享一组居家核心训练动作 💪') .fontSize(14).fontColor(T1).lineHeight(22).width('100%') .padding(14).backgroundColor('#FFFFFF').borderRadius(14) }

最近动态是三条硬编码的帖子——没有ForEach,直接写了三个Text。这是因为用户主页的帖子列表和社区页面的帖子列表是独立的数据源。社区页面显示所有人的帖子,用户主页只显示当前用户的帖子。原型阶段用硬编码够用,真实项目需要从后端获取。

状态管理的关键模式

整个社区交互依赖一个核心模式:修改字段→创建新数组→赋值给@State变量

// ❌ 错误方式:直接修改数组 this.posts[idx].liked = true // 引用没变,UI不更新 // ✅ 正确方式:创建新数组 this.posts[idx].liked = true this.posts = this.posts.slice() // 引用变了,UI更新

ArkUI的@State检测的是变量引用的变化,不是对象内部属性的变化。直接修改this.posts[idx].liked不会触发重新渲染——必须让this.posts指向一个新对象。

这是和其他框架的一个重要区别。Vue用Object.definePropertyProxy检测属性变化,React用setState的不可变更新。ArkUI选择了最简单的方式:只检测引用变化。优点是实现简单、性能可预测;缺点是开发者必须手动创建新数组,容易忘记导致bug。

在社区页面中,这个模式出现了5次:发帖、点赞、关注、展开评论、发送评论。每次都是同样的套路——修改字段、创建新数组。如果以后交互更多(删除帖子、置顶、举报),这个模式会重复更多次。到时候应该抽成一个通用的更新函数。

踩坑记录

ForEach和@State的配合。ForEach渲染帖子列表时,如果直接修改this.posts[idx]的属性而不创建新数组,UI不会更新。点赞、关注、评论三个交互各踩了一次这个坑。最终的解决方案都是this.posts = this.posts.slice()

评论输入框的双向绑定。TextInput({ text: p.comment }).onChange((v: string) => { p.comment = v })配合实现双向绑定。但p.comment是Post对象的属性,直接修改它不会触发@State更新。发送评论时必须手动创建新数组才能让评论列表刷新。

路由参数的类型断言。router.getParams() as Record<string, Object>——返回值必须断言为Record才能访问具体字段。如果不加as,TypeScript会报类型错误。但运行时如果参数不存在,router.getParams()返回undefined,需要判空处理。

UserProfile的关注状态独立。UserProfile页面有自己的@State followed,和CommunityPage的posts[idx].followed是独立的。在社区页面关注某人后跳转到用户主页,主页的关注按钮仍然是未关注状态——因为两个页面的状态没有同步。真实项目应该用全局状态管理(AppStorage或自定义状态服务)来同步关注状态。

发布逻辑的代码位置。发布按钮的onClick逻辑包含数组操作——如果在导航栏的Row里放太多逻辑,代码可读性会下降。更好的做法是把发布逻辑抽成一个方法,onClick里只调用方法名。当前代码为了减少方法数量把逻辑内联了,但代价是build方法变长。


从"静态展示"到"动态交互",社区页面的变化不只是多了几个onClick。它验证了ArkUI状态管理的核心机制——@State的引用检测、ForEach的渲染触发、路由参数的传递。这些机制在首页、训练页、进度页都在用,但社区页面把它们集中到了一个场景里:一个页面里同时有数组更新(发帖)、对象属性更新(点赞)、条件渲染(评论框)、页面间传参(用户主页)。如果说其他页面是单个知识点的练习,社区页面就是综合考试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询