Как создать компонент для скролла лейблов в React Native
Меня зовут Родион, почти семь лет я занимаюсь фронтенд-разработкой и последние три года работаю с React Native в компании Customertimes.
Просматривая хайповое приложения Clubhouse, я заметил необычный скролл при выборе интересов пользователя. В этой статье я решил рассказать, как реализовать подобный скролл для лейблов в React Native без использования сторонних модулей и библиотек.
Схема компонента
Мы сделаем несколько рядов горизонтальных scroll-компонентов, поверх которых будет находиться PanResponder. Он будет перехватывать все жесты по скроллу и управлять поведением scroll-компонентов. В качестве scroll-компонента будет выступать Flatlist, поскольку он лучше оптимизирован для показа большого перечня данных.
Также предусмотрим дополнительный функционал для нашего компонента:
- Не блокировать/перехватывать жесты, если наш компонент уже находится в другом scroll-компоненте;
- Динамическое изменение элементов для ряда и количество самих рядов для скролла;
- Обязательные и необязательные props компонента;
- Если обязательные props не установлены, компонент не должен рендериться.
- Если количество элементов для скролла недостаточное, рендерим их без Flatlist, просто в View c flexDirection row.
Реализация компонента
Для начала создадим родительский компонент, в котором будем рендерить наш компонент с двумя обязательными параметрами — data и renderItem (названия идентичны для Flatlist). Параметр rows отвечает за количество рядов с горизонтальным скроллом.
const App = () => { const pressOnItem = item => { //do something with selected label }; return ( <> <View style={{ flex: 1, justifyContent: 'center' }}> <LabelScroller rows={3} data={data} renderItem={({ item, index }) => ( <TouchableOpacity key={item.id} onPress={() => pressOnItem(item)} style={styles.item} activeOpacity={0.8}> <Image style={styles.itemImage} source={{ uri: item.image }} /> <Text style={styles.itemLabel}>{item.label}</Text> </TouchableOpacity> )} /> </View> </> ); };
Один элемент списка (лейбл) — touchable-обертка над картинкой и текстом разной длины. Тестовые данные я получил с помощью mockaroo.
Prop data — массив с данными лейблов. Нам необходимо разделить его на подмассивы с неравным количеством, чтоб в первых рядах было меньше элементов, а в последних — больше. Схематически это можно изобразить таким образом:
1 Array ***** 2 Array ********* 3 Array ************* 4 Array ******************
Первый ряд скроллов будет содержать наименьшее количество элементов и будет прокручиваться медленнее, чем последний ряд с наибольшим количеством элементов. Разделение массивов на подмассивы с разным количеством элементов реализует функция splitArrayToUnequal. Также мемоизируем результат этой функции и добавим в наш компонент prop reverse (первый ряд будет содержать наибольшее количество элементов) и random (случайный порядок рядов).
const splitArrayToUnequal = (dataArray, size) => { let array = [...dataArray]; let i = size; let dividedArray = []; let tempCount = dataArray.length; while (0 < i) { // don't slice last array if (i === 1) { dividedArray.push(array); return dividedArray; } let rowCount = Math.ceil((tempCount / i) * 0.6); // get 60% items from each sub-array if (rowCount < minimumInRow) rowCount = minimumInRow; tempCount = tempCount - rowCount; //at each iteration take piece from the entire array if (tempCount <= 0) return dividedArray; const reducedItems = array.slice(0, rowCount); dividedArray.push(reducedItems); array.splice(0, rowCount); i--; } return dividedArray; }; const dividedByRow = useMemo(() => { let array = splitArrayToUnequal(data, rows); if (reverse) return array.reverse(); if (random) { return array.sort(() => 0.5 - Math.random()); } return array; }, [data, rows, reverse, random]);
Теперь, имея данные, мы можем отобразить их в Flatlist:
- Устанавливаем расположение дочерних элементов Flatlist горизонтально в строке, скрываем индикатор скролла;
- В объект scrollListRefs сохраним reference каждого скролла для дальнейшего управления им;
- По коллбэку onScroll сохраняем горизонтальную позицию скролла;
- Через коллбэк onContentSizeChange определяем полную длину ряда в пикселях.
const scrollListRefs = useRef({}).current; … return ( <View> {dividedByRow.map((rowData, i) => ( <FlatList key={i.toString()} ref={ref => (scrollListRefs[i] = ref)} data={rowData} renderItem={renderItem} keyExtractor={(item, index) => 'key' + index} horizontal showsVerticalScrollIndicator={false} showsHorizontalScrollIndicator={false} scrollEventThrottle={16} nestedScrollEnabled onScroll={e => { scrollListRefs[i].scrollPositonX = e.nativeEvent.contentOffset.x; }} onContentSizeChange={contentWidth => { scrollListRefs[i].scrollFullWidth = contentWidth; }} /> ))} </View>
Теперь каждый из рядов можно проскроллить отдельно:
PanResponder — это оболочка React Native для обработчиков респондентов, предоставляемых gesture responder system. Обернем все Flatlist в обработчик жестов (panResponder.panHandlers) и будем их обрабатывать. Методы, которые используются:
- В onMoveShouldSetPanResponder мы «фильтруем» жест юзера — если он совершает свайп — возвращаем true и последующие коллбэки вызываются; если только тап по экрану — возвращаем false;
- Респонденты onPanResponderTerminationRequest и onShouldBlockNativeResponder позволяют не перехватывать жесты, если наш компонент находится уже в другом scroll-компоненте или panResponder;
- onPanResponderMove вызывается, если хендлер жестов активирован и обнаружено движение (свайп). Этот коллбэк вызывается часто — на каждый пиксель «движения».
В переменной tempPanResponderMoveX хранятся последние горизонтальные координаты касания пользователя. В calculatedOffset вычисляется новый offset для каждого Flatlist в зависимости от длины (scrollRowFullWidth) ряда, который в дальнейшем суммируется с нынешней позицией скролла или вычитается. Если gesureState.dx > 0, юзер свайпает в левую сторону, и наоборот: если свайп в правую сторону — к позиции скролла добавляем новый offset. Сам скролл к новыми координатам запускается методом scrollToOffset c параметром animated false, поскольку метод onPanResponderMove вызывается часто, и анимация не будет успевать за обновлением позиции скролла.
let tempPanResponderMoveX = useRef(null).current; … const panResponder = useRef( PanResponder.create({ onMoveShouldSetPanResponder: (e, gestureState) => { // skip if a tap return Math.abs(gestureState.dx) >= 1 || Math.abs(gestureState.dy) >= 1; }, // some hooks for enabling scrolling outside panresponder onStartShouldSetPanResponderCapture: () => false, onMoveShouldSetPanResponderCapture: () => false, onShouldBlockNativeResponder: () => false, onPanResponderTerminationRequest: () => false, // when user moves element onPanResponderMove: (e, gestureState) => { if (tempPanResponderMoveX) { const diff = gestureState.moveX - tempPanResponderMoveX; for (const row in scrollListRefs) { const scrollRef = scrollListRefs[row]; if (!scrollRef) return; const scrollRowPositionX = scrollRef.scrollPositonX || 0; const scrollRowFullWidth = scrollRef.scrollFullWidth; const calculatedOffset = (Math.abs(diff) * scrollRowFullWidth) / deviceWidth / 4; const newOffset = gestureState.dx > 0 ? scrollRowPositionX - calculatedOffset : scrollRowPositionX + calculatedOffset; scrollRef.scrollToOffset({ offset: newOffset, animated: false }); } } tempPanResponderMoveX = gestureState.moveX; }, }) ).current; return ( <View {...panResponder.panHandlers}> // render Flatlists... </View> );
Теперь все элементы скроллятся со скоростью, пропорциональной длине ряда. При дефолтных настройках нижний скролл содержит наибольшее количество элементов и прокручивается быстрее, чем верхний. Когда юзер завершает свайп и убирает палец с экрана, элементы просто перестают двигаться. Давайте добавим анимацию завершения свайпа. Для этого используем метод onPanResponderRelease, который вызывается как раз в момент завершения жеста:
- Логика определения нового offset примерно такая же, как и для onPanResponderMove. Он зависит от длины в пикселях конкретного Flatlist;
- Если юзер завершает движение быстро (со скоростью жеста, большей чем 1,3), увеличиваем offset еще дополнительно на значение gestureState.vx.
onPanResponderRelease: (e, gestureState) => { const { vx } = gestureState; const swipeEffort = Math.abs(vx) > 1.3; for (const row in scrollListRefs) { const scrollRef = scrollListRefs[row]; const scrollRowPositionX = scrollRef.scrollPositonX || 0; const scrollRowFullWidth = scrollRef.scrollFullWidth; if (!scrollRef) return; let calculatedOffset = (scrollRowFullWidth * 0.1) / 7; if (swipeEffort) { calculatedOffset = calculatedOffset * Math.abs(vx); } const newOffset = gestureState.dx > 0 ? scrollRowPositionX - calculatedOffset : scrollRowPositionX + calculatedOffset; scrollRef.scrollToOffset({ offset: newOffset, animated: true }); } tempPanResponderMoveX = null; }
С анимацией завершения свайпа картинка заметно лучше. Осталось добавить завершающие штрихи к нашему компоненту:
- defaultProps, в котором определено дефолтное количество рядов, минимальное количество элементов для скролла и минимальное количество элементов в одном ряду;
- Код в useEffect нужен для динамического обновления состояния, если изменились props rows и data;
- Если обязательные props data и renderItem не установлены — возвращаем null;
- Если количество элементов (лейблов) не превышает минимальное значение для нашего компонента — рендерим элементы без Flatlist.
const LabelScroller = ({ data, renderItem, rows, minLabelLength, minimumInRow, reverse, random }) => { const scrollListRefs = useRef({}).current; let tempPanResponderMoveX = useRef(null).current; const deviceWidth = Dimensions.get('window').width; useEffect(() => { // reset refs if data/rows change for (const row in scrollListRefs) { const scrollRef = scrollListRefs[row]; if (!scrollRef) { delete scrollListRefs[row]; return; } scrollRef.scrollToOffset({ offset: 0, animated: false }); scrollRef.scrollPositonX = 0; tempPanResponderMoveX = null; } }, [rows, data]); if (!data || !renderItem) { console.warn("LabelScroller required 'data' and 'renderItem' prop - returned null!"); return null; } if (data.length <= minLabelLength) { //if not enough elems - render without flatlist return ( <View style={styles.containerWithoutScrolling}> {data.map((item, index) => renderItem({ item, index }))} </View> ); } … } LabelScroller.defaultProps = { minLabelLength: 20, rows: 3, minimumInRow: 5 }; export default LabelScroller; const styles = StyleSheet.create({ containerWithoutScrolling: { flexDirection: 'row', flexWrap: 'wrap' } });
Благодаря Flatlist, который не рендерит одновременно все элементы сразу, можно отображать большое количество элементов. Ниже я изобразил вывод 1000 элементов с 13 рядами скролла:
Заключение
Написав небольшой компонент на React Native, можно убедиться, что обрабатывать жесты с помощью PanResponder достаточно просто и дополнительные библиотеки не требуются. Flatlist — эффективный способ создания простых и понятных списков.
Полный код компонента можно найти на моей страничке GitHub.
1 коментар
Додати коментар Підписатись на коментаріВідписатись від коментарів