最近在为公司开发 HarmonyOS NEXT 的应用,对 HarmonyOS NEXT 有所了解的朋友应该知道,它与当前市面上所搭载的 HarmonyOS 系统的手机不同,现在市面上的 HarmonyOS 仍基于 Android 平台,应用也大多数是基于 Java/Kotlin 开发,而即将发布的 HarmonyOS NEXT 版本,将不再兼容原有的 Android 应用,而是使用一套基于 ArkTS 的开发框架 ArkUI。
在开发期间我也深深地感受到,与同为声明式 UI 框架的 Jetpack Compose 相比,ArkUI 仍旧十分难用,一部分是 ArkTS 这个语言与 Kotlin 的差距,一部分是 ArkUI 设计者在开发时考虑的场景欠缺,一部分是代码编辑器 DevEco Studio 功能不完善,当然还有一部分是我对 ArkUI/ArkTS 的不熟悉。
尽管每天开发时都在口吐莲花,但我不得不说,ArkUI 确实有一个组件我得吹爆它,就是 AlphabetIndexer
,翻译过来叫字母索引器,夸它的原因也很简单,虽然 Android 中也有 AlphabetIndexer
,可是它仅提供了一些基础接口,具体逻辑还需要自己实现,虽然扩展性高,不过对于相同的效果,我们往往会选择自己手搓或者使用三方库。
对于不知道 AlphabetIndexer
是什么的朋友,我先上个效果图:
右边的可以点击滑动的就是 AlphabetIndexer
,当列表数据非常多时,它能够快速帮我们定位到需要寻找的数据附近,在通讯录中也很常见。
废话了这么久,开整。
首先定义好我们的数据类,数据有两种格式,一种是分类标题字母 RegionAlphabet
,只需包含一个 string
类型的字段;一种是数据详情 RegionDetail
,需要包含一个 string
类型的名称和一个 number
类型的区码。
export class RegionAlphabet {
alphabet: string = "";
constructor(alphabet: string) {
this.alphabet = alphabet
}
}
export class RegionDetail {
region: string = "";
code: number = 0;
}
export type BaseRegion = RegionAlphabet | RegionDetail
另外还定义了一个 BaseRegion
类型关联这两种数据类,方便我们后面的数据处理。
export class RegionViewModel {
static alphabets: string[] = [
"热", "A", "B", "C", "D", "E", "F", "G", "H", "I",
"J", "K", "L", "M", "N", "O", "P", "Q", "R", "S",
"T", "U", "V", "W", "X", "Y", "Z"
]
static hotRegions: RegionDetail[] = [
{ "region": "中国大陆", "code": 86 },
{ "region": "中国香港", "code": 852 },
{ "region": "中国澳门", "code": 853 },
{ "region": "中国台湾", "code": 886 },
{ "region": "美国", "code": 1 },
{ "region": "日本", "code": 81 }
]
private static regions: RegionDetail[] = [
{ "region": "阿富汗", "code": 93 },
{ "region": "阿尔巴尼亚", "code": 355 },
{ "region": "阿尔及利亚", "code": 213 },
{ "region": "美属萨摩亚", "code": 1684 },
{ "region": "安道尔", "code": 376 },
{ "region": "安哥拉", "code": 244 },
...
}
这里有 3 个数据源,首先是我们 AlphabetIndexer
用到的字母索引列表,除了常规的 26 个字母,我们还会在前面放置常用的标签,减少用户的操作步骤。第二个数据源就是常用的数据,第三个类型是总数据,这部分数据太多,我只截取小部分数据示例,读者知道是这种格式即可。
我们还需要将数据源统一成一个数组暴露出去,处理一下数据:
export class RegionViewModel {
...
static getRegions(): BaseRegion[] {
let groupBy = RegionViewModel.regions.sort((a, b) => {
let pinyinA: string = pinyin4js.convertToPinyinString(a.region, '', pinyin4js.WITHOUT_TONE)
let pinyinB: string = pinyin4js.convertToPinyinString(b.region, '', pinyin4js.WITHOUT_TONE)
if (pinyinA < pinyinB) {
return -1
} else if (pinyinA > pinyinB) {
return 1
} else {
return 0
}
}).reduce((acc, curr) => {
let firstLetter: string = pinyin4js.convertToPinyinString(curr.region, '', pinyin4js.WITHOUT_TONE)
.charAt(0)
.toUpperCase();
(acc[firstLetter] ??= []).push(curr);
return acc;
}, {} as Record<string, RegionDetail[]>)
let list: BaseRegion[] = []
list.push(new RegionAlphabet("热门"))
list.push(...RegionViewModel.hotRegions)
Object.keys(groupBy).forEach(key => {
list.push(new RegionAlphabet(key))
list.push(...groupBy[key])
})
return list
}
}
这里使用了一个官方库 @ohos/pinyin4js,用来将汉字转为拼音,需要先提前安装:
➜ ohpm install @ohos/pinyin4js
上面的代码虽然有点长,但做的事情不多。首先是将数组内的 RegionDetail
元素按照 region
属性转为拼音的顺序进行排序,然后按照其拼音首字母进行分组,这样就可以与我们定义的拼音索引对应,最后再按组顺序插入到列表中,返回结果。
需要注意一个坑的地方,由于 ArkTS 的语言特性,RegionDetail
数据是通过对象转换而来的,而不是 new
出来的实例,所以实际插入到数组中是一个 Object
,而不是 RegionDetail
,在后面使用时需要留意。
数据准备完毕,开始写界面。
@Entry
@Component
struct RegionPage {
@State private selectedIndex: number = 0
@State private regions: BaseRegion[] = RegionViewModel.getRegions()
private scroller: Scroller = new Scroller()
build() {
Column() {
Toolbar({ title: 'Region' })
Stack({ alignContent: Alignment.End }) {
List({ scroller: this.scroller }) {
ForEach(this.regions, (item: BaseRegion) => {
ListItem() {
if (item instanceof RegionAlphabet) {
Text(item.alphabet).fontColor(Color.Gray).fontSize(12).height(32)
} else {
Row() {
Text((item as RegionDetail).region).fontWeight(FontWeight.Bold).fontSize(16).layoutWeight(1)
Text(`+${(item as RegionDetail).code}`).fontSize(16).fontColor(Color.Gray)
}
.height($r('sys.float.titlebar_default_height'))
.alignItems(VerticalAlign.Center)
.onClick(() => router.back({ url: Routers.LOGIN_PAGE, params: { regionCode: (item as RegionDetail).code } }))
}
}.padding({ left: 24, right: 44 })
})
}.onScrollIndex(index => {
let item = this.regions[index]
if (item instanceof RegionAlphabet) {
this.selectedIndex = RegionViewModel.alphabets.indexOf(item.alphabet)
} else {
if (index < RegionViewModel.hotRegions.length + 1) {
this.selectedIndex = 0
return
}
let region = (item as RegionDetail).region
let alphabet: string = pinyin4js.convertToPinyinString(region, '', pinyin4js.WITHOUT_TONE)
.charAt(0)
.toUpperCase()
this.selectedIndex = RegionViewModel.alphabets.indexOf(alphabet)
}
})
AlphabetIndexer({ arrayValue: RegionViewModel.alphabets, selected: 0 })
.selected(this.selectedIndex)
.height('100%')
.onSelect(index => {
if (index == 0) {
this.scroller.scrollToIndex(0)
return
}
let letter = RegionViewModel.alphabets[index]
let position = this.regions.findIndex(value => (value as RegionAlphabet).alphabet == letter)
if (position > 0) {
this.scroller.scrollToIndex(position)
}
})
}
}
.height('100%')
.width('100%')
}
}
界面的布局就不展开介绍了,需要关注一点是列表数据项的判断,上文提到,RegionDetail
在数组内仅仅是一个 Object
,所以不能像 RegionAlphabet
那样通过 instanceof
来判断,只能筛选完 RegionAlphabet
之后再通过 as
来强转,你也可以使用其他写法来规避这个问题。
重点看下关联逻辑。
List
中需要传入一个 Scroller
,后续在 AlphabetIndexer
中可以用来控制滚动,同时 List
在滚动时也需要通知 AlphabetIndexer
刷新状态,所以需要一个变量 selectedIndex
来记录选中的索引。
当 List
滚动时会通过 onScrollIndex()
回调监听。如果当前可见的第一个列表项数据是 RegionAlphabet
时,AlphabetIndexer
选中的就是 RegionAlphabet
对应在索引数据源的下标;如果当前可见的第一个列表项数据不是 RegionAlphabet
,判断该项在数组中的位置是否小于 hotRegions
的长度加 1
(加 1
是因为需要加上第一个 RegionAlphabet
),是的话直接将 AlphabetIndexer
选中的下标置 0
,如果不是的话,将数据项强转为 RegionDetail
,取出其 region
字段的拼音首字母,选中该字母在索引数据源的下标即可。
使用 AlphabetIndexer
选中时会通过 onSelect()
回调监听。当选中下标为 0
的项时,直接将 List
滚到顶部;否则获取选中的字母,滚动到对应的 RegionAlphabet
所在的位置。
整个流程就是这么简单,虽然 AlphabetIndexer
在项目中的使用率不高,但这个组件的出现确实方便了我们开发,样式也符合我们的常见场景,Android 开发者实名羡慕。