react-native 通讯录字母列表
背景
需求:实现一个选择快递的页面,操作类似于通讯录,设计图如下
上半部分的搜索交互及实现这里就不赘述,重点探讨字母列表的实现。
需要实现的点:
- 主列表分组,头部不吸顶
- 字母列表可以滑动或点击,变更当前选中的字母,且主列表跟随滑动定位
- 主列表滚动时,字母列表当前选中的字母对应变化
搜索了目前已有的开源组件,要么最后一次更新的时间也比较久远,要么不适用于我们的场景,要么 star 数量不多 😂。因此打算重新造个轮子,即使将来有什么问题,也能自行维护。
AlphabetList
将我们的组件命名为 AlphabetList,它将接收一个 dataSource :
<AlphabetList dataSource={dataSource} />
dataSource 的结构如下:
interface IListItem {
name: string;
value: string | number;
}
interface ISectionItem {
sectionKey: string;
data: {
name: string;
value: string
}[]
}
interface DataSource: SectionItem[]
例如:
[
{
sectionKey: '常',
data: [
{
name: '顺丰物流',
value: '1',
},
{
name: '京东快递',
value: '2',
},
],
},
{
sectionKey: 'A',
data: [
{
name: '安能物流',
value: '3',
},
{
name: '澳大利亚邮政',
value: '4',
},
],
}
]
AlphabetList 的结构如下:
<View>
{/* 主列表 */}
<MainList />
{/* 右侧字母列表 */}
<SideBar />
</View>
MainList
主列表分组 Main,那么使用 SectionList 是一个不错的选择(当然也可以用 FlatList 来实现)
当滚动 MainList 时,通过 onListViewableItemsChanged 的回调可以得到当前窗口可视的分组,从而改变当前选中的字母
const MainList = ({
dataSource,
onMainListViewableItemsChanged,
}) => {
return (
<SectionList
renderItem={({ item }) => (<ListItem item={item} />)}
renderSectionHeader={({ section }) => <SectionHeader section={section} />}
keyExtractor={(item) => `${item.value}`}
sections={dataSource}
stickySectionHeadersEnabled={false}
onViewableItemsChanged={onMainListViewableItemsChanged}
/>
);
};
export default MainList;
在 AlphabetList 中:
const AlphabetList = ({ dataSource }: IProps) => {
// 右侧字母表列表
const letterlist = dataSource.map((item) => {
return item.sectionKey;
});
const [currentSectionKey, setCurrentSectionKey] = useState(letterlist);
const onMainListViewableItemsChanged = (info: {
viewableItems: ViewToken[];
}) => {
// 将选中的字母设置为当前第一个可见 section 的 sectionKey
setCurrentSectionKey(info.viewableItems[0].section.sectionKey);
};
return (
<View style={styles.container}>
{/* 主列表 start */}
<MainList
dataSource={dataSource}
onMainListTouchStart={() => setCurrentResponder('mainList')}
onMainListViewableItemsChanged={onMainListViewableItemsChanged}
/>
{/* 主列表 end */}
{/* 右侧字母列表 start */}
<SideBar />
{/* 右侧字母列表 end */}
</View>
);
};
export default AlphabetList;
SideBar
侧边栏的实现比起来则要复杂一些,这里使用一个绝对定位的 FlatList,实际上简单使用 View 去实现也是可以的。
const SideBar = ({
letterlist,
// 回调
handleSideBarItemRespond,
currentSectionKey
}: IProps) => {
return (
<FlatList
style={styles.wrap}
scrollEnabled={false}
data={letterlist}
renderItem={({ item, index }) => (
<Item
item={item}
handleSideBarItemRespond={handleSideBarItemRespond}
letterlist={letterlist}
/>
)}
keyExtractor={item => item}
/>
);
};
一开始我的想法是通过监听 Item 的 onPress | onPressIn ,从一项滑动到另一项的时候,依次触发事件,实验后发现,在释放手势之前,仅会有一个响应的元素。
这里就得提一下 RN 的手势系统了。
Responder
在 React Native 中,响应手势的基本单位是responder,并且仅有一个 responder 拥有控制权。在 View 上有这些属性:
- onStartShouldSetResponder:点击时是否变成一个 responder
- onMoveShouldSetResponder:滑动时是否变成一个 responder
- onResponderGrant: 点击时的回调
- onResponderMove:移动时的回调
- onResponderTerminationRequest:当别的元素希望成为 responder 时,是否要释放当前的 responder?
- onResponderRelease:当 reponder 释放时的回调
- 等等
通过上述这些 API 我们就能很容易的实现一些手势。初次之外,RN 还提供了更高级的抽象 PanResponder,它可以给我们提供更多手势信息,这里不展开解释。
不过当我尝试在每个 Item 上设置手势响应,并且设置 onResponderTerminationRequest 为 () => true,手势依然没有在切换 Item 时释放原 responder。
因此只能通过计算高度来获取滑动时当前的字母
// evt.nativeEvent.pageY: 触摸事件的 pageY
// sideBarTop: SideBar 的 pageY
// letterHeight:每个字母的高度
const index = Math.floor((evt.nativeEvent.pageY - sideBarTop) / letterHeight);
要获取元素距离屏幕的高度,可以在 onLayout 的时候,调用 measure 来获取:
const SideBar = ({
letterlist,
handleSideBarItemRespond,
currentSectionKey,
letterHeight,
}: IProps) => {
const [sideBarTop, setSideBarTop] = useState(0);
const sideBarRef = useRef<View>(null);
const measure = () => {
if (sideBarRef.current) {
sideBarRef.current.measure((x, y, width, height, pageX, pageY) => {
setSideBarTop(pageY);
});
}
};
return (
<View onLayout={measure} ref={sideBarRef}>
<FlatList<string>
// ...
/>
</View>
);
};
然后就是 Item 的实现:
const Item = ({
item,
handleSideBarItemRespond,
isCurrentSectionKey,
letterlist,
letterHeight,
sideBarTop,
}: {
item: string;
isFirst: boolean;
handleSideBarItemRespond: (item: string) => void;
letterlist: string[];
letterHeight: number;
sideBarTop?: number;
}) => {
const panResponder = PanResponder.create({
onStartShouldSetPanResponderCapture: () => true,
onStartShouldSetPanResponder: () => true,
// 尝试通过 onResponderTerminationRequest 来终止手势,发现不生效,只能通过计算偏移量来获取当前选中
onPanResponderTerminationRequest: () => true,
onPanResponderGrant: () => {
handleSideBarItemRespond(item);
},
onPanResponderMove: (evt, gs) => {
if (!sideBarTop) {
return;
}
const index = Math.floor(
(evt.nativeEvent.pageY - sideBarTop) / letterHeight,
);
if (index >= 0 && index < letterlist.length) {
// 触发侧边字母选中的回调
handleSideBarItemRespond(letterlist[index]);
}
},
});
return (
<View {...panResponder.panHandlers}>
<Text>
{item}
</Text>
</View>
);
};
到此为止,侧边栏的逻辑已经完成。现在处理侧边栏事件的回调逻辑, 主要通过 scrollToLocation 来实现列表滚动到对应的位置
const AlphabetList = ({ dataSource, companyList, onCompanySelect }: IProps) => {
// ...
const mainListRef = useRef<SectionList<IListItem>>(null);
const handleSideBarItemRespond = (item: string) => {
if (!mainListRef.current) {
return;
}
// 记录当前选中的字母
setCurrentSectionKey(item);
mainListRef.current.scrollToLocation({
sectionIndex: dataSource.findIndex((data) => data.sectionKey === item),
itemIndex: 0,
animated: false,
});
};
return (
<View style={styles.container}>
{/* 主列表 start */}
<MainList
// ...
ref={mainListRef}
/>
// ...
</View>
);
};
这里需要通过 React.forwardRef 获取到 MainList 的 ref
const MainList: React.ForwardRefRenderFunction<
SectionList<IListItem>,
IProps
> = (
{
dataSource,
// ...
},
ref,
) => {
// ...
return (
<SectionList
// ...
ref={ref}
/>
);
};
export default React.forwardRef(MainList)
到这一步,已经基本完成了一个通讯录字母列表。
其他细节
在完成上述功能后,由于点击侧边栏会滚动主列表,此时也会触发 MainList 的 onViewableItemsChanged,因此需要一个标识来区分是什么动作触发的:
const AlphabetList = ({ dataSource, companyList, onCompanySelect }: IProps) => {
// 标识位:用于区分当前的操作是由什么触发的
const [currentResponder, setCurrentResponder] = useState('');
const mainListRef = useRef<SectionList<IListItem>>(null);
// 右侧字母表列表
const letterlist = dataSource.map((item) => {
return item.sectionKey;
});
const [currentSectionKey, setCurrentSectionKey] = useState(letterlist[0]);
const handleSideBarItemRespond = (item: string) => {
if (!mainListRef.current) {
return;
}
setCurrentResponder('sideBar');
// ...
};
const onMainListViewableItemsChanged = (info: {
viewableItems: ViewToken[];
}) => {
if (
// 侧边栏触发的滚动则不触发
currentResponder === 'sideBar' ||
!info.viewableItems[0].section.sectionKey
) {
return;
}
setCurrentSectionKey(info.viewableItems[0].section.sectionKey);
};
return (
<View style={styles.container}>
{/* 主列表 start */}
<MainList
// ...
onMainListTouchStart={() => setCurrentResponder('mainList')}
// ...
/>
{/* 主列表 end */}
// ...
</View>
);
};
踩坑
功能是基本上完成了,然而由于这里的数据量比较多,大概有 600 条数据,且由于 SectionList 也是基于 VirtualList,因此导致了以下长列表固有的几个问题:
- 快速滚动 MainList,当 SectionList 来不及渲染时,会导致屏幕空白
- 当使用 scrollToLocation 时,如果定位到来不及渲染的区域,将会报错,提示需要设置 getItemLayout 属性,这个属性是用于计算主列表每一项高度以及偏移量的
在尝试了一些优化的手段后,如 React.memo, initialNumToRender,getItemLayout 等属性依然无法有效解决上述问题。。
RN 的 list 真的是让人一言难尽。。不知道是否原生组件或者 flutter 对长列表的支持会更好一点呢 😂