前言

做表单页、筛选页和设置页时,选择器几乎绕不开。

简单场景用 TextPicker、DatePicker、TimePicker 就够了。比如选择性别、选择生日、选择时间段,这类数据结构稳定,展示形式也比较固定。ArkUI 已经提供了对应的基础选择器,直接使用能节省很多开发时间。TextPicker 本身就是官方提供的文本滑动选择器组件,适合处理普通文本选项。

问题会出现在更复杂的业务选择上。

比如商品规格选择,选项里要同时展示颜色、库存、价格变化和禁用状态。再比如课程难度选择,选项里可能有图标、难度等级、预计学习时长。健康记录里的饮水量、热量、运动强度,也经常需要把数字、单位和视觉提示放在一起。

这类场景继续硬套 TextPicker,会遇到两个问题。展示能力不够,复杂样式很难塞进去;交互逻辑要自己补,选中态、禁用态、联动状态都容易散在页面里。

HarmonyOS 6.0.2 API 22 新增 Picker 容器组件,方向是让开发者自定义构造 Picker 选择器。它更适合承载自定义选项样式,把选择器的滑动选择能力和选项视图构造拆开处理。

这次我按一个真实表单场景来整理:先做单列语言选择器,再扩展到商品规格选择,最后补上状态管理、性能和无障碍处理。

一、先判断该不该用 Picker 容器

选择器组件不要一上来就追求自定义。能用系统现成组件完成的场景,优先使用现成组件。

我一般按下面几个问题判断:

选项是否只有纯文本。

是否需要图标、标签、颜色块、库存提示。

是否存在禁用项、推荐项、热门项。

是否需要按业务状态动态改变选项样式。

是否要和页面里的其他筛选条件联动。

如果只是纯文本选项,比如语言、城市、排序方式,TextPicker 会更省事。它的 API 成熟,交互稳定,也符合系统默认体验。

如果选项本身要承载更多信息,Picker 容器的价值会更明显。它可以把每个选项的 UI 交给开发者构造,选项内容不再局限于一段文本。Row、Column、Text、Image、SymbolGlyph、Badge 这些组件都可以组合进去。

我比较常见的使用场景有几类。

商品规格选择:颜色、尺码、容量、库存、价格差异。

内容筛选:分类、难度、时长、标签组合。

健康记录:饮水量、热量、训练强度、睡眠质量。

设置页选项:语言、主题、提醒频率、默认单位。

数据看板筛选:时间范围、指标类型、对比维度。

Picker 容器适合处理这些带有“业务状态”的选项。选中态、不可选态、推荐态、说明文本,都可以在 Builder 里统一处理。

二、单列语言选择器的基础结构

先从最简单的语言选择器开始。这个例子不追求复杂效果,只把几个关键点跑通:数据模型、选中索引、选项视图、选择回调。

数据先定义清楚。

interface LanguageOption {
  label: string;
  code: string;
  desc: string;
  symbol: ResourceStr;
}

const languageOptions: LanguageOption[] = [
  {
    label: '中文',
    code: 'zh-CN',
    desc: '简体中文',
    symbol: $r('sys.symbol.character')
  },
  {
    label: 'English',
    code: 'en-US',
    desc: 'United States',
    symbol: $r('sys.symbol.globe')
  },
  {
    label: '日本語',
    code: 'ja-JP',
    desc: 'Japan',
    symbol: $r('sys.symbol.globe')
  },
  {
    label: '한국어',
    code: 'ko-KR',
    desc: 'Korea',
    symbol: $r('sys.symbol.globe')
  }
];

这里不要只存一个字符串。哪怕当前只显示语言名称,也建议把 labelcodedesc 分开。后面要做国际化、保存用户偏好、和服务端字段对齐时,结构会清楚很多。

页面状态可以这样写:

@State selectedIndex: number = 0;
@State selectedCode: string = languageOptions[0].code;

选项 Builder 负责处理每一行的展示。

@Builder
function LanguageOptionBuilder(item: LanguageOption, selected: boolean) {
  Row({ space: 12 }) {
    SymbolGlyph(item.symbol)
      .fontSize(22)
      .fontColor(selected ? '#0A59F7' : '#666666')

    Column({ space: 4 }) {
      Text(item.label)
        .fontSize(selected ? 18 : 16)
        .fontWeight(selected ? FontWeight.Medium : FontWeight.Regular)
        .fontColor(selected ? '#0A59F7' : '#182431')

      Text(item.desc)
        .fontSize(12)
        .fontColor('#8A8A8A')
    }
    .alignItems(HorizontalAlign.Start)

    Blank()

    if (selected) {
      SymbolGlyph($r('sys.symbol.checkmark'))
        .fontSize(18)
        .fontColor('#0A59F7')
    }
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 12, bottom: 12 })
  .borderRadius(12)
  .backgroundColor(selected ? '#EAF2FF' : Color.Transparent)
  .accessibilityText(`${item.label}${item.desc}${selected ? ',已选中' : ''}`)
}

这个 Builder 里只做 UI,不做数据计算。选中态通过 selected 控制,颜色、字号、右侧勾选图标都放在同一个地方处理。

