【HarmonyOS 6】“人物“页面的UI布局拆解

【HarmonyOS 6】"人物"页面的UI布局拆解

往期回顾:

上一篇我们拆了"首页"的布局。这篇来看第二个 Tab------人物

"首页"解决的是"我现在该做什么","人物"页解决的是"我关心的人是谁"。两个页面的布局思路完全不同:首页是多种卡片的混合拼图,人物页则是搜索栏 + 同一种列表项的纵向重复。

布局越单一,反而越考验列表项的设计和交互细节。


一、页面全貌

"人物"页面的结构非常简洁:

复制代码
Column({ space: 14 })
 ├─ 标题区(人物库 + 副标题)
 ├─ 搜索栏
 └─ 列表容器
     └─ ForEach → 列表项 + Divider

对应代码骨架:

typescript 复制代码
@Component
export struct ContactListView {
  @Prop contacts: ContactProfile[] = [];
  @State searchQuery: string = '';
  onContactClick?: (contact: ContactProfile) => void;

  build() {
    Column({ space: 14 }) {
      Row() { Text('人物库')  Blank() }
      TextInput({ placeholder: '搜索姓名、标签、城市、关系...' })
      Column() {
        ForEach(this.getFilteredContacts(), (item: ContactProfile, index: number) => {
          Column() {
            Row({ space: 14 }) { 头像  姓名/职称  关系 }
            Divider()
          }
        }, ...)
      }
      .borderRadius(16)
      .clip(true)
      .shadow(...)
    }
    .width('100%')
  }
}

和"首页"一样,外层是 Column({ space: 14 }),间距统一管理。但这个页面没有 Scroll------因为 ContactListView 是被嵌入到 Index.etsScroll 里面的,滚动由外层统一处理。


二、标题区:左标题右留白

typescript 复制代码
Row() {
  Column({ space: 6 }) {
    Text('人物库')
      .fontSize(22)
      .fontWeight(FontWeight.Bold)
      .fontColor(Theme.getTextPrimary(this.currentMode))
    Text('已收录 ' + this.contacts.length.toString() + ' 位亲友与伙伴,用心经营每一段关系')
      .fontSize(13)
      .fontColor(Theme.getTextSecondary(this.currentMode))
  }
  .alignItems(HorizontalAlign.Start)
  Blank()
}
.width('100%')

2.1 主标题 + 副标题

复制代码
人物库
已收录 4 位亲友与伙伴,用心经营每一段关系

主标题 22 号加粗,副标题 13 号常规灰色。6vp 的 space 让两行文字紧凑但不拥挤。

副标题里有一个动态数字:this.contacts.length.toString()。当用户新增或删除人物时,这个数字会自动更新,给用户一种"我的关系网络在成长"的反馈。

2.2 Blank() 的作用

typescript 复制代码
Blank()

右侧没有操作按钮,但仍然用了 Blank()。这是因为 Row 默认会把子元素从左到右排列,Blank() 把标题区推到最左边,确保左对齐。如果以后需要在右侧加一个排序或筛选按钮,直接在 Blank() 后面加就行。

2.3 和"首页"标题的区别

"首页"的标题是独立的 Text('人情管家'),26 号字。"人物"页的标题是 Column 包裹的主副标题,22 号字。字号更小,因为"人物"页的信息重心在列表项上,标题不需要太抢眼。


三、搜索栏:圆角 TextInput

typescript 复制代码
TextInput({ placeholder: '搜索姓名、标签、城市、关系...', text: this.searchQuery })
  .width('100%')
  .height(48)
  .fontSize(14)
  .fontColor(Theme.getTextPrimary(this.currentMode))
  .placeholderColor(Theme.getTextMuted(this.currentMode))
  .backgroundColor(Theme.getSurface(this.currentMode))
  .borderRadius(20)
  .padding({ left: 16, right: 16 })
  .border({ width: 1, color: Theme.getBorder(this.currentMode) })
  .onChange((value: string) => {
    this.searchQuery = value;
  })

