HarmonyOS应用<民族图鉴>开发第17篇:民族列表页——分类筛选与多视图切换深度解析

📖 引言

如果说首页是应用的"门脸",那列表页就是应用的"货架"------用户浏览内容、找到感兴趣的东西的地方。

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

架构设计要点

  1. 分层清晰:每层职责单一,各司其职
  2. 状态驱动:用状态变量控制显示什么,状态变了 UI 自动变
  3. 渐进增强:搜索、筛选、视图切换,功能一层层叠加
  4. 统一数据源:不管网格还是列表,用的是同一个数据源,保证一致性

💡 为什么用 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);
  }
}

什么时候调用?------用户点击搜索、点击回车、点击搜索结果跳转的时候,都可以加。

💡 搜索功能的"进阶之路"

  1. 青铜:一个输入框,点按钮才搜索
  2. 白银:实时搜索 + 清除按钮
  3. 黄金:搜索历史 + 热门推荐 + 高亮匹配
  4. 铂金:拼音搜索 + 多音字 + 模糊匹配 + 相关度排序
  5. 钻石:搜索建议 + 纠错 + 联想 + 智能排序

「民族图鉴」目前在黄金级别,对于 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. 视觉元素:一个图标或插画,直观告诉用户"这里是空的"
  2. 主文案:明确说明"没有找到相关内容"
  3. 辅助文案:告诉用户怎么办------"换个关键词试试"、"检查一下拼写"

更高阶的空状态还会给推荐内容、快捷操作按钮(比如"清除筛选条件")。但基础版有这三样就够了,比空白页面强一万倍。


⚠️ 常见问题与解决方案

问题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),规范、好查找
  • 备选排序:按地区、按人口、按热度(以后可以加)

💡 筛选与排序的设计原则

  1. 少即是多:筛选维度不要太多,2-3个就够了,多了用户会懵
  2. 默认最优:默认的筛选和排序要是大多数用户想要的
  3. 状态可见:用户选了什么筛选条件,要清楚地显示出来
  4. 一键重置:提供"清除筛选"或"重置"按钮,让用户能快速回到初始状态

6.3 字母索引/侧边导航的实现

数据量大的时候(比如56个民族、几百个城市),用户想找某个条目,一直划太慢了。这时候就需要"字母索引"------右边一列字母,点哪个就跳转到哪个。

6.3.1 字母索引的原理
复制代码
┌─────────────────────┐ ┌──┐
│ A                   │ │ A│
│ 阿昌族              │ │ B│
│ 白族                │ │ C│
│                     │ │ D│
│ B                   │ │ E│
│ 保安族              │ │ F│
│ 布朗族              │ │ G│
│                     │ │ H│
│ ...                 │ │...│
└─────────────────────┘ └──┘
         列表              索引栏

核心思路:

  1. 数据按拼音首字母分组
  2. 每组有一个组标题(A、B、C...)
  3. 右边有一列字母索引
  4. 点击某个字母,列表滚动到对应分组
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)

没有数据的时候显示空状态,我们前面已经讲过了。这里再补充一点:

空状态的几种场景

  1. 初始空状态:一进来就没有数据
  2. 搜索空状态:搜索后没有结果
  3. 筛选空状态:筛选后没有结果
  4. 删除后空状态:用户把内容都删光了

不同场景的空状态文案应该不一样,不要所有空状态都显示"暂无数据"。

6.5.3 错误状态(Error)

网络请求失败、数据加载出错的时候,显示错误状态。

错误状态三要素:

  1. 错误图标:一个感叹号或者网络错误的图标
  2. 错误文案:告诉用户"加载失败了"
  3. 重试按钮:让用户可以"点击重试"
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 列表页的信息层级

列表页的信息不是越多越好------信息太多,用户找不到重点。要分层展示:

第一层:卡片基础信息(列表页)

  • 民族名称(最核心)
  • 民族图片(视觉识别)
  • 一句话简介(辅助了解)
  • 人口数量(辅助信息)

第二层:详情页概览(点进去)

  • 基础信息:人口、分布、语言、文字
  • 简介:详细介绍
  • 文化特色:节日、服饰、饮食、建筑

第三层:深度内容(详情页往下滑)

  • 历史渊源
  • 文化艺术
  • 风俗习惯
  • 著名人物

层层递进,用户想看多少就看多少。不要一上来就把所有信息都塞给用户------会吓跑的。

💡 信息架构的核心原则

  1. 渐进式披露:先给摘要,想看详细的再点进去
  2. 多维分类:提供多种分类方式,满足不同需求
  3. 重点突出:最重要的信息放最前面、最显眼的位置
  4. 一致性:整个 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篇)我们将讲解民族列表项------卡片设计与信息展示

  • 网格卡片的设计细节
  • 列表项的设计细节
  • 长按收藏的手势交互
  • 卡片的无障碍适配

卡片是列表的细胞,每个卡片做好了,整个列表的质感就上去了。


🔗 相关链接


💡 提示:列表页是内容型应用的"主战场"------用户大多数时间都在各种列表页里逛。列表页做得好不好,直接决定了用户对整个应用的印象。但很多开发者不重视列表页,觉得"不就是把数据列出来吗"------大错特错。搜索好不好用、筛选合不合理、卡片漂不漂亮、滚动流不流畅、没结果时友不友好...... 这些细节堆积起来,就是用户口中的"这个 App 很好用"和"这个 App 很难用"的区别。做列表页,要有"工匠精神",把每个细节都打磨到位。