接入 Picker 容器时,我会先把数据和选中状态包在一个独立组件里,避免页面直接堆太多选择逻辑。

@Component
struct LanguagePickerPanel {
  @State private selectedIndex: number = 0;

  private options: LanguageOption[] = languageOptions;

  @Builder
  itemBuilder(index: number, selected: boolean) {
    LanguageOptionBuilder(this.options[index], selected)
  }

  build() {
    Column({ space: 16 }) {
      Text('选择语言')
        .fontSize(20)
        .fontWeight(FontWeight.Medium)
        .width('100%')
        .textAlign(TextAlign.Start)

      Picker({
        options: {
          selected: this.selectedIndex,
          count: this.options.length,
          content: (index: number, selected: boolean) => {
            this.itemBuilder(index, selected)
          }
        }
      })
        .height(280)
        .onSelect((index: number) => {
          this.selectedIndex = index;
          const current = this.options[index];
          this.onLanguageChanged(current.code);
        })
    }
    .padding(16)
  }

  private onLanguageChanged(code: string): void {
    console.info(`language changed: ${code}`);
  }
}

这段代码重点体现结构。Picker 管选项选择,Builder 管选项展示,业务方法管选择结果。后面如果要把语言选择器放进弹窗、设置页或首次启动引导页,组件内部结构不用大改。

三、复杂选项要把展示状态提前算好

复杂选择器最怕把业务判断写进 Builder 里。

比如商品规格选择。一个选项可能有名称、价格差、库存、是否推荐、是否禁用。很多人会直接在 Builder 里判断库存、判断价格、判断推荐状态。选项少的时候没问题,选项一多,滚动时就容易卡。

我更喜欢先把展示状态整理成 ViewModel。

interface SpecOption {
  id: string;
  name: string;
  priceDelta: number;
  stock: number;
  recommended: boolean;
}

interface SpecOptionViewModel {
  id: string;
  title: string;
  subtitle: string;
  disabled: boolean;
  recommended: boolean;
}

function buildSpecViewModels(options: SpecOption[]): SpecOptionViewModel[] {
  return options.map((item) => {
    const disabled = item.stock <= 0;
    const priceText = item.priceDelta > 0 ? `${item.priceDelta}` : '无加价';
    const stockText = disabled ? '暂不可选' : `库存 ${item.stock}`;

    return {
      id: item.id,
      title: item.name,
      subtitle: `${priceText} · ${stockText}`,
      disabled,
      recommended: item.recommended
    };
  });
}

Builder 只接收已经整理好的 ViewModel。

@Builder
function SpecOptionBuilder(item: SpecOptionViewModel, selected: boolean) {
  Row({ space: 12 }) {
    Column({ space: 4 }) {
      Row({ space: 8 }) {
        Text(item.title)
          .fontSize(16)
          .fontWeight(selected ? FontWeight.Medium : FontWeight.Regular)
          .fontColor(item.disabled ? '#B0B0B0' : selected ? '#0A59F7' : '#182431')

        if (item.recommended) {
          Text('推荐')
            .fontSize(10)
            .fontColor('#FFFFFF')
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .backgroundColor('#FF7A00')
            .borderRadius(8)
        }
      }

      Text(item.subtitle)
        .fontSize(12)
        .fontColor(item.disabled ? '#C8C8C8' : '#8A8A8A')
    }
    .alignItems(HorizontalAlign.Start)

    Blank()

    if (selected) {
      SymbolGlyph($r('sys.symbol.checkmark_circle_fill'))
        .fontSize(20)
        .fontColor('#0A59F7')
    }
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 14, bottom: 14 })
  .borderRadius(12)
  .opacity(item.disabled ? 0.45 : 1)
  .backgroundColor(selected ? '#EAF2FF' : Color.Transparent)
  .accessibilityText(`${item.title}${item.subtitle}${item.recommended ? ',推荐' : ''}${selected ? ',已选中' : ''}`)
}

组件内部再处理选择逻辑。

@Component
struct SpecPickerPanel {
  @State private selectedIndex: number = 0;

  private specs: SpecOptionViewModel[] = buildSpecViewModels([
    { id: '128g', name: '128GB', priceDelta: 0, stock: 8, recommended: false },
    { id: '256g', name: '256GB', priceDelta: 300, stock: 12, recommended: true },
    { id: '512g', name: '512GB', priceDelta: 700, stock: 0, recommended: false }
  ]);

  @Builder
  specItemBuilder(index: number, selected: boolean) {
    SpecOptionBuilder(this.specs[index], selected)
  }

  build() {
    Column({ space: 16 }) {
      Text('选择容量')
        .fontSize(20)
        .fontWeight(FontWeight.Medium)
        .width('100%')

      Picker({
        options: {
          selected: this.selectedIndex,
          count: this.specs.length,
          content: (index: number, selected: boolean) => {
            this.specItemBuilder(index, selected)
          }
        }
      })
        .height(240)
        .onSelect((index: number) => {
          const item = this.specs[index];

          if (item.disabled) {
            return;
          }

          this.selectedIndex = index;
          this.onSpecChanged(item.id);
        })
    }
    .padding(16)
  }