3.1 高度 48 + 圆角 20

typescript 复制代码
.height(48)
.borderRadius(20)

48vp 的高度比默认的 TextInput 更高,手指点击更容易命中。borderRadius(20) 接近高度的一半,形成胶囊形状,和整页的圆润风格一致。

3.2 placeholder 提示搜索范围

typescript 复制代码
placeholder: '搜索姓名、标签、城市、关系...'

placeholder 不只写了"搜索",还列出了可搜索的字段。这比单纯写"搜索..."更友好------用户不需要猜"能不能搜城市"。

3.3 描边而非填充

typescript 复制代码
.border({ width: 1, color: Theme.getBorder(this.currentMode) })

搜索栏用的是 1vp 描边,而不是填充背景色。Theme.getBorder() 在亮色模式下是 #E5EAF5,非常浅的蓝灰色。描边比填充更轻盈,不会让搜索栏在页面中太突出------毕竟搜索是辅助功能,列表才是主角。

3.4 实时搜索:onChange 而不是 onSubmit

typescript 复制代码
.onChange((value: string) => {
  this.searchQuery = value;
})

搜索是实时的------每输入一个字符,列表就会立即过滤。不需要按回车或点搜索按钮。这种"边输边搜"的体验,在人物数量不多(几十到几百)的场景下非常合适。


四、搜索过滤逻辑

搜索栏背后是一个 getFilteredContacts() 方法:

typescript 复制代码
private getFilteredContacts(): ContactProfile[] {
  const query = this.searchQuery.trim().toLowerCase();
  if (!query) {
    return this.contacts;
  }
  return this.contacts.filter(contact => {
    const matchName = contact.name.toLowerCase().includes(query);
    const matchRelation = contact.relation.toLowerCase().includes(query);
    const matchTags = contact.tags && contact.tags.some(tag => tag.toLowerCase().includes(query));
    const matchCity = contact.city && contact.city.toLowerCase().includes(query);
    return matchName || matchRelation || matchTags || matchCity;
  });
}

4.1 四个维度匹配

搜索覆盖了四个字段:姓名、关系、标签、城市。用户输入"咖啡",会匹配到标签里有"爱咖啡"的人物;输入"深圳",会匹配到城市是深圳的人物。

4.2 toLowerCase 容错

typescript 复制代码
const query = this.searchQuery.trim().toLowerCase();

输入和匹配都转成小写,用户不需要关心大小写。trim() 去掉首尾空格,避免误匹配。

4.3 空查询直接返回全部

typescript 复制代码
if (!query) {
  return this.contacts;
}

搜索栏为空时,不做任何过滤,直接返回完整列表。这是最常见的做法,也是最符合用户预期的。


五、列表容器:圆角 + 裁剪 + 阴影

typescript 复制代码
Column() {
  ForEach(this.getFilteredContacts(), (item: ContactProfile, index: number) => {
    // 列表项 + Divider
  }, ...)
}
.width('100%')
.borderRadius(16)
.clip(true)
.shadow({ radius: 12, color: Theme.getShadow(this.currentMode), offsetX: 0, offsetY: 4 })

5.1 为什么要 clip(true)

typescript 复制代码
.clip(true)

这是整页最关键的一个属性。列表容器设了 borderRadius(16),但列表项本身没有圆角。如果不加 .clip(true),第一个和最后一个列表项的直角会超出容器的圆角范围,看起来就像"圆角容器里装了方角内容"。

.clip(true) 让超出圆角范围的内容被裁剪掉,视觉上就是"所有列表项被包在一个圆角卡片里"。

5.2 阴影加在容器上,不是每个列表项

typescript 复制代码
.shadow({ radius: 12, color: Theme.getShadow(this.currentMode), offsetX: 0, offsetY: 4 })

阴影只加在列表容器上,每个列表项本身没有阴影。这样整个列表看起来是一个统一的"卡片",而不是一堆散落的小卡片。

