Skyroc Native UI

IndexBar

字母索引列表,右缘悬浮索引条

索引栏(IndexBar)是「分组列表 + 右缘悬浮字母条」的组合,典型场景是城市选择、通讯录。列表本体整套复用 AnchorNav(滚动定位的高度模型、点击侧栏时的程序化滚动抑制、触感策略都只在那边有一份),IndexBar 只负责把「下标」这层身份换成「字母」。

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

基础用法

items 的每一项是一个分组:title 既是分组标题也是侧栏上的那个字符,因此必须在 items 内唯一。滚动列表时右侧字母会联动高亮,点击字母则滚动到对应分组。

IndexBarBasic.tsx
Loading…
import { Divider, IndexBar, Text } from '@skyroc/native-ui';
import type { IndexBarChild } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
import { CITY_ITEMS } from './shared';

const IndexBarBasic = () => {
  const [activeIndex, setActiveIndex] = useState(CITY_ITEMS[0].title);
  const [pressedItem, setPressedItem] = useState<IndexBarChild | null>(null);

  return (
    <View className="flex-1 bg-background">
      <View className="flex-row items-center gap-2 px-4 py-3">
        <Text className="text-xs text-muted-foreground">当前索引:{activeIndex}</Text>
        <Text className="flex-1 text-right text-xs text-muted-foreground">
          {pressedItem ? `点击了 ${pressedItem.text}` : '试着滚动列表看高亮联动'}
        </Text>
      </View>

      <Divider className="my-0" />

      <View className="flex-1">
        <IndexBar
          items={CITY_ITEMS}
          onIndexChange={setActiveIndex}
          onPressItem={setPressedItem}
        />
      </View>
    </View>
  );
};

export { IndexBarBasic };

onIndexChange 的参数是分组的 title(而不是下标),点击侧栏与滚动联动都会触发。onPressItem 是子项点击回调。

IndexBar 需要一个有确定高度的父容器(例如 flex-1View),否则列表撑不开。

何时使用

  • 长列表按首字母分组,需要快速跳转:城市、联系人、品牌。
  • 分组不需要字母索引条时用 AnchorNav;只有几个分类用 Sidebar

吸顶与触感

sticky(默认 true)控制分组标题吸顶,haptic(默认 true)控制点击侧栏索引时的轻触反馈(仅原生端有效)。

IndexBarBehavior.tsx
Loading…
import { Button, Divider, IndexBar, Text } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
import { CITY_ITEMS } from './shared';

const IndexBarBehavior = () => {
  const [activeIndex, setActiveIndex] = useState(CITY_ITEMS[0].title);
  const [sticky, setSticky] = useState(true);
  const [haptic, setHaptic] = useState(true);

  return (
    <View className="flex-1 bg-background">
      <View className="flex-row flex-wrap gap-2 px-4 py-3">
        <Button
          size="sm"
          variant={sticky ? 'solid' : 'outline'}
          onPress={() => setSticky(current => !current)}
        >
          {sticky ? '吸顶已开启' : '吸顶已关闭'}
        </Button>
        <Button
          size="sm"
          variant={haptic ? 'solid' : 'outline'}
          onPress={() => setHaptic(current => !current)}
        >
          {haptic ? '触感已开启' : '触感已关闭'}
        </Button>
      </View>

      <View className="flex-row items-center gap-2 px-4 pb-3">
        <Text className="text-xs text-muted-foreground">当前索引:{activeIndex}</Text>
        <Text className="flex-1 text-right text-xs text-muted-foreground">haptic 仅在原生端触发</Text>
      </View>

      <Divider className="my-0" />

      <View className="flex-1">
        <IndexBar
          haptic={haptic}
          items={CITY_ITEMS}
          sticky={sticky}
          onIndexChange={setActiveIndex}
        />
      </View>
    </View>
  );
};

export { IndexBarBehavior };

高度度量

itemHeight(默认 40)与 sectionHeaderHeight(默认 32)不只是样式:滚动定位按这两个值累加算出目标偏移量,改了样式高度却不改它们,跳转就会偏。

IndexBarMetrics.tsx
Loading…
import { Divider, IndexBar, Text } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
import { CITY_ITEMS } from './shared';

const ITEM_HEIGHT = 52;
const SECTION_HEADER_HEIGHT = 40;

const IndexBarMetrics = () => {
  const [activeIndex, setActiveIndex] = useState(CITY_ITEMS[0].title);

  return (
    <View className="flex-1 bg-background">
      <View className="flex-row items-center gap-2 px-4 py-3">
        <Text className="text-xs text-muted-foreground">当前索引:{activeIndex}</Text>
        <Text className="flex-1 text-right text-xs text-muted-foreground">
          itemHeight={ITEM_HEIGHT} · sectionHeaderHeight={SECTION_HEADER_HEIGHT}
        </Text>
      </View>

      <Divider className="my-0" />

      <View className="flex-1">
        <IndexBar
          itemHeight={ITEM_HEIGHT}
          items={CITY_ITEMS}
          sectionHeaderHeight={SECTION_HEADER_HEIGHT}
          onIndexChange={setActiveIndex}
        />
      </View>
    </View>
  );
};

