鸿蒙 HarmonyOS 6 | Picker 容器组件自定义选择器开发实战
做表单页、筛选页和设置页时,选择器几乎绕不开。简单场景用 TextPicker、DatePicker、TimePicker 就够了。比如选择性别、选择生日、选择时间段,这类数据结构稳定,展示形式也比较固定。ArkUI 已经提供了对应的基础选择器,直接使用能节省很多开发时间。TextPicker 本身就是官方提供的文本滑动选择器组件,适合处理普通文本选项。
前言
做表单页、筛选页和设置页时,选择器几乎绕不开。
简单场景用 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')
}
];
这里不要只存一个字符串。哪怕当前只显示语言名称,也建议把 label、code、desc 分开。后面要做国际化、保存用户偏好、和服务端字段对齐时,结构会清楚很多。
页面状态可以这样写:
@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 的问题。
我通常只保留一个真正的业务状态,比如 selectedSpecId。selectedIndex 只作为 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 或自定义弹窗作为降级方案。
更多推荐




所有评论(0)