5.3 不用 List 组件,用 Column + ForEach

人物列表用的是 Column + ForEach,而不是 List + ListItem

原因是这个列表的数据量很小(几个人物),不需要 List 的懒加载能力。Column + ForEach 更简单,也更容易控制分隔线的样式。


六、列表项:左图右文 + 右侧关系标签

这是"人物"页的核心组件。每个列表项的结构:

复制代码
┌────────────────────────────────────────┐
│  ┌────┐  林嘉宁              客户      │
│  │ 林 │  品牌市场负责人                 │
│  └────┘                                │
├────────────────────────────────────────┤   ← Divider(左侧缩进)
typescript 复制代码
Column() {
  Row({ space: 14 }) {
    // 头像
    Column() {
      Text(item.name.substring(0, 1))
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
    }
    .width(44)
    .height(44)
    .backgroundColor(Theme.primary)
    .borderRadius(8)
    .justifyContent(FlexAlign.Center)

    // 中间:姓名 + 职称
    Column({ space: 4 }) {
      Text(item.name)
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .fontColor(Theme.getTextPrimary(this.currentMode))

      if (item.title && item.title !== '待完善' && item.title !== '新建人物档案') {
        Text(item.title)
          .fontSize(13)
          .fontColor(Theme.getTextSecondary(this.currentMode))
      }
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Start)

    // 右侧:关系
    Text(item.relation)
      .fontSize(13)
      .fontColor(Theme.getTextSecondary(this.currentMode))
  }
  .width('100%')
  .padding({ top: 12, bottom: 12, left: 16, right: 16 })
  .backgroundColor(Theme.getSurface(this.currentMode))

  // 分隔线
  if (index < this.getFilteredContacts().length - 1) {
    Divider()
      .color(Theme.getBorder(this.currentMode))
      .strokeWidth(0.5)
      .margin({ left: 74, right: 16 })
  }
}
.onClick(() => {
  if (this.onContactClick) {
    this.onContactClick(item);
  }
})

接下来逐层拆开。


七、头像:44×44 圆角方块

typescript 复制代码
Column() {
  Text(item.name.substring(0, 1))
    .fontSize(18)
    .fontWeight(FontWeight.Bold)
    .fontColor('#FFFFFF')
}
.width(44)
.height(44)
.backgroundColor(Theme.primary)
.borderRadius(8)
.justifyContent(FlexAlign.Center)

7.1 不是圆形,是圆角方块

typescript 复制代码
.borderRadius(8)

44vp 的宽高配 8vp 的圆角,形成的是圆角方块,不是正圆。这和"首页"优先人物卡片的 52×52 正圆头像(borderRadius(26))形成区分------列表里的头像是紧凑的,用方块更节省空间;首页的头像是醒目的,用圆形更突出。

7.2 统一蓝色背景

typescript 复制代码
.backgroundColor(Theme.primary)

所有列表项的头像背景色都是 Theme.primary#2F80ED),没有按人物区分颜色。这是有意为之------列表项的信息密度已经够高(姓名 + 职称 + 关系),如果头像颜色再各不相同,视觉上会太杂。统一蓝色让列表更整齐。

7.3 文字头像:substring(0, 1)

typescript 复制代码
Text(item.name.substring(0, 1))

和"首页"一样,取姓名首字作为头像内容。18 号加粗白色字,在蓝色背景上非常清晰。


八、中间信息:姓名 + 条件职称

typescript 复制代码
Column({ space: 4 }) {
  Text(item.name)
    .fontSize(16)
    .fontWeight(FontWeight.Medium)
    .fontColor(Theme.getTextPrimary(this.currentMode))

  if (item.title && item.title !== '待完善' && item.title !== '新建人物档案') {
    Text(item.title)
      .fontSize(13)
      .fontColor(Theme.getTextSecondary(this.currentMode))
  }
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)

8.1 条件渲染职称

