两个 @State 驱动签到:校园快递我的页的负边距上浮统计设计
2026/9/4 7:15:00 网站建设 项目流程

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

应用背景:11 校园快递(campus-express)第四个 Tab 是"我的"页,对应源码ProfileTab.ets(共 290 行)。它是用户的个人中心,橙色主题#EA580C。本文逐行拆解UserInfo 用户信息卡StatGrid 负边距上浮统计AchievementWall 成就墙WeekChart 周活跃条形图MenuList 菜单列表五大模块,重点讲解"如何用负 margin 让一张卡片'浮'在头部之上",以及"签到按钮如何通过checkedexp两个@State变量驱动整页反馈"。

"我的"页是三页内容 Tab 里最"个人化"的一个:它不服务于某个具体任务,而是把"我是谁、我做了多少、我能去哪"打包成一个入口总站。和首页、寄件、取件相比,它更像一个"聚合面板"——把分散在各处的功能、数据、成就收拢到一处,是用户离开应用前最后停留的地方。读懂它,你就掌握了"个人中心"这一类高频页面的标准拼装法。

个人中心页有个很反直觉的特征:它"看着复杂、交互却最少"。首页有搜索、寄件页有四处选择、取件页有筛选,而"我的"页几乎全是静态展示 + 导航入口,真正的状态变化只有"签到"这一处(驱动checkedexp两个@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 提示内容)。注意MenuItembadge是必填的string而非可选属性,无角标时传空串'',渲染时用if (m.badge.length > 0)判断是否显示。

这四类接口恰好对应个人中心的四个信息维度:量化(StatItem 数字)、激励(BadgeItem 成就)、趋势(WeekBar 活跃)、导航(MenuItem 入口)。一个页面用四套结构把"我是谁、我多强、我怎么走"讲清楚,结构虽杂但职责分明。真实项目里,这四类数据往往来自四个不同的后端接口(用户画像 / 成就系统 / 行为统计 / 菜单配置),前端各自建模、各自渲染,互不耦合——这也是个人中心页适合作为"聚合页"示范的原因。

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

二、页面骨架:单层 Scroll

build() { 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渐变卡本身就是"伪 Header",AchievementWallWeekChart各自内部自带标题文字(如Text('成就墙')Text('本周取件活跃度')),所以不需要额外的 SectionTitle 组件。safeTop直接放进UserInfopadding({ top: this.safeTop + 30 })里补偿。其余仍是Scroll + Column({ space: 0 })的纵向流,六个区块依次排开,各区块自带margin({ top: 14 })控制间距。这种"把头部卡当首屏第一块、省掉独立 Header"的做法,在个人中心、聊天详情页里很常见,能让首屏更连贯。

注意Scroll是唯一的外层容器(没有Header()兄弟节点),所以它直接layoutWeight(1)占满剩余空间,而safeTopUserInfo内部的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 (exp>100) exp=100更稳妥,因为状态永远合法,渲染永远不用兜底。

签到按钮的视觉态也值得注意:未签到时背景是纯白#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,提示"已获得"或"未解锁",增加交互反馈。

BadgeItemlocked: boolean是驱动这一切的唯一状态。真实项目里,badges应该由后端根据用户行为计算出locked,前端只负责"按 locked 渲染"。再次印证本系列一贯的"数据驱动 UI":UI 里零业务判断,所有 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的涟漪

回看"我的"页的全部交互,其实只有两个真正改变状态的点——签到按钮的checkedexpchecked驱动签到按钮的文字("签到"↔"✓ 已签到")和背景色(白底↔半透明白底),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.length>0)角标 + 图标圆底 + Divider 分隔列表菜单标准写法
AboutInfo居中灰字三行页脚收尾感处理

"我的"页最大的两个收获:一是负 margin 制造层叠——margin({ top: -28 })+.shadow()让数据卡浮在头像卡之上,是"大图头 + 浮起数据"个人中心范式的核心;二是状态透明化渲染——未解锁徽章用opacity淡化 + 背景色切换而非隐藏,在"激励用户"和"诚实展示"之间取了平衡。

最后给一个可落地的工程建议:四个 Tab 里反复出现的SectionTitleProgressDivider、各类卡片样式,应该被抽进一个公共widgets.ets,由四个页面统一引用。当你发现"同一段 UI 在三个文件里各抄一遍",就是它该变成公共组件的时刻——这能让 11 号应用的维护成本从"改四处"降到"改一处",也是把 demo 推向可交付产品的关键一步。具体到本页,WeekChart(两处)、Header(三处)、SectionTitle(多处)都是现成的抽取候选,动起手来就是一次很实在的重构练习。

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

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

立即咨询