Skyroc Native UI

AnchorNav

左侧分组侧栏 + 右侧分组列表的锚点导航,点击定位、滚动联动

锚点导航(AnchorNav)把左侧的分组侧栏和右侧的分组列表绑在一起:点侧栏滚动到对应分组,滚动列表反过来高亮对应的侧栏项。右侧列表是 React Native 的 SectionList(分组标题默认吸顶),左侧默认复用 Sidebar,也可以整个换成自定义节点。

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

AnchorNav 的根节点是 flex-1 flex-row,必须放在一个有确定高度的容器里才能滚动。

基础用法

items 是分组数据,每组含 titlechildren;子项的 key 必填,text 为显示文本。侧栏项的 badge / dot / disabled 也写在分组上。

AnchorNavBasic.tsx
Loading…
import { AnchorNav, Divider, Text } from '@skyroc/native-ui';
import type { AnchorNavChild } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
import { LIBRARY_DATA } from './shared';

const AnchorNavBasic = () => {
  const [activeIndex, setActiveIndex] = useState(0);
  const [pressedItem, setPressedItem] = useState<AnchorNavChild | null>(null);

  function handlePressItem(item: AnchorNavChild) {
    setPressedItem(item);
  }

  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} · {LIBRARY_DATA[activeIndex].title}
        </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">
        <AnchorNav
          items={LIBRARY_DATA}
          onIndexChange={setActiveIndex}
          onPressItem={handlePressItem}
        />
      </View>
    </View>
  );
};

export { AnchorNavBasic };

何时使用

  • 商品分类、组件索引这类「左分类、右内容」的二级列表。
  • 分组数量在十个上下、每组条目不多,需要在分组之间快速跳转。
  • 只需要一列可选分类、右侧内容自行渲染时用 Sidebar;按首字母索引长列表时用 IndexBar

高度模型

这是使用 AnchorNav 最需要注意的一点:滚动定位是按固定高度算出来的,不是量出来的

组件用 itemHeight(默认 64)与 sectionHeaderHeight(默认 32)累加出每个分组头的纵向起点,点击侧栏的定位和滚动联动的反查共用这张表。为了让计算成立,组件会把这两个值以 style 强制套在子项与分组头的外层节点上 —— 自定义渲染的内容必须能在这个高度内显示完整。

因此:

  • 改了子项或分组头的视觉高度,必须同步改 itemHeight / sectionHeaderHeight
  • classNames.separator 不要改分隔线的粗细(默认是 StyleSheet.hairlineWidth),它同样计入偏移量;改了会让定位随分组数线性漂移。
  • 子项高度不能随内容变化,AnchorNav 不支持不等高列表。

受控与命令式定位

activeIndex / onIndexChange 控制高亮(非受控时用 defaultActiveIndex)。注意 activeIndex 只负责高亮,不驱动滚动 —— 外部要跳转请用 ref.scrollToSection(index),它和点击侧栏走同一条路径(滚动 + 更新索引),免得「改 prop」和「滚动联动改 prop」互相打架。

AnchorNavControlled.tsx
Loading…
import { AnchorNav, Button, Divider, Text } from '@skyroc/native-ui';
import type { AnchorNavChild, AnchorNavRef } from '@skyroc/native-ui';
import { useRef, useState } from 'react';
import { View } from 'react-native';
import { LIBRARY_DATA } from './shared';