typescript 复制代码
if (item.title && item.title !== '待完善' && item.title !== '新建人物档案')

职称不是必填的。新建人物时,title 可能是 '待完善''新建人物档案'------这些占位文字不应该显示在列表里。条件渲染过滤掉了这些无意义的文字,让列表项更干净。

8.2 layoutWeight(1) 弹性填充

typescript 复制代码
.layoutWeight(1)

中间信息区域用 layoutWeight(1) 填满剩余宽度,把右侧的关系标签推到最右边。无论姓名多长,关系标签始终右对齐。

8.3 space: 4 的紧凑间距

typescript 复制代码
Column({ space: 4 })

姓名和职称之间只有 4vp 的间距。这是整页最小的间距值,因为列表项的高度需要控制------如果间距太大,列表会变长,需要更多滚动。


九、右侧关系标签

typescript 复制代码
Text(item.relation)
  .fontSize(13)
  .fontColor(Theme.getTextSecondary(this.currentMode))

关系标签是最简单的纯文字,没有背景、没有圆角、没有描边。13 号字 + textSecondary 灰色,视觉上退到最远处。

为什么不做成药丸标签?因为关系标签在列表项里是"辅助信息",不是"操作入口"。药丸标签会暗示"可以点击",纯文字则明确表示"这只是信息"。


十、分隔线:左侧缩进对齐

typescript 复制代码
if (index < this.getFilteredContacts().length - 1) {
  Divider()
    .color(Theme.getBorder(this.currentMode))
    .strokeWidth(0.5)
    .margin({ left: 74, right: 16 })
}

10.1 条件渲染:最后一项不显示

typescript 复制代码
if (index < this.getFilteredContacts().length - 1)

最后一个列表项后面不需要分隔线。这个判断确保分隔线只出现在两个列表项之间。

10.2 左侧缩进 74vp

typescript 复制代码
.margin({ left: 74, right: 16 })

这是整页最有设计感的细节。分隔线不是从左到右贯穿,而是从 74vp 的位置开始------刚好是头像右侧的位置。

复制代码
│  ┌────┐  林嘉宁              客户      │
│  │ 林 │  品牌市场负责人                 │
│  └────┘                                │
│──────────────────────────────────│ ← 左侧 74vp 缩进
│  ┌────┐  周以珊              朋友      │

左侧缩进让分隔线和文字左对齐,而不是和头像左对齐。视觉上,分隔线是"文字区域的分隔",不是"整行的分隔"。这种设计在 iOS 的设置页和微信的通讯录里都很常见。

10.3 74vp 是怎么算出来的

复制代码
左侧 padding 16 + 头像宽度 44 + 间距 14 = 74

刚好是头像右侧到列表项左边缘的距离。

10.4 strokeWidth(0.5)

typescript 复制代码
.strokeWidth(0.5)

0.5vp 的线宽比默认的 1vp 更细。在浅色背景上,1vp 的分隔线会显得太重,0.5vp 则若有若无,刚好够区分两个列表项。


十一、FAB 按钮:悬浮在右下角

FAB(Floating Action Button)不在 ContactListView 组件里,而是在 Index.ets 中条件渲染的:

typescript 复制代码
if (this.currentTab === 1 && !this.selectedContact) {
  Row() {
    Blank()
    Button('+')
      .width(56)
      .height(56)
      .borderRadius(28)
      .fontSize(36)
      .fontWeight(FontWeight.Regular)
      .fontColor('#FFFFFF')
      .backgroundColor(Theme.getFab(this.currentMode))
      .shadow({ radius: 12, color: Theme.getShadow(this.currentMode), offsetX: 0, offsetY: 6 })
      .onClick(() => {
        this.showCreatePanel = true;
      })
  }
  .width('100%')
  .padding({ left: 18, right: 18 })
  .margin({ bottom: 58 })
}

11.1 条件渲染:只在人物 Tab 显示

typescript 复制代码
if (this.currentTab === 1 && !this.selectedContact)

