Skyroc Native UI

TreeSelect

分类选择,左侧分组导航 + 右侧子项列表

分类选择(TreeSelect)是「左栏分组 + 右栏子项」的两级选择器,常见于商品分类、地区筛选。左栏由内部的 Sidebar 渲染,右栏是可滚动的子项列表,支持单选与多选。

import { TreeSelect } from '@skyroc/native-ui';

基础用法

items 一次声明左右两栏:每个分组的 text 是左栏标题,children 是右栏子项。单选模式下 activeId 是单个子项 id,未选中时为 null

TreeSelectBasic.tsx
Loading…
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 —— 调用方不会收到「点了却没变」的假信号;已选中项仍可取消。

TreeSelectMultiple.tsx
Loading…
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 表示,否则受控模式会被判定成非受控。

分组角标

分组上的 badgedot 会透传给左侧 SidebardefaultMainActiveIndex 设置初始激活的分组。

TreeSelectBadge.tsx
Loading…
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% 不透明度、文字转灰且不响应点击。

TreeSelectDisabled.tsx
Loading…
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 控制选中值,两者可以分别受控。

TreeSelectControlled.tsx
Loading…
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 为空时不会调用。

TreeSelectCustomContent.tsx
Loading…
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 都不触发)。
TreeSelectEvents.tsx
Loading…
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 为空数组时左右两栏保持稳定空态。

TreeSelectDynamicItems.tsx
Loading…
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归属作用位置
rootclassNames根容器(横向排布、高度)
sidebarclassNames左侧导航的根节点(默认 w-24,调宽窄从这里改)
contentclassNames右侧滚动区
contentItemclassNames单个子项容器
contentItemTextclassNames子项文字(选中态加粗变主题色)
selectedIconclassNames选中勾的 colorClassName,只接受 accent-* 颜色类
sidebarClassNames左侧 Sidebar 的各内部 slot
TreeSelectCustomSlots.tsx
Loading…
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,单选时是 menuitemselected,禁用态一并写进 accessibilityStateaccessibilityLabel 取子项文本。

API

TreeSelect

除下表外,TreeSelect 透传 View 的属性(testIDstyle 等,children 除外)。

属性说明类型默认值
items分组数据TreeSelectItem[][]
height组件高度number300
multiple是否多选booleanfalse
max多选时最大可选数量,达到上限后点击未选中项无效且不触发 onClickItemnumberInfinity
activeId受控选中值,多选传数组、单选传单个 id,空值用 nullTreeSelectActiveId-
defaultActiveId非受控初始选中值,缺省时单选为 null、多选为空数组TreeSelectActiveId-
onActiveIdChange选中值变化回调(activeId: TreeSelectActiveId) => void-
mainActiveIndex受控的左侧导航索引;索引是位置而非身份,items 变动后需自行校正number-
defaultMainActiveIndex非受控初始导航索引number0
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

子项唯一标识。

number | string

TreeSelectActiveId

选中值:多选是子项 id 数组,单选是单个 id,null 表示未选中(不能用 undefined,否则会被判定成非受控)。

TreeSelectSlots

可通过 classNames 覆盖的 slot 名称;左栏内部的 slot 走 sidebarClassNames。

'content' | 'contentItem' | 'contentItemText' | 'root' | 'selectedIcon' | 'sidebar'

SlotClassNames

classNames 的取值形态:把 slot 名映射到类名,每个 slot 都可选。本页用到的 slot 见 TreeSelectSlots。

Partial<Record<Slots, string>>

TreeSelectItem

左侧分组数据。

字段类型说明
text*string分组标题。
idTreeSelectChildId唯一标识,默认取分组下标;分组会动态增删或重排时必须显式传值。
childrenTreeSelectChild[]该分组下的子项列表。
badgeReactNode左栏徽标内容。
dotboolean是否显示小红点。
disabledboolean是否禁用该分组。

TreeSelectChild

右侧子项数据。

字段类型说明
id*TreeSelectChildId唯一标识,也是选中值。
text*string显示文本。
disabledboolean是否禁用该子项。

包内还导出了 treeSelectVariants