在移动端表单中,当选项数量众多且具有层级关系时,单选按钮和复选框都不再适用。省市区地址选择、商品规格选择、身高体重选择——这些场景需要一种能容纳大量选项、支持层级联动、操作直观的组件。这就是 TextPicker——滚轮式文本选择器。

HarmonyOS NEXT ArkUI 提供了 TextPicker 组件——一个可滚动的文本选择器,用户通过在滚轮上滑动来选择选项。本文将通过构建一个"省市区三级地址选择器",深入讲解 TextPicker 的 API、多列联动机制和级联数据管理。

关键词:HarmonyOS、ArkUI、TextPicker、滚轮选择器、地址选择、三级联动、级联数据

一、TextPicker 组件 API

1.1 基本用法

TextPicker({ range: ['选项A', '选项B', '选项C'], selected: 0 })
  .onChange((value: string | string[], index: number | number[]) => {
    // value: 当前选中的文本
    // index: 当前选中的索引
    console.log('选中:', (value as string), '索引:', (index as number));
  })

核心参数与属性:

参数/属性 类型 说明
range string[] | Resource 数据源数组,TextPicker 展示的选项列表
selected number 默认选中的索引(0-based)
value string 默认选中的值(与 selected 二选一)
.onChange() callback 选中的值变化时的回调

1.2 onChange 回调的类型

TextPicker 的回调签名与其他组件不同——它的 onChange 参数类型是 (value: string | string[], index: number | number[]) => void。这是因为 TextPicker 支持多列模式(通过 CascadePickerMultiPicker),此时 valueindex 都是数组。

但在单列模式下(如 Demo 中的三个独立 TextPicker),value 始终是 stringindex 始终是 number。因此在回调中使用类型断言:

.onChange((value: string | string[], index: number | number[]) => {
  this.onProvinceChange(value as string, index as number);
})

这是一个容易忽视的 API 细节——如果你给回调参数标注 (value: string, index: number),编译器会报错,因为形参类型必须兼容 string | string[]

1.3 动态 range 更新

TextPicker 的 range 是一个构造参数而非属性方法,这意味着你无法直接通过链式调用更新数据源。但当你用 @State 变量驱动 range(通过方法返回值)时,每当 @State 变化,range 会自动重新计算。

Demo 中三个 TextPicker 的 range 都通过方法动态生成:

// 省份 range 固定——直接返回全部省份名
TextPicker({ range: this.getProvinceNames(), selected: this.provinceIndex })

// 城市 range 动态——根据当前选中的省份返回该省的城市
TextPicker({ range: this.getCityNames(), selected: this.cityIndex })

// 区县 range 动态——根据当前选中的城市返回该市的区县
TextPicker({ range: this.getDistrictNames(), selected: this.districtIndex })

provinceIndex 变化时,getCityNames() 返回新省份的城市列表,城市 TextPicker 自动更新。同理,当 cityIndex 变化时,getDistrictNames() 返回新城市的区县列表。

1.4 索引越界保护

由于 selected 参数始终使用当前的 cityIndexdistrictIndex,当上级选项变化时(如切换省份),下级的 selected 可能指向一个不再存在的索引。例如原省份有 3 个城市,cityIndex = 2;新省份只有 2 个城市,cityIndex = 2 就越界了。

解决方案是在上级 onChange 回调中重置下级索引:

onProvinceChange(value: string, index: number): void {
  this.provinceIndex = index;
  this.cityIndex = 0;      // 重置城市索引
  this.districtIndex = 0;  // 重置区县索引
}

onCityChange(value: string, index: number): void {
  this.cityIndex = index;
  this.districtIndex = 0;  // 重置区县索引
}

每次切换省份,城市和区县都回到第一个选项;每次切换城市,区县回到第一个选项。这种"级联重置"确保了索引用不越界。

二、三级地址选择器的整体设计

2.1 页面架构

AddressPickerPage
├── 标题栏 — "地址选择" + 历史记录数
├── 当前选择预览卡片
│   ├── 完整地址(省 市 区)
│   └── 已保存状态标签
├── 三级滚轮选择区(三个 TextPicker 并排)
│   ├── 省份列
│   ├── 城市列(联动更新)
│   └── 区县列(联动更新)
├── "确认选择"按钮
└── 历史记录列表(含删除按钮)

2.2 数据类设计

Demo 中定义了两个类:

class AddressNode {
  name: string;
  children: AddressNode[];