const AnchorNavControlled = () => {
  const [activeIndex, setActiveIndex] = useState(0);
  const [pressedItem, setPressedItem] = useState<AnchorNavChild | null>(null);

  const anchorRef = useRef<AnchorNavRef>(null);

  const isFirstSection = activeIndex === 0;
  const isLastSection = activeIndex === LIBRARY_DATA.length - 1;

  function handlePressItem(item: AnchorNavChild) {
    setPressedItem(item);
  }

  return (
    <View className="flex-1 bg-background">
      <View className="flex-row items-center gap-3 px-4 py-3">
        <Button
          disabled={isFirstSection}
          size="sm"
          variant="outline"
          onPress={() => anchorRef.current?.scrollToSection(activeIndex - 1)}
        >
          上一组
        </Button>
        <Button
          disabled={isLastSection}
          size="sm"
          variant="tonal"
          onPress={() => anchorRef.current?.scrollToSection(activeIndex + 1)}
        >
          下一组
        </Button>
      </View>

      <View className="flex-row items-center gap-2 px-4 pb-3">
        <Text className="text-xs text-muted-foreground">
          当前分组:{activeIndex} · {LIBRARY_DATA[activeIndex].title}
        </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">
        <AnchorNav
          ref={anchorRef}
          activeIndex={activeIndex}
          items={LIBRARY_DATA}
          onIndexChange={setActiveIndex}
          onPressItem={handlePressItem}
        />
      </View>
    </View>
  );
};

export { AnchorNavControlled };

索引是位置而非身份:items 增删或重排后,调用方需要自行校正 activeIndex

自定义子项

renderItem 接收 (item, section) 返回自定义节点,外层仍会套上 itemHeight 的固定高度。传了 renderItemonPressItem 不再触发,点击交互由自定义内容自己处理,accessibilityRole / accessibilityLabel 也需要自己补。

AnchorNavCustomItem.tsx
Loading…
import { AnchorNav, Divider, Text } from '@skyroc/native-ui';
import type { AnchorNavChild, AnchorNavSection } from '@skyroc/native-ui';
import { useState } from 'react';
import { Pressable, View } from 'react-native';
import { LIBRARY_DATA } from './shared';

const CUSTOM_ITEM_HEIGHT = 72;

function getExampleCount(key: string) {
  const seed = [...key].reduce((sum, character) => sum + character.charCodeAt(0), 0);

  return 2 + (seed % 7);
}

const AnchorNavCustomItem = () => {
  const [activeIndex, setActiveIndex] = useState(0);
  const [pressedItem, setPressedItem] = useState<AnchorNavChild | null>(null);

  function handlePressItem(item: AnchorNavChild) {
    setPressedItem(item);
  }

  function renderLibraryItem(item: AnchorNavChild, section: AnchorNavSection) {
    return (
      <Pressable
        accessibilityRole="button"
        className="h-full flex-row items-center gap-3 px-3 active:opacity-80"
        onPress={() => handlePressItem(item)}
      >
        <View className="size-10 items-center justify-center rounded-xl bg-primary/10">
          <Text className="text-sm font-semibold text-primary">{section.title.slice(0, 1)}</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">
            {section.title} · {getExampleCount(item.key)} 个示例
          </Text>
        </View>
        <Text className="text-xs font-medium text-primary">查看</Text>
      </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} · {LIBRARY_DATA[activeIndex].title}
        </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">
        <AnchorNav
          itemHeight={CUSTOM_ITEM_HEIGHT}
          items={LIBRARY_DATA}
          renderItem={renderLibraryItem}
          sectionHeaderHeight={28}
          onIndexChange={setActiveIndex}
        />
      </View>
    </View>
  );
};

export { AnchorNavCustomItem };

自定义侧栏

renderSidebar 完全取代默认的 Sidebar,此时 sidebarClassNamesclassNames.sidebar 一并失效,自定义节点自己管样式。滚动定位、高亮联动与触感反馈仍由 AnchorNav 负责,通过入参 { activeIndex, items, onPressIndex } 拿。

AnchorNavCustomSidebar.tsx
Loading…
import { AnchorNav, Divider, Text } from '@skyroc/native-ui';
import type { AnchorNavChild, AnchorNavSidebarContext } from '@skyroc/native-ui';
import { useState } from 'react';
import { Pressable, ScrollView, View } from 'react-native';
import { LIBRARY_DATA } from './shared';

