HarmonyOS 实战:用 List 与 AlphabetIndexer 打造高效城市选择功能

HarmonyOS 实战:用 List 与 AlphabetIndexer 打造高效城市选择功能

在移动应用开发中,城市选择功能是很多 App 的必备模块。想象一下,当用户需要从数百个城市中找到自己所在的城市时,如果没有高效的导航方式,体验会有多糟糕。今天我将带大家实现一个 HarmonyOS 平台上的城市选择器,通过 List 与 AlphabetIndexer 的联动,让用户轻松找到目标城市。

功能需求分析

我们需要实现的城市选择功能应该包含这些核心特性:

  • 展示历史选择城市,方便用户快速访问

  • 提供热门城市快捷入口

  • 按字母顺序分组展示所有城市

  • 右侧字母索引条,支持点击快速跳转

  • 列表滚动时自动同步索引位置

这种交互模式在通讯录、词典等应用中也非常常见,掌握了这个技巧可以举一反三。

核心组件介绍

实现这个功能我们主要依赖 HarmonyOS 的两个核心组件:

List 组件:作为容器展示大量数据,支持分组展示和滚动控制,通过 Scroller 可以精确控制滚动位置。

AlphabetIndexer 组件:字母索引条,支持自定义字母数组和选中样式,通过 onSelect 事件可以监听用户选择的索引位置。

这两个组件的联动是实现功能的关键,也是最容易出现问题的地方。

实现步骤详解

1. 数据结构定义

首先我们需要定义城市数据的结构,以及存储各类城市数据:

复制代码
// 定义城市分组数据结构

interface BKCityContent {

  initial: string        // 字母首字母

  cityNameList: string\[] // 该字母下的城市列表

}

// 组件内部数据定义

@State hotCitys: string\[] = \['北京', '上海', '广州', '深圳', '天津', ...]

@State historyCitys: string\[] = \['北京', '上海', '广州', ...]

@State cityContentList: BKCityContent\[] = \[

  { initial: 'A', cityNameList: \['阿拉善', '鞍山', '安庆', ...] },

  { initial: 'B', cityNameList: \['北京', '保定', '包头', ...] },

  // 其他字母分组...

]

2. 索引数组构建

索引数组需要包含特殊分组(历史、热门)和所有城市首字母,我们在页面加载时动态生成:

复制代码
@State arr: string\[] = \[]

scroller: Scroller = new Scroller()

aboutToAppear() {

  // 先添加特殊分组标识

  this.arr.push("#", "🔥")  // #代表历史,🔥代表热门

  // 再添加所有城市首字母

&#x20; for (let index = 0; index < this.cityContentList.length; index++) {

&#x20;   const element = this.cityContentList\[index];

&#x20;   this.arr.push(element.initial)

&#x20; }

}

3. 列表 UI 构建

使用 List 组件构建主内容区,包含历史城市、热门城市和按字母分组的城市列表:

复制代码
List({ scroller: this.scroller }) {

&#x20; // 历史城市分组

&#x20; ListItemGroup({ header: this.header("历史") }) {

&#x20;   ListItem() {

&#x20;     Flex({ wrap: FlexWrap.Wrap }) {

&#x20;       ForEach(this.historyCitys, (item: string) => {

&#x20;         Text(item)

&#x20;           .width("33.33%")

&#x20;           .margin({ top: 20, bottom: 20 })

&#x20;           .textAlign(TextAlign.Center)

&#x20;       })

&#x20;     }

&#x20;   }

&#x20; }

&#x20; // 热门城市分组

&#x20; ListItemGroup({ header: this.header("热门") }) {

&#x20;   // 结构类似历史城市...

&#x20; }

&#x20; // 字母分组城市

&#x20; ForEach(this.cityContentList, (item: BKCityContent) => {

&#x20;   ListItemGroup({ header: this.header(item.initial) }) {

&#x20;     ForEach(item.cityNameList, (item2: string) => {

&#x20;       ListItem() {

&#x20;         Text(item2)

&#x20;           .fontSize(20)

&#x20;       }

&#x20;     })

&#x20;   }

&#x20; })

}

.width("100%")

.backgroundColor(Color.White)

4. 索引器 UI 构建

在列表右侧添加 AlphabetIndexer 组件作为索引条:

