HarmonyOS 应用 · 校园宿舍拼单首页深度解析:一段沉浸式渐变与数据可视化的 ArkUI 之旅
本文以 HarmonyOS 6.0(API 23)+ ArkTS 声明式 UI 为技术底座,逐行拆解「校园宿舍拼单」应用首屏(
HomeTab)的设计理念、布局结构、状态管理与交互细节。所有截图均来自本人电脑正在运行的 HarmonyOS 模拟器真机画面,未做任何美化与裁剪修饰。
一、项目缘起:为什么是"宿舍拼单"
高校宿舍是天然的高密度熟人小生态——四人间或六人间里,室友们的生活半径高度重合:从早八的豆浆到深夜的泡面,从换季的洗衣液到周末的炸鸡外卖,几乎每一笔消费都能找到"拼"的契机。单独点一杯奶茶要 12 元的配送费,三个人拼一单只要 8 元;囤一箱 12 瓶可乐单买 36 元,宿舍团下来只要 18 元。这些看似微不足道的差价,乘以一个学期、一栋宿舍楼,就是一笔可观的"宿舍小金库"。
正是基于这种真实而高频的需求,「校园宿舍拼单」应用应运而生。整个工程以 ArkTS 为开发语言、ArkUI 声明式范式为 UI 框架、Stage 模型为应用骨架,目标是打造一款轻量但体验完整的拼单工具。它由底部四个 Tab 组成:首页(推荐)、拼单(发起)、订单(进行中/历史)、我的(个人中心)。其中,首页是用户停留时间最长、信息密度最高的门面,也是整个项目里视觉效果与交互细节打磨得最用心的部分。
下文围绕entry/src/main/ets/pages/HomeTab.ets这一个文件,从架构、主题、布局、组件、状态、交互六大维度,进行一次完整而细致的拆解。
项目源码开源:https://gitee.com/codenestFlow/HarmonyOSHub
二、整体架构:首页在工程中的位置
整个应用入口是Index.ets。它是一个@Entry装饰的@Component,通过 ArkUI 的Tabs容器承载四个TabContent,分别对应HomeTab、Func1Tab、Func2Tab、ProfileTab。底部 Tab 栏由自定义的@Builder bar(title, icon, idx)渲染,配合@State current: number与onChange实现切换。
@Entry @Component struct Index { @State current: number = 0; @StorageProp('safeBottom') safeBottom: number = 0; build() { Tabs({ barPosition: BarPosition.End, index: this.current }) { TabContent() { HomeTab() }.tabBar(this.bar('首页', '🏠', 0)) TabContent() { Func1Tab() }.tabBar(this.bar('拼单', '📦', 1)) TabContent() { Func2Tab() }.tabBar(this.bar('订单', '📋', 2)) TabContent() { ProfileTab() }.tabBar(this.bar('我的', '👤', 3)) } .barBackgroundColor('#FFFFFF') .barHeight(60 + this.safeBottom) ... } }可以看到,HomeTab是四个 Tab 的第一个(idx = 0),且它的图标是"🏠"小屋 emoji,文案为"首页"。Tab 栏整体高度为60 + this.safeBottom,正是因为 Tab 处于屏幕最底部,需要让出 HarmonyOS 导航条的高度,否则导航条会盖住 Tab 图标,造成视觉割裂。这一点也是首页乃至整个 App 之所以能"看起来像一款成熟产品"的关键基础——安全区适配。
HomeTab自身的结构非常清晰:最外层是一个Column,上半部分是固定不滚动的Header()(标题区),下半部分是占满剩余高度的Scroll,内部是一个Column({ space: 14 }),依次堆叠 6 个@Builder渲染的区块:HeroCard→QuickActionRow→WeekChart→ChipRow→SectionTitle('热门拼单')→GroupList→RankCard。
build() { Column() { this.Header() Scroll() { Column({ space: 14 }) { this.HeroCard() this.QuickActionRow() this.WeekChart() this.ChipRow() this.SectionTitle('热门拼单') this.GroupList() this.RankCard() } .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) }这里有几个非常专业的细节:
- 整体背景色为
C.bg(浅蓝灰#F2F5FB),既不会与白色卡片产生强烈对比,又能让卡片边界自然浮现出来,是一种偏克制的"舒适感"配色。 Scroll不显示滚动条(scrollBar(BarState.Off)),符合现代 App 的极简趋势。- 底部 padding 加上
safeBottom——这是为了防止最后一个区块(RankCard 品类排行)的内容被底部 Tab 栏遮挡,是沉浸式布局的标准做法。
下面这张图,是 HarmonyOS 模拟器里首页首屏的真实运行效果:
可以看到,从上到下依次是:状态栏、Header(宿舍拼单)、HeroCard(渐变统计卡 + 搜索框)、QuickActionRow(4 个圆形快捷入口)、WeekChart(本周拼单趋势条形图)、ChipRow(分类筛选)、热门拼单标题 + 第一张 GroupCard 露出、底部 Tab 栏。整张图信息密度高却不拥挤,关键在于每个区块之间留了 14vp 的纵向间距(Column({ space: 14 })),视觉节奏感极好。
三、沉浸式全屏与安全区适配
在 HarmonyOS 上做"沉浸式"看起来是 UI 工作,本质却是应用启动时就要完成的窗口配置。这件事由entry/src/main/ets/entryability/EntryAbility.ets完成:
async onWindowStageCreate(windowStage: window.WindowStage): Promise<void> { windowStage.loadContent('pages/Index', (err) => { ... try { const win = windowStage.getMainWindowSync(); win.setWindowLayoutFullScreen(true); win.setWindowSystemBarProperties({ statusBarContentColor: '#1C2333', navigationBarContentColor: '#1C2333' }); const top = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM); const bottom = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR); AppStorage.setOrCreate('safeTop', px2vp(top.topRect.height)); AppStorage.setOrCreate('safeBottom', px2vp(bottom.bottomRect.height)); } catch (e) { ... } }); }这段代码做了三件事:
- 全屏布局:
setWindowLayoutFullScreen(true)让窗口内容延伸到状态栏与导航条区域,渐变色 HeroCard 才能从屏幕最顶部开始绘制。 - 状态栏图标颜色:
setWindowSystemBarProperties把状态栏文字与导航条图标的颜色都设为#1C2333,与Theme.ets里的C.text完全一致。 - 动态避让区域:
getWindowAvoidArea分别拿到系统顶部状态栏与底部导航条的高度,px2vp()换算后存入AppStorage的safeTop与safeBottom。
接下来的事情交给 ArkUI 的响应式系统。HomeTab通过两个装饰器订阅这两个值:
@StorageProp('safeTop') safeTop: number = 0; @StorageProp('safeBottom') safeBottom: number = 0;@StorageProp从AppStorage单例读取数据,并在数据变化时自动触发 UI 刷新。这意味着折叠屏展开、刘海屏切换、旋转屏幕时,UI 都能毫秒级重新对齐——开发者只需要声明"我要读哪个 key",ArkUI 负责"key 变了就重新渲染"。
在Header与外层Column中,safeTop + 10作为顶部 padding 使用,让标题文字永远刚好落在状态栏下方 10vp 处。这种"看不见但能感受到"的细节,是判断一款 App 是否专业的试金石。
四、全局主题系统:颜色与尺寸的"中央仓库"
ArkTS 严格模式不允许any、不允许类型断言,因此所有可复用的视觉常量必须显式声明。entry/src/main/ets/common/Theme.ets用两个 class 把"颜色"和"尺寸"集中起来:
export class C { static readonly bg: string = '#F2F5FB'; static readonly card: string = '#FFFFFF'; static readonly cardSoft: string = '#F4F6FB'; static readonly stroke: string = '#EAEEF6'; static readonly text: string = '#1C2333'; static readonly textSub: string = '#5A6483'; static readonly textDim: string = '#9AA3BC'; static readonly primary: string = '#FF6B35'; static readonly primarySoft: string = '#FFEDE5'; static readonly accent: string = '#FFB020'; static readonly warn: string = '#FF9F1C'; static readonly danger: string = '#FF5A6E'; static readonly ok: string = '#2BB673'; } export class D { static readonly rLg: number = 20; static readonly rMd: number = 14; static readonly rSm: number = 10; static readonly pad: number = 16; }C是"Color"的缩写:底色bg是淡蓝灰#F2F5FB,卡片白card,软卡片cardSoft用于头像底色;文字层级分text、textSub、textDim三档。主色调是热情的橙红primary #FF6B35,搭配金黄色accent #FFB020做渐变,契合"美食拼单"的烟火气。danger用于价格强调,ok用于"已满员"等成功状态。
D是"Design"的缩写:3 档圆角(20/14/10)和统一内边距 16。整个项目所有"圆角 20vp 的大卡片"、“圆角 10vp 的小按钮”、"内边距 16vp 的容器"都从这里取数。这种"设计 token"思路和前端领域的 Tailwind Config 异曲同工,但它是用 ArkTS 原生 class 实现的——没有运行时代价,没有打包体积,类型系统还会帮你杜绝拼写错误。
五、Header:标题与副标题的克制美学
@Builder 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: 6 }) Text('今天想拼点什么?') .fontSize(13).fontColor(C.textDim) .width('100%') .padding({ left: D.pad, right: D.pad, bottom: 12 }) } .width('100%').backgroundColor(C.card) }Header只有两行文字:主标题"宿舍拼单",副标题"今天想拼点什么?"——一句口语化的问句迅速建立情感连接,把用户从"我打开了一个 App"的陌生感里拉出来。主标题 22 号加粗主文色,副标题 13 号暗淡色。背景是纯白C.card,在视觉上把"标题区"与下方"渐变 HeroCard 区"做一个轻量切分。
整个 Header 的纵向高度由safeTop + 10 + (22 字高) + 6 + (13 字高) + 12自解释计算,不依赖任何 magic number,对后续做多端适配非常友好。
六、HeroCard:渐变背景与数据冲击力
HeroCard 是首页的"门面担当"。它用一张从橙红 (C.primary) 到金黄 (C.accent) 的 135° 线性渐变打底,承载两个核心元素:上方的 3 栏统计数字,下方的搜索框。
@Builder HeroCard() { Column({ space: 14 }) { Row() { Column({ space: 4 }) { Text('本周拼单').fontSize(12).fontColor('#FFFFFF').opacity(0.85) Text('128').fontSize(28).fontWeight(FontWeight.Bold).fontColor('#FFFFFF') } .alignItems(HorizontalAlign.Start).layoutWeight(1) Column({ space: 4 }) { Text('累计省').fontSize(12).fontColor('#FFFFFF').opacity(0.85) Text('¥386').fontSize(28).fontWeight(FontWeight.Bold).fontColor('#FFFFFF') } .alignItems(HorizontalAlign.Start).layoutWeight(1) Column({ space: 4 }) { Text('参与人').fontSize(12).fontColor('#FFFFFF').opacity(0.85) Text('24').fontSize(28).fontWeight(FontWeight.Bold).fontColor('#FFFFFF') } .alignItems(HorizontalAlign.Start).layoutWeight(1) } .width('100%') Row({ space: 8 }) { Text('🔍').fontSize(16) TextInput({ placeholder: '搜索拼单商品、店铺...', text: this.searchText }) .onChange((val: string) => { this.searchText = val; }) .backgroundColor('#33FFFFFF').borderRadius(D.rSm) .height(38).layoutWeight(1) .placeholderColor('#FFFFFFCC').placeholderFont({ size: 13 }) .fontColor('#FFFFFF') } .width('100%') .padding({ left: 12, right: 12, top: 8, bottom: 8 }) .backgroundColor('#22FFFFFF').borderRadius(D.rMd) } .width('100%') .padding(18) .borderRadius(D.rLg) .linearGradient({ angle: 135, colors: [[C.primary, 0.0], [C.accent, 1.0]] }) }3 栏统计:本周拼单 128 单 / 累计省 ¥386 / 参与人 24 人。每栏都是一个Column({ space: 4 }),由"小标签 + 大数字"组成。3 个Column的layoutWeight(1)让它们等分父容器的水平空间,形成稳定的"三足鼎立"。文字颜色全部用纯白#FFFFFF,小标签加opacity(0.85)让层级退后 1 档——这是大厂 Banner 常用的"明度对比而非灰度对比"手法,既避免了死板的灰白,又保持了渐变背景的活力。
搜索框:用TextInput实现,placeholder 是"搜索拼单商品、店铺…"。它和左侧的"🔍"放大镜 emoji 共用一个Row,整体再被一个半透明白色Row容器(背景色#22FFFFFF)包裹,形成"凹陷在卡片里"的视觉。#22FFFFFF是带 22% 不透明度的白色,相当于 macOS 上的 frosted glass——既能隐约看到底下橙黄渐变,又能让前景文字清晰可读。@State searchText: string = ''维护搜索词,onChange时双向同步,是 ArkTS 的"受控输入"标准写法。
圆角与渐变:外层borderRadius(D.rLg)是 20vp,配合padding(18),让卡片有适度的"呼吸感"。.linearGradient({ angle: 135, colors: [[C.primary, 0.0], [C.accent, 1.0]] })是 ArkUI 的线性渐变语法,angle: 135表示从左上到右下的对角线方向,颜色从橙红平滑过渡到金黄,是生活类 App 的经典配色。
七、QuickActionRow:4 个圆形入口的网格
紧跟在 HeroCard 下方的是 4 个圆形快捷入口,对应"奶茶、外卖、日用、福利"四个最高频的场景:
@Builder QuickActionRow() { Row() { ForEach(this.quickActions, (qa: QuickAction) => { Column({ space: 8 }) { Row() { Text(qa.emoji).fontSize(24) } .width(46).height(46) .backgroundColor(qa.bgColor).borderRadius(14) .justifyContent(FlexAlign.Center) Text(qa.label).fontSize(11).fontColor(C.textSub) } .layoutWeight(1) .onClick(() => { promptAction.showToast({ message: qa.label }); }) }, (qa: QuickAction) => qa.label) } .width('100%') .padding({ top: 16, bottom: 4 }) }数据结构由接口QuickAction定义:emoji+label+bgColor。4 个图标的背景分别是#FFEDE5淡橙红、#FFF3E0淡金黄、#E8F5E9淡草绿、#F3E5F5淡紫粉——典型的"莫兰迪色"风格,让 emoji 头像更突出又不喧宾夺主。每个圆形图标 46×46vp,圆角 14vp,居中显示 emoji,下方再放一个 11 字号的小标签。
ForEach(this.quickActions, (qa) => {...}, (qa) => qa.label)是 ArkUI 的列表渲染。第三参数 keyGenerator 是推荐做法——它告诉框架"如何唯一标识每一项",有助于提升列表渲染性能和组件复用准确性,让后续局部刷新只重绘变化的单元格。onClick里调用promptAction.showToast弹轻提示,是"可点击但暂未实装"的标准占位写法。layoutWeight(1)让 4 个入口等分宽度,无论 6.1 寸小屏还是 10 寸平板都不会挤成一团。
八、WeekChart:声明式"条形图"零依赖实现
数据可视化听起来要拉图表库,但在 ArkUI 声明式范式下,纯组件拼装就能做出足够精美的条形图。WeekChart渲染一周 7 天的拼单数量,点击柱子还能切换选中态:
@Builder WeekChart() { Column({ space: 12 }) { Row() { Text('本周拼单趋势').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text) Blank() Text('查看 >').fontSize(12).fontColor(C.textDim) } .width('100%') Row({ space: 0 }) { ForEach(this.barData, (b: BarData, idx: number) => { Column({ space: 6 }) { Stack({ alignContent: Alignment.Bottom }) { Column() .width(18) .height(this.barData[idx].value * 1.2) .borderRadius(6) .linearGradient({ angle: 180, colors: this.selectedBar === idx ? [[C.primary, 0.0], [C.accent, 1.0]] : [[C.primarySoft, 0.0], [C.primary, 1.0]] }) .opacity(this.selectedBar === idx ? 1.0 : 0.6) } .width(28).height(120) Text(b.day).fontSize(11) .fontColor(this.selectedBar === idx ? C.primary : C.textDim) .fontWeight(this.selectedBar === idx ? FontWeight.Bold : FontWeight.Normal) } .layoutWeight(1) .onClick(() => { this.selectedBar = idx; }) }, (b: BarData) => b.day) } .width('100%') } .width('100%') .padding(16) .backgroundColor(C.card) .borderRadius(D.rLg) .border({ width: 1, color: C.stroke }) }整张图的关键技巧只有一个——用Stack({ alignContent: Alignment.Bottom })把柱子"钉"在底部。Stack是 ArkUI 的层叠容器。把一根Column(柱子)放在 Stack 里并通过alignContent: Alignment.Bottom让它向底部对齐,再用height(b.value * 1.2)动态控制柱高——这样不管数据是 45 还是 100,柱子都从坐标原点"长"出来,而不是从顶部往下"垂"下去。
每根柱子是 18vp 宽、圆角 6vp 的Column,外层包裹一个 28×120vp 的Stack占据固定位置。视觉上每根柱子之间留出约 10vp 的间隙,呈现"独立的柱子"而非"连成一片"。
选中态:用@State selectedBar: number = 5默认选中"周六"(数组下标 5)。当某根柱子被点击时,this.selectedBar = idx立即触发重渲染——选中柱的渐变从primarySoft -> primary(浅到深)升级为primary -> accent(橙红到金黄),不透明度从 0.6 变为 1.0;柱下的星期标签颜色从textDim变为主色primary,字重从 Normal 变为 Bold。这种"渐变色 + 不透明度 + 文字颜色 + 字重"四重变化的组合,让选中态既醒目又不突兀。
7 天数据依次为 60/80/45/90/70/100/55。乘以 1.2 之后最高柱为 120vp,刚好填满 Stack 高度。右下角的"查看 >“没有 onClick,纯展示——这是用户研究的经典手法:看到 “>” 用户的潜意识会想"点一下会怎样”,于是点击率显著高于"详情"等纯文字按钮。
九、ChipRow:横向滚动的分类筛选
拼单可能涉及饮料、零食、外卖、日用等多个品类,首页需要一个轻量的筛选器。ChipRow用一个横向Scroll包住一排TextChip 实现:
@Builder ChipRow() { Scroll() { Row({ space: 10 }) { ForEach(this.cats, (cat: string, idx: number) => { Text(cat) .fontSize(13) .fontColor(this.activeCat === idx ? '#FFFFFF' : C.textSub) .padding({ left: 14, right: 14, top: 7, bottom: 7 }) .backgroundColor(this.activeCat === idx ? C.primary : C.card) .borderRadius(16) .onClick(() => { this.activeCat = idx; promptAction.showToast({ message: '筛选: ' + cat }); }) }, (cat: string) => cat) } } .scrollable(ScrollDirection.Horizontal) .scrollBar(BarState.Off).width('100%') }5 个分类:全部、饮料、零食、外卖、日用(实际数据多时可继续追加,横向滚动会自动支持)。@State activeCat: number = 0默认选中"全部"。选中态用主色背景 + 白字,未选中用白色卡片背景 + 次文色——这种"填充 vs 描边"的对比,比单纯换文字颜色更有"按下了"的物理感。
Scroll的scrollable(ScrollDirection.Horizontal)确保只能横向滚动,scrollBar(BarState.Off)隐藏滚动条——配合 16vp 圆角,整行 Chip 看起来像一颗颗糖果,活泼而不轻浮。onClick里调用promptAction.showToast弹"筛选: xxx",是给用户即时反馈的小技巧。
十、热门拼单列表:GroupList + GroupCard 的设计哲学
如果说 HeroCard 是首页的"封面",那 GroupList 才是首页的"内容"。它承担着把用户从"逛"引导到"买"的核心转化职责。
@Builder GroupList() { Column({ space: 12 }) { ForEach(this.groups, (g: GroupItem) => { this.GroupCard(g) }, (g: GroupItem) => g.id.toString()) } .width('100%') }groups是一个长度为 5 的数组,每一项都是GroupItem接口的实例:奶茶拼单 第二杯半价(一点点,¥12)、炸鸡全家桶(肯德基,¥39)、可乐 12 瓶装(校园超市,¥18)、麻辣烫拼单(张亮麻辣烫,¥22)、蛋挞一盒 6 只(烘焙坊,¥15)。每条数据包含 emoji、名称、店铺、价格、已拼人数、需要人数、剩余提示、评分、热度、标签共 10 个字段,几乎涵盖了拼单场景下"决策所需的全部信息"。
GroupCard是整个首页代码量最大的单个@Builder:
@Builder GroupCard(g: GroupItem) { Column({ space: 10 }) { Row({ space: 12 }) { Stack({ alignContent: Alignment.TopEnd }) { Row() { Text(g.emoji).fontSize(40) } .width(68).height(68) .backgroundColor(C.cardSoft) .borderRadius(D.rMd) .justifyContent(FlexAlign.Center) Text(g.tag) .fontSize(9).fontColor('#FFFFFF') .padding({ left: 5, right: 5, top: 2, bottom: 2 }) .backgroundColor(C.danger).borderRadius(4) .margin({ top: 0, right: 0 }) } .width(68).height(68) Column({ space: 5 }) { Text(g.name).fontSize(15).fontWeight(FontWeight.Medium).fontColor(C.text) .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) Row({ space: 8 }) { Text('⭐ ' + g.rating).fontSize(11).fontColor(C.warn) Text('🔥 ' + g.hot).fontSize(11).fontColor(C.textDim) Text(g.shop).fontSize(11).fontColor(C.textDim) } Row({ space: 6 }) { Text('¥' + g.price).fontSize(16).fontWeight(FontWeight.Bold).fontColor(C.danger) Text('· ' + g.left).fontSize(11).fontColor(C.warn) } } .alignItems(HorizontalAlign.Start) .layoutWeight(1) Button('立即拼') .fontSize(13).fontColor('#FFFFFF').backgroundColor(C.primary) .borderRadius(D.rSm).height(34) .onClick(() => { promptAction.showToast({ message: '加入: ' + g.name }); }) } .width('100%') Stack({ alignContent: Alignment.Start }) { Column().width('100%').height(6).backgroundColor(C.cardSoft).borderRadius(3) Column() .width(g.joined / g.need * 100 + '%') .height(6) .borderRadius(3) .linearGradient({ angle: 0, colors: [[C.primary, 0.0], [C.accent, 1.0]] }) } .width('100%') Row() { Text('已拼 ' + g.joined + '/' + g.need + ' 人') .fontSize(11).fontColor(C.textSub) Blank() if (g.joined >= g.need) { Text('已满员').fontSize(11).fontColor(C.ok).fontWeight(FontWeight.Medium) } else { Text('差 ' + (g.need - g.joined) + ' 人成团').fontSize(11).fontColor(C.primary) } } .width('100%') } .width('100%') .padding(14) .backgroundColor(C.card) .borderRadius(D.rLg) .border({ width: 1, color: C.stroke }) }整个 GroupCard 可以拆成"上中下"三个区域来理解:
上层(核心信息区):由Row({ space: 12 })横向排列三个元素——左侧的 emoji 头像、中间的拼单详情、右侧的"立即拼"按钮。
emoji 头像:用一个 68×68vp 的圆角矩形
Row作为容器,里面居中放一个 40 字号的 emoji。再用Stack({ alignContent: Alignment.TopEnd })在右上角叠加一个"标签",比如"热销"“爆品”“划算”“热门”“限特”。Stack 的TopEnd对齐让标签自然出现在右上角,无需手动算坐标。这种"头像 + 角标"的设计在电商 App 中非常常见,但在这里通过纯声明式语法一气呵成。拼单详情:纵向
Column({ space: 5 })装 3 行文字。第一行是拼单名称(maxLines(1)+textOverflow: Ellipsis保证单行省略,避免卡片高度参差不齐);第二行是评分 ⭐、热度 🔥、店铺名(用 emoji 代替图标,省资源又国际化);第三行是价格¥XX(danger 红色加粗,强调价格刺激转化)+ 状态文字"剩2单/已满"。立即拼按钮:
Button高度 34vp,圆角 10vp,主色背景白字。onClick弹 toast 占位。在小尺寸屏幕上,按钮宽度由文字自适应,整个卡片剩余空间全部交给中间的"详情列"——这是典型的"次要按钮"处理方式:它不能太大抢戏,但又必须足够显眼让用户能"想点就点"。
中层(拼单进度条):核心技巧是双层Column+Stack模拟进度条。
Stack({ alignContent: Alignment.Start }) { Column().width('100%').height(6).backgroundColor(C.cardSoft).borderRadius(3) Column() .width(g.joined / g.need * 100 + '%') .height(6) .borderRadius(3) .linearGradient({ angle: 0, colors: [[C.primary, 0.0], [C.accent, 1.0]] }) } .width('100%')底层是一个浅色(C.cardSoft)的"轨道"Column,宽度 100%;上层是一个渐变(橙红→金黄)的"进度"Column,宽度根据g.joined / g.need计算得到,比如已拼 3 人、需要 5 人,进度就是 60%。Stack默认按子组件顺序层叠,配合alignContent: Alignment.Start让上层进度条从最左侧开始。这种"百分比宽度 + 渐变"是实现精美进度条最轻量的方法,不需要任何 Canvas 与第三方库。
下层(人数与成团提示):左边"已拼 X/Y 人"用次文色,右边根据是否满员动态切换文案——满员显示绿色"已满员",否则显示主色"差 N 人成团"。if (g.joined >= g.need)这种条件渲染让 UI 自动适配业务状态,开发者无需手动维护"成团/未成团"两份模板。
下图是首页中部——热门拼单列表的真机截图,可以看到 4 张并排的 GroupCard,每张卡片都包含了 emoji 头像 + 角标、详情三行、立即拼按钮、进度条、人数与成团提示:
可以看到,第三张卡片"可乐 12 瓶装"(校园超市)只剩 2 单,进度条只填了 1/3;第一张"奶茶拼单 第二杯半价"剩 2 单,进度条填了 3/5;而第二张"炸鸡全家桶"(肯德基)已经满员,进度条完全填满且文字变成绿色"已满员"——这正是条件渲染 + 动态宽度的视觉效果。
十一、RankCard:品类排行,简洁有力的收尾
首页最末端的RankCard是一个"小而美"的数据排行榜,告诉用户"今天大家都在拼什么"。
@Builder RankCard() { Column({ space: 14 }) { Row() { Text('品类排行').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text) Blank() Text('更新于 10:00').fontSize(11).fontColor(C.textDim) } .width('100%') ForEach(this.rankItems, (r: RankItem, idx: number) => { Row({ space: 12 }) { Text((idx + 1).toString()) .fontSize(16).fontWeight(FontWeight.Bold) .fontColor(idx < 2 ? C.primary : C.textDim) .width(24) Text(r.emoji).fontSize(20) Text(r.name).fontSize(14).fontColor(C.text).layoutWeight(1) Stack({ alignContent: Alignment.Start }) { Column().width('100%').height(6).backgroundColor(C.cardSoft).borderRadius(3) Column() .width(r.count / r.total * 100 + '%') .height(6) .borderRadius(3) .backgroundColor(C.primary) } .width(80) Text(r.count + '单').fontSize(12).fontColor(C.primary).fontWeight(FontWeight.Medium) } .width('100%') }, (r: RankItem) => r.id.toString()) } .width('100%') .padding(16) .backgroundColor(C.card) .borderRadius(D.rLg) .border({ width: 1, color: C.stroke }) }数据结构只有 3 项:奶茶 128 单、炸鸡 96 单、麻辣烫 72 单。视觉布局是 5 列等比:名次(24vp 固定宽) + emoji(20 字号) + 名称(layoutWeight(1)自动撑满) + 80vp 宽的小进度条 + 单数。前 2 名的名次数字用主色 primary,第 3 名用暗淡色 textDim——这是一个低成本但效果显著的"金牌银牌铜牌"视觉区分。
进度条宽度由r.count / r.total * 100 + '%'计算。r.total = 200是参考总量,让 3 个品类的进度条长度有合理的比例差异。
下图是首页底部的真机截图,可以看到蛋挞一盒 6 只(限特标签)的 GroupCard,以及下方的品类排行卡片:
仔细看右下角,会发现奶茶 🧋 的进度条最长(128 单),炸鸡 🍟 居中(96 单),麻辣烫 🍜 最短(72 单),三根柱子的视觉差异恰好对应它们的销量排名。
十二、状态管理与响应式:ArkTS 的"单向数据流"
整个HomeTab用到了 3 个@State状态变量:
@State activeCat: number = 0; @State selectedBar: number = 5; @State searchText: string = '';activeCat:当前选中的分类下标。点击 Chip 时this.activeCat = idx触发 ChipRow 重渲染。selectedBar:当前选中的柱状图下标。点击柱子时切换,配合 WeekChart 的多重视觉变化。searchText:搜索框受控输入。onChange实时同步,保证与 HeroCard 内TextInput一致。
外加 2 个从AppStorage订阅的全局值:
@StorageProp('safeTop') safeTop: number = 0; @StorageProp('safeBottom') safeBottom: number = 0;其中 3 个@State变量是组件自身的状态,构成组件内部的"事实来源";而 2 个@StorageProp是只读单向同步(AppStorage → 组件),其事实来源是AppStorage,组件只能读取不能修改。UI 永远只是状态的映射,状态变化由用户事件触发,整个数据流是单向的:用户操作 → 状态更新 → 框架自动重渲染。
相比传统 Android 的命令式 UI(手动findViewById+setText+setVisibility),单向数据流有 3 个明显优势:无中间态错误(控件不可能 setText 后视图不刷新);可预测性(给定相同状态,UI 一定渲染出相同结果);天然支持多端(同一份代码在手机、平板、二合一设备上都能正确渲染)。
十三、ArkTS 严格模式下的工程化细节
整个HomeTab完全符合项目 README 里描述的代码规范:所有数据结构(GroupItem、QuickAction、BarData、RankItem)都用interface显式声明,没有any/object,编译器能在 IDE 阶段就发现字段拼写错误;ForEach第三参数 keyGenerator 是推荐做法,有助于提升列表渲染性能和组件复用准确性;全文没有as any、as number、as string这种"逃生舱口";每一个 UI 区块都是独立的@Builder,让build()函数保持简洁——一眼就能看清首页由哪几部分组成。
最值得称道的是主题统一:所有颜色、圆角、间距都从Theme.ets取数。如果产品经理某天说"主色想从橙红换成森绿",只需要改C.primary一个值,整个首页的所有 HeroCard、按钮、进度条、Chip、Tag 会瞬间切换——这是"设计系统"在 ArkTS 工程中的最直接落地。
十四、可演进方向
虽然首页已经相当精致,但要走向产品级,还有几条自然的演进路径:把private groups接入真实接口实现"数据真实化"(源码中groups是 private 普通成员变量,非@State;若要实现数据变化自动驱动 UI 刷新,需将其改为@State);用 ArkUI 原生Refresh组件包住外层Scroll实现下拉刷新;让 HeroCard 三个数字与真实用户行为打通,让 Banner 数据"活"起来;WeekChart 拓展为"日 / 周 / 月"维度切换;让 ChipRow 的activeCat真正过滤 GroupList 而不只是弹 toast;searchText接入商品搜索实现"边输入边联想";最后给 GroupCard 加一个进入页面的飞入动画,让用户的视觉节奏更舒服。这些改造大多不需要重写 UI,只需要在现有"状态 → 渲染"的链路里增加数据和触发点。
十五、写在最后
回到开篇的三段式真机截图——
从顶部 Header 到底部 Tab,从渐变 HeroCard 到条形图 WeekChart,从 ChipRow 到 GroupList 再到 RankCard,整张首页用约 380 行 ArkTS 代码,构建出了一个视觉舒适、信息密度合理、交互细节到位的高质量首屏。它没有炫技,没有冗余的装饰,每一行代码都在解决一个具体的视觉或交互问题。
这正是 HarmonyOS ArkUI 声明式范式的精髓——用最少的代码,描述最丰富的 UI。当你熟悉了@Component/@Builder/@State/@StorageProp/ForEach这五个核心概念后,写一个完整的页面会比想象中轻松得多;而像 Theme token、沉浸式全屏、安全区适配这些"看不见的工程",一旦做好,就能让整个 App 的体验瞬间拉满。
如果你也对 HarmonyOS 应用开发感兴趣,不妨从这种"小而美"的单页面项目开始尝试。当你把首页打磨到这种程度,整个 App 的门面也就立住了。