function renderCustomSidebar(context: AnchorNavSidebarContext) {
  const { activeIndex, items, onPressIndex } = context;

  return (
    <ScrollView
      className="w-20 shrink-0 grow-0 bg-muted/50"
      contentContainerClassName="gap-1 py-2"
      showsVerticalScrollIndicator={false}
    >
      {items.map((item, index) => (
        <Pressable
          accessibilityRole="button"
          className={
            activeIndex === index
              ? 'mx-2 min-h-12 justify-center rounded-xl bg-primary/10 px-2'
              : 'mx-2 min-h-12 justify-center rounded-xl px-2'
          }
          disabled={item.disabled}
          key={item.key ?? index}
          onPress={() => onPressIndex(index)}
        >
          <Text
            className={
              activeIndex === index
                ? 'text-center text-xs font-semibold text-primary'
                : 'text-center text-xs text-muted-foreground'
            }
          >
            {item.title}
          </Text>
        </Pressable>
      ))}
    </ScrollView>
  );
}

const AnchorNavCustomSidebar = () => {
  const [activeIndex, setActiveIndex] = useState(0);
  const [pressedItem, setPressedItem] = useState<AnchorNavChild | null>(null);

  function handlePressItem(item: AnchorNavChild) {
    setPressedItem(item);
  }

  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} · {LIBRARY_DATA[activeIndex].title}
        </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">
        <AnchorNav
          items={LIBRARY_DATA}
          renderSidebar={renderCustomSidebar}
          onIndexChange={setActiveIndex}
          onPressItem={handlePressItem}
        />
      </View>
    </View>
  );
};

export { AnchorNavCustomSidebar };

自定义侧栏的节点排在列表之后渲染:常规流里就是列表右侧的一列,绝对定位则悬浮在列表之上 —— 靠的是绘制顺序而不是 zIndex(后者在 Android 上不总可靠)。

插槽与行为定制

classNames 覆盖 AnchorNav 自己的插槽,sidebarClassNames 透传给默认 Sidebar 的插槽,两者分层清晰:

slot作用位置
root根容器(横向布局)
sidebar默认侧栏的外层容器(宽度约束写在这里)
content右侧 SectionList
sectionHeader分组标题容器
sectionHeaderText分组标题文字
item默认子项的 Pressable
itemText默认子项文字
separator子项之间的分隔线(不要改粗细)

sticky 关闭分组标题吸顶,haptic 关闭点击侧栏时的轻触反馈(基于 expo-hapticsselectionAsync)。

AnchorNavSlots.tsx
Loading…
import { AnchorNav, Divider, Text } from '@skyroc/native-ui';
import { useState } from 'react';
import { View } from 'react-native';
import { LIBRARY_DATA } from './shared';

const AnchorNavSlots = () => {
  const [activeIndex, setActiveIndex] = useState(0);

  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} · {LIBRARY_DATA[activeIndex].title}
        </Text>
        <Text className="flex-1 text-right text-xs text-muted-foreground">滚动列表可观察高亮联动</Text>
      </View>

      <Divider className="my-0" />

      <View className="flex-1">
        <AnchorNav
          haptic={false}
          itemHeight={52}
          items={LIBRARY_DATA}
          sectionHeaderHeight={40}
          sticky={false}
          classNames={{
            content: 'bg-secondary',
            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-24 bg-primary/5'
          }}
          sidebarClassNames={{
            indicator: 'h-8 w-1 rounded-sm bg-destructive',
            itemText: 'text-xs'
          }}
          onIndexChange={setActiveIndex}
        />
      </View>
    </View>
  );
};

export { AnchorNavSlots };

侧栏宽度默认是 w-20,通过 classNames.sidebar 覆盖;这个约束落在 Sidebar 外层的普通 View 上,用来隔离 RN Web 给竖向 ScrollView 注入的 flexGrow: 1,直接改 sidebarClassNames.root 达不到同样效果。

