Как создать компонент для скролла лейблов в React Native

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Меня зовут Родион, почти семь лет я занимаюсь фронтенд-разработкой и последние три года работаю с React Native в компании Customertimes.

Просматривая хайповое приложения Clubhouse, я заметил необычный скролл при выборе интересов пользователя. В этой статье я решил рассказать, как реализовать подобный скролл для лейблов в React Native без использования сторонних модулей и библиотек.

Схема компонента

Мы сделаем несколько рядов горизонтальных scroll-компонентов, поверх которых будет находиться PanResponder. Он будет перехватывать все жесты по скроллу и управлять поведением scroll-компонентов. В качестве scroll-компонента будет выступать Flatlist, поскольку он лучше оптимизирован для показа большого перечня данных.

Также предусмотрим дополнительный функционал для нашего компонента:

  1. Не блокировать/перехватывать жесты, если наш компонент уже находится в другом scroll-компоненте;
  2. Динамическое изменение элементов для ряда и количество самих рядов для скролла;
  3. Обязательные и необязательные props компонента;
  4. Если обязательные props не установлены, компонент не должен рендериться.
  5. Если количество элементов для скролла недостаточное, рендерим их без 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.

👍НравитсяПонравилось13
В избранноеВ избранном3
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Статья хороша. )

Подписаться на комментарии