export { IndexBarMetrics };

自定义子项

renderItem(item, section) 自定义子项内容,外层仍然会被钉在 itemHeight 上,所以内容必须能在这个高度内显示完整(demo 里用 h-full 撑满)。

IndexBarCustomItem.tsx
Loading…
import { Divider, IndexBar, Text } from '@skyroc/native-ui';
import type { IndexBarChild, IndexBarSection } from '@skyroc/native-ui';
import { useState } from 'react';
import { Pressable, View } from 'react-native';
import { CITY_ITEMS, toAreaCode } from './shared';

/** 自定义子项的高度,同时是 IndexBar 的滚动定位度量,所以只在这里写一次 */
const CUSTOM_ITEM_HEIGHT = 64;

const IndexBarCustomItem = () => {
  const [activeIndex, setActiveIndex] = useState(CITY_ITEMS[0].title);
  const [pressedItem, setPressedItem] = useState<IndexBarChild | null>(null);

  /** 外层已经被钉在 CUSTOM_ITEM_HEIGHT 上,这里只负责把内容撑满并垂直居中 */
  function renderCityItem(item: IndexBarChild, section: IndexBarSection) {
    return (
      <Pressable
        className="h-full flex-row items-center gap-3 px-3 active:opacity-80"
        onPress={() => setPressedItem(item)}
      >
        <View className="h-10 w-10 items-center justify-center rounded-full bg-primary/10">
          <Text className="text-sm font-semibold text-primary">{section.title}</Text>
        </View>

        <View className="flex-1 gap-1">
          <Text className="text-sm font-medium text-foreground">{item.text}</Text>
          <Text className="text-xs text-muted-foreground">区号 {toAreaCode(item.key)}</Text>
        </View>
      </Pressable>
    );
  }

  return (
    <View className="flex-1 bg-background">
      <View className="flex-row items-center gap-2 px-4 py-3">
        <Text className="text-xs text-muted-foreground">当前索引:{activeIndex}</Text>
        <Text className="flex-1 text-right text-xs text-muted-foreground">
          {pressedItem ? `点击了 ${pressedItem.text}` : '试着滚动列表看高亮联动'}
        </Text>
      </View>

      <Divider className="my-0" />

      <View className="flex-1">
        <IndexBar
          itemHeight={CUSTOM_ITEM_HEIGHT}
          items={CITY_ITEMS}
          renderItem={renderCityItem}
          sectionHeaderHeight={28}
          onIndexChange={setActiveIndex}
        />
      </View>
    </View>
  );
};

export { IndexBarCustomItem };

传了 renderItemonPressItem 不再生效 —— 点击交互由自定义内容自行处理。

命令式定位

ref.scrollToIndex(index) 按字母定位,行为与点击侧栏一致(滚动 + 更新高亮);字母不在 items 中时静默忽略。

IndexBarImperative.tsx
Loading…
import { Button, Divider, IndexBar, Text } from '@skyroc/native-ui';
import type { IndexBarChild, IndexBarRef } from '@skyroc/native-ui';
import { useRef, useState } from 'react';
import { View } from 'react-native';
import { CITY_ITEMS } from './shared';

/** 命令式定位的几个落点,取首、中、尾 */
const QUICK_INDEXES = ['A', 'M', 'Z'];

const IndexBarImperative = () => {
  const [activeIndex, setActiveIndex] = useState(CITY_ITEMS[0].title);
  const [pressedItem, setPressedItem] = useState<IndexBarChild | null>(null);

  const indexBarRef = useRef<IndexBarRef>(null);

  return (
    <View className="flex-1 bg-background">
      {/* 对外的定位入口是字母而不是下标 */}
      <View className="flex-row items-center gap-3 px-4 py-3">
        {QUICK_INDEXES.map(index => (
          <Button
            key={index}
            color="primary"
            size="sm"
            variant="outline"
            onPress={() => indexBarRef.current?.scrollToIndex(index)}
          >
            {`跳到 ${index}`}
          </Button>
        ))}
      </View>

      <View className="flex-row items-center gap-2 px-4 pb-3">
        <Text className="text-xs text-muted-foreground">当前索引:{activeIndex}</Text>
        <Text className="flex-1 text-right text-xs text-muted-foreground">
          {pressedItem ? `点击了 ${pressedItem.text}` : '试着滚动列表看高亮联动'}
        </Text>
      </View>

      <Divider className="my-0" />

      <View className="flex-1">
        <IndexBar
          ref={indexBarRef}
          items={CITY_ITEMS}
          onIndexChange={setActiveIndex}
          onPressItem={setPressedItem}
        />
      </View>
    </View>
  );
};

export { IndexBarImperative };