复制代码
AlphabetIndexer({ arrayValue: this.arr, selected: this.current })

&#x20; .itemSize(20)

&#x20; .font({ size: "20vp" })

&#x20; .selectedFont({ size: "20vp" })

&#x20; .height("100%")

&#x20; .autoCollapse(false)

&#x20; .onSelect(index => {

&#x20;   this.current = index

&#x20;   // 点击索引时滚动列表

&#x20;   this.scroller.scrollToIndex(index)

&#x20; })

解决联动核心问题

很多开发者在实现时会遇到一个问题:索引器与列表位置不匹配。这是因为 List 的分组索引和 AlphabetIndexer 的索引需要正确映射。

原代码中的问题在于滚动回调处理不正确,我们需要修正这个逻辑:

复制代码
// 正确的列表滚动回调处理

.onScrollIndex((start) => {

&#x20; // 计算当前滚动到的分组对应的索引器位置

&#x20; if (start === 0) {

&#x20;   this.current = 0; // 历史分组对应索引0

&#x20; } else if (start === 1) {

&#x20;   this.current = 1; // 热门分组对应索引1

&#x20; } else {

&#x20;   // 字母分组从索引2开始

&#x20;   this.current = start - 2 + 2;&#x20;

&#x20; }

})

更好的解决方案是创建一个映射数组,明确记录每个索引器项对应的列表分组索引:

复制代码
// 定义映射关系数组

private listIndexMap: number\[] = \[]

aboutToAppear() {

&#x20; // 构建索引映射:索引器索引 -> 列表分组索引

&#x20; this.listIndexMap = \[0, 1]  // 历史在列表第0组,热门在列表第1组

&#x20; this.cityContentList.forEach((item, index) => {

&#x20;   this.listIndexMap.push(index + 2)  // 字母分组从列表第2组开始

&#x20; })

}

// 使用映射数组处理滚动

.onScrollIndex((start) => {

&#x20; const index = this.listIndexMap.indexOf(start)

&#x20; if (index !== -1) {

&#x20;   this.current = index

&#x20; }

})

// 索引器选择时也使用映射数组

.onSelect((index: number) => {

&#x20; this.current = index

&#x20; const listIndex = this.listIndexMap\[index]

&#x20; if (listIndex !== undefined) {

&#x20;   this.scroller.scrollToIndex(listIndex, true)

&#x20; }

})

这种映射方式更灵活,即使后续添加新的分组类型也不容易出错。

完整优化代码

结合以上优化点,我们的完整代码应该是这样的(包含 UI 美化和交互优化):

复制代码
interface BKCityContent {

&#x20; initial: string

&#x20; cityNameList: string\[]

}

@Component

@Entry

