最近在为公司开发 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 开发者实名羡慕。