样式覆盖

className 作用于根节点,classNames 按 slot 细粒度覆盖:

slot作用位置
root根节点
content列表内容容器(右内边距给索引条让位)
sectionHeader分组标题容器
sectionHeaderText分组标题文字
item子项容器
itemText子项文字
separator子项之间的分隔线
sidebar右缘悬浮的索引条容器
sidebarItem单个索引字母的容器
sidebarItemText索引字母文字(激活态加粗变主题色)
IndexBarSlots.tsx
Loading…
import { Divider, IndexBar, Text } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
import { CITY_ITEMS } from './shared';

const IndexBarSlots = () => {
  const [activeIndex, setActiveIndex] = useState(CITY_ITEMS[0].title);

  return (
    <View className="flex-1 bg-background">
      <View className="flex-row items-center gap-2 px-4 py-3">
        <Text className="text-xs text-muted-foreground">当前索引:{activeIndex}</Text>
      </View>

      <Divider className="my-0" />

      <View className="flex-1">
        <IndexBar
          className="border-y border-border"
          items={CITY_ITEMS}
          classNames={{
            // 索引条加宽了,列表的右内边距要跟着加宽,否则文字会钻到字母底下
            content: 'bg-secondary pr-10',
            item: 'mx-2 rounded-xl bg-background px-4',
            itemText: 'text-sm font-medium text-primary',
            sectionHeader: 'bg-primary/10 px-4',
            sectionHeaderText: 'text-sm font-semibold text-primary',
            // 只把线藏起来,不动高度:分隔线的占位是滚动定位的度量之一
            separator: 'mx-0 my-0 opacity-0',
            sidebar: 'w-10',
            sidebarItem: 'h-6 w-6',
            // 只放大字号,颜色留给 active 变体去决定,覆盖了就分不出激活态了
            sidebarItemText: 'text-sm'
          }}
          onIndexChange={setActiveIndex}
        />
      </View>
    </View>
  );
};

export { IndexBarSlots };

三个容易踩的点:

  • 加宽 sidebar 时要同步加大 content 的右内边距,否则列表文字会钻到字母底下(两者默认都是 28)。
  • separator 只能改颜色 / 透明度,不要改粗细或外边距 —— 它的占位是滚动定位的度量之一。
  • sidebarItemText 覆盖颜色会盖掉激活态,只改字号即可。

索引条是绝对定位悬浮在列表之上的,激活态因此只改文字本身、不加底衬 —— 任何色块都会挡住底下的内容。索引项之间也刻意不留 gap:间隙既会漏出点不到的死区,也会让相邻项的 hitSlop 互相重叠,把点击判给隔壁字母。

无障碍

每个索引字母是 accessibilityRole="button",带 accessibilityLabel(字母本身)与 selected 状态;索引项只有 20dp 宽,横向用 hitSlop 补到 44pt,纵向靠各项首尾相接兜住。

API

IndexBar

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

属性说明类型默认值
items*分组数据,顺序即侧栏索引顺序IndexBarSection[]-
onIndexChange激活索引变化回调,参数是该分组的 title;点击侧栏与滚动联动都会触发(index: string) => void-
onPressItem子项点击回调;传了 renderItem 时不生效(item: IndexBarChild) => void-
renderItem自定义子项渲染,外层仍会套上 itemHeight 的固定高度(item: IndexBarChild, section: IndexBarSection) => ReactNode-
itemHeight子项高度;同时是滚动定位的度量,并被强制套在每个子项外层number40
sectionHeaderHeight分组标题高度;同样参与滚动定位计算number32
sticky是否开启分组标题吸顶booleantrue
haptic点击侧栏索引时是否触发轻触反馈(仅原生端)booleantrue
className根节点类名string-
classNames各 slot 的类名覆盖,见「样式覆盖」一节SlotClassNames<IndexBarSlots>-
ref组件 ref,用于命令式定位Ref<IndexBarRef>-

类型

import type { IndexBarChild, IndexBarProps, IndexBarRef, IndexBarSection, IndexBarSlots } from '@skyroc/native-ui';

IndexBarSlots

可通过 classNames 覆盖的 slot 名称。

'content' | 'item' | 'itemText' | 'root' | 'sectionHeader' | 'sectionHeaderText' | 'separator' | 'sidebar' | 'sidebarItem' | 'sidebarItemText'

SlotClassNames

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

Partial<Record<Slots, string>>

IndexBarSection

分组数据。

字段类型说明
title*string索引字母,同时是分组标题与侧栏字符,必须在 items 内唯一。
children*IndexBarChild[]该分组的子项列表。

IndexBarChild

子项数据。

字段类型说明
key*string唯一标识。
text*string显示文本。

IndexBarRef

组件实例暴露的方法。

字段类型说明
scrollToIndex*(index: string) => void定位到指定索引,行为与点击侧栏一致;索引不存在时静默忽略。

包内还导出了 indexBarVariants