AnchorNav
左侧分组侧栏 + 右侧分组列表的锚点导航,点击定位、滚动联动
锚点导航(AnchorNav)把左侧的分组侧栏和右侧的分组列表绑在一起:点侧栏滚动到对应分组,滚动列表反过来高亮对应的侧栏项。右侧列表是 React Native 的 SectionList(分组标题默认吸顶),左侧默认复用 Sidebar,也可以整个换成自定义节点。
import { AnchorNav } from '@skyroc/native-ui';AnchorNav 的根节点是 flex-1 flex-row,必须放在一个有确定高度的容器里才能滚动。
基础用法
items 是分组数据,每组含 title 与 children;子项的 key 必填,text 为显示文本。侧栏项的 badge / dot / disabled 也写在分组上。
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」互相打架。
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 的固定高度。传了 renderItem 时 onPressItem 不再触发,点击交互由自定义内容自己处理,accessibilityRole / accessibilityLabel 也需要自己补。
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,此时 sidebarClassNames 与 classNames.sidebar 一并失效,自定义节点自己管样式。滚动定位、高亮联动与触感反馈仍由 AnchorNav 负责,通过入参 { activeIndex, items, onPressIndex } 拿。
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-haptics 的 selectionAsync)。
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 的全部属性(style、testID 等)到根节点,children 除外。
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| items* | 分组数据 | AnchorNavSection[] | - |
| activeIndex | 受控激活分组索引;只负责高亮,不驱动滚动 | number | - |
| defaultActiveIndex | 非受控默认激活分组索引 | number | 0 |
| onIndexChange | 激活分组变化回调,点击侧栏与滚动联动都会触发 | (index: number) => void | - |
| onPressItem | 点击子项回调;传了 renderItem 时不生效 | (item: AnchorNavChild) => void | - |
| itemHeight | 子项高度,同时是滚动定位的度量,会强制套在每个子项外层 | number | 64 |
| sectionHeaderHeight | 分组标题高度,同样参与滚动定位的计算 | number | 32 |
| sticky | 是否开启分组标题吸顶 | boolean | true |
| haptic | 点击侧栏时是否触发轻触反馈 | boolean | true |
| 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 覆盖的插槽名称。
SidebarSlots
默认 Sidebar 可通过 sidebarClassNames 覆盖的插槽名称。
SlotClassNames
classNames 的取值形态:把 slot 名映射到类名,每个 slot 都可选。本页用到的 slot 见 AnchorNavSlots / SidebarSlots。
AnchorNavSection
分组数据。
| 字段 | 类型 | 说明 |
|---|---|---|
| title* | string | 分组标题,同时作为侧栏项的文字。 |
| children* | AnchorNavChild[] | 子项列表。 |
| key | string | 唯一标识,只用于侧栏列表的 key;不传时回退到下标。 |
| badge | ReactNode | 徽标内容,显示在侧栏对应项上。 |
| dot | boolean | 是否在侧栏对应项显示小红点。 |
| disabled | boolean | 是否禁用,只作用于侧栏该项——禁用后无法点击跳转,滚动联动仍会高亮。 |
AnchorNavChild
分组内的子项数据。
| 字段 | 类型 | 说明 |
|---|---|---|
| key* | string | 唯一标识,作为列表 key。 |
| text* | string | 显示文本,同时作为默认渲染的无障碍标签。 |
AnchorNavSidebarContext
renderSidebar 拿到的上下文。
| 字段 | 类型 | 说明 |
|---|---|---|
| activeIndex* | number | 当前激活分组索引。 |
| items* | AnchorNavSection[] | 分组数据,与传入的 items 是同一份引用。 |
| onPressIndex* | (index: number) => void | 定位到指定分组,与点击默认侧栏走同一条路径(滚动 + 高亮 + 触感)。 |
AnchorNavRef
ref 暴露的方法。
| 字段 | 类型 | 说明 |
|---|---|---|
| scrollToSection* | (index: number) => void | 定位到指定分组,行为与点击侧栏一致(滚动 + 更新激活索引);索引越界时不做任何事。 |