
📖 引言
如果说首页是应用的"门脸",那列表页就是应用的"货架"------用户浏览内容、找到感兴趣的东西的地方。
56 个民族,怎么展示给用户?直接堆 56 个卡片让用户慢慢翻吗?------ 那样体验太差了。用户需要各种方式来"找"他想要的内容:
- 搜索:知道名字,直接搜,最快
- 筛选:按地区、按人口、按语系,慢慢挑
- 浏览:什么都不知道,就随便翻翻,看缘分
- 切换视图:有时候想看大图,有时候想看列表,各有各的好
「民族图鉴」的百科列表页,把这些功能都集成了:
- 顶部搜索框,支持实时搜索、搜索历史、热门搜索
- 搜索框下面是分类筛选标签(全部地区 / 西南 / 北方 / 东北 / 东南 / 西北)
- 右边一个视图切换按钮,网格模式和列表模式可以来回切
- 内容区支持网格 4 列和列表两种展示方式
- 搜索/筛选没有结果时,有友好的空状态提示
这一篇,我们就来拆解这个功能丰富的列表页。从页面架构,到搜索筛选,再到多视图切换和性能优化。列表页是内容型应用最核心也最考验功底的页面,做好它,你的应用就成功了一半。
🎯 学习目标
完成本文后,你将能够:
- ✅ 掌握复杂列表页的整体架构设计
- ✅ 学会实现带历史记录和热门推荐的搜索功能
- ✅ 理解分类筛选标签的实现方式与交互细节
- ✅ 掌握网格/列表双视图切换的实现原理
- ✅ 学会用 LazyForEach 构建高性能列表
- ✅ 理解空状态设计的重要性与实现方法
- ✅ 写出功能完整、体验流畅、性能优良的列表页
💡 需求分析
民族列表页的核心需求
| 需求点 | 说明 | 为什么重要 |
|---|---|---|
| 搜索功能 | 实时搜索、搜索历史、热门搜索 | 用户快速找到目标 |
| 分类筛选 | 按地区、按语系等维度筛选 | 缩小范围,精准浏览 |
| 多视图切换 | 网格模式 / 列表模式切换 | 不同场景不同需求 |
| 高性能滚动 | 56 个民族滚动流畅 | 基础体验,不能卡 |
| 点击跳转 | 点击卡片进入详情页 | 内容消费的入口 |
| 空状态 | 搜索/筛选无结果时友好提示 | 不让用户困惑 |
页面结构总览
整个页面从上到下分为 4 层:
┌─────────────────────────────────────┐
│ ← 民族百科 56 │ ← 导航栏:返回 + 标题 + 数量
├─────────────────────────────────────┤
│ 🔍 搜索民族名称、拼音... ✕ │ ← 搜索栏:输入框 + 清除按钮
│ ┌───────────────────────────────┐ │
│ │ 搜索历史 │ │
│ │ [汉族] [藏族] [蒙古族] ... │ │ ← 搜索建议面板:历史 + 热门
│ │ 热门搜索 │ │
│ │ [汉族🔥] [壮族🔥] ... │ │
│ └───────────────────────────────┘ │
├─────────────────────────────────────┤
│ [全部地区][西南][北方][东北]... 🗒 │ ← 筛选栏:分类标签 + 视图切换
├─────────────────────────────────────┤
│ │
│ ┌──┐ ┌──┐ ┌──┐ ┌──┐ │
│ │汉│ │壮│ │满│ │回│ ... │ ← 内容区:网格 / 列表
│ └──┘ └──┘ └──┘ └──┘ │
│ ┌──┐ ┌──┐ ┌──┐ ┌──┐ │
│ │苗│ │维│ │土│ │彝│ ... │
│ └──┘ └──┘ └──┘ └──┘ │
│ │
└─────────────────────────────────────┘
两种视图模式的对比
| 维度 | 网格模式 | 列表模式 |
|---|---|---|
| 信息密度 | 高,一屏能看 12-16 个 | 低,一屏能看 6-8 个 |
| 视觉效果 | 更丰富,更有"逛"的感觉 | 更简洁,信息更完整 |
| 适用场景 | 随便逛逛、视觉浏览 | 快速查找、信息对比 |
| 点击目标 | 较小,容易误触 | 较大,好点 |
两种模式各有优劣,所以给用户选择权------想怎么看就怎么看。
🛠️ 核心实现
步骤1:页面架构------四层结构
列表页看起来复杂,但拆解开就是四层:导航栏、搜索栏、筛选栏、内容区。用一个 Column 从上到下排起来就行。
typescript
// pages/EthnicListPage.ets
@Component
export struct EthnicListPage {
// ========== 状态变量 ==========
@State searchText: string = ''; // 搜索词
@State viewMode: 'grid' | 'list' = 'grid'; // 视图模式
@State selectedRegion: string = 'all'; // 选中的地区
@State showSearchHistory: boolean = false; // 是否显示搜索建议面板
private lazyDataSource: EthnicListDataSource = ...; // 懒加载数据源
// ========== 页面构建 ==========
build() {
Column() {
// 1. 顶部导航栏
this.buildNavBar()
// 2. 搜索栏
this.buildSearchBar()
// 3. 筛选栏(地区 + 视图切换)
this.buildFilterBar()
// 4. 内容区(根据状态显示不同内容)
if (this.lazyDataSource.totalCount() === 0) {
this.buildEmptyView() // 空状态
} else if (this.viewMode === 'grid') {
this.buildGridView() // 网格视图
} else {
this.buildListView() // 列表视图
}
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.background_color'))
}
}
架构设计要点:
- 分层清晰:每层职责单一,各司其职
- 状态驱动:用状态变量控制显示什么,状态变了 UI 自动变
- 渐进增强:搜索、筛选、视图切换,功能一层层叠加
- 统一数据源:不管网格还是列表,用的是同一个数据源,保证一致性
💡 为什么用 Column 而不是 List 做整体布局?
因为导航栏、搜索栏、筛选栏都是固定在顶部的,不跟着内容滚动。只有下面的内容区是滚动的。所以整体用 Column(垂直排列,不滚动),内容区单独用 Scroll/List 来滚动。这是"固定头部 + 滚动内容"的经典布局模式。
步骤2:搜索功能------从简单到完整
搜索功能看似简单,只是一个输入框,但要做得好用,细节可不少。
2.1 基础搜索框
typescript
@State searchText: string = '';
@Builder
buildSearchBar(): void {
Row() {
// 搜索图标
Text('\u{1F50D}') // 🔍
.fontSize(16)
.fontColor($r('app.color.text_hint'))
// 输入框
TextInput({
placeholder: $r('app.string.search_placeholder'),
text: this.searchText
})
.layoutWeight(1)
.backgroundColor('#00000000') // 透明背景
.placeholderColor($r('app.color.text_hint'))
.fontColor($r('app.color.text_primary'))
.fontSize(14)
.onChange((value: string) => {
this.searchText = value;
this.filterData(); // 实时过滤
})
// 清除按钮(有内容才显示)
if (this.searchText.length > 0) {
Text('\u2715') // ✕
.fontSize(14)
.fontColor($r('app.color.text_hint'))
.padding(8)
.onClick(() => {
this.searchText = '';
this.filterData();
})
}
}
.width('100%')
.height(40)
.padding({ left: 16, right: 16 })
.backgroundColor($r('app.color.card_background'))
.borderRadius(20) // 胶囊形状
.border({ width: 1, color: $r('app.color.border_color') })
.alignItems(VerticalAlign.Center)
}
核心交互:
- 输入即搜索:onChange 里调用 filterData(),实时过滤
- 清除按钮:输入了内容才显示,点一下清空
- 胶囊形状:大圆角,现代感强
2.2 搜索过滤逻辑
typescript
/**
* 根据搜索词和筛选条件过滤数据
*/
private filterData(): void {
let result: EthnicGroup[] = [...ETHNIC_GROUPS];
// 1. 搜索过滤:中文名、英文名、拼音、地区
if (this.searchText.trim().length > 0) {
const query = this.searchText.trim().toLowerCase();
result = result.filter((item: EthnicGroup) =>
item.name.toLowerCase().includes(query) ||
item.nameEn.toLowerCase().includes(query) ||
item.pinyin.toLowerCase().includes(query) ||
item.region.includes(query)
);
}
// 2. 地区过滤
if (this.selectedRegion !== 'all') {
const provinces: string[] = this.regionMap[this.selectedRegion] || [];
result = result.filter((item: EthnicGroup) =>
item.provinces.some((p: string) => provinces.includes(p))
);
}
// 3. 更新数据源
this.lazyDataSource.setData(result);
}
多维度搜索:名字、英文名、拼音、地区,用户怎么搜都能搜到。
2.3 搜索建议面板(历史 + 热门)
搜索框聚焦、且没输入内容的时候,下面弹出一个面板,显示搜索历史和热门搜索。这是很多主流 App 的标准做法。
typescript
// 搜索历史(内存存储)
private searchHistory: string[] = [];
// 热门搜索关键词
private hotSearchKeywords: HotKeyword[] = [
{ zh: '汉族', en: 'Han' },
{ zh: '壮族', en: 'Zhuang' },
{ zh: '苗族', en: 'Miao' },
// ... 更多
];
// 搜索栏外层是 Column,里面有输入框 + 建议面板
@Builder
buildSearchBar(): void {
Column() {
// 输入框(和上面一样)
this.buildSearchInput()
// 搜索建议面板(聚焦且无输入时显示)
if (this.showSearchHistory && this.searchText.trim().length === 0) {
this.buildSearchSuggestionPanel()
}
}
.width('100%')
.padding({ left: 16, right: 16 })
}
建议面板实现:
typescript
@Builder
buildSearchSuggestionPanel(): void {
Column() {
// ---- 搜索历史 ----
if (this.searchHistory.length > 0) {
Row() {
Text('搜索历史')
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_secondary'))
Blank()
Text('清空')
.fontSize(12)
.fontColor($r('app.color.text_hint'))
.onClick(() => {
this.searchHistory = [];
})
}
.width('100%')
.padding({ top: 8, bottom: 4 })
// 历史标签(流式布局)
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.searchHistory, (keyword: string, index: number) => {
Text(keyword)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.margin(4)
.borderRadius(12)
.backgroundColor('#F0F0F0')
.onClick(() => {
// 点击历史词,直接搜索
this.searchText = keyword;
this.filterData();
this.showSearchHistory = false;
})
}, (item, index) => `history_${index}`)
}
Divider().margin({ top: 8, bottom: 8 })
}
// ---- 热门搜索 ----
Row() {
Text('热门搜索')
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.hotSearchKeywords, (keyword: HotKeyword, index: number) => {
Text(this.getLocalizedText(keyword.zh, keyword.en))
.fontSize(12)
// 前三名标红加粗
.fontColor(index < 3
? $r('app.color.primary_color')
: $r('app.color.text_secondary'))
.fontWeight(index < 3 ? FontWeight.Medium : FontWeight.Normal)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.margin(4)
.borderRadius(12)
// 前三名背景也不一样
.backgroundColor(index < 3 ? '#FFF3E0' : '#F0F0F0')
.onClick(() => {
this.searchText = this.getLocalizedText(keyword.zh, keyword.en);
this.filterData();
this.showSearchHistory = false;
})
}, (item, index) => `hot_${index}`)
}
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.card_background'))
.borderRadius(12)
.margin({ top: 8 })
.shadow({ color: '#00000015', radius: 8, offsetY: 2 })
}
设计细节:
- 历史记录支持清空
- 热门搜索前三名高亮(不同颜色+不同背景),引导用户点击
- 标签流式布局(FlexWrap.Wrap),一行放不下自动换行
- 点任何一个标签直接搜索,面板关闭
2.4 搜索历史管理
typescript
/**
* 添加搜索关键词到历史记录
* - 去重:已有的移到最前面
* - 限制数量:最多 10 条
*/
private addToSearchHistory(keyword: string): void {
const trimmed = keyword.trim();
if (trimmed.length === 0) return;
// 移除已有相同关键词
this.searchHistory = this.searchHistory.filter(k => k !== trimmed);
// 加到最前面
this.searchHistory.unshift(trimmed);
// 限制最多 10 条
if (this.searchHistory.length > 10) {
this.searchHistory = this.searchHistory.slice(0, 10);
}
}
什么时候调用?------用户点击搜索、点击回车、点击搜索结果跳转的时候,都可以加。
💡 搜索功能的"进阶之路":
- 青铜:一个输入框,点按钮才搜索
- 白银:实时搜索 + 清除按钮
- 黄金:搜索历史 + 热门推荐 + 高亮匹配
- 铂金:拼音搜索 + 多音字 + 模糊匹配 + 相关度排序
- 钻石:搜索建议 + 纠错 + 联想 + 智能排序
「民族图鉴」目前在黄金级别,对于 56 个民族的数据量来说,完全够用了。如果是电商、内容平台那种海量数据,就要往铂金、钻石级做了。
步骤3:分类筛选------标签栏
筛选是搜索的"慢版本"------用户不知道具体搜什么,但知道大致类别,就可以用筛选慢慢挑。
3.1 筛选标签实现
typescript
// 地区选项配置
private regionOptions: RegionOption[] = [
{ key: 'all', zhLabel: '全部地区', enLabel: 'All Regions' },
{ key: 'southwest', zhLabel: '西南', enLabel: 'Southwest' },
{ key: 'north', zhLabel: '北方', enLabel: 'North' },
{ key: 'northeast', zhLabel: '东北', enLabel: 'Northeast' },
{ key: 'southeast', zhLabel: '东南', enLabel: 'Southeast' },
{ key: 'northwest', zhLabel: '西北', enLabel: 'Northwest' }
];
@State selectedRegion: string = 'all';
筛选栏 UI:
typescript
@Builder
buildFilterBar(): void {
Row() {
// 地区筛选标签(横向滚动)
Scroll() {
Row({ space: 8 }) {
ForEach(this.regionOptions, (option: RegionOption) => {
Text(this.getLocalizedText(option.zhLabel, option.enLabel))
.fontSize(12)
.fontColor(
this.selectedRegion === option.key
? $r('app.color.text_on_primary') // 选中:白色字
: $r('app.color.text_secondary') // 未选中:灰色字
)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(
this.selectedRegion === option.key
? $r('app.color.primary_color') // 选中:主色背景
: $r('app.color.card_background') // 未选中:白色背景
)
.onClick(() => {
this.selectedRegion = option.key;
this.filterData(); // 重新过滤
})
}, option => option.key)
}
}
.scrollBar(BarState.Off)
.layoutWeight(1) // 占满剩余空间
// 视图切换按钮
Text(this.viewMode === 'grid' ? '\u{1F4DE}' : '\u{1F4CB}')
.fontSize(20)
.padding(8)
.onClick(() => {
this.viewMode = this.viewMode === 'grid' ? 'list' : 'grid';
})
}
.width('100%')
.height(36)
.padding({ left: 16, right: 16 })
.margin({ top: 12 })
.alignItems(VerticalAlign.Center)
}
交互细节:
- 选中态:主色背景 + 白色文字,对比强烈,一眼看出选中了哪个
- 未选中:白色背景 + 灰色文字,低调不抢戏
- 横向可滚动:分类多了也不怕,左右滑就行
- 滚动条隐藏:简洁干净
3.2 筛选逻辑
typescript
// 地区-省份映射表
private readonly regionMap: Record<string, string[]> = {
'southwest': ['云南', '四川', '贵州', '重庆', '西藏'],
'north': ['内蒙古', '甘肃', '宁夏', '青海', '河北', '河南', '北京', '山西', '陕西'],
'northeast': ['辽宁', '吉林', '黑龙江'],
'southeast': ['福建', '浙江', '广东', '广西', '湖南', '湖北', '江西', '安徽', '海南', '台湾'],
'northwest': ['新疆']
};
// 过滤时地区匹配
if (this.selectedRegion !== 'all') {
const provinces: string[] = this.regionMap[this.selectedRegion] || [];
result = result.filter((item: EthnicGroup) =>
item.provinces.some((p: string) => provinces.includes(p))
);
}
思路:
- 每个地区分类对应一组省份
- 民族数据里有
provinces字段(主要分布的省份列表) - 只要民族的省份和分类的省份有交集,就算匹配
💡 筛选维度可以有很多:除了地区,还可以按语系、按人口、按信仰、按传统节日...... 维度越多,用户找起来越方便。但也不是越多越好------太多了用户选择困难。一般 2-3 个筛选维度就差不多了。
步骤4:双视图切换------网格 vs 列表
同一个数据,两种展示方式。怎么实现?
很简单------用一个状态变量 viewMode 控制显示哪个,数据是共用的。
4.1 网格视图
typescript
@Builder
buildGridView(): void {
Scroll() {
Grid() {
LazyForEach(this.lazyDataSource, (ethnic: EthnicGroup, index: number) => {
GridItem() {
this.buildGridCard(ethnic, index)
}
// 点击反馈
.opacity(this.pressedIndex === index ? 0.8 : 1)
.scale({
x: this.pressedIndex === index ? 0.95 : 1,
y: this.pressedIndex === index ? 0.95 : 1
})
.animation({ duration: 150, curve: Curve.EaseInOut })
}, (ethnic, index) => `${ethnic.id}_${index}`)
}
.columnsTemplate('1fr 1fr 1fr 1fr') // 4 列
.rowsGap(10)
.columnsGap(10)
.padding({ left: 16, right: 16, top: 8, bottom: 48 })
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
}
4 列网格,每一项是一个小卡片(首字圆形图标 + 名称),信息密度高,适合浏览。
4.2 列表视图
typescript
@Builder
buildListView(): void {
List() {
LazyForEach(this.lazyDataSource, (ethnic: EthnicGroup, index: number) => {
ListItem() {
this.buildListItem(ethnic, index)
}
.opacity(this.pressedIndex === index ? 0.8 : 1)
.scale({
x: this.pressedIndex === index ? 0.97 : 1,
y: this.pressedIndex === index ? 0.97 : 1
})
.animation({ duration: 150, curve: Curve.EaseInOut })
}, (ethnic, index) => `${ethnic.id}_${index}`)
}
.padding({ left: 16, right: 16 })
.layoutWeight(1)
}
列表模式,每项一行(图标 + 名称 + 地区 + 人口 + 箭头),信息更完整,适合快速查找。
4.3 共用数据源,切换不丢失位置
不管是网格还是列表,用的是同一个 lazyDataSource,所以:
- 搜索和筛选的结果是一致的
- 切换视图时数据不会变
- 性能也好,不用重新加载
切换视图的体验优化:
- 切换时可以加个淡入淡出动画,不生硬
- 滚动位置要不要保留?------ 一般不用,视图不一样,保留位置没意义
- 切换按钮放在筛选栏右边,随手就能点到
💡 什么时候需要双视图?
- 内容项比较多(>20)
- 既有浏览需求又有查找需求
- 内容可以用不同密度展示
如果只有十几个东西,或者只有一种合理的展示方式,就不用折腾双视图了。多一个功能多一份维护成本,用不上就是浪费。
步骤5:空状态------没结果时怎么办
搜索了一个不存在的词,或者筛选条件太严格,一个结果都没有,页面一片空白?------ 用户会以为 App 坏了。
空状态设计很重要,它告诉用户"发生了什么"以及"接下来该怎么办"。
typescript
@Builder
buildEmptyView(): void {
Column({ space: 12 }) {
// 图标
Text('\u{1F50D}') // 🔍
.fontSize(48)
.opacity(0.3)
// 主文案
Text($r('app.string.no_data'))
.fontSize(16)
.fontColor($r('app.color.text_hint'))
// 辅助文案
Text($r('app.string.list_try_keyword'))
.fontSize(12)
.fontColor($r('app.color.text_hint'))
.opacity(0.7)
}
.layoutWeight(1) // 占满剩余空间,垂直居中
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
空状态三要素:
- 视觉元素:一个图标或插画,直观告诉用户"这里是空的"
- 主文案:明确说明"没有找到相关内容"
- 辅助文案:告诉用户怎么办------"换个关键词试试"、"检查一下拼写"
更高阶的空状态还会给推荐内容、快捷操作按钮(比如"清除筛选条件")。但基础版有这三样就够了,比空白页面强一万倍。
⚠️ 常见问题与解决方案
问题1:列表滚动卡顿,掉帧
现象 :
列表滑动的时候不流畅,尤其是快速滑动的时候,感觉一顿一顿的。
原因排查:
| 可能原因 | 说明 |
|---|---|
| 用了 ForEach 全量渲染 | 一次创建 56 个组件,滚动时也不回收 |
| 卡片太复杂 | 每个卡片里嵌套太深、图片太多 |
| 搜索过滤太频繁 | 每次输入都过滤,输入快了就卡 |
| 阴影/模糊太耗性能 | 每个卡片都有阴影,渲染压力大 |
解决方案:
1. 用 LazyForEach 代替 ForEach
typescript
// ❌ ForEach:一次全部创建
Grid() {
ForEach(this.list, item => {
GridItem() { Card(item) }
}, item => item.id)
}
// ✅ LazyForEach:懒加载,滑到才创建
Grid() {
LazyForEach(this.dataSource, item => {
GridItem() { Card(item) }
}, item => item.id)
}
2. 加防抖,搜索不要太频繁
typescript
// 300ms 防抖,用户停下来才搜索
private debouncedFilter = debounce(() => {
this.filterData();
}, 300);
.onChange((value: string) => {
this.searchText = value;
this.debouncedFilter(); // 防抖后的过滤
})
3. 简化卡片,减少阴影和嵌套
typescript
// ❌ 每个卡片都有阴影,性能差
.shadow({ radius: 8, ... })
// ✅ 用浅色背景 + 圆角代替阴影,也有层次感
.backgroundColor('#FFFFFF')
.borderRadius(12)
56 个民族其实不算多,正常怎么写都不该卡。如果真的卡了,先看看是不是哪里写得太"重"了------阴影、模糊、图片、深嵌套...... 能简化就简化。
问题2:搜索和筛选同时用,结果为空,不知道是哪个的问题
现象 :
用户选了"西北地区",又搜了"苗族",结果一个都没有。用户不知道是"西北没有苗族"还是"搜索词错了"。
解决方案:
方案1:空状态文案更具体
typescript
// 通用空状态文案
let emptyTip = '没有找到相关民族';
// 如果既有搜索又有筛选,文案更具体
if (this.searchText && this.selectedRegion !== 'all') {
emptyTip = '该地区没有找到匹配的民族,试试其他地区?';
}
告诉用户具体是怎么回事,以及建议的操作。
方案2:提供快捷操作
typescript
// 空状态里加个"清除筛选"按钮
if (this.selectedRegion !== 'all') {
Text('清除地区筛选')
.fontSize(12)
.fontColor($r('app.color.primary_color'))
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12)
.border({ width: 1, color: $r('app.color.primary_color') })
.onClick(() => {
this.selectedRegion = 'all';
this.filterData();
})
}
用户一点就清除筛选,马上能看到结果,体验好很多。
问题3:筛选标签太多,一行放不下
现象 :
分类标签有 7、8 个,手机屏幕窄,一行放不下,后面的看不到。
解决方案:
方案1:横向滚动(推荐)
typescript
Scroll() {
Row({ space: 8 }) {
ForEach(this.options, option => {
Text(option.label)
// ...
})
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
标签栏横向滚动,是最常用的解决方案。用户左右滑就能看到所有标签。
方案2:下拉筛选
如果分类特别多(十几个),横向滚动也不太好找,可以做成"下拉筛选"------点一下弹出选择器,选一个就好。
但一般 5-8 个标签,横向滚动就够了。
问题4:视图切换按钮用户找不到
现象 :
有些用户不知道可以切换视图,一直用默认的网格模式,以为只有这一种看法。
解决方案:
方案1:按钮更明显
不要只用一个小图标,加个文字标签,或者用更醒目的颜色。
方案2:首次使用引导
用户第一次进来,加个高亮气泡提示:"点击这里可以切换视图哦~",看完就消失。
方案3:让用户自己选默认视图
在设置里加个选项,让用户自己选默认用网格还是列表。
但说实话,大多数用户可能根本不在乎用什么视图。所以这个功能是"锦上添花"的------有更好,没有也不影响核心体验。不要花太多精力在上面。
问题5:搜索历史丢失了怎么办
现象 :
用户搜了很多关键词,第二天打开 App,历史记录都没了。
原因 :
搜索历史只存在内存里(searchHistory 数组),应用杀掉就没了。
解决方案:
持久化存储------用 Preferences 保存
typescript
// 页面加载时,从 Preferences 读取历史
aboutToAppear(): void {
this.loadSearchHistory();
}
private async loadSearchHistory(): Promise<void> {
const history = await Preferences.get('search_history', '[]');
try {
this.searchHistory = JSON.parse(history);
} catch {
this.searchHistory = [];
}
}
// 添加历史时,同时保存到 Preferences
private addToSearchHistory(keyword: string): void {
// ... 更新内存中的数组
Preferences.put('search_history', JSON.stringify(this.searchHistory));
}
这样用户下次打开,历史记录还在。
搜索历史不是什么重要数据,丢了也没关系。但如果能记住,用户会觉得"这个 App 很懂我"。细节决定体验。
🧠 进阶拓展:列表页的深度设计
6.1 列表页的四种常见设计模式
列表页不只是"把数据排成一列"这么简单。根据内容类型和用户场景的不同,列表有四种典型的设计模式。
6.1.1 单列列表模式
特点:一行一个条目,信息完整,从上到下依次排列。
适用场景:
- 内容信息量大,每个条目有很多字段
- 用户需要仔细阅读每个条目
- 以查找为主要目的的列表
优点:
- 信息完整,每个条目展示充分
- 阅读体验好,不容易漏看信息
- 交互空间大,可以放很多操作按钮
缺点:
- 信息密度低,一屏没几条
- 浏览效率低,要划很多下才能看完
联系人列表、邮件列表、设置页都是典型的单列列表。
6.1.2 双列网格模式
特点:一行两个卡片,图文并茂,视觉冲击力强。
适用场景:
- 内容以图片为主
- 浏览型、探索型场景
- 商品、图片、视频类内容
优点:
- 信息密度适中,一屏能看到不少
- 视觉效果好,图片展示充分
- 适合"逛"的体验
缺点:
- 文字信息展示有限
- 卡片大小固定,内容长短不一的话不好处理
小红书、淘宝、美团都是典型的双列网格。「民族图鉴」默认的网格视图就是这种模式。
6.1.3 瀑布流模式
特点:每列宽度一样,高度不一样,错落有致。
适用场景:
- 图片高度不统一的内容
- 视觉导向、探索型内容
- UGC(用户生成内容)平台
优点:
- 视觉活泼,不单调
- 图片展示完整,不会被裁切
- 有"发现惊喜"的感觉
缺点:
- 实现复杂,需要计算每列高度
- 性能要求高,图片懒加载要处理好
- 排序感弱,不适合需要按顺序浏览的内容
Pinterest、小红书首页、花瓣网都是瀑布流的典型代表。
6.1.4 分段列表模式
特点:内容按字母、按日期、按分类分成若干段,每段有个标题。
适用场景:
- 内容量大,需要快速定位
- 有明确的分类/分组维度
- 联系人、城市索引等
优点:
- 结构清晰,一目了然
- 可以快速跳转定位
- 适合大量数据的列表
缺点:
- 需要额外的分组标题,占空间
- 需要有明确的分组维度
手机通讯录、城市选择页都是典型的分段列表。56个民族也可以按地区、按首字母分段。
💡 四种模式怎么选?
- 信息多、查找为主 → 单列列表
- 图片多、浏览为主 → 双列网格
- 图片高低不一、视觉导向 → 瀑布流
- 数据量大、需要快速定位 → 分段列表
「民族图鉴」为什么默认双列网格?因为民族有图片、有文字,双列网格既能展示图片又能展示名称,视觉效果好,适合"逛"的体验。同时也提供了列表模式,满足不同用户的偏好。
6.2 筛选与排序的深度设计
筛选和排序是列表页的"左膀右臂"------筛选帮用户减少选项,排序帮用户调整顺序。
6.2.1 筛选的三种常见形式
形式1:标签筛选(最常用)
筛选条件以标签的形式横向排列,点一下就选中,再点一下取消。
[全部] [华北] [东北] [华东] [中南] [西南] [西北]
优点:
- 直观,一目了然
- 操作简单,点一下就好
- 选中状态清晰
缺点:
- 筛选条件多了放不下
- 只能单选,不支持多条件组合
适合:筛选条件少(5-8个)、单选的场景。
形式2:下拉筛选
点击按钮弹出下拉菜单,选择一个筛选条件。
▼ 地区
全部
华北地区
东北地区
...
优点:
- 占空间小,一个按钮就行
- 可以放很多选项
缺点:
- 操作多一步(要点开才能看)
- 当前选中的是什么不明显
适合:筛选条件多、空间有限的场景。
形式3:侧边筛选栏
从侧边滑出来一个面板,可以设置多个筛选条件。
优点:
- 可以放很多筛选维度
- 支持多条件组合筛选
- 功能强大
缺点:
- 操作复杂,学习成本高
- 实现难度大
适合:电商、旅游等筛选维度多的复杂场景。
「民族图鉴」的选择:标签筛选
因为我们只有"地区"这一个筛选维度,6个地区,用标签筛选最简单直接,用户一看就懂。
6.2.2 排序的设计
排序和筛选是好搭档------筛选缩小范围,排序调整顺序。
常见的排序维度:
| 排序方式 | 说明 | 适用场景 |
|---|---|---|
| 默认排序 | 编辑精选、综合排序 | 大多数场景 |
| 按名称 | 拼音首字母 A-Z | 通讯录、城市列表 |
| 按时间 | 最新发布、最近更新 | 资讯、动态 |
| 按热度 | 浏览量、点赞数 | 内容社区 |
| 按距离 | 由近到远 | 本地生活、地图 |
| 按价格 | 从低到高、从高到低 | 电商 |
「民族图鉴」的排序:
- 默认排序:按拼音首字母(A-Z),规范、好查找
- 备选排序:按地区、按人口、按热度(以后可以加)
💡 筛选与排序的设计原则:
- 少即是多:筛选维度不要太多,2-3个就够了,多了用户会懵
- 默认最优:默认的筛选和排序要是大多数用户想要的
- 状态可见:用户选了什么筛选条件,要清楚地显示出来
- 一键重置:提供"清除筛选"或"重置"按钮,让用户能快速回到初始状态
6.3 字母索引/侧边导航的实现
数据量大的时候(比如56个民族、几百个城市),用户想找某个条目,一直划太慢了。这时候就需要"字母索引"------右边一列字母,点哪个就跳转到哪个。
6.3.1 字母索引的原理
┌─────────────────────┐ ┌──┐
│ A │ │ A│
│ 阿昌族 │ │ B│
│ 白族 │ │ C│
│ │ │ D│
│ B │ │ E│
│ 保安族 │ │ F│
│ 布朗族 │ │ G│
│ │ │ H│
│ ... │ │...│
└─────────────────────┘ └──┘
列表 索引栏
核心思路:
- 数据按拼音首字母分组
- 每组有一个组标题(A、B、C...)
- 右边有一列字母索引
- 点击某个字母,列表滚动到对应分组
6.3.2 代码实现思路
typescript
// 1. 数据分组
interface EthnicGroup {
letter: string; // 首字母
dataList: EthnicInfo[]; // 该字母下的民族列表
}
private groupByPinyin(list: EthnicInfo[]): EthnicGroup[] {
const groupMap = new Map<string, EthnicInfo[]>();
for (const item of list) {
const firstLetter = item.pinyin.charAt(0).toUpperCase();
if (!groupMap.has(firstLetter)) {
groupMap.set(firstLetter, []);
}
groupMap.get(firstLetter)!.push(item);
}
// 按字母排序
const sortedLetters = Array.from(groupMap.keys()).sort();
return sortedLetters.map(letter => ({
letter,
dataList: groupMap.get(letter)!
}));
}
// 2. 渲染分组列表
List() {
ForEach(this.groupedList, (group: EthnicGroup) => {
// 组标题
ListItem() {
Text(group.letter)
.fontSize(14)
.fontColor('#999')
.backgroundColor('#F5F5F5')
.padding({ left: 24, top: 8, bottom: 8 })
.width('100%')
}
// 组内列表
ForEach(group.dataList, (item: EthnicInfo) => {
ListItem() {
EthnicListItem({ item: item })
}
}, item => item.id)
}, g => g.letter)
}
6.3.3 侧边索引栏
右边的字母索引栏,实现起来也不复杂:
typescript
// 右边的字母索引
Stack({ alignContent: Alignment.End }) {
List() { /* 列表内容 */ }
// 索引栏
Column({ space: 2 }) {
ForEach(this.letters, (letter: string) => {
Text(letter)
.fontSize(12)
.fontColor(this.currentLetter === letter ? '#409EFF' : '#666')
.fontWeight(this.currentLetter === letter ? FontWeight.Bold : FontWeight.Normal)
.width(24)
.height(24)
.textAlign(TextAlign.Center)
.onClick(() => {
// 点击字母,滚动到对应位置
this.scrollToLetter(letter);
})
})
}
.padding({ right: 4, top: 12, bottom: 12 })
.backgroundColor('#FFFFFF')
.borderRadius(12)
.margin({ right: 8 })
}
.width('100%')
.height('100%')
💡 56个民族需要字母索引吗?
其实56个不算多,正常划几下也就到了。但如果以后民族数量增加了(比如加上各个支系),或者用户觉得找起来麻烦,就可以加上字母索引。
另外,还可以考虑按地区分组、按语系分组等其他分组方式。不同的分组维度,适合不同的用户需求。
6.4 搜索与列表的联动
搜索框和列表不是两个独立的功能------它们是联动的。搜索是为了快速找到列表里的内容,列表是搜索结果的展示。
6.4.1 联动的几种模式
模式1:即时过滤(最简单)
用户在搜索框输入,列表实时过滤显示匹配的结果。
搜索框:傣
列表:
傣族
优点:
- 实现简单
- 反馈即时
- 用户容易理解
缺点:
- 如果列表数据量大,每次输入都过滤可能会卡
- 搜索结果和原始列表混在一起,用户可能困惑
「民族图鉴」现在用的就是这种模式。
模式2:搜索页模式(最清晰)
点击搜索框跳转到一个专门的搜索页,搜索历史、热门搜索、搜索结果都在这个页面。
优点:
- 功能完整,可以放很多搜索相关的功能
- 交互清晰,用户知道"现在在搜索"
- 不影响原列表的体验
缺点:
- 多一步跳转
- 实现稍微复杂一点
大多数 App(微信、淘宝、美团)用的都是这种模式。
模式3:顶部悬浮模式
搜索框固定在顶部,下面是列表。搜索时列表变成搜索结果,不搜索时是正常列表。
优点:
- 随时可以搜索,不用跳转
- 搜索和列表无缝切换
缺点:
- 状态切换容易让用户困惑
- 需要处理好"搜索中"和"非搜索"两个状态
6.4.2 「民族图鉴」的方案选择
我们现在用的是模式1(即时过滤),因为实现简单,对于56个民族的数据量来说完全够用。
但如果以后内容多了,建议升级到模式2(搜索页模式):
- 首页搜索框只是入口,点击跳转到搜索页
- 搜索页有搜索历史、热门搜索、搜索建议
- 输入后显示搜索结果列表
这样功能更完整,体验也更好。
💡 搜索的本质是什么?
搜索的本质是"快速定位"------用户知道自己想要什么,想最快找到。
列表的本质是"浏览探索"------用户不知道自己想要什么,想看看有什么。
两者互补:有明确目标的时候用搜索,没有明确目标的时候逛列表。一个好的列表页,应该让两种需求都能得到满足。
6.5 列表的三种状态设计
一个完整的列表页,不只有"有数据"这一种状态。至少要有三种状态:加载中、空状态、错误状态。
6.5.1 加载状态(Loading)
用户刚进入页面,数据还在加载,这时候要显示加载状态。
常见的加载样式:
| 样式 | 说明 | 适用场景 |
|---|---|---|
| 骨架屏 | 灰色的占位框,模拟内容布局 | 内容丰富的列表,体验最好 |
| 加载圈 | 中间一个转圈圈的动画 | 简单场景,实现容易 |
| 下拉刷新 | 顶部一个加载动画 | 下拉刷新的时候 |
骨架屏是目前最流行的加载状态------它能让用户知道"内容大概长这样,马上就出来",减少等待的焦虑感。
typescript
// 骨架屏示例
@Builder
buildLoadingSkeleton(): void {
Column({ space: 12 }) {
ForEach([1, 2, 3, 4, 5], () => {
Row({ space: 12 }) {
// 图片占位
Rect()
.width(80)
.height(80)
.fill('#EEEEEE')
.borderRadius(8)
Column({ space: 8 }) {
// 标题占位
Rect()
.width(120)
.height(16)
.fill('#EEEEEE')
.borderRadius(4)
// 副标题占位
Rect()
.width(80)
.height(12)
.fill('#EEEEEE')
.borderRadius(4)
}
.layoutWeight(1)
}
.width('100%')
})
}
.width('100%')
.padding(16)
}
6.5.2 空状态(Empty)
没有数据的时候显示空状态,我们前面已经讲过了。这里再补充一点:
空状态的几种场景:
- 初始空状态:一进来就没有数据
- 搜索空状态:搜索后没有结果
- 筛选空状态:筛选后没有结果
- 删除后空状态:用户把内容都删光了
不同场景的空状态文案应该不一样,不要所有空状态都显示"暂无数据"。
6.5.3 错误状态(Error)
网络请求失败、数据加载出错的时候,显示错误状态。
错误状态三要素:
- 错误图标:一个感叹号或者网络错误的图标
- 错误文案:告诉用户"加载失败了"
- 重试按钮:让用户可以"点击重试"
typescript
@Builder
buildErrorView(): void {
Column({ space: 16 }) {
Text('\u{26A0}\u{FE0F}') // ⚠️
.fontSize(48)
.opacity(0.5)
Text('加载失败,请检查网络')
.fontSize(16)
.fontColor('#666')
Button('重新加载')
.width(120)
.height(40)
.backgroundColor('#409EFF')
.onClick(() => {
this.loadData();
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
💡 状态设计的重要性 :
很多开发者只关心"正常状态",忽略了加载、空、错误这些异常状态。但用户对产品的印象,往往就是在这些异常状态下形成的。
加载快不快、出错了能不能重试、没数据有没有引导------这些细节做好了,用户会觉得"这个 App 很靠谱"。
6.6 「民族图鉴」56个民族列表的信息架构
最后,我们来聊聊56个民族的信息架构------怎么组织这56个民族,让用户最容易找到、最容易理解。
6.6.1 信息架构的几种方式
方式1:按拼音首字母(最标准)
按民族名称的拼音首字母分组,A、B、C... 这是最规范、最没有争议的排序方式。
优点:
- 规范,大家都熟悉
- 查找效率高,配合字母索引很快
- 客观,没有争议
缺点:
- 没有分类概念,用户不知道哪些民族有关系
适合:需要快速查找的场景。
方式2:按地区分布(最直观)
按民族主要分布的地区分组:华北、东北、华东、中南、西南、西北。
优点:
- 有地理概念,容易理解
- 和民族分布知识结合,有教育意义
缺点:
- 很多民族分布在多个地区,放在哪一组有争议
- 同一地区的民族可能没什么关系
适合:地理学习、分布探索的场景。
方式3:按语系语族(最专业)
按民族的语言系属分类:汉藏语系、阿尔泰语系、南亚语系、南岛语系、印欧语系......
优点:
- 专业,有学术价值
- 能体现民族之间的亲缘关系
缺点:
- 太专业,普通用户看不懂
- 分类太细,有的语族只有一两个民族
适合:学术研究、专业学习的场景。
方式4:按人口数量(最简单)
按人口多少排序:汉族、壮族、满族、回族、苗族......
优点:
- 简单直接,一目了然
- 用户可能对人口多的民族更熟悉
缺点:
- 没有分类,信息架构弱
- 人口少的民族容易被忽略
适合:简单浏览、入门了解的场景。
6.6.2 「民族图鉴」的选择:多维切换
我们不选单一的排序方式,而是提供多种排序/分组方式,让用户自己选:
- 默认:按拼音首字母(规范、好查找)
- 按地区:按六大地区分组(地理学习)
- 按人口:按人口数量排序(简单直观)
- 按语系:按语言系属分类(专业学习)
这样不同需求的用户都能找到适合自己的浏览方式。
当然,第一版我们只做"默认(拼音)"和"按地区"两种就够了。其他的可以以后慢慢加。
6.6.3 列表页的信息层级
列表页的信息不是越多越好------信息太多,用户找不到重点。要分层展示:
第一层:卡片基础信息(列表页)
- 民族名称(最核心)
- 民族图片(视觉识别)
- 一句话简介(辅助了解)
- 人口数量(辅助信息)
第二层:详情页概览(点进去)
- 基础信息:人口、分布、语言、文字
- 简介:详细介绍
- 文化特色:节日、服饰、饮食、建筑
第三层:深度内容(详情页往下滑)
- 历史渊源
- 文化艺术
- 风俗习惯
- 著名人物
层层递进,用户想看多少就看多少。不要一上来就把所有信息都塞给用户------会吓跑的。
💡 信息架构的核心原则:
- 渐进式披露:先给摘要,想看详细的再点进去
- 多维分类:提供多种分类方式,满足不同需求
- 重点突出:最重要的信息放最前面、最显眼的位置
- 一致性:整个 App 的信息架构要统一,不要东一个西一个
好的信息架构,用户用起来觉得"自然"------想找什么都能找到,又感觉不到"架构"的存在。
📝 本章小结
核心知识点
本文深入讲解了民族列表页的完整实现,从架构到细节:
1. 页面架构
- 四层结构:导航栏 + 搜索栏 + 筛选栏 + 内容区
- 整体 Column 固定布局,内容区单独滚动
- 状态驱动 UI,统一数据源
- 渐进式功能叠加:搜索 → 筛选 → 视图切换
2. 搜索功能
- 基础:输入框 + 实时过滤 + 清除按钮
- 进阶:搜索历史 + 热门推荐 + 建议面板
- 多维度:中文名、英文名、拼音、地区
- 历史管理:去重 + 限数 + 持久化
3. 分类筛选
- 标签式筛选:横向滚动,选中态高亮
- 多维度筛选:搜索 + 地区,可以组合使用
- 数据映射:地区 → 省份 → 民族,多级关联匹配
4. 双视图切换
- 网格模式:4 列,信息密度高,适合浏览
- 列表模式:单列,信息完整,适合查找
- 共用数据源,切换简单
- 点击反馈统一:透明度 + 缩放 + 动画
5. 空状态设计
- 三要素:图标 + 主文案 + 辅助文案
- 明确告诉用户"没结果"以及"怎么办"
- 进阶:快捷操作(清除筛选)、推荐内容
6. 性能优化
- LazyForEach 懒加载
- 搜索防抖
- 简化卡片结构
- 减少阴影、模糊等耗性能的效果
最佳实践总结
✅ 列表页四层架构:导航 + 搜索 + 筛选 + 内容
typescript
Column() {
NavBar()
SearchBar()
FilterBar()
ContentList()
}
✅ 搜索三件套:实时搜索 + 清除按钮 + 搜索历史
实时过滤 ✅
清除按钮 ✅
历史记录 ✅
热门推荐 ✅
✅ 筛选标签:横向滚动 + 选中高亮
typescript
Scroll() { Row() { /* 标签 */ } }
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
✅ 双视图用一个状态变量切换,共用数据源
typescript
@State viewMode: 'grid' | 'list' = 'grid';
if (viewMode === 'grid') {
GridView()
} else {
ListView()
}
// 用同一个 dataSource
✅ 空状态一定要有,哪怕很简单
🔍
没有找到相关内容
换个关键词试试吧
✅ 列表一定要用 LazyForEach,不要用 ForEach
typescript
// ✅ 懒加载,性能好
LazyForEach(dataSource, item => <Item data={item} />)
// ❌ 全量渲染,数量多了会卡
ForEach(list, item => <Item data={item} />)
下一篇预告
列表页的框架搭好了,下一篇我们深入到列表里的每一项------卡片设计。
下一篇(第18篇)我们将讲解民族列表项------卡片设计与信息展示:
- 网格卡片的设计细节
- 列表项的设计细节
- 长按收藏的手势交互
- 卡片的无障碍适配
卡片是列表的细胞,每个卡片做好了,整个列表的质感就上去了。
🔗 相关链接
- 项目源码 : GitCode 仓库
- Grid 组件 : 官方文档
- List 组件 : 官方文档
- TextInput 组件 : 官方文档
- LazyForEach : 官方文档
💡 提示:列表页是内容型应用的"主战场"------用户大多数时间都在各种列表页里逛。列表页做得好不好,直接决定了用户对整个应用的印象。但很多开发者不重视列表页,觉得"不就是把数据列出来吗"------大错特错。搜索好不好用、筛选合不合理、卡片漂不漂亮、滚动流不流畅、没结果时友不友好...... 这些细节堆积起来,就是用户口中的"这个 App 很好用"和"这个 App 很难用"的区别。做列表页,要有"工匠精神",把每个细节都打磨到位。