【HarmonyOS 6】"人物"页面的UI布局拆解
往期回顾:
- 【HarmonyOS 6】基于API23的底部悬浮导航
- 【HarmonyOS 6】底部悬浮导航的沉浸光感适配(API23)
- 【HarmonyOS 6】底部悬浮导航的迷你栏适配(API23)
- 【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.ets 的 Scroll 里面的,滚动由外层统一处理。
二、标题区:左标题右留白
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 布局,核心要点:
- 搜索栏:48vp 胶囊形 TextInput,描边而非填充,实时搜索覆盖姓名/关系/标签/城市四个维度。
- clip(true):列表容器加圆角 + 裁剪,确保列表项不超出圆角范围。
- 44×44 圆角方块头像:统一蓝色背景,和首页的 52×52 正圆头像形成区分。
- 条件渲染职称:过滤掉"待完善"等占位文字,列表更干净。
- Divider 左侧缩进 74vp:和文字左对齐,不是和头像左对齐,这是列表设计的经典细节。
- strokeWidth(0.5):0.5vp 的分隔线比 1vp 更轻盈,若有若无。
- FAB 条件渲染:只在人物 Tab 且未打开详情时显示,暗色模式下自动切换配色。
- 回调传事件 :列表项点击通过
onContactClick回调通知父组件,子组件只负责 UI。