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,分别对应 HomeTabFunc1TabFunc2TabProfileTab。底部 Tab 栏由自定义的 @Builder bar(title, icon, idx) 渲染,配合 @State current: numberonChange 实现切换。

@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 渲染的区块:HeroCardQuickActionRowWeekChartChipRowSectionTitle('热门拼单')GroupListRankCard

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)
}

这里有几个非常专业的细节:

  1. 整体背景色C.bg(浅蓝灰 #F2F5FB),既不会与白色卡片产生强烈对比,又能让卡片边界自然浮现出来,是一种偏克制的"舒适感"配色。
  2. Scroll 不显示滚动条scrollBar(BarState.Off)),符合现代 App 的极简趋势。
  3. 底部 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) { ... }
  });
}

这段代码做了三件事:

  1. 全屏布局setWindowLayoutFullScreen(true) 让窗口内容延伸到状态栏与导航条区域,渐变色 HeroCard 才能从屏幕最顶部开始绘制。
  2. 状态栏图标颜色setWindowSystemBarProperties 把状态栏文字与导航条图标的颜色都设为 #1C2333,与 Theme.ets 里的 C.text 完全一致。
  3. 动态避让区域getWindowAvoidArea 分别拿到系统顶部状态栏与底部导航条的高度,px2vp() 换算后存入 AppStoragesafeTopsafeBottom

接下来的事情交给 ArkUI 的响应式系统。HomeTab 通过两个装饰器订阅这两个值:

@StorageProp('safeTop') safeTop: number = 0;
@StorageProp('safeBottom') safeBottom: number = 0;

@StoragePropAppStorage 单例读取数据,并在数据变化时自动触发 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 用于头像底色;文字层级分 texttextSubtextDim 三档。主色调是热情的橙红 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 个 ColumnlayoutWeight(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 描边"的对比,比单纯换文字颜色更有"按下了"的物理感。

Scrollscrollable(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 里描述的代码规范:所有数据结构(GroupItemQuickActionBarDataRankItem)都用 interface 显式声明,没有 any / object,编译器能在 IDE 阶段就发现字段拼写错误;ForEach 第三参数 keyGenerator 是推荐做法,有助于提升列表渲染性能和组件复用准确性;全文没有 as anyas numberas 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 的门面也就立住了。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