组件概述
TabLayout 是一个 React Native 标签页组件,支持水平和垂直布局、自定义标签样式、等宽/不等宽布局、平滑滚动和居中定位功能。
Props 参数说明
参数名 | 类型 | 默认值 | 说明 |
---|---|---|---|
data | T[] | [] | 数据源数组 |
selectIndex | number | 0 | 初始选中的标签索引 |
tabSpace | number | 0 | 标签之间的间距 |
isEquWidth | boolean | - | 是否启用等宽标签 |
isVertical | boolean | - | 是否垂直布局 |
itemWidth | number | - | 单个标签宽度(当 isEquWidth=false 时生效) |
builder | (item: T, index: number, isSelected: boolean) => React.ReactNode | - | 自定义标签渲染函数 |
onSelectChange | (index: number) => void | () => {} | 标签选中回调 |
keyExtractor | (item: T, index: number) => string | - | 提取唯一键的函数 |
style | StyleProp | - | 自定义容器样式 |
使用示例
// 水平布局示例
<TabLayout
data={['标签1', '标签2', '标签3']}
selectIndex={0}
tabSpace={10}
isEquWidth={true}
builder={(item, index, isSelected) => (
<Text style={{color: isSelected ? 'red' : 'black'}}>
{item}
</Text>
)}
onSelectChange={(index) => console.log('选中:', index)}
/>
// 垂直布局示例
<TabLayout
data={['标签1', '标签2', '标签3']}
selectIndex={0}
tabSpace={10}
isVertical={true}
builder={(item, index, isSelected) => (
<Text style={{color: isSelected ? 'red' : 'black'}}>
{item}
</Text>
)}
onSelectChange={(index) => console.log('选中:', index)}
/>
- 注: 如果不能确定父组件是否正常更新,可以给
<TabLayout>
添加key
强制刷新,强制让<TabLayout>
在数据变化时重建。
源码
import React, {Component, createRef} from 'react';
import {
FlatList,
LayoutChangeEvent,
StyleProp,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native';
type TabLayoutProps<T> = {
/**
* 数据源
*/
data?: T[];
/**
* 选中的索引
*/
selectIndex?: number;
/**
* 标签之间的间距
*/
tabSpace?: number;
/**
* 是否等宽
*/
isEquWidth?: boolean;
/**
* 是否垂直
*/
isVertical?: boolean;
/**
* 单个item宽度
*/
itemWidth?: number;
/**
* 单个item高度
*/
itemHeight?: number;
/**
* 自定义item渲染
* @param item 当前项
* @param isSelected 是否选中
* @param index 索引
*/
builder?: (item: T, index: number, isSelected: boolean) => React.ReactNode;
/**
* 选中回调
* @param index 选中的索引
*/
onSelectChange?: (index: number) => void;
/**
* 用于提取给定索引处的项的唯一键。键用于缓存和作为跟踪项重新排序的react键。默认提取器检查item.key,然后回退到使用索引,如React一样。
*/
keyExtractor?: ((item: T, index: number) => string) | undefined;
/**
* 自定义样式
*/
style?: StyleProp<ViewStyle> | undefined;
/**
* 是否弹性换行
*/
isWrap?: boolean;
};
type TabLayoutState = {
selectedIndex: number;
containerWidth: number;
containerHeight: number;
itemWidthList: number[];
itemHeightList: number[];
};
export default class TabLayout<T> extends Component<
TabLayoutProps<T>,
TabLayoutState
> {
static defaultProps = {
selectIndex: 0,
tabSpace: 0,
onSelectChange: () => {},
builder: undefined,
data: [],
};
private listRef = createRef<FlatList<any>>();
state: TabLayoutState = {
selectedIndex: 0,
containerWidth: 0,
containerHeight: 0,
itemWidthList: [],
itemHeightList: [],
};
componentDidUpdate(
prevProps: Readonly<TabLayoutProps<T>>,
prevState: Readonly<TabLayoutState>,
snapshot?: any,
) {
if (prevProps.selectIndex !== this.props.selectIndex) {
const index = this.props.selectIndex ?? 0;
this.scrollToIndex(index);
}
if (prevProps.data !== this.props.data) {
this.setState({
containerWidth: 0, // 重置宽度,让 onLayout 重新计算
containerHeight: 0, // 重置高度
itemHeightList: new Array(this.props.data?.length ?? 0).fill(0),
itemWidthList: new Array(this.props.data?.length ?? 0).fill(0),
});
}
}
// 获取容器尺寸
onLayout = (event: LayoutChangeEvent) => {
const {width, height} = event.nativeEvent.layout;
if (
width !== this.state.containerWidth ||
height !== this.state.containerHeight
) {
this.setState({containerWidth: width, containerHeight: height});
}
this.setState({
selectedIndex: this.props.selectIndex ?? 0,
});
};
// 计算尺寸
getItemSize = (index: number) => {
const {isVertical} = this.props;
const {itemWidthList, itemHeightList} = this.state;
if (isVertical) {
return itemHeightList[index] ?? 0;
} else {
return itemWidthList[index] ?? 0;
}
};
handlePress = (index: number) => {
const {onSelectChange} = this.props;
onSelectChange?.(index);
this.scrollToIndex(index);
};
scrollToIndex = (index: number) => {
if (this.state.selectedIndex !== index) {
this.setState({selectedIndex: index});
}
if (!this.listRef.current) {
return;
}
const len = this.props.data?.length ?? 0;
if (index < (this.props.data?.length ?? 0) && index >= 0) {
this.listRef.current.scrollToIndex({
index,
animated: true,
viewPosition: index === len - 1 ? 0 : 0.5,
});
}
};
renderItem = ({item, index}: {item: T; index: number}) => {
const isSelected = index === this.state.selectedIndex;
const isVertical = this.props.isVertical;
return (
<TouchableOpacity
activeOpacity={1}
onPress={() => this.handlePress(index)}
onLayout={e => {
this.updateSize(e, index, isVertical);
}}>
{this.props.builder?.(item, index, isSelected)}
</TouchableOpacity>
);
};
updateSize(
e: LayoutChangeEvent,
index: number,
isVertical: boolean | undefined,
) {
if (isVertical) {
const itemHeightList = this.state.itemHeightList;
const h = itemHeightList[index];
let newH = e.nativeEvent.layout.height;
if (h > 0 && h >= newH) {
return;
}
itemHeightList[index] = newH;
this.setState({
itemHeightList: itemHeightList,
});
} else {
const itemWidthList = this.state.itemWidthList;
const w = itemWidthList[index];
let newW = e.nativeEvent.layout.width;
if (w > 0 && w >= newW) {
return;
}
itemWidthList[index] = newW;
this.setState({
itemWidthList: itemWidthList,
});
}
}
render() {
const {
data,
tabSpace,
isVertical,
keyExtractor,
style,
isWrap,
isEquWidth,
} = this.props;
const {selectedIndex} = this.state;
if (isWrap) {
return (
<View
style={[
{
flexDirection: 'row',
flexWrap: 'wrap',
},
style,
]}>
{data?.map((item, index) => {
return (
<View
style={{
marginRight: tabSpace,
marginBottom: tabSpace,
}}
key={keyExtractor?.(item, index)}>
{this.renderItem({item, index})}
</View>
);
})}
</View>
);
}
if (isEquWidth) {
return (
<View
style={[
{
flexDirection: 'row',
},
style,
]}>
{data?.map((item, index) => {
return (
<View style={{flex: 1}} key={keyExtractor?.(item, index)}>
{this.renderItem({item, index})}
</View>
);
})}
</View>
);
}
return (
<View style={style}>
<FlatList
ref={this.listRef}
data={data}
horizontal={!isVertical}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: isVertical ? undefined : tabSpace,
paddingVertical: isVertical ? tabSpace : undefined,
}}
keyExtractor={keyExtractor}
renderItem={this.renderItem}
bounces={false}
onLayout={this.onLayout}
getItemLayout={(d, index) => ({
length: this.getItemSize(index),
offset: this.getItemSize(index) * index,
index,
})}
initialScrollIndex={selectedIndex}
/>
</View>
);
}
}