  private onSpecChanged(id: string): void {
    console.info(`spec changed: ${id}`);
  }
}

禁用项的处理要注意。选中回调里如果发现当前项不可选,就不要更新业务状态。实际项目里还可以配合轻提示,比如提示“该规格暂时无货”。

四、状态管理要保持单一来源

选择器组件最容易混乱的地方,是选中状态同时存在多个位置。

页面里有一个 selectedIndex,业务模型里有一个 selectedId,弹窗关闭时又传回一个选择结果。几个状态如果不同步,就会出现 UI 显示选中 A,提交时却传了 B 的问题。

我通常只保留一个真正的业务状态,比如 selectedSpecIdselectedIndex 只作为 UI 层派生状态。

@State selectedSpecId: string = '256g';

private getSelectedIndex(): number {
  const index = this.specs.findIndex((item) => item.id === this.selectedSpecId);
  return index >= 0 ? index : 0;
}

Picker 初始化时从业务 ID 反推索引。

Picker({
  options: {
    selected: this.getSelectedIndex(),
    count: this.specs.length,
    content: (index: number, selected: boolean) => {
      this.specItemBuilder(index, selected)
    }
  }
})
  .onSelect((index: number) => {
    const item = this.specs[index];

    if (item.disabled) {
      return;
    }

    this.selectedSpecId = item.id;
  })

这样提交表单时,只提交 selectedSpecId。UI 选中态可以随时通过 ID 计算出来,不需要再额外维护一份状态。

如果选择器放在弹窗里,我会再加一层临时状态。

@State confirmedSpecId: string = '256g';
@State draftSpecId: string = this.confirmedSpecId;

private openSpecDialog(): void {
  this.draftSpecId = this.confirmedSpecId;
  this.showSpecDialog = true;
}

private confirmSpecDialog(): void {
  this.confirmedSpecId = this.draftSpecId;
  this.showSpecDialog = false;
}

private cancelSpecDialog(): void {
  this.draftSpecId = this.confirmedSpecId;
  this.showSpecDialog = false;
}

弹窗里的选择只改 draftSpecId,用户点击确定后再写回 confirmedSpecId。这种结构更符合用户预期,也能避免误触导致状态立刻变化。

五、性能和无障碍不要等到最后补

Picker 容器允许开发者自由构造选项 UI,灵活性更高,同时也更容易写重。

Builder 里尽量只做渲染,不做数据转换、数组过滤、复杂计算。需要计算的内容提前生成 ViewModel。选项数量比较多时,不要给每一项塞太深的组件层级。图标、徽标、说明文本够用就好,复杂说明可以放到选中后的详情区域。

图片资源也要控制。选择器滚动时,如果每一项都加载网络图片,体验会很差。商品规格、语言、分类这类场景优先使用本地资源、SymbolGlyph 或简单色块。需要展示真实图片时,建议控制尺寸,并提前处理缓存。

无障碍也要在 Builder 阶段一起处理。每个选项都应该有清晰的 accessibilityText。选中状态、禁用状态、推荐状态要体现在无障碍描述里。

.accessibilityText(
  `${item.title}${item.subtitle}${item.disabled ? ',不可选择' : ''}${selected ? ',已选中' : ''}`
)

如果选择器支持左右滑动、上下滚动或弹窗确认,也要保证键盘、焦点和读屏路径可用。特别是设置页、健康记录、表单提交这类场景,无障碍不是可选项。

版本兼容也要提前处理。Picker 容器是 HarmonyOS 6.0.2 API 22 的新增能力,低版本不能直接使用。项目如果还要兼容 API 20 或更早版本,就需要准备 fallback 方案。简单场景可以回退到 TextPicker,复杂场景可以临时保留自定义 List 弹窗。

if (canUsePickerContainer()) {
  // 使用 Picker 容器组件
  showPickerContainer();
} else {
  // 低版本回退到 TextPicker 或自定义弹窗
  showFallbackPicker();
}

这个判断可以放到组件入口或路由入口。业务页面只关心选择结果,不直接关心底层用了哪一种选择器。

总结

Picker 容器组件适合处理带自定义展示需求的选择场景。它的价值主要体现在选项视图可以自己构造,选中态、禁用态、推荐态、说明信息都能放进同一套 Builder 里处理。

接入时我会按这几个步骤走。

先判断是否需要 Picker 容器。纯文本选项优先用 TextPicker,日期和时间优先用 DatePicker、TimePicker。遇到图标、说明、状态、库存、价格差、推荐标签这类复杂展示,再考虑 Picker 容器。

数据层先整理成稳定的 ViewModel,Builder 只负责 UI。业务状态用 ID 保存,索引只服务于 UI。弹窗场景用草稿状态和确认状态分开处理。复杂选项提前处理性能,别在 Builder 里做重计算。无障碍文本跟选项 UI 一起写,选中、禁用、推荐都要能被读屏识别。

版本号也要写准确。Picker 容器对应 HarmonyOS 6.0.2 API 22 新增能力,不能放进 API 20 的标题和正文里。项目还要兼容旧版本时,准备 TextPicker 或自定义弹窗作为降级方案。

Logo

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

更多推荐