TreeSelect
分类选择,左侧分组导航 + 右侧子项列表
分类选择(TreeSelect)是「左栏分组 + 右栏子项」的两级选择器,常见于商品分类、地区筛选。左栏由内部的 Sidebar 渲染,右栏是可滚动的子项列表,支持单选与多选。
import { TreeSelect } from '@skyroc/native-ui';基础用法
items 一次声明左右两栏:每个分组的 text 是左栏标题,children 是右栏子项。单选模式下 activeId 是单个子项 id,未选中时为 null。
import { TreeSelect } from '@skyroc/native-ui';
import type { TreeSelectItem } from '@skyroc/native-ui';
import { View } from 'react-native';
/** 把「分组名 + 子项名」的简写转成 items,子项 id 用「组序号-项序号」保证全局唯一 */
function toItems(groups: [string, string[]][]): TreeSelectItem[] {
return groups.map(([text, children], groupIndex) => ({
children: children.map((childText, childIndex) => ({
id: `${groupIndex}-${childIndex}`,
text: childText
})),
id: text,
text
}));
}
const CITY_ITEMS = toItems([
['浙江', ['杭州', '宁波', '温州', '嘉兴', '湖州']],
['江苏', ['南京', '苏州', '无锡', '常州']],
['福建', ['福州', '厦门', '泉州']]
]);
const TreeSelectBasic = () => {
return (
<View className="bg-background p-4">
<View className="overflow-hidden rounded-xl border border-border/60">
<TreeSelect
defaultActiveId="0-0"
items={CITY_ITEMS}
/>
</View>
</View>
);
};
export { TreeSelectBasic };组件高度默认 300,可用 height 调整 —— 左右两栏都靠它撑开,不给高度右栏就没有滚动空间。
何时使用
- 两级分类的筛选或选择,且一级数量不多、二级需要滚动。
- 层级只有一级用
Sidebar;三级及以上、或需要滚轮交互用Picker的级联模式。
多选与上限
multiple 开启多选,此时 activeId 是 id 数组。max 限制最多可选数量:达到上限后点击未选中项不产生任何效果,也不会触发 onClickItem —— 调用方不会收到「点了却没变」的假信号;已选中项仍可取消。
import { Text, TreeSelect } from '@skyroc/native-ui';
import type { TreeSelectActiveId, TreeSelectItem } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
/** 把「分组名 + 子项名」的简写转成 items,子项 id 用「组序号-项序号」保证全局唯一 */
function toItems(groups: [string, string[]][]): TreeSelectItem[] {
return groups.map(([text, children], groupIndex) => ({
children: children.map((childText, childIndex) => ({
id: `${groupIndex}-${childIndex}`,
text: childText
})),
id: text,
text
}));
}
/** 把选中值渲染成一行文本 */
function formatActiveId(activeId: TreeSelectActiveId) {
if (Array.isArray(activeId)) return activeId.join('、') || '无';
return activeId === null ? '无' : String(activeId);
}
const TAG_ITEMS = toItems([
['口味', ['麻辣', '清淡', '酸甜', '咸鲜']],
['菜系', ['川菜', '粤菜', '徽菜']],
['忌口', ['不吃香菜', '不吃葱', '不吃辣']]
]);
const MAX_COUNT = 3;
const TreeSelectMultiple = () => {
const [tags, setTags] = useState<TreeSelectActiveId>(['0-0', '1-1']);
const selectedTagCount = Array.isArray(tags) ? tags.length : 0;
return (
<View className="gap-2 bg-background p-4">
<View className="overflow-hidden rounded-xl border border-border/60">
<TreeSelect
multiple
activeId={tags}
items={TAG_ITEMS}
max={MAX_COUNT}
onActiveIdChange={setTags}
/>
</View>
<Text className="text-sm text-muted-foreground">
已选 {selectedTagCount} / {MAX_COUNT}:{formatActiveId(tags)}
</Text>
</View>
);
};
export { TreeSelectMultiple };空值用 null 而不是 undefined 表示,否则受控模式会被判定成非受控。
分组角标
分组上的 badge 与 dot 会透传给左侧 Sidebar。defaultMainActiveIndex 设置初始激活的分组。
import { TreeSelect } from '@skyroc/native-ui';
import type { TreeSelectItem } from '@skyroc/native-ui';
import { View } from 'react-native';
const BADGE_ITEMS: TreeSelectItem[] = [
{ children: [{ id: 'all-1', text: '全部订单' }], id: 'all', text: '全部' },
{ badge: 3, children: [{ id: 'pay-1', text: '待付款订单' }], id: 'pay', text: '待付款' },
{ children: [{ id: 'ship-1', text: '待收货订单' }], dot: true, id: 'ship', text: '待收货' },
{ badge: 128, children: [{ id: 'refund-1', text: '退款订单' }], id: 'refund', text: '退款' }
];
const TreeSelectBadge = () => {
return (
<View className="bg-background p-4">
<View className="overflow-hidden rounded-xl border border-border/60">
<TreeSelect
defaultActiveId="pay-1"
defaultMainActiveIndex={1}
items={BADGE_ITEMS}
height={220}
/>
</View>
</View>
);
};
export { TreeSelectBadge };禁用项
分组与子项都可以单独 disabled:禁用的子项降到 50% 不透明度、文字转灰且不响应点击。
import { TreeSelect } from '@skyroc/native-ui';
import type { TreeSelectItem } from '@skyroc/native-ui';
import { View } from 'react-native';
const DISABLED_ITEMS: TreeSelectItem[] = [
{
children: [
{ id: 'draft-1', text: '草稿一' },
{ disabled: true, id: 'draft-2', text: '草稿二(禁用)' },
{ id: 'draft-3', text: '草稿三' }
],
id: 'draft',
text: '草稿'
},
{ children: [{ id: 'review-1', text: '审核中的内容' }], disabled: true, id: 'review', text: '审核中' },
{ children: [{ id: 'published-1', text: '已发布的内容' }], id: 'published', text: '已发布' }
];
const TreeSelectDisabled = () => {
return (
<View className="bg-background p-4">
<View className="overflow-hidden rounded-xl border border-border/60">
<TreeSelect
height={220}
items={DISABLED_ITEMS}
/>
</View>
</View>
);
};
export { TreeSelectDisabled };受控模式
mainActiveIndex + onMainActiveIndexChange 控制当前分组,activeId + onActiveIdChange 控制选中值,两者可以分别受控。
import { Button, Text, TreeSelect } from '@skyroc/native-ui';
import type { TreeSelectActiveId, TreeSelectItem } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
/** 把「分组名 + 子项名」的简写转成 items,子项 id 用「组序号-项序号」保证全局唯一 */
function toItems(groups: [string, string[]][]): TreeSelectItem[] {
return groups.map(([text, children], groupIndex) => ({
children: children.map((childText, childIndex) => ({
id: `${groupIndex}-${childIndex}`,
text: childText
})),
id: text,
text
}));
}
/** 把选中值渲染成一行文本 */
function formatActiveId(activeId: TreeSelectActiveId) {
if (Array.isArray(activeId)) return activeId.join('、') || '无';
return activeId === null ? '无' : String(activeId);
}
const CITY_ITEMS = toItems([
['浙江', ['杭州', '宁波', '温州', '嘉兴', '湖州']],
['江苏', ['南京', '苏州', '无锡', '常州']],
['福建', ['福州', '厦门', '泉州']]
]);
const TreeSelectControlled = () => {
const [activeId, setActiveId] = useState<TreeSelectActiveId>('1-0');
const [navIndex, setNavIndex] = useState(1);
return (
<View className="gap-4 bg-background p-4">
<View className="overflow-hidden rounded-xl border border-border/60">
<TreeSelect
activeId={activeId}
items={CITY_ITEMS}
mainActiveIndex={navIndex}
onActiveIdChange={setActiveId}
onMainActiveIndexChange={setNavIndex}
/>
</View>
<View className="flex-row flex-wrap items-center gap-3">
<Button
color="secondary"
variant="outline"
onPress={() => setNavIndex(value => Math.max(0, value - 1))}
>
上一组
</Button>
<Button
color="primary"
variant="tonal"
onPress={() => setNavIndex(value => Math.min(CITY_ITEMS.length - 1, value + 1))}
>
下一组
</Button>
<Button
color="secondary"
variant="ghost"
onPress={() => setActiveId(null)}
>
清空选中
</Button>
</View>
<Text className="text-sm text-muted-foreground">
分组:{CITY_ITEMS[navIndex].text} / 选中:{formatActiveId(activeId)}
</Text>
</View>
);
};
export { TreeSelectControlled };注意 mainActiveIndex 是位置而不是身份:items 增删或重排后需要调用方自行校正。
自定义右侧内容
renderContent(item, index) 完全替换右栏,参数是当前分组及其下标。items 为空时不会调用。
import { Text, TreeSelect } from '@skyroc/native-ui';
import type { TreeSelectItem } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
/** 把「分组名 + 子项名」的简写转成 items,子项 id 用「组序号-项序号」保证全局唯一 */
function toItems(groups: [string, string[]][]): TreeSelectItem[] {
return groups.map(([text, children], groupIndex) => ({
children: children.map((childText, childIndex) => ({
id: `${groupIndex}-${childIndex}`,
text: childText
})),
id: text,
text
}));
}
const CUSTOM_ITEMS = toItems([
['设计', []],
['研发', []],
['测试', []]
]);
const TreeSelectCustomContent = () => {
const [navIndex, setNavIndex] = useState(0);
return (
<View className="gap-2 bg-background p-4">
<View className="overflow-hidden rounded-xl border border-border/60">
<TreeSelect
height={220}
items={CUSTOM_ITEMS}
renderContent={(item, index) => (
<View className="flex-1 items-center justify-center gap-2 p-4">
<Text className="text-base font-semibold">{item.text}</Text>
<Text className="text-center text-sm text-muted-foreground">第 {index + 1} 个分组的自定义内容</Text>
</View>
)}
onClickNav={setNavIndex}
/>
</View>
<Text className="text-sm text-muted-foreground">最近点击的分组下标:{navIndex}</Text>
</View>
);
};
export { TreeSelectCustomContent };点击事件
onClickNav(index):点击左栏分组。onClickItem(item):点击右栏子项,仅在选中值确实变化时触发(单选重复点同一项、多选越过max都不触发)。
import { Text, TreeSelect } from '@skyroc/native-ui';
import type { TreeSelectChild, TreeSelectItem } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
const EVENT_ITEMS: TreeSelectItem[] = [
{
children: [
{ id: 'fruit-apple', text: '苹果' },
{ id: 'fruit-orange', text: '橙子' }
],
id: 'fruit',
text: '水果'
},
{
children: [
{ id: 'drink-water', text: '水' },
{ disabled: true, id: 'drink-tea', text: '茶(禁用)' }
],
id: 'drink',
text: '饮品'
}
];
const TreeSelectEvents = () => {
const [navEvent, setNavEvent] = useState('尚未点击分组');
const [itemEvent, setItemEvent] = useState('尚未点击子项');
function handleClickItem(item: TreeSelectChild) {
setItemEvent(`onClickItem:${item.id}`);
}
return (
<View className="gap-2 bg-background p-4">
<View className="overflow-hidden rounded-xl border border-border/60">
<TreeSelect
defaultActiveId="fruit-apple"
height={220}
items={EVENT_ITEMS}
onClickItem={handleClickItem}
onClickNav={index => setNavEvent(`onClickNav:${index}`)}
/>
</View>
<Text className="text-sm text-muted-foreground">{navEvent}</Text>
<Text className="text-sm text-muted-foreground">{itemEvent}</Text>
</View>
);
};
export { TreeSelectEvents };动态数据与空值
items 变短或受控索引越界时,激活索引会自动收敛到合法范围,不会出现「右侧一片空白、左侧也没有激活项」;items 为空数组时左右两栏保持稳定空态。
import { Button, Text, TreeSelect } from '@skyroc/native-ui';
import type { TreeSelectItem } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
/** 把「分组名 + 子项名」的简写转成 items,子项 id 用「组序号-项序号」保证全局唯一 */
function toItems(groups: [string, string[]][]): TreeSelectItem[] {
return groups.map(([text, children], groupIndex) => ({
children: children.map((childText, childIndex) => ({
id: `${groupIndex}-${childIndex}`,
text: childText
})),
id: text,
text
}));
}
/** 分组会被裁短,用来验证 items 变短后激活索引自动收敛,不会留下空白右栏 */
const SHRINK_ITEMS = toItems([
['第一组', ['A1', 'A2']],
['第二组', ['B1', 'B2']],
['第三组', ['C1', 'C2']],
['第四组', ['D1', 'D2']]
]);
const TreeSelectDynamicItems = () => {
const [groupCount, setGroupCount] = useState(SHRINK_ITEMS.length);
const shrinkItems = SHRINK_ITEMS.slice(0, groupCount);
return (
<View className="gap-4 bg-background p-4">
<View className="overflow-hidden rounded-xl border border-border/60">
<TreeSelect
height={200}
items={shrinkItems}
/>
</View>
<View className="flex-row flex-wrap items-center gap-3">
<Button
color="secondary"
variant="outline"
onPress={() => setGroupCount(count => Math.max(0, count - 1))}
>
删掉最后一组
</Button>
<Button
color="primary"
variant="tonal"
onPress={() => setGroupCount(count => Math.min(SHRINK_ITEMS.length, count + 1))}
>
加回一组
</Button>
<Text className="text-sm text-muted-foreground">当前 {groupCount} 组</Text>
</View>
</View>
);
};
export { TreeSelectDynamicItems };分组的 id 默认取它在 items 中的下标,因此分组会动态增删或重排时必须显式传 id,否则下标会串位。
样式覆盖
className 追加到根容器上,classNames 覆盖本组件的 slot,左栏内部的 slot 走 sidebarClassNames:
| slot | 归属 | 作用位置 |
|---|---|---|
root | classNames | 根容器(横向排布、高度) |
sidebar | classNames | 左侧导航的根节点(默认 w-24,调宽窄从这里改) |
content | classNames | 右侧滚动区 |
contentItem | classNames | 单个子项容器 |
contentItemText | classNames | 子项文字(选中态加粗变主题色) |
selectedIcon | classNames | 选中勾的 colorClassName,只接受 accent-* 颜色类 |
| — | sidebarClassNames | 左侧 Sidebar 的各内部 slot |
import { TreeSelect } from '@skyroc/native-ui';
import type { TreeSelectItem } from '@skyroc/native-ui';
import { View } from 'react-native';
/** 把「分组名 + 子项名」的简写转成 items,子项 id 用「组序号-项序号」保证全局唯一 */
function toItems(groups: [string, string[]][]): TreeSelectItem[] {
return groups.map(([text, children], groupIndex) => ({
children: children.map((childText, childIndex) => ({
id: `${groupIndex}-${childIndex}`,
text: childText
})),
id: text,
text
}));
}
const CITY_ITEMS = toItems([
['浙江', ['杭州', '宁波', '温州', '嘉兴', '湖州']],
['江苏', ['南京', '苏州', '无锡', '常州']],
['福建', ['福州', '厦门', '泉州']]
]);
const TreeSelectCustomSlots = () => {
return (
<View className="bg-background p-4">
<View className="overflow-hidden rounded-xl border border-border/60">
<TreeSelect
className="rounded-xl border border-primary-200"
classNames={{
content: 'bg-primary-50',
contentItem: 'px-5 py-4',
contentItemText: 'tracking-wide',
root: 'bg-primary-50',
selectedIcon: 'accent-success',
sidebar: 'w-28 self-stretch bg-primary-100'
}}
defaultActiveId="0-1"
height={220}
items={CITY_ITEMS}
sidebarClassNames={{ indicator: 'h-6 bg-success', itemText: 'font-medium' }}
/>
</View>
</View>
);
};
export { TreeSelectCustomSlots };左栏固定了宽度并拉满高度(w-24 self-stretch):Sidebar 根节点默认 self-start 且宽度由最长标题撑开,放进定高的行容器里高度只到内容高、宽度还会随分组数据跳动。
无障碍
右栏子项按模式给出不同语义:多选时是 accessibilityRole="checkbox" 配 checked,单选时是 menuitem 配 selected,禁用态一并写进 accessibilityState,accessibilityLabel 取子项文本。
API
TreeSelect
除下表外,TreeSelect 透传 View 的属性(testID、style 等,children 除外)。
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| items | 分组数据 | TreeSelectItem[] | [] |
| height | 组件高度 | number | 300 |
| multiple | 是否多选 | boolean | false |
| max | 多选时最大可选数量,达到上限后点击未选中项无效且不触发 onClickItem | number | Infinity |
| activeId | 受控选中值,多选传数组、单选传单个 id,空值用 null | TreeSelectActiveId | - |
| defaultActiveId | 非受控初始选中值,缺省时单选为 null、多选为空数组 | TreeSelectActiveId | - |
| onActiveIdChange | 选中值变化回调 | (activeId: TreeSelectActiveId) => void | - |
| mainActiveIndex | 受控的左侧导航索引;索引是位置而非身份,items 变动后需自行校正 | number | - |
| defaultMainActiveIndex | 非受控初始导航索引 | number | 0 |
| onMainActiveIndexChange | 导航激活索引变化回调 | (index: number) => void | - |
| onClickNav | 点击左侧导航回调 | (index: number) => void | - |
| onClickItem | 点击子项回调,仅在选中值确实变化时触发 | (item: TreeSelectChild) => void | - |
| renderContent | 自定义右侧内容,接收当前分组及其下标;items 为空时不调用 | (item: TreeSelectItem, index: number) => ReactNode | - |
| className | 根容器类名,合并在 classNames.root 之后 | string | - |
| classNames | 各 slot 的类名覆盖,见「样式覆盖」一节 | SlotClassNames<TreeSelectSlots> | - |
| sidebarClassNames | 左侧 Sidebar 各 slot 的类名,左栏由内部渲染,只能从这里透传 | SlotClassNames<SidebarSlots> | - |
类型
import type {
TreeSelectActiveId,
TreeSelectChild,
TreeSelectChildId,
TreeSelectItem,
TreeSelectProps,
TreeSelectSlots
} from '@skyroc/native-ui';TreeSelectChildId
子项唯一标识。
TreeSelectActiveId
选中值:多选是子项 id 数组,单选是单个 id,null 表示未选中(不能用 undefined,否则会被判定成非受控)。
TreeSelectSlots
可通过 classNames 覆盖的 slot 名称;左栏内部的 slot 走 sidebarClassNames。
SlotClassNames
classNames 的取值形态:把 slot 名映射到类名,每个 slot 都可选。本页用到的 slot 见 TreeSelectSlots。
TreeSelectItem
左侧分组数据。
| 字段 | 类型 | 说明 |
|---|---|---|
| text* | string | 分组标题。 |
| id | TreeSelectChildId | 唯一标识,默认取分组下标;分组会动态增删或重排时必须显式传值。 |
| children | TreeSelectChild[] | 该分组下的子项列表。 |
| badge | ReactNode | 左栏徽标内容。 |
| dot | boolean | 是否显示小红点。 |
| disabled | boolean | 是否禁用该分组。 |
包内还导出了 treeSelectVariants。