  constructor(name: string, children: AddressNode[]) {
    this.name = name;
    this.children = children;
  }
}

class SelectedAddress {
  province: string;
  city: string;
  district: string;

  constructor(province: string, city: string, district: string) {
    this.province = province;
    this.city = city;
    this.district = district;
  }

  getFullAddress(): string {
    return this.province.concat(' ').concat(this.city).concat(' ').concat(this.district);
  }

  equals(other: SelectedAddress): boolean {
    return this.province === other.province &&
      this.city === other.city &&
      this.district === other.district;
  }
}

AddressNode 是树形数据节点。每个节点有一个 name(名称)和一个 children 数组(子节点)。省份是根节点,城市是子节点,区县是孙节点。区县的 children 为空数组 []

SelectedAddress 表示用户确认后的完整地址。getFullAddress() 返回空格分隔的完整地址字符串。equals() 用于去重——当用户反复确认同一地址时,只保留一条记录。

2.3 地址数据

Demo 包含 6 个省/直辖市,共计 16 个城市、40+ 个区县,覆盖了中国主要的区域:

省份 城市 区县数
北京市 北京市 4(朝阳/海淀/西城/东城)
上海市 上海市 4(浦东/徐汇/静安/黄浦)
广东省 广州/深圳/东莞 3+3+3
浙江省 杭州/宁波/温州 3+3+2
江苏省 南京/苏州/无锡 3+3+2
四川省 成都/绵阳 4+2

直辖市(北京、上海)的数据结构比较特殊——省份和城市同名(“北京市"下的"北京市”)。这种设计真实反映了行政区划的实际情况:直辖市下不设地级市,直接辖区县。

2.4 名称提取方法

三个方法负责从 AddressNode 树中提取当前层级的名称数组:

getProvinceNames() — 总是返回所有省份名:

getProvinceNames(): string[] {
  let names: string[] = [];
  let data = this.getAddressData();
  for (let i = 0; i < data.length; i++) {
    names = names.slice().concat(data[i].name);
  }
  return names;
}

getCityNames() — 返回当前选中省份的城市名:

getCityNames(): string[] {
  let names: string[] = [];
  let data = this.getAddressData();
  let province = data[this.provinceIndex];
  if (province) {
    for (let i = 0; i < province.children.length; i++) {
      names = names.slice().concat(province.children[i].name);
    }
  }
  return names;
}

getDistrictNames() — 返回当前选中城市的区县名:

getDistrictNames(): string[] {
  let names: string[] = [];
  let data = this.getAddressData();
  let province = data[this.provinceIndex];
  if (province) {
    let city = province.children[this.cityIndex];
    if (city) {
      for (let i = 0; i < city.children.length; i++) {
        names = names.slice().concat(city.children[i].name);
      }
    }
  }
  return names;
}

注意所有方法都使用 names.slice().concat(...) 而非 names.push(...)——这是 ArkTS 的不可变数据惯例。虽然 names 是局部变量(非 @State),但保持一致性有助于避免误用。
在这里插入图片描述

三、三级联动机制

3.1 联动逻辑

三级联动是地址选择器的核心。Demo 通过 @State 变量链实现联动:

provinceIndex (省份索引)
    │
    ▼ onChange → 更新 cityIndex=0, districtIndex=0
cityIndex (城市索引)
    │
    ▼ onChange → 更新 districtIndex=0
districtIndex (区县索引)

这三个 @State 变量形成了一个简短的依赖链。当上游索引变化时,下游索引自动重置为 0,确保数据一致性。

关键代码:

onProvinceChange(value: string, index: number): void {
  this.provinceIndex = index;
  this.cityIndex = 0;
  this.districtIndex = 0;
  this.saved = false;
}

onCityChange(value: string, index: number): void {
  this.cityIndex = index;
  this.districtIndex = 0;
  this.saved = false;
}

onDistrictChange(value: string, index: number): void {
  this.districtIndex = index;
  this.saved = false;
}

saved = false 的作用:用户切换选项后,之前可能的"已保存"状态失效,预览区回到"请选择省/市/区"的提示状态。

3.2 三列布局

三个 TextPicker 在 Row 中水平并排,每个占等宽:

