两个 @State 驱动签到:校园快递我的页的负边距上浮统计设计

发布时间:2026/9/4 7:15:03
两个 @State 驱动签到:校园快递我的页的负边距上浮统计设计 两个 State 驱动签到校园快递我的页的负边距上浮统计设计应用背景11 校园快递campus-express第四个 Tab 是我的页对应源码ProfileTab.ets共 290 行。它是用户的个人中心橙色主题#EA580C。本文逐行拆解UserInfo 用户信息卡、StatGrid 负边距上浮统计、AchievementWall 成就墙、WeekChart 周活跃条形图、MenuList 菜单列表五大模块重点讲解如何用负 margin 让一张卡片浮在头部之上以及签到按钮如何通过checked和exp两个State变量驱动整页反馈。我的页是三页内容 Tab 里最个人化的一个它不服务于某个具体任务而是把我是谁、我做了多少、我能去哪打包成一个入口总站。和首页、寄件、取件相比它更像一个聚合面板——把分散在各处的功能、数据、成就收拢到一处是用户离开应用前最后停留的地方。读懂它你就掌握了个人中心这一类高频页面的标准拼装法。个人中心页有个很反直觉的特征它看着复杂、交互却最少。首页有搜索、寄件页有四处选择、取件页有筛选而我的页几乎全是静态展示 导航入口真正的状态变化只有签到这一处驱动checked和exp两个State。这个特征决定了它的架构取向——把有限的交互做精签到进度联动把大量空间让给信息陈列统计、成就、菜单。理解了这点你才不会在我的页上乱堆状态、把简单页面写复杂。一、数据模型四类结构我的页数据面最杂作者用四个接口分别建模interface StatItem { value: string; label: string; } interface BadgeItem { emoji: string; name: string; locked: boolean; } interface WeekBar { day: string; value: number; } interface MenuItem { icon: string; label: string; badge: string; action: string; }StatItem是统计网格里的数值 标签BadgeItem是成就墙的一枚徽章多了locked布尔表示是否解锁WeekBar与首页的同名接口一致周活跃数据MenuItem是菜单项包含图标icon、标签label、角标badge空串表示无角标、动作action点击时 Toast 提示内容。注意MenuItem的badge是必填的string而非可选属性无角标时传空串渲染时用if (m.badge.length 0)判断是否显示。这四类接口恰好对应个人中心的四个信息维度量化StatItem 数字、激励BadgeItem 成就、趋势WeekBar 活跃、导航MenuItem 入口。一个页面用四套结构把我是谁、我多强、我怎么走讲清楚结构虽杂但职责分明。真实项目里这四类数据往往来自四个不同的后端接口用户画像 / 成就系统 / 行为统计 / 菜单配置前端各自建模、各自渲染互不耦合——这也是个人中心页适合作为聚合页示范的原因。项目源码开源https://gitee.com/codenestFlow/HarmonyOSHub二、页面骨架单层 Scrollbuild() { Column() { Scroll() { Column({ space: 0 }) { this.UserInfo() this.StatGrid() this.AchievementWall() this.WeekChart() this.MenuList() this.AboutInfo() } .width(100%) .padding({ bottom: this.safeBottom 20 }) } .layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top) } .width(100%).height(100%).backgroundColor(C.bg) }和别的页不同这里没有独立 Header也没有SectionTitle区块标题——UserInfo渐变卡本身就是伪 HeaderAchievementWall和WeekChart各自内部自带标题文字如Text(成就墙)、Text(本周取件活跃度)所以不需要额外的 SectionTitle 组件。safeTop直接放进UserInfo的padding({ top: this.safeTop 30 })里补偿。其余仍是Scroll Column({ space: 0 })的纵向流六个区块依次排开各区块自带margin({ top: 14 })控制间距。这种把头部卡当首屏第一块、省掉独立 Header的做法在个人中心、聊天详情页里很常见能让首屏更连贯。注意Scroll是唯一的外层容器没有Header()兄弟节点所以它直接layoutWeight(1)占满剩余空间而safeTop用UserInfo内部的padding而非height补偿——这和首页Header 固定 Scroll 弹性是两种不同的骨架。选择哪种取决于头部是否要吸顶个人中心的头像卡不吸顶随内容滚走所以放 Scroll 里首页标题要吸顶所以放 Scroll 外。一句话要吸顶就放滚动区外不吸顶就放滚动区内。三、UserInfo 用户信息卡这是全页视觉重心一块渐变头像卡左侧圆形头像、右侧昵称与等级、底部一条签到进度。// UserInfo — 用户信息卡 Builder UserInfo() { Column({ space: 14 }) { Row({ space: 14 }) { Column() { Text().fontSize(34) } .width(64).height(64) .backgroundColor(#33FFFFFF).borderRadius(32) .justifyContent(FlexAlign.Center) Column({ space: 4 }) { Row({ space: 8 }) { Text(李同学).fontSize(18).fontWeight(FontWeight.Bold).fontColor(#FFFFFF) Text(Lv. this.level).fontSize(11).fontColor(#FFFFFF) .padding({ left: 8, right: 8, top: 2, bottom: 2 }) .backgroundColor(#33FFFFFF).borderRadius(10) } Text(梅园 6 栋 302 室).fontSize(12).fontColor(#FFFFFF).opacity(0.85) } .alignItems(HorizontalAlign.Start).layoutWeight(1) Column({ space: 4 }) { Text(this.checked ? ✓ 已签到 : 签到).fontSize(12).fontColor(#FFFFFF) .padding({ left: 12, right: 12, top: 6, bottom: 6 }) .backgroundColor(this.checked ? #33FFFFFF : #FFFFFF) .fontColor(this.checked ? #FFFFFF : C.primary) .borderRadius(16) .onClick(() { this.checked !this.checked; if (this.checked) { this.exp Math.min(this.exp 10, 100); } promptAction.showToast({ message: this.checked ? 签到成功 10 经验 : 已取消 }); }) } .alignItems(HorizontalAlign.End) } .width(100%) Column({ space: 6 }) { Row() { Text(经验值).fontSize(11).fontColor(#FFFFFF).opacity(0.8) Blank() Text(this.exp / 100).fontSize(11).fontColor(#FFFFFF).opacity(0.8) } .width(100%) Progress({ value: this.exp, total: 100 }).color(#FFFFFF).backgroundColor(#33FFFFFF).width(100%) } .width(100%) } .width(100%) .padding({ left: D.pad, right: D.pad, top: this.safeTop 30, bottom: 24 }) .linearGradient({ angle: 135, colors: [[C.primary, 0.0], [C.accent, 1.0]] }) }三处亮点。其一头像用半透明白底圆而非图片backgroundColor(#33FFFFFF).borderRadius(32)把 64×64 的 Column 变成正圆里面放一只 emoji。这延续了全应用零资源 emoji的思路头像框自带半透明质感叠在渐变底上形成层次。其二Lv.this.level动态等级等级胶囊用半透明白底#33FFFFFF等级值来自State level而非写死字符串——这意味着等级可随经验增长而变化。其三签到按钮驱动State checked和State exp双状态点击切换checked布尔签到成功时this.exp Math.min(this.exp 10, 100)经验条Progress和右侧exp / 100文字同时跟着涨——两个State变量联动驱动整块 UI这就是一处状态、多处联动的迷你示范。Math.min(this.exp 10, 100)里的Math.min很关键它把经验封顶在 100避免连点签到把进度条撑爆。这种写状态时就做边界保护的习惯比事后在渲染里if (exp100) exp100更稳妥因为状态永远合法渲染永远不用兜底。签到按钮的视觉态也值得注意未签到时背景是纯白#FFFFFF、文字是C.primary橙色形成白底橙字的强对比签到后变成#33FFFFFF半透明白 白字融入渐变底——一次点击完成从醒目到融入的视觉过渡用checked布尔同时控制背景色和文字色是双态按钮的简洁写法。四、StatGrid 负边距上浮统计这是全页最骚的布局技巧让统计网格负 margin 上浮、盖在头像卡上形成层叠卡片。// StatGrid — 统计网格 Builder StatGrid() { Row() { ForEach(this.stats, (s: StatItem) { Column({ space: 4 }) { Text(s.value).fontSize(18).fontWeight(FontWeight.Bold).fontColor(C.text) Text(s.label).fontSize(11).fontColor(C.textDim) } .layoutWeight(1).alignItems(HorizontalAlign.Center) }, (s: StatItem) s.label) } .width(90%) .padding({ top: 16, bottom: 16 }) .backgroundColor(C.card) .borderRadius(D.rLg) .border({ width: 1, color: C.stroke }) .margin({ top: -28, left: D.pad, right: D.pad }) .shadow({ radius: 12, color: #10000000, offsetX: 0, offsetY: 4 }) }核心就是那行.margin({ top: -28 })一个负的顶部外边距把整张统计网格向上拽了 28vp使其叠在 UserInfo 渐变卡的底边上而不是规规矩矩排在它下方。再配.shadow({ radius: 12, color: #10000000, offsetY: 4 })投影网格卡看起来像浮在头像卡之上的一张白卡——这种层叠是移动端个人中心最经典的大图头 浮起数据卡范式微信、淘宝、B 站个人页都是这个套路。注意统计网格宽度是90%而非100%配合margin({ left: D.pad, right: D.pad })让浮卡比渐变头略窄两侧露出一点渐变底色形成卡片悬浮的层次感。Row里用ForEach layoutWeight(1)均分四列每列只有数值 标签两行文字居中对齐没有多余的描边或背景——因为整张卡已经有C.card白底和C.stroke描边内部不需要再画格子线。负 margin 能生效前提是 UserInfo 和 StatGrid 在同一个纵向Column流里、且 StatGrid 没有backgroundColor之外的遮挡。ArkUI 的margin支持负值这一点和 CSS 一致。但要小心负 margin 会让两张卡在视觉上重叠重叠区域的点击命中以后渲染者在上为准所以 StatGrid 必须写在 UserInfo 之后它确实是且两卡可点区域不要冲突。这条技巧虽好用多了会让页面层叠混乱适合在头部卡 一张浮卡这种明确的主从关系里用别滥用到普通卡片之间。那么为什么是-28而不是别的值这个数值要配合头部卡的padding({ bottom: 24 })和视觉平衡来调拽少了浮卡和头像卡之间留一道白缝层叠感不成立拽多了浮卡盖住头像卡里的经验条信息被遮挡。经验做法是——让浮卡的上沿大约压到头像卡内容底边再往上一点形成约半个卡身高度的重叠。调这个值时建议用先目测、再微调 ±4的方式不必追求精确但要保证两边信息都完整可见。五、AchievementWall 成就墙成就墙把用户的勋章平铺成可换行的标签云未解锁的用透明度淡化。// AchievementWall — 成就墙 Builder AchievementWall() { Column({ space: 12 }) { Text(成就墙).fontSize(16).fontWeight(FontWeight.Bold).fontColor(C.text).width(100%) Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) { ForEach(this.badges, (b: BadgeItem) { Column({ space: 6 }) { Row() { Text(b.emoji).fontSize(28).opacity(b.locked ? 0.4 : 1) } .width(52).height(52) .backgroundColor(b.locked ? C.cardSoft : C.primarySoft) .borderRadius(26) .justifyContent(FlexAlign.Center) Text(b.name).fontSize(10).fontColor(b.locked ? C.textDim : C.textSub) Text(b.locked ? 未解锁 : 已获得).fontSize(9).fontColor(b.locked ? C.textDim : C.primary) } .width(30%) .padding({ top: 10, bottom: 10 }) .alignItems(HorizontalAlign.Center) .onClick(() { promptAction.showToast({ message: b.locked ? b.name 未解锁 : b.name 已获得 }); }) }, (b: BadgeItem) b.name) } .width(100%) } .width(100%) .padding(14) .backgroundColor(C.card) .borderRadius(D.rLg) .border({ width: 1, color: C.stroke }) .margin({ top: 14, left: D.pad, right: D.pad }) }成就墙用Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween })做三列自动换行布局每个徽章宽度30%SpaceBetween让三个一行自动均分间距。每个徽章是一个Column顶部 52×52 圆底borderRadius(26)放 emoji下方两行文字——名称 状态已获得/未解锁。锁定态的处理比单纯改透明度更丰富三层视觉变化——emoji 用opacity(0.4)淡化、圆底背景从C.primarySoft浅橙变成C.cardSoft浅灰、名称文字从C.textSub变成C.textDim、状态文字从C.primary橙变成C.textDim灰。这种灰掉未解锁项的做法比直接隐藏更好——它让用户看到还有哪些成就可冲制造追求动机游戏化设计的常见手段。每个徽章可点击弹 Toast提示已获得或未解锁增加交互反馈。BadgeItem的locked: boolean是驱动这一切的唯一状态。真实项目里badges应该由后端根据用户行为计算出locked前端只负责按 locked 渲染。再次印证本系列一贯的数据驱动 UIUI 里零业务判断所有 yes/no 都来自数据字段。游戏化Gamification是个人中心最常用的留存手段而成就墙是其中成本最低、见效最快的一种。它的设计要点有三一是已得与未得必须同框未得淡化而非隐藏制造差一点就拿到的吸引力二是徽章要有可炫耀性emoji 名字 状态标签让用户愿意截图分享三是解锁规则要渐进先易后难避免用户一开始就觉得遥不可及。11 号的成就墙用locked布尔把前两点做出来了第三点属于后端规则配置demo 里用写死的badges数组示意即可。六、WeekChart 周活跃条形图这里的条形图和首页的 WeekChart 是同一套思路但数据更我的——它画的是本周取件活跃度。// WeekChart — 周活跃 Builder WeekChart() { Column({ space: 12 }) { Row() { Text(本周取件活跃度).fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text) Blank() Text(连续 4 天).fontSize(12).fontColor(C.primary) } .width(100%) Row({ space: 6 }) { ForEach(this.weekBars, (w: WeekBar) { Column({ space: 6 }) { Column() { Column() .width(100%) .height(Math.floor(w.value / this.maxBar * 70)) .backgroundColor(C.primary) .borderRadius(4) } .width(100%).height(70) Text(w.day).fontSize(11).fontColor(C.textDim) } .layoutWeight(1).alignItems(HorizontalAlign.Center) }, (w: WeekBar) w.day) } .width(100%) } .width(100%) .padding(14) .backgroundColor(C.card) .borderRadius(D.rLg) .border({ width: 1, color: C.stroke }) .margin({ top: 14, left: D.pad, right: D.pad }) }公式Math.floor(w.value / this.maxBar * 70)与首页的* 80略有不同——柱子最大高度从 80vp 降到 70vp因为我的页的 WeekChart 多了一行标题Text(本周取件活跃度)Text(连续 4 天)整体卡片更高柱子适当缩短以保持比例。标题行用Row Blank()把标题左对齐、连续 4 天右对齐Blank()在 ArkUI 里是弹性占位相当于 Flex 里的flex: 1空白——这是一行两端对齐的标准写法。连续 4 天用C.primary橙色高亮暗示你最近很活跃是数据驱动的激励文案。当你在多个页面用到同一种图表就该考虑把它抽成独立组件比如Component struct BarChart而不是每页复制一遍——这是从能跑到好维护的关键一跃。这里其实暴露了一个真实的代码坏味道WeekChart在首页和我的页各出现一次、结构几乎一样。按三处重复就该抽取的原则两处重复虽未到三处但既然字段结构完全一致抽成BarChart(weekBars: WeekBar[], maxBar: number)组件几乎零成本还能让改柱色、加动画、加 tooltip这类需求只改一处。建议读者把这两个WeekChart合并作为练习组件抽取的第一课——这比再写十个 demo 更能提升工程能力。七、MenuList 菜单列表菜单区把优惠券 / 收件地址 / 代取记录 / 消息通知 / 安全中心 / 帮助反馈六项功能收拢部分带角标。// MenuList — 功能菜单 Builder MenuList() { Column({ space: 0 }) { ForEach(this.menus, (m: MenuItem) { Row({ space: 12 }) { Row() { Text(m.icon).fontSize(20) } .width(36).height(36) .backgroundColor(C.cardSoft).borderRadius(D.rSm) .justifyContent(FlexAlign.Center) Text(m.label).fontSize(14).fontColor(C.text).layoutWeight(1) if (m.badge.length 0) { Text(m.badge).fontSize(11).fontColor(C.danger) .padding({ left: 8, right: 8, top: 2, bottom: 2 }) .backgroundColor(C.primarySoft).borderRadius(8) } Text().fontSize(14).fontColor(C.textDim) } .width(100%) .padding({ left: 14, right: 14, top: 12, bottom: 12 }) .onClick(() { promptAction.showToast({ message: m.action }); }) if (m.label ! 帮助反馈) { Divider().color(C.stroke).strokeWidth(0.5).margin({ left: 62 }) } }, (m: MenuItem) m.label) } .width(100%) .backgroundColor(C.card) .borderRadius(D.rLg) .border({ width: 1, color: C.stroke }) .margin({ top: 14, left: D.pad, right: D.pad }) }细节很讲究。其一是图标用圆底包裹每个菜单项的m.icon不是裸 emoji而是放在 36×36 的C.cardSoft圆底里borderRadius(D.rSm)形成图标格子 文字 箭头的三段式行布局比裸 emoji 更整齐。其二是if (m.badge.length 0)条件角标badge是必填的string但只有非空时才渲染角标——优惠券显示3张、消息通知显示5其余项不画。角标用C.danger红字 C.primarySoft浅橙底而非传统的红底白字配色更柔和。其三是Divider 分隔线源码用if (m.label ! 帮助反馈)控制是否画分隔线——除最后一项外每项底部都有一条margin({ left: 62 })缩进的细线缩进量正好对齐图标右侧让菜单项之间有清晰的视觉分隔。其四是右侧统一用右箭头暗示可进入二级页这是 iOS/Android 列表项的标准语义符号。点击事件弹的是m.action如查看优惠券、管理地址而非m.label——action字段专门存点击后的提示语和label显示文字分离这是接口设计里显示与行为分离的细腻做法。八、AboutInfo 关于信息// AboutInfo — 关于 Builder AboutInfo() { Column({ space: 6 }) { Text(校园快递 v1.0.0).fontSize(12).fontColor(C.textDim) Text(Powered by HarmonyOS 6.0 · ArkTS).fontSize(11).fontColor(C.textDim) Text(© 2025 校园智慧生活).fontSize(10).fontColor(C.textDim) } .width(100%).padding({ top: 20, bottom: 10 }).alignItems(HorizontalAlign.Center) }极简的页脚版本号 技术栈 版权声明三行居中、灰字、字号递减12/11/10上方留 20 的大间距把收尾感做足。版本号v1.0.0在真实项目里应从配置文件读取bundleName/版本字段而非写死字符串方便发版时统一更新。Powered by HarmonyOS 6.0 · ArkTS标明技术栈© 2025 校园智慧生活是版权声明——这三行是 App关于页的标准三件套。九、交互汇总两个State的涟漪回看我的页的全部交互其实只有两个真正改变状态的点——签到按钮的checked和exp。checked驱动签到按钮的文字签到↔✓ 已签到和背景色白底↔半透明白底exp驱动经验条Progress和右侧exp / 100文字其余区块StatGrid / AchievementWall / WeekChart / MenuList都是静态展示点击只弹 Toast。这和寄件页四个状态驱动五块 UI形成有趣对照个人中心页往往看着信息多其实可交互少因为大部分是导航入口而非录入控件。这也解释了为什么我的页用Scroll单层包裹、把空间让给内容而不是像寄件页那样堆表单状态。这个观察可以反过来指导你什么时候该给页面加状态如果一个区块的数据会随用户操作而变如签到涨经验它需要State如果只是静态陈列如成就列表、菜单项就保持private常量。把会变和不变分清楚状态数量自然克制页面也就不会越写越乱。很多新手把整页所有数据都设成State结果重绘范围失控、性能下降——记住状态是稀缺资源能不变就不变。十、与系列其他页面的对照把我的页和前三者放一起四个 Tab 恰好覆盖了移动应用最经典的四种页面形态首页信息总览、寄件页表单录入、取件页历史记录、我的页个人中心。它们共享C/D双类主题、StorageProp安全区、Progress/Divider/Blank等内置控件但各自用不同的状态复杂度和布局特色讲不同的故事。我的页贡献了两个本系列里最独特的技巧负 margin 浮起卡片StatGrid和条件透明度锁定态AchievementWall。把这两个技巧和首页的沉浸式安全区、寄件页的选择器三板斧、取件页的时间轴三件套放在一起你就有了一套相当完整的 ArkUI 页面组件库。这四种页面形态的通用性极高几乎任何 App 都能拆成信息首页 若干表单页 若干记录列表页 一个个人中心。本系列用 11 号应用把这四种形态各写了一遍等于给读者备了一套可复制骨架。下次接到新需求先判断它属于四种形态里的哪一种再去对应页面里抄骨架、换数据、改主题色开发效率能成倍提升——这正是系列化 demo的最大价值不是给你看一个功能而是给你一套可复用的模式库。十一、本章小结与工程经验模块核心技术学习价值UserInfo半透明白底头像圆 checked/exp双状态联动头像卡与经验条StatGridmargin({ top: -28 })负边距浮起 width(90%)层叠卡片范式AchievementWallopacity(0.4)C.cardSoft/C.primarySoft背景切换锁定态淡化WeekChart自绘条形图 标题行Blank()两端对齐图表组件化思路MenuListif(badge.length0)角标 图标圆底 Divider 分隔列表菜单标准写法AboutInfo居中灰字三行页脚收尾感处理我的页最大的两个收获一是负 margin 制造层叠——margin({ top: -28 }).shadow()让数据卡浮在头像卡之上是大图头 浮起数据个人中心范式的核心二是状态透明化渲染——未解锁徽章用opacity淡化 背景色切换而非隐藏在激励用户和诚实展示之间取了平衡。最后给一个可落地的工程建议四个 Tab 里反复出现的SectionTitle、Progress、Divider、各类卡片样式应该被抽进一个公共widgets.ets由四个页面统一引用。当你发现同一段 UI 在三个文件里各抄一遍就是它该变成公共组件的时刻——这能让 11 号应用的维护成本从改四处降到改一处也是把 demo 推向可交付产品的关键一步。具体到本页WeekChart两处、Header三处、SectionTitle多处都是现成的抽取候选动起手来就是一次很实在的重构练习。