FAB 只在两个条件同时满足时显示:

  • 当前是人物 Tab(currentTab === 1
  • 没有选中任何人物(!this.selectedContact),即没有打开详情页

当用户点进人物详情时,FAB 会自动隐藏,因为详情页不需要"新增人物"的操作。

11.2 56×56 正圆

typescript 复制代码
.width(56)
.height(56)
.borderRadius(28)

56vp 是 Material Design 推荐的 FAB 尺寸。borderRadius(28) 是宽度的一半,形成正圆。

11.3 Row + Blank() 右对齐

typescript 复制代码
Row() {
  Blank()
  Button('+')
}

Blank() 把按钮推到最右边。配合 padding({ left: 18, right: 18 }),按钮距离右边缘 18vp,和页面左右边距对齐。

11.4 暗色模式下的 FAB 配色

typescript 复制代码
static getFab(mode: number): string {
  return mode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK ? Theme.primary : Theme.fab;
}

亮色模式下 FAB 用 Theme.fab#1C2240 深蓝),暗色模式下用 Theme.primary#2F80ED 亮蓝)。

这是一个有意思的设计选择:亮色模式下 FAB 是深色的,和浅色页面形成强对比;暗色模式下 FAB 是亮色的,和深色页面形成强对比。无论哪种模式,FAB 都是最醒目的元素。

11.5 margin({ bottom: 58 })

typescript 复制代码
.margin({ bottom: 58 })

FAB 距离底部 58vp,刚好悬浮在底部导航栏上方。这个值需要和导航栏高度配合------太小会遮挡导航栏,太大会让 FAB 离内容太近。


十二、点击跳转详情

typescript 复制代码
.onClick(() => {
  if (this.onContactClick) {
    this.onContactClick(item);
  }
})

列表项的点击事件通过回调函数 onContactClick 传递给父组件。在 Index.ets 中:

typescript 复制代码
ContactListView({
  contacts: this.contacts,
  onContactClick: (contact: ContactProfile) => {
    this.selectedContact = contact;
  }
})

点击后设置 selectedContact,触发 ContactDetailView 的渲染。这种"子组件通知父组件"的模式,在鸿蒙组件化开发中非常常见------子组件只负责 UI,状态管理交给父组件。


十三、和"首页"的对比

两个页面用了不同的列表设计思路:

对比项 首页·近期待办 人物·列表项
容器 独立卡片 + space 间距 统一容器 + Divider
阴影 每个卡片独立阴影 容器整体一个阴影
分隔方式 间距(无 Divider) Divider 左侧缩进
头像形状 44×44 圆角方块
左侧装饰 4vp 色条 头像
信息行数 2 行 2 行(条件)
右侧元素 关系文字 关系文字

核心区别:首页用独立卡片表达不同优先级,人物页用统一容器表达平等关系。独立卡片有阴影和间距,视觉上每条待办都是"一件事";统一容器有分隔线,视觉上所有人物都是"一个列表"。


十四、总结

这篇我们拆解了"人物"页面的 UI 布局,核心要点:

  1. 搜索栏:48vp 胶囊形 TextInput,描边而非填充,实时搜索覆盖姓名/关系/标签/城市四个维度。
  2. clip(true):列表容器加圆角 + 裁剪,确保列表项不超出圆角范围。
  3. 44×44 圆角方块头像:统一蓝色背景,和首页的 52×52 正圆头像形成区分。
  4. 条件渲染职称:过滤掉"待完善"等占位文字,列表更干净。
  5. Divider 左侧缩进 74vp:和文字左对齐,不是和头像左对齐,这是列表设计的经典细节。
  6. strokeWidth(0.5):0.5vp 的分隔线比 1vp 更轻盈,若有若无。
  7. FAB 条件渲染:只在人物 Tab 且未打开详情时显示,暗色模式下自动切换配色。
  8. 回调传事件 :列表项点击通过 onContactClick 回调通知父组件,子组件只负责 UI。