Row() {
  Column() {
    Text('省份').fontSize(11).fontColor('#BBBBCC').margin({ bottom: 6 })
    TextPicker({ range: this.getProvinceNames(), selected: this.provinceIndex })
      .onChange(...)
  }
  .layoutWeight(1).alignItems(HorizontalAlign.Center)

  Column() {
    Text('城市').fontSize(11).fontColor('#BBBBCC').margin({ bottom: 6 })
    TextPicker({ range: this.getCityNames(), selected: this.cityIndex })
      .onChange(...)
  }
  .layoutWeight(1).alignItems(HorizontalAlign.Center)

  Column() {
    Text('区县').fontSize(11).fontColor('#BBBBCC').margin({ bottom: 6 })
    TextPicker({ range: this.getDistrictNames(), selected: this.districtIndex })
      .onChange(...)
  }
  .layoutWeight(1).alignItems(HorizontalAlign.Center)
}

每列包含一个标签(11sp 灰色文字)和一个 TextPicker。.layoutWeight(1) 三等分父容器宽度。每列居中对齐。在典型手机屏幕(360dp)上,每列约 120dp——对 TextPicker 的滚轮来说足够显示中文选项。
在这里插入图片描述

四、地址确认与历史记录

4.1 确认选择

用户在三列滚轮中选好省市区后,点击"确认选择"按钮:

confirmAddress(): void {
  let addr = this.getCurrentAddress();
  // 去重检查
  let exists = false;
  for (let i = 0; i < this.history.length; i++) {
    if (this.history[i].equals(addr)) {
      exists = true;
      break;
    }
  }
  if (exists) {
    promptAction.showToast({ message: '该地址已保存', duration: 1500 });
    return;
  }
  this.history = this.history.slice().concat(addr);
  this.saved = true;
  promptAction.showToast({ message: '地址已保存', duration: 1500 });
}

流程分四步:

  1. 调用 getCurrentAddress() 获取当前三省市区名称,组装为 SelectedAddress
  2. 遍历 history 检查是否重复(equals() 比较省市区三个字段)
  3. 如果重复,Toast 提示"该地址已保存",不重复添加
  4. 如果不重复,通过 slice().concat(addr) 创建新数组,更新 @State history

4.2 预览区状态切换

预览区根据 saved 状态展示不同内容:

Column() {
  Text('当前选择').fontSize(11).fontColor('#BBBBCC').margin({ bottom: 6 })
  Text(this.saved ?
    this.getCurrentAddress().getFullAddress() : '请选择省/市/区')
    .fontSize(20)
    .fontColor(this.saved ? '#1a1a2e' : '#BBBBCC')
    .fontWeight(this.saved ? FontWeight.Bold : FontWeight.Normal)
  if (this.saved) {
    Text('已保存到历史记录')
      .fontSize(11).fontColor('#52C41A').margin({ top: 6 })
  }
}
  • 未确认时saved = false):显示灰色"请选择省/市/区",字号 20sp,常规字重
  • 已确认后saved = true):显示深色完整地址(如"广东省 深圳市 南山区"),加粗,下方显示绿色"已保存到历史记录"标签

注意:用户切换任何 TextPicker 时,saved 会被重置为 false,预览区回到提示状态。这确保"已保存"标签只在当前地址确实已保存后才显示。

4.3 历史记录列表

if (this.history.length > 0) {
  Column() {
    Row() {
      Text('历史记录').fontSize(14).fontColor('#1a1a2e').fontWeight(FontWeight.Medium)
      Blank()
      Text('左滑删除').fontSize(11).fontColor('#CCCCDD')
    }
    .width('100%').margin({ bottom: 10 })

    ForEach(this.history, (addr: SelectedAddress, idx: number) => {
      Row() {
        Column() {
          Text(addr.getFullAddress())
            .fontSize(14).fontColor('#1a1a2e').fontWeight(FontWeight.Medium)
          Text('已保存')
            .fontSize(11).fontColor('#CCCCDD').margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start).layoutWeight(1)

        Text('删除')
          .fontSize(12).fontColor('#FF4D4F')
          .padding({ top: 5, bottom: 5, left: 10, right: 10 })
          .borderRadius(12)
          .backgroundColor('#FFF1F0')
          .onClick(() => { this.deleteHistory(idx); })
      }
      .width('100%').padding({ top: 12, bottom: 12 })

      if (idx < this.history.length - 1) {
        Divider().height(1).color('#F8F9FA')
      }
    }, (addr: SelectedAddress, idx: number) => idx.toString())
  }
  .width('100%').padding(Spacing.LG)
  .backgroundColor('#FFFFFF').borderRadius(BorderRadius.LG)
  .margin({ ... })
}

每条历史记录显示完整地址 + "已保存"副标签 + 红色"删除"按钮。记录间有浅灰分割线。当 history.length === 0 时,整个历史记录区域不渲染。

