HarmonyOS 应用 · 校园宿舍拼单首页深度解析:一段沉浸式渐变与数据可视化的 ArkUI 之旅
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 这一个文件,从架构、主题、布局、组件、状态、交互六大维度,进行一次完整而细致的拆解。

二、整体架构:首页在工程中的位置
整个应用入口是 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 包住一排 Text Chip 实现:
@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 的门面也就立住了。
更多推荐



所有评论(0)