鸿蒙原生实战:用 ArkUI 搭校园快递首页 —— 安全区适配与进度条列表

应用背景:11 校园快递(campus-express)是鸿蒙原生校园工具系列的第 11 个应用,采用橙色主题 #EA580C,整体走"沉浸式全屏 + 安全区适配"路线。本文逐行拆解 HomeTab.ets(共 310 行),聚焦 沉浸式全屏安全区适配HeroCard 渐变统计卡WeekChart 条形图ExpressList 进度条列表 四大核心组件,并穿插讲解 SearchBarQuickActionRankList 等辅助模块的实现细节。

首页是用户打开应用后第一个看到的页面,承担着"信息总览 + 快捷入口 + 动态推送"三重职责。它不像寄件页那样强交互,也不像取件页那样重时间轴,而是把最能体现产品价值的关键数据,用一张渐变头部卡 + 两个列表的形式平铺在首屏。理解首页,本质上是理解"如何用最克制的布局,把最多的高频信息讲清楚"。

沉浸式全屏是 11 号应用最鲜明的工程特征:它不同于传统"状态栏留白 + 内容区"的保守做法,而是让背景一路铺到屏幕最顶端,状态栏文字直接浮在橙色之上。这么做视觉张力更强、品牌感更足,代价是开发者必须手动处理安全区避让——而这条"动态计算避让区 → 写入 AppStorage → 各页面用 @StorageProp 读取"的链路,正是首页源码最值得反复揣摩的隐藏主线。后面每一节的布局注释里,你都会看到 safeTop / safeBottom 的身影,它们就是这条链路的末端。

一、页面整体结构与沉浸式安全区适配

首页的根结构是一个 Column,内部自上而下排布 Header()Scroll()Scroll 通过 layoutWeight(1) 占据除头部外的全部剩余空间,并把内部 Columnspace 设为 14,让卡片之间保持统一呼吸感。最关键的细节在 Scrollpadding 上:

// 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 安全区适配机制。safeTopsafeBottom 两个值来自 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 应用首页的骨架,务必记牢。

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

配图

二、数据模型:六类接口定义

首页数据量不小,作者把零散字段收敛成 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 描述一条快递动态,字段覆盖了"谁发的、什么单号、什么公司、什么时间、什么状态、用户打几星、运输进度多少"——一个列表项要呈现的所有维度,一次性建模干净。StatItemvalue/label/color 三件套概括了 HeroCard 里的三组统计,color 字段预留了未来按状态变色的能力。WeekBarRankItem 则分别是条形图和排行榜的数据源。

把数据先抽象成接口,再让 @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 的 linearGradientangle 表示方向,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 })
}

两个细节值得记:TextInputbackgroundColor('transparent'),让输入框"融"进外层白卡片里,不出现双层背景;onChange 把输入实时写回 @State searchText,这是 ArkUI 单向数据流的标准写法——数据往下流、事件往上回。.border({ width: 1, color: C.stroke }) 用描边而非阴影来定义卡片边界,这与整个应用的扁平化设计语言保持一致。D.rMdD 尺寸类里的"中圆角"常量,所有同级卡片共用,保证圆角尺度统一。

输入法(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 号首页刻意停留在"纯声明式堆叠",正是为了让读者先看清单图表的底层数学,再决定要不要上重型方案——这是一个很有教学意义的取舍示范。

校园快递首页 HeroCard · 渐变统计卡与双按钮

八、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.rankfontColor(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沉浸式全屏安全区适配
HeroCardlinearGradient + 双按钮渐变头部 + 主次操作区分
QuickActionborder 描边 Row+ForEach扁平化快捷入口
WeekChartMath.floor 映射高度纯展示型自绘条形图
ExpressListProgress 内置控件列表内嵌进度指示
RankList条件字体色排名排行榜视觉层次

沉浸式安全区适配 是首页最值得迁移到任何项目的经验:EntryAbility 动态获取避让区 → 写入 AppStorage → 各页面用 @StorageProp 读取,这条链路让 UI 在不同机型上都能"贴边而不越界"。其次,数据先抽象成 interface、UI 只负责渲染 的写法,让列表类页面天然具备可扩展性。最后,能复用内置控件(如 Progress)就别手搓,既能减代码又能保证一致性。

如果把首页拆成"一条主线 + 七块积木",主线是沉浸式安全区适配,七块积木是 Header / HeroCard / SearchBar / QuickAction / WeekChart / ExpressList / RankList。主线决定了页面能不能"贴边不越界",积木决定了页面好不好看、信息密不密。两者解耦,意味着你可以单独替换任意一块积木而不动主线——这正是组件化想要达到的"高内聚、低耦合"。下次写首页,先画主线(安全区 + 滚动骨架),再往里填积木,思路会清晰很多。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