联动细节

  • 点击侧栏后会抑制滚动联动,直到程序化滚动落幕(onMomentumScrollEnd)或用户上手拖动(onScrollBeginDrag),否则高亮会沿途逐格跳过去。
  • 分组的 disabled 只作用于侧栏该项 —— 禁用后无法点击跳转,但滚动到该组时仍会高亮。
  • 触感反馈按「每次点击」触发,不按索引是否变化去重:点击是一次明确的用户动作。

无障碍

默认渲染下,分组标题是 accessibilityRole="header",子项是 accessibilityRole="button" 且以 item.text 作为 accessibilityLabel。使用 renderItem / renderSidebar 时这些语义需要在自定义节点里自行补齐。

API

AnchorNav

除下表外,AnchorNav 透传 View 的全部属性(styletestID 等)到根节点,children 除外。

属性说明类型默认值
items*分组数据AnchorNavSection[]-
activeIndex受控激活分组索引;只负责高亮,不驱动滚动number-
defaultActiveIndex非受控默认激活分组索引number0
onIndexChange激活分组变化回调,点击侧栏与滚动联动都会触发(index: number) => void-
onPressItem点击子项回调;传了 renderItem 时不生效(item: AnchorNavChild) => void-
itemHeight子项高度,同时是滚动定位的度量,会强制套在每个子项外层number64
sectionHeaderHeight分组标题高度,同样参与滚动定位的计算number32
sticky是否开启分组标题吸顶booleantrue
haptic点击侧栏时是否触发轻触反馈booleantrue
renderItem自定义子项渲染,外层仍会套上 itemHeight 的固定高度(item: AnchorNavChild, section: AnchorNavSection) => ReactNode-
renderSidebar自定义侧栏渲染,传了就完全取代默认 Sidebar(context: AnchorNavSidebarContext) => ReactNode-
className根节点类名string-
classNames各插槽类名;separator 不要改线条粗细,传了 renderSidebar 时 sidebar 不生效SlotClassNames<AnchorNavSlots>-
sidebarClassNames默认 Sidebar 内部各插槽的类名;传了 renderSidebar 时不生效SlotClassNames<SidebarSlots>-
ref组件 ref,用于命令式定位Ref<AnchorNavRef>-

类型

import type {
  AnchorNavChild,
  AnchorNavProps,
  AnchorNavRef,
  AnchorNavSection,
  AnchorNavSidebarContext,
  AnchorNavSlots
} from '@skyroc/native-ui';

AnchorNavSlots

AnchorNav 可通过 classNames 覆盖的插槽名称。

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

SidebarSlots

默认 Sidebar 可通过 sidebarClassNames 覆盖的插槽名称。

'content' | 'indicator' | 'item' | 'itemText' | 'root'

SlotClassNames

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

Partial<Record<Slots, string>>

AnchorNavSection

分组数据。

字段类型说明
title*string分组标题,同时作为侧栏项的文字。
children*AnchorNavChild[]子项列表。
keystring唯一标识,只用于侧栏列表的 key;不传时回退到下标。
badgeReactNode徽标内容,显示在侧栏对应项上。
dotboolean是否在侧栏对应项显示小红点。
disabledboolean是否禁用,只作用于侧栏该项——禁用后无法点击跳转,滚动联动仍会高亮。

AnchorNavChild

分组内的子项数据。

字段类型说明
key*string唯一标识,作为列表 key。
text*string显示文本,同时作为默认渲染的无障碍标签。

AnchorNavSidebarContext

renderSidebar 拿到的上下文。

字段类型说明
activeIndex*number当前激活分组索引。
items*AnchorNavSection[]分组数据,与传入的 items 是同一份引用。
onPressIndex*(index: number) => void定位到指定分组,与点击默认侧栏走同一条路径(滚动 + 高亮 + 触感)。

AnchorNavRef

ref 暴露的方法。

字段类型说明
scrollToSection*(index: number) => void定位到指定分组,行为与点击侧栏一致(滚动 + 更新激活索引);索引越界时不做任何事。