Как создать компонент для скролла лейблов в 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 коментар
Додати коментар Підписатись на коментаріВідписатись від коментарів