鸿蒙原生实战:用 ArkUI 搭校园快递首页 —— 安全区适配与进度条列表
鸿蒙原生实战:用 ArkUI 搭校园快递首页 —— 安全区适配与进度条列表
应用背景:11 校园快递(campus-express)是鸿蒙原生校园工具系列的第 11 个应用,采用橙色主题
#EA580C,整体走"沉浸式全屏 + 安全区适配"路线。本文逐行拆解HomeTab.ets(共 310 行),聚焦 沉浸式全屏安全区适配、HeroCard 渐变统计卡、WeekChart 条形图、ExpressList 进度条列表 四大核心组件,并穿插讲解SearchBar、QuickAction、RankList等辅助模块的实现细节。
首页是用户打开应用后第一个看到的页面,承担着"信息总览 + 快捷入口 + 动态推送"三重职责。它不像寄件页那样强交互,也不像取件页那样重时间轴,而是把最能体现产品价值的关键数据,用一张渐变头部卡 + 两个列表的形式平铺在首屏。理解首页,本质上是理解"如何用最克制的布局,把最多的高频信息讲清楚"。
沉浸式全屏是 11 号应用最鲜明的工程特征:它不同于传统"状态栏留白 + 内容区"的保守做法,而是让背景一路铺到屏幕最顶端,状态栏文字直接浮在橙色之上。这么做视觉张力更强、品牌感更足,代价是开发者必须手动处理安全区避让——而这条"动态计算避让区 → 写入 AppStorage → 各页面用 @StorageProp 读取"的链路,正是首页源码最值得反复揣摩的隐藏主线。后面每一节的布局注释里,你都会看到 safeTop / safeBottom 的身影,它们就是这条链路的末端。
一、页面整体结构与沉浸式安全区适配
首页的根结构是一个 Column,内部自上而下排布 Header() 与 Scroll()。Scroll 通过 layoutWeight(1) 占据除头部外的全部剩余空间,并把内部 Column 的 space 设为 14,让卡片之间保持统一呼吸感。最关键的细节在 Scroll 的 padding 上:
// HomeTab.ets — 页面根结构(含安全区)
@Component
export struct HomeTab {
@StorageProp('safeTop') safeTop: number = 0;
@StorageProp('safeBottom') safeBottom: number = 0;
@State searchText: string = '';
build() {
Column() {
this.Header()
Scroll() {
Column({ space: 14 }) {
this.HeroCard(); this.SearchBar(); this.QuickAction();
this.WeekChart(); this.SectionTitle('快递动态', '全部 >');
this.ExpressList(); this.SectionTitle('快递公司排行', '详情 >');
this.RankList();
}
.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)
}
}
本页最大的学习价值,正是 @StorageProp 安全区适配机制。safeTop 和 safeBottom 两个值来自 AppStorage,由 EntryAbility.onWindowStageCreate() 中通过 getWindowAvoidArea() 动态计算写入。也就是说,应用一旦进入沉浸式全屏,系统状态栏会被界面淹没,原本"状态栏下方"的空间需要开发者自己补偿。Scroll 底部 padding 用了 D.pad + this.safeBottom + 20 的表达式:既保留了卡片左右与底部的常规留白 D.pad,又额外叠加了 safeBottom(避让底部导航条),最后加 20 让最后一张卡片不与导航条贴死。
scrollBar(BarState.Off) 关闭了滚动条,align(Alignment.Top) 保证内容从顶部对齐——这两行组合在"内容可能超出一屏"的列表页里是标配:既不影响阅读,又不会因滚动条闪烁而破坏沉浸式观感。
顺带一提,Column(...).height('100%') 配合 Scroll().layoutWeight(1) 的写法,保证了"无论内容多长,滚动区域始终恰好占满 Header 以下的所有空间"。如果漏掉 layoutWeight(1),Scroll 会按内容高度撑开,在内容不足一屏时出现底部空白、内容超过一屏时又无法滚动的诡异状态。这种"固定 Header + 弹性滚动区"的二分结构,是几乎所有多 Tab 应用首页的骨架,务必记牢。

二、数据模型:六类接口定义
首页数据量不小,作者把零散字段收敛成 6 个 interface,这是 ArkTS 声明式开发中非常值得借鉴的做法。先看核心几个:
interface Express {
id: number; emoji: string; code: string; company: string;
time: string; tag: string; rating: number; progress: number;
}
interface StatItem { value: string; label: string; color: string; }
interface WeekBar { day: string; value: number; }
interface RankItem {
id: number; rank: number; name: string; value: string;
emoji: string; percent: number;
}
interface QuickItem { emoji: string; label: string; }
Express 描述一条快递动态,字段覆盖了"谁发的、什么单号、什么公司、什么时间、什么状态、用户打几星、运输进度多少"——一个列表项要呈现的所有维度,一次性建模干净。StatItem 用 value/label/color 三件套概括了 HeroCard 里的三组统计,color 字段预留了未来按状态变色的能力。WeekBar 与 RankItem 则分别是条形图和排行榜的数据源。
把数据先抽象成接口,再让 @Builder 只负责"怎么画",是"数据驱动 UI"的雏形。当产品经理想增减一个快递公司或调整一周数据,开发者只需改数组,不动任何布局代码——这正是组件化最大的红利。
三、Header 标题栏与安全区
Header 是首页唯一的固定(吸顶)元素,它没有用 Scroll 包裹,因此始终可见。它只放了一个标题文字,但 padding 处理得很讲究:
// 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: 12 })
}
.width('100%').backgroundColor(C.card)
}
.padding({ top: this.safeTop + 10 }) 让标题文字主动避让状态栏——这是沉浸式全屏下的标准补偿手法。safeTop 随设备动态变化:在刘海屏上它可能是 60+,在普通屏上可能仅 20。作者没有写死一个 Magic Number,而是把 @StorageProp 读到的值直接参与运算,这意味着同一份代码在任意机型上都不会出现"标题被状态栏压住"或"顶部留白过大"的尴尬。
此外 Header 背景用 C.card(白色卡片色),与下方 C.bg(浅灰页面背景)形成轻微层次,让标题区像一块"浮在列表上方的面板",视觉上把"品牌"与"内容"做了区隔。
这里还有一个值得注意的取舍:Header 没有放进 Scroll,因此它"吸顶"是天然成立的——只要它写在 Scroll 之前且不被滚动容器包裹,就永远停留在原位。相较之下,有些应用会把标题也丢进滚动区再用 sticky 逻辑吸顶,代码复杂且容易在滚动抖动时闪烁。11 号首页选择最朴素的"分置法",用最小的认知成本换来了稳定的吸顶效果,这在 demo 与中小型项目里通常是更优解。
四、HeroCard 渐变头部统计卡
这是首页的"门面"。它用 linearGradient 铺了一块橙→浅橙的渐变,把站点名、三项统计、两个主操作全部压在一张卡里。
// HeroCard — 头部统计卡
@Builder HeroCard() {
Column({ space: 16 }) {
Row() {
Text('菜鸟驿站 · 梅园站点').fontSize(13).fontColor('#FFFFFF').opacity(0.85)
Blank()
Text('本月统计').fontSize(11).fontColor('#FFFFFF').opacity(0.7)
}.width('100%')
Row({ space: 12 }) {
ForEach(this.stats, (s: StatItem) => {
Column({ space: 4 }) {
Text(s.value).fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text(s.label).fontSize(11).fontColor('#FFFFFF').opacity(0.8)
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
}, (s: StatItem) => s.label)
}.width('100%')
Row({ space: 12 }) {
Button('扫码取件')
.fontSize(14).fontColor(C.primary).backgroundColor('#FFFFFF')
.borderRadius(D.rSm).height(40).layoutWeight(1)
.onClick(() => { promptAction.showToast({ message: '扫码取件' }); })
Button('一键代取')
.fontSize(14).fontColor('#FFFFFF').backgroundColor('#33FFFFFF')
.borderRadius(D.rSm).height(40).layoutWeight(1)
.onClick(() => { promptAction.showToast({ message: '一键代取' }); })
}.width('100%')
}
.width('100%').padding(20).borderRadius(D.rLg)
.linearGradient({ angle: 135, colors: [[C.primary, 0.0], [C.accent, 1.0]] })
}
渐变从主色 C.primary(#EA580C)过渡到强调色 C.accent(#FB923C),角度 135° 形成右上偏亮的对角线光感。ForEach(this.stats) 把"待取件 3 / 本月取件 12 / 代取费 ¥35"三组数据横向均分(layoutWeight(1)),白字在橙色底上对比清晰。
关于渐变角度有个小知识点:ArkUI 的 linearGradient 用 angle 表示方向,0° 指向正上方、顺时针增加。135° 意味着"从左下指向右上",所以亮色 C.accent 放在 colors 数组的末尾(比例 1.0),就落在右上方——这正好模拟了自然光从左上打来的高光感。如果你想做"左上亮"的反向光,把 angle 改成 315° 即可。这种用角度控制光向的技巧,在一切需要"伪 3D 立体感"的卡片上都能用。
双按钮的设计语言差别明显:扫码取件 是"白底橙字"的实底按钮,是主操作;一键代取 用 #33FFFFFF(33 是十六进制透明度,约 20% 白)做半透明底、白字,是次操作。这种"主实底、次描边/透明"的搭配,是移动端引导用户优先点击主按钮的通用范式。两个按钮都通过 promptAction.showToast 给出轻量反馈——在 demo 阶段用 Toast 替代真实跳转,既演示了交互闭环,又不引入多余的页面跳转复杂度。
五、SearchBar 搜索栏
搜索栏是首页通向"查件"能力的入口,结构极简:一个放大镜 emoji 加一个 TextInput。
// SearchBar — 运单号查询
@Builder SearchBar() {
Row({ space: 10 }) {
Text('🔍').fontSize(18)
TextInput({ placeholder: '输入运单号查询', text: this.searchText })
.backgroundColor('transparent').borderRadius(D.rSm).height(40)
.layoutWeight(1).placeholderColor(C.textDim).placeholderFont({ size: 14 })
.onChange((val: string) => { this.searchText = val; })
}
.width('100%').padding({ left: 14, right: 14 })
.backgroundColor(C.card).borderRadius(D.rMd)
.border({ width: 1, color: C.stroke })
}
两个细节值得记:TextInput 设 backgroundColor('transparent'),让输入框"融"进外层白卡片里,不出现双层背景;onChange 把输入实时写回 @State searchText,这是 ArkUI 单向数据流的标准写法——数据往下流、事件往上回。.border({ width: 1, color: C.stroke }) 用描边而非阴影来定义卡片边界,这与整个应用的扁平化设计语言保持一致。D.rMd 是 D 尺寸类里的"中圆角"常量,所有同级卡片共用,保证圆角尺度统一。
输入法(IME)的弹出也是首页需要考虑的隐式问题:当 TextInput 获焦时,系统键盘会顶起整个布局。由于 SearchBar 不在滚动区顶部,键盘弹起一般不会遮挡它,但若把它放进列表更深处,就必须配合 Scroll 的自动滚动或 keyboardAvoidMode 来避让。好在 11 号把它放在首屏最显眼的位置,规避了这套复杂度。
六、QuickAction 四宫格快捷入口
四宫格把"寄件 / 取件 / 代取 / 查件"四个最高频动作平铺成一行,每个占 layoutWeight(1) 等宽。
// QuickAction — 快捷入口
@Builder QuickAction() {
Row({ space: 10 }) {
ForEach(this.quickActions, (item: QuickItem) => {
Column({ space: 6 }) {
Text(item.emoji).fontSize(26)
Text(item.label).fontSize(12).fontColor(C.textSub)
}
.layoutWeight(1).padding({ top: 14, bottom: 14 })
.backgroundColor(C.card).borderRadius(D.rMd)
.border({ width: 1, color: C.stroke })
.onClick(() => { promptAction.showToast({ message: item.label }); })
}, (item: QuickItem) => item.label)
}.width('100%')
}
每个入口由"emoji 图标 + 文字标签"组成,图标用大字号 emoji 代替图片资源,好处是零资源依赖、随时可换、不增加包体。四个入口整体不再套外层卡片,而是各自独立描边,视觉上更像"四块可点的瓷砖"。点击任意一块都弹对应标签的 Toast,演示了 ForEach 中闭包捕获 item 的能力——注意箭头函数参数 item 就是当前遍历对象,因此每个入口的点击事件天然绑定到自己的数据。
用 emoji 当图标虽省事,也有代价:不同系统/字体下 emoji 的渲染尺寸和风格不一,无法像 SVG/icon font 那样精确控制线宽与色相。在追求像素级统一的品牌项目里,更稳妥的做法是把图标换成 Image 资源或矢量字体。11 号用 emoji 是为"零资源 demo"服务,这个取舍在真实产品里要按需调整。

七、WeekChart 本周取件趋势
这是首页唯一的自绘图表。它用纯 ArkUI 组件叠出一组柱状图,没有引入任何图表库。
// WeekChart — 条形图
@Builder WeekChart() {
Column({ space: 12 }) {
Row() {
Text('本周取件趋势').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text)
Blank()
Text('峰值 95 件').fontSize(12).fontColor(C.textDim)
}.width('100%')
Row({ space: 6 }) {
ForEach(this.weekBars, (w: WeekBar) => {
Column({ space: 6 }) {
Column() {
Column()
.width('100%')
.height(Math.floor(w.value / this.maxBar * 80))
.backgroundColor(C.primary).borderRadius(4)
}.width('100%').height(80)
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 })
}
关键在于那行高度公式:Math.floor(w.value / this.maxBar * 80)。它的含义是——把每天的数值 w.value 先除以最大值 maxBar(95),归一化到 0~1,再乘以图表区总高 80vp,得到柱子实际高度。用 Math.floor 取整避免小数点带来的亚像素抖动。外层 Column().height(80) 固定了"零线到底线"的轨道高度,内层柱子从顶部对齐(alignItems(HorizontalAlign.Center) + 内层 Column 默认顶端对齐)向下生长——于是所有柱子底部天然对齐,视觉上是一组规整的对比条形。
用 Math.floor 取整避免小数点带来的亚像素抖动。外层 Column().height(80) 固定了"零线到底线"的轨道高度,内层柱子从顶部对齐向下生长——于是所有柱子底部天然对齐,视觉上是一组规整的对比条形。
这种"用纯布局比例堆出图表"的手法,在需求不复杂时非常划算:零依赖、零额外组件、改数据即改图。但当你的图表需要动画过渡、点击 tooltip、或动态增删系列时,就该考虑引入 ECharts 等图表库了。11 号首页刻意停留在"纯声明式堆叠",正是为了让读者先看清单图表的底层数学,再决定要不要上重型方案——这是一个很有教学意义的取舍示范。

八、ExpressList 快递动态列表
快递动态是首页信息密度最高的区域。每条用 ForEach 渲染,内部把"图标 / 信息 / 时间状态"三组信息横向排布,并在底部嵌一条 Progress 进度条。
// ExpressList — 快递列表
@Builder ExpressList() {
Column({ space: 10 }) {
ForEach(this.expresses, (e: Express) => {
Column({ space: 10 }) {
Row({ space: 12 }) {
Row() { Text(e.emoji).fontSize(22) }
.width(46).height(46).backgroundColor(C.primarySoft).borderRadius(D.rSm)
.justifyContent(FlexAlign.Center)
Column({ space: 4 }) {
Row({ space: 8 }) {
Text(e.company).fontSize(14).fontWeight(FontWeight.Medium).fontColor(C.text)
Text(e.tag).fontSize(10)
.fontColor(e.tag === '待取件' ? C.warn : C.ok)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(C.cardSoft).borderRadius(4)
}.width('100%')
Text('单号: ' + e.code).fontSize(11).fontColor(C.textDim)
Row({ space: 2 }) {
ForEach([1, 2, 3, 4, 5], (n: number) =>
Text(n <= e.rating ? '★' : '☆').fontSize(12)
.fontColor(n <= e.rating ? C.warn : C.textDim),
(n: number) => n.toString())
}.width('100%')
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column({ space: 4 }) {
Text(e.time).fontSize(11).fontColor(C.textDim)
Text(e.progress === 100 ? '已完成' : '运输中').fontSize(10).fontColor(C.textDim)
}.alignItems(HorizontalAlign.End)
}.width('100%')
Progress({ value: e.progress, total: 100 }).color(C.primary).width('100%')
}
.width('100%').padding(14)
.backgroundColor(C.card).borderRadius(D.rMd)
.border({ width: 1, color: C.stroke })
.onClick(() => { promptAction.showToast({ message: e.company }); })
}, (e: Express) => e.id.toString())
}.width('100%')
}
三个亮点:其一是状态标签的颜色三元切换——e.tag === '待取件' ? C.warn : C.ok,待取件用警示黄、已取件用成功绿;其二是星级评分用 ForEach([1..5]) 直接生成,n <= e.rating 决定画实心星还是空心星,纯声明式、零分支判断 UI;其三是 Progress 组件——ArkUI 内置的进度条控件替代了手写 Stack + 宽度百分比的老办法,一行 Progress({ value, total:100 }) 就完成,代码量骤减且效果一致。整张卡片可点,点击弹公司名 Toast,符合"列表项即入口"的习惯。
ForEach([1,2,3,4,5]) 这种"用固定数组造循环"的写法,在生成星级、步骤条、分页器时极常见。要注意它的 key 用了 n.toString(),因为 1~5 不重复,能稳定标识。若循环生成的是可变数据,务必换成数据自身的 id,否则重绘时会出现星星错位。

九、RankList 快递公司排行
排行榜把本周各快递公司的取件量做了从高到低的排序展示。
// RankList — 排行榜
@Builder RankList() {
Column({ space: 10 }) {
ForEach(this.ranks, (r: RankItem) => {
Row({ space: 12 }) {
Text(r.rank.toString()).fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(r.rank === 1 ? C.warn : C.textDim).width(24)
Text(r.emoji).fontSize(20)
Text(r.name).fontSize(14).fontColor(C.text).layoutWeight(1)
Text(r.value).fontSize(14).fontColor(C.primary).fontWeight(FontWeight.Medium)
}
.width('100%').padding({ left: 14, right: 14, top: 10, bottom: 10 })
.backgroundColor(C.card).borderRadius(D.rSm)
.border({ width: 1, color: C.stroke })
.onClick(() => { promptAction.showToast({ message: r.name }); })
}, (r: RankItem) => r.id.toString())
}.width('100%')
}
序号 r.rank 用 fontColor(r.rank === 1 ? C.warn : C.textDim) 做了条件着色——第一名橙色高亮、其余淡化,制造"冠军突出"的视觉焦点。width(24) 给序号固定宽度,保证后面的 emoji 与名字纵向对齐,整列看起来像一张规整的报表。数据上"顺丰 8 件 / 圆通 5 件 / 京东 4 件"三项,展示了本周取件量排名前三的快递公司,与首页 HeroCard 的"本月取件 12"等统计指标共同构成首页的数据全景。

十、与系列其他应用首页的对比
把 11 号首页和 10 号水电缴费首页放在一起看,会发现"同一个骨架,两种性格":两者都是 Header + Scroll(Column(...)) 的纵向滚动结构,都用了 @StorageProp 安全区与 C/D 双类主题。但 10 号更"功能导向"——首屏就是可交互的柱状图和操作卡片;11 号更"信息导向"——首屏是渐变 HeroCard + 动态列表,把"待取件提醒"这种时效性内容推到最前面。
差别背后是产品定位不同:水电缴费是低频刚需(每月一次),用户进来的目的是"马上操作";快递是高频轻交互(每天看状态),用户进来的目的是"扫一眼有没有新件"。把高频信息前置、把操作收进快捷入口,正是 11 号首页的设计巧思。
更宏观地看,11 号首页的"渐变头部 + 卡片流"范式,在社交、电商、工具类应用里都通用:顶部一块品牌渐变承载身份与关键指标,下方用等宽卡片平铺功能与动态。你把这套骨架存进自己的组件库,换套配色、换组数据源,几乎能 10 分钟搭出一个新 App 的首页——这也是本系列用统一 C/D 双类主题的根本原因:代码可以复制,风格必须统一。
十一、本章小结与工程经验
| 组件 | 核心技术 | 学习价值 |
|---|---|---|
| Header | @StorageProp safeTop | 沉浸式全屏安全区适配 |
| HeroCard | linearGradient + 双按钮 | 渐变头部 + 主次操作区分 |
| QuickAction | border 描边 Row+ForEach | 扁平化快捷入口 |
| WeekChart | Math.floor 映射高度 | 纯展示型自绘条形图 |
| ExpressList | Progress 内置控件 | 列表内嵌进度指示 |
| RankList | 条件字体色排名 | 排行榜视觉层次 |
沉浸式安全区适配 是首页最值得迁移到任何项目的经验:EntryAbility 动态获取避让区 → 写入 AppStorage → 各页面用 @StorageProp 读取,这条链路让 UI 在不同机型上都能"贴边而不越界"。其次,数据先抽象成 interface、UI 只负责渲染 的写法,让列表类页面天然具备可扩展性。最后,能复用内置控件(如 Progress)就别手搓,既能减代码又能保证一致性。
如果把首页拆成"一条主线 + 七块积木",主线是沉浸式安全区适配,七块积木是 Header / HeroCard / SearchBar / QuickAction / WeekChart / ExpressList / RankList。主线决定了页面能不能"贴边不越界",积木决定了页面好不好看、信息密不密。两者解耦,意味着你可以单独替换任意一块积木而不动主线——这正是组件化想要达到的"高内聚、低耦合"。下次写首页,先画主线(安全区 + 滚动骨架),再往里填积木,思路会清晰很多。
更多推荐




所有评论(0)