struct CitySelector {

&#x20; @State isShow: boolean = true

&#x20; @State selectedCity: string = ""

&#x20; @State currentIndex: number = 0

&#x20;&#x20;

&#x20; // 城市数据定义...

&#x20; hotCitys: string\[] = \['北京', '上海', '广州', ...]

&#x20; historyCitys: string\[] = \['北京', '上海', ...]

&#x20; cityContentList: BKCityContent\[] = \[/\* 城市数据 \*/]

&#x20;&#x20;

&#x20; @State indexArray: string\[] = \[]

&#x20; scroller: Scroller = new Scroller()

&#x20; private listIndexMap: number\[] = \[]

&#x20; aboutToAppear() {

&#x20;   // 构建索引数组和映射关系

&#x20;   this.indexArray = \["#", "🔥"]

&#x20;   this.listIndexMap = \[0, 1]

&#x20;  &#x20;

&#x20;   this.cityContentList.forEach((item, index) => {

&#x20;     this.indexArray.push(item.initial)

&#x20;     this.listIndexMap.push(index + 2)

&#x20;   })

&#x20; }

&#x20; // 标题构建器

&#x20; @Builder header(title: string) {

&#x20;   Text(title)

&#x20;     .fontWeight(FontWeight.Bold)

&#x20;     .fontColor("#666666")

&#x20;     .fontSize(16)

&#x20;     .padding({ left: 16, top: 12, bottom: 8 })

&#x20; }

&#x20; // 城市网格构建器(用于历史和热门城市)

&#x20; @Builder cityGrid(cities: string\[]) {

&#x20;   Flex({ wrap: FlexWrap.Wrap }) {

&#x20;     ForEach(cities, (item: string) => {

&#x20;       Text(item)

&#x20;         .padding(12)

&#x20;         .margin(6)

&#x20;         .backgroundColor("#F5F5F5")

&#x20;         .borderRadius(6)

&#x20;         .onClick(() => {

&#x20;           this.selectedCity = item

&#x20;         })

&#x20;     })

&#x20;   }

&#x20;   .padding(10)

&#x20; }

&#x20; build() {

&#x20;   Column() {

&#x20;     // 顶部标题栏

&#x20;     Row() {

&#x20;       Text(this.selectedCity ? \`已选: \${this.selectedCity}\` : "选择城市")

&#x20;         .fontSize(18)

&#x20;         .fontWeight(FontWeight.Bold)

&#x20;     }

&#x20;     .padding(16)

&#x20;     .width("100%")

&#x20;    &#x20;

&#x20;     // 主内容区

&#x20;     Stack({ alignContent: Alignment.End }) {

&#x20;       // 城市列表

&#x20;       List({ scroller: this.scroller }) {

&#x20;         // 历史城市分组

&#x20;         ListItemGroup({ header: this.header("历史") }) {

&#x20;           ListItem() { this.cityGrid(this.historyCitys) }

&#x20;         }

&#x20;        &#x20;

&#x20;         // 热门城市分组

&#x20;         ListItemGroup({ header: this.header("热门") }) {

&#x20;           ListItem() { this.cityGrid(this.hotCitys) }

&#x20;         }

&#x20;        &#x20;

&#x20;         // 字母分组城市

&#x20;         ForEach(this.cityContentList, (item: BKCityContent) => {

&#x20;           ListItemGroup({ header: this.header(item.initial) }) {

&#x20;             ForEach(item.cityNameList, (city: string) => {

&#x20;               ListItem() {

&#x20;                 Text(city)

&#x20;                   .padding(16)

&#x20;                   .width("100%")

&#x20;                   .onClick(() => { this.selectedCity = city })

&#x20;               }

&#x20;             })

&#x20;           }

&#x20;         })

&#x20;       }

&#x20;       .onScrollIndex((start) => {

&#x20;         const index = this.listIndexMap.indexOf(start)

&#x20;         if (index !== -1) {

&#x20;           this.currentIndex = index

&#x20;         }

&#x20;       })

&#x20;      &#x20;

&#x20;       // 字母索引器

&#x20;       AlphabetIndexer({&#x20;

&#x20;         arrayValue: this.indexArray,&#x20;

&#x20;         selected: this.currentIndex&#x20;

&#x20;       })

&#x20;       .itemSize(24)

&#x20;       .selectedFont({ color: "#007AFF" })

&#x20;       .height("90%")

&#x20;       .autoCollapse(false)

&#x20;       .onSelect((index: number) => {

&#x20;         this.currentIndex = index

&#x20;         const listIndex = this.listIndexMap\[index]

&#x20;         if (listIndex !== undefined) {

&#x20;           this.scroller.scrollToIndex(listIndex, true)

&#x20;         }

&#x20;       })

&#x20;       .padding({ right: 8 })

&#x20;     }

&#x20;     .flexGrow(1)

&#x20;   }

&#x20;   .width("100%")

&#x20;   .height("100%")

&#x20;   .backgroundColor("#F9F9F9")

&#x20; }

}

功能扩展建议

基于这个基础功能,你还可以扩展更多实用特性:

  1. 城市搜索功能:添加搜索框,实时过滤城市列表

  2. 选择动画:为城市选择添加过渡动画,提升体验

  3. 定位功能:调用定位 API,自动推荐当前城市

  4. 数据持久化:保存用户的历史选择,下次打开时恢复

  5. 样式主题:支持深色 / 浅色模式切换

总结

通过本文的讲解,我们学习了如何使用 HarmonyOS 的 List 和 AlphabetIndexer 组件实现高效的城市选择功能。核心要点是理解两个组件的工作原理,建立正确的索引映射关系,实现双向联动。

这种列表加索引的模式在很多场景都能应用,掌握后可以显著提升应用中大数据列表的用户体验。希望本文对你有所帮助,如果你有更好的实现方式,欢迎在评论区交流讨论!