4.4 删除历史

deleteHistory(index: number): void {
  let newHistory: SelectedAddress[] = [];
  for (let i = 0; i < this.history.length; i++) {
    if (i !== index) {
      newHistory = newHistory.slice().concat(this.history[i]);
    }
  }
  this.history = newHistory;
}

通过遍历跳过指定索引来创建新数组。头部的历史记录计数(“N 条记录”)自动更新。

五、预览区与滚轮区的视觉设计

5.1 预览卡片

预览区使用白色圆角卡片,内边距 20dp。地址文字使用 20sp 字号——比一般正文大不少,让用户清楚地看到当前选择结果。选择前后颜色和字重的对比强化了"已确认"vs"未确认"的状态差异。

5.2 滚轮区域

三个 TextPicker 并排在一个白色圆角卡片中。每列上方有灰色标签标明"省份"“城市”“区县”。TextPicker 本身的滚动交互由系统处理——用户上下滑动滚轮选择选项,选中项居中高亮显示。

三列并排的设计参考了 iOS 原生地址选择器的经典布局——一眼就能看到省市区三个层级,无需额外的步骤切换。

六、交互流程演示

6.1 默认状态

进入页面,三个 TextPicker 分别显示第一个省份(北京市)、第一个城市、第一个区县。预览区显示"请选择省/市/区"。标题栏显示"0 条记录"。

6.2 选择地址

在省份 TextPicker 上向下滑动,选择"广东省"。城市 TextPicker 自动更新为广东省的城市列表(广州/深圳/东莞),区县 TextPicker 自动更新为第一个城市的区县。

在城市 TextPicker 上滑动,选择"深圳市"。区县 TextPicker 自动更新为深圳市的区县列表(南山/福田/罗湖)。

在区县 TextPicker 上滑动,选择"南山区"。预览区仍然显示"请选择省/市/区"(因为尚未确认)。

6.3 确认保存

点击"确认选择"按钮。Toast 提示"地址已保存"。预览区变为"广东省 深圳市 南山区"(深色加粗),下方显示绿色"已保存到历史记录"。标题栏变为"1 条记录"。历史记录区域出现一条记录。

6.4 再次选择

在省份 TextPicker 切换到"浙江省"。城市和区县自动重置为第一个。选中"杭州市"→"西湖区",再次点击"确认选择"。标题栏变为"2 条记录"。历史记录区域出现两条记录。

如果再次确认"广东省 深圳市 南山区",Toast 提示"该地址已保存",不会重复添加。

6.5 删除记录

点击第一条历史记录右侧的红色"删除"按钮。该记录消失。标题栏更新为"1 条记录"。

七、级联选择器的扩展思路

本文构建的三级地址选择器是一个标准模板,可以轻松扩展到其他级联场景:

商品规格选择:品类 → 品牌 → 型号。例如"手机 → Apple → iPhone 16 Pro"。

组织架构选择:公司 → 部门 → 小组。例如"某某集团 → 技术部 → 前端组"。

课程目录选择:学科 → 章节 → 知识点。例如"数学 → 高等数学 → 微积分"。

只需替换 AddressNode 树的数据内容,其余联动逻辑完全不变。

八、总结

本文通过"三级地址选择器"这个实战案例,全面讲解了 ArkUI TextPicker 滚轮选择器的使用方法。核心知识点包括:

  1. TextPicker 基础 APIrange 数据源 + selected 默认索引 + onChange 回调(注意参数类型 string | string[]
  2. 树形数据结构AddressNode 递归定义省份→城市→区县的三级关系
  3. 级联联动机制:上游索引变化 → 重置下游索引 → range 动态重新计算
  4. 三列并排布局Row + layoutWeight(1) 三等分 + TextPicker 每列独立
  5. 索引越界保护onChange 回调中重置下级索引为 0
  6. 地址确认与去重SelectedAddress.equals() 防止重复保存
  7. 历史记录管理slice().concat() 不可变添加 + 过滤删除
  8. TypeScript 类型细节onChange 回调参数必须兼容 string | string[]number | number[]

滚轮选择器是移动端处理大量选项的最佳方案。它比下拉菜单承载更多选项(滚轮可无限滚动),比单选按钮更节省空间。TextPicker 的级联模式让开发者可以构建多层级的选项关系,让用户在几十上百个选项中快速定位目标——这是地址选择、商品筛选、组织导航等场景中不可或缺的交互模式。


Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