如何为React Native FlatList实现重排序动画?(点击图标置顶选中项)
嘿,刚好我之前做过几乎一模一样的需求!这种「点击选中项跳转到顶部+交换动画」的实现,核心是用React Native的Animated库精准控制列表项的位置过渡,而不是依赖FlatList的默认排序逻辑。我给你拆解下具体实现思路和可运行的代码示例:
核心思路
- 状态分层管理:用数组维护列表数据源,标记选中项;用
useRef存储每个列表项的实时布局位置和动画实例,避免状态频繁更新导致的重绘问题。 - 动画触发逻辑:
- 首次选中:让目标项从原位置平滑移动到列表顶部,动画结束后更新数据源顺序。
- 切换选中:同时触发旧选中项和新选中项的位置交换动画,动画完成后再更新数据源(避免列表瞬间重排打断动画)。
- 布局精准获取:通过
onLayout事件记录每个项的坐标,确保动画位移的计算完全贴合实际界面位置。
完整代码示例
import React, { useState, useRef, useCallback } from 'react'; import { View, Text, TouchableOpacity, Animated, FlatList, StyleSheet } from 'react-native'; export default function AnimatedSortList() { // 列表数据源:包含ID、文本、选中状态 const [data, setData] = useState([ { id: '1', text: '日常购物清单', isSelected: false }, { id: '2', text: '工作待办事项', isSelected: false }, { id: '3', text: '周末出行计划', isSelected: false }, { id: '4', text: '学习笔记整理', isSelected: false }, ]); // 存储每个列表项的布局信息(坐标、宽高) const itemLayouts = useRef({}); // 存储每个项的动画实例,避免重复创建 const animatedValues = useRef({}); // 记录列表项的实时布局 const handleItemLayout = useCallback((itemId) => (event) => { const { x, y, width, height } = event.nativeEvent.layout; itemLayouts.current[itemId] = { x, y, width, height }; }, []); // 处理选中事件:核心动画逻辑 const handleSelectItem = useCallback((selectedId) => { const currentSelectedIdx = data.findIndex(item => item.isSelected); const newSelectedIdx = data.findIndex(item => item.id === selectedId); // 重复点击同一项,直接返回 if (currentSelectedIdx === newSelectedIdx) return; const newSelectedLayout = itemLayouts.current[selectedId]; // 首次选中:直接移到顶部 if (currentSelectedIdx === -1) { const anim = new Animated.ValueXY({ x: 0, y: newSelectedLayout.y }); animatedValues.current[selectedId] = anim; Animated.timing(anim, { toValue: { x: 0, y: 0 }, duration: 350, useNativeDriver: true, }).start(() => { // 动画结束后更新数据源 const newData = [...data]; const selectedItem = newData.splice(newSelectedIdx, 1)[0]; selectedItem.isSelected = true; newData.unshift(selectedItem); setData(newData); // 清理动画实例,避免内存泄漏 delete animatedValues.current[selectedId]; }); return; } // 切换选中项:交换两个项的位置动画 const currentSelectedItem = data[currentSelectedIdx]; const currentSelectedLayout = itemLayouts.current[currentSelectedItem.id]; // 初始化两个动画实例 const currentAnim = new Animated.ValueXY({ x: 0, y: 0 }); const newAnim = new Animated.ValueXY({ x: 0, y: 0 }); animatedValues.current[currentSelectedItem.id] = currentAnim; animatedValues.current[selectedId] = newAnim; // 计算位移距离:旧选中项移到新项原位置,新项移到顶部 const currentTargetY = newSelectedLayout.y - currentSelectedLayout.y; const newTargetY = 0 - newSelectedLayout.y; // 并行执行两个动画 Animated.parallel([ Animated.timing(currentAnim, { toValue: { x: 0, y: currentTargetY }, duration: 350, useNativeDriver: true, }), Animated.timing(newAnim, { toValue: { x: 0, y: newTargetY }, duration: 350, useNativeDriver: true, }), ]).start(() => { // 更新数据源顺序 const newData = [...data]; newData[currentSelectedIdx].isSelected = false; const movedItem = newData.splice(newSelectedIdx, 1)[0]; movedItem.isSelected = true; newData.unshift(movedItem); setData(newData); // 清理动画实例 delete animatedValues.current[currentSelectedItem.id]; delete animatedValues.current[selectedId]; }); }, [data]); // 带淡出动画的删除逻辑 const handleDeleteItem = useCallback((itemId) => { const anim = new Animated.Value(1); animatedValues.current[itemId] = anim; Animated.timing(anim, { toValue: 0, duration: 200, useNativeDriver: true, }).start(() => { setData(prev => prev.filter(item => item.id !== itemId)); delete animatedValues.current[itemId]; }); }, []); // 渲染单个列表项 const renderItem = ({ item }) => { const anim = animatedValues.current[item.id]; // 组合动画变换:位移+透明度 const transform = anim ? anim.getTranslateTransform() : []; const opacity = anim ? anim.y.interpolate({ inputRange: [0, 100], outputRange: [1, 1], // 删除时单独用透明度动画,这里保持默认 }) : 1; return ( <Animated.View style={[styles.itemContainer, { transform, opacity }]} onLayout={handleItemLayout(item.id)} > {/* 选中/未选中圆形图标 */} <TouchableOpacity style={[styles.selectIcon, item.isSelected && styles.selectedIcon]} onPress={() => handleSelectItem(item.id)} > {item.isSelected && <View style={styles.innerCircle} />} </TouchableOpacity> <Text style={styles.itemText}>{item.text}</Text> <TouchableOpacity onPress={() => handleDeleteItem(item.id)}> <Text style={styles.deleteText}>删除</Text> </TouchableOpacity> </Animated.View> ); }; return ( <FlatList data={data} keyExtractor={(item) => item.id} renderItem={renderItem} style={styles.list} contentContainerStyle={{ padding: 16 }} /> ); } const styles = StyleSheet.create({ list: { flex: 1, backgroundColor: '#f8f8f8' }, itemContainer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: 16, marginVertical: 8, backgroundColor: '#fff', borderRadius: 12, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, elevation: 3, }, selectIcon: { width: 24, height: 24, borderRadius: 12, borderWidth: 2, borderColor: '#999', alignItems: 'center', justifyContent: 'center', marginRight: 12, }, selectedIcon: { borderColor: '#2563eb', }, innerCircle: { width: 12, height: 12, borderRadius: 6, backgroundColor: '#2563eb', }, itemText: { flex: 1, fontSize: 16, color: '#333' }, deleteText: { color: '#dc2626', fontSize: 14 }, });
关键注意点
- 动画时机:一定要在动画完全结束后再更新数据源,否则FlatList会立刻重排,导致动画被打断。
- 布局复用问题:FlatList的项会被复用,所以
onLayout事件要每次都记录最新的布局位置,避免使用旧坐标导致动画错位。 - 内存泄漏:动画结束后必须删除
animatedValues中的对应实例,防止内存占用过高。 - 性能优化:开启
useNativeDriver: true,让动画在原生线程执行,避免JS线程阻塞导致的卡顿。
内容的提问来源于stack exchange,提问作者j_d
相关产品推荐
相关产品推荐

