最近在为公司开发 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

右边的可以点击滑动的就是 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 开发者实名羡慕。