Сегодня трудно кого-то удивить возможностью свайпать элементы списка в мобильных приложениях. В одном нашем react-native приложении тоже была такая функциональность, но недавно возникла необходимость расширить её возможностью перетаскивать элементы списка. А поскольку процесс поиска решения стоил мне некоторого количества нервных клеток, я решил запилить небольшую статью, чтобы сэкономить драгоценное время будущим поколениям.
В нашем приложении для создания swipeable-списка мы использовали пакет react-native-swipe-list-view
. Первой мыслью было взять какой-нибудь пакет с drag'n'drop функциональностью и скрестить ежа с ужом.
Поиск по просторам интернета дал трёх кандидатов: react-native-draggable-list
, react-native-sortable-list
и react-native-draggable-flatlist
.
С помощью первого пакета не удалось запустить даже прилагаемый пример (впрочем, не только мне, о соответствующей проблеме указано в issues).
Со вторым пакетом пришлось повозиться, но создать draggable & swipable список получилось. Однако, результат не вдохновил — компонент безбожно глючило: мигание перерисовки, проваливание элементов далеко за пределы списка, а то и вовсе их исчезновение. Стало понятно, что в таком виде им пользоваться нельзя.
Последний пакет поначалу тоже вел себя как капризная дама, однако потом оказалось, что я просто не умел его готовить. Подобрав ключик к сердцу этой «дамы», мне удалось добиться приемлемого результата.
В нашем проекте был swipeable список, к которому нужно прикрутить drag and drop, но на практике лучше начать с другого края: сначала сделать перетаскиваемый список, а потом добавить возможность свайпать.
Предполагается, что читатели знают, как создать проект react-native, поэтому сосредоточимся на создании нужного нам списка. В обсуждаемом ниже примере приведен код на TypeScript.
Делаем draggable-list
Итак, начнем с установки пакета:
yarn add react-native-draggable-flatlist
Импортируем нужные модули:
import React, { Component } from 'react'
import { View } from 'react-native'
import styles from './styles'
import DraggableFlatList, { RenderItemInfo, OnMoveEndInfo } from 'react-native-draggable-flatlist'
import ListItem from './components/ListItem'
import fakeData from './fakeData.json'
Здесь DraggableFlatList
— это компонент из установленного пакета, реализующий возможность перетаскивания, ListItem
— наш компонент для отображения элемента списка (код будет представлен ниже), fakeData
— json файл, в котором содержатся фейковые данные — в данном случае, массив объектов вида:
{"id": 0, "name": "JavaScript", "favorite": false}
В реальном приложении эти данные скорее всего придут в ваш компонент из пропсов или будут загружены из сети, но в нашем случае обойдёмся малой кровью.
Так как в данном примере используется TypeScript, опишем некоторые сущности:
type Language = {
id: number,
name: string,
favorite: boolean,
}
interface AppProps {}
interface AppState {
data: Array<Language>
}
Тип Language
говорит нам о том, какие поля будут иметь элементы списка.
В данном примере мы ничего не будем получать из пропсов, поэтому интерфейс AppProps
тривиален, а в стейте мы будем хранить массив объектов Language
, что и указано в интерфейсе AppState
.
Поскольку код компонента не очень большой, приведу его целиком:
class App extends Component<AppProps, AppState> {
constructor(props: AppProps) {
super(props)
this.state = {
data: fakeData,
}
}
onMoveEnd = ({ data }: OnMoveEndInfo<Language>) => {
this.setState({ data: data ? [...data] : [] })
}
render() {
return (
<View style={styles.root}>
<DraggableFlatList
data={this.state.data}
renderItem={this.renderItem}
keyExtractor={(item) => item.id.toString()}
scrollPercent={5}
onMoveEnd={this.onMoveEnd}
/>
</View>
)
}
renderItem = ({ item, move, moveEnd, isActive }: RenderItemInfo<Language>) => {
return (
<ListItem
name={item.name}
move={move}
moveEnd={moveEnd}
isActive={isActive}
/>
)
}
}
Метод onMoveEnd
вызывается, когда перемещение элемента закончено. В этом случае, нам необходимо положить список с новым порядком элементов в стейт, поэтому вызываем метод this.setState
.
Метод renderItem
служит для отображения элемента списка и принимает объект типа RenderItemInfo<Language>. Этот объект включает в себя следующие поля:
-
item
— очередно элемент массива, переданного в качестве данных в список, -
move
иmoveEnd
— функции, вызываемые при перемещении элемента списка, эти функции предоставляет компонентDraggableFlatList
, -
isActive
— поле логического типа, определяющее, является ли элемент перетаскиваемым в данный момент.
Компонент для отображения элемента списка, фактически, представляет собой TouchableOpacity
, который при долгом нажатии вызывает move
, а при отпускании — moveEnd
.
import React from 'react'
import { Text, TouchableOpacity } from 'react-native'
import styles from './styles'
interface ListItemProps {
name: string,
move: () => void,
moveEnd: () => void,
isActive: boolean,
}
const ListItem = ({ name, move, moveEnd, isActive }: ListItemProps) => {
return (
<TouchableOpacity
style={[styles.root, isActive && styles.active]}
onLongPress={move}
onPressOut={moveEnd}
>
<Text style={styles.text}>{name}</Text>
</TouchableOpacity>
)
}
export default ListItem
Стили для всех компонентов вынесены в отдельные файлы и здесь не приводятся, но их можно посмотреть в репозитории.
Получившийся результат:
Добавляем возможность свайпать
Ну что ж, с первой частью мы успешно справились, приступаем ко второй части Марлезонского балета.
Для добавления возможности свайпать элементы списка воспользуемся пакетом react-native-swipe-list-view
.
Для начала давайте его установим:
yarn add react-native-swipe-list-view
В этом пакете есть компонент SwipeRow
, который, согласно документации, должен включать в себя два компонента:
<SwipeRow>
<View style={hiddenRowStyle} />
<View style={visibleRowStyle} />
</SwipeRow>
Обратите внимание, что первый View рисуется под вторым.
Давайте изменим код компонента ListItem
.
import React from 'react'
import { Text, TouchableOpacity, View, Image } from 'react-native'
import { SwipeRow } from 'react-native-swipe-list-view'
import { Language } from '../../App'
import styles from './styles'
const heart = require('./icons8-heart-24.png')
const filledHeart = require('./icons8-heart-24-filled.png')
interface ListItemProps {
item: Language,
move: () => void,
moveEnd: () => void,
isActive: boolean,
onHeartPress: () => void,
}
const ListItem = ({ item, move, moveEnd, isActive, onHeartPress }: ListItemProps) => {
return (
<SwipeRow
rightOpenValue={-180}>
<View style={styles.hidden}>
<TouchableOpacity onPress={onHeartPress}>
<Image source={item.favorite ? filledHeart : heart} />
</TouchableOpacity>
</View>
<TouchableOpacity
activeOpacity={1}
style={[styles.root, isActive && styles.active]}
onLongPress={move}
onPressOut={moveEnd}
>
<Text style={styles.text}>{item.name}</Text>
</TouchableOpacity>
</SwipeRow>
)
}
export default ListItem
Во-первых, мы добавили компонент SwipeRow
со свойством rightOpenValue
, которое определяет расстояние, на которое можно свайпать элемент.
Во-вторых, мы переместили внутрь SwipeRow
наш TouchableOpacity
и добавили View, который будет рисоваться под этой кнопкой.
Внутри этой View рисуется картинка, определяющая, является ли язык любимым. При нажатии на неё значение должно меняться на противоположное, а так как данные находятся в родительском компоненте, то необходимо прокинуть сюда коллбэк, выполняющий это действие.
Внесём необходимые изменения в родительский компонент:
import React, { Component } from 'react'
import { View } from 'react-native'
import styles from './styles'
import DraggableFlatList, { RenderItemInfo, OnMoveEndInfo } from 'react-native-draggable-flatlist'
import ListItem from './components/ListItem'
import fakeData from './fakeData.json'
export type Language = {
id: number,
name: string,
favorite: boolean,
}
interface AppProps {}
interface AppState {
data: Array<Language>
}
class App extends Component<AppProps, AppState> {
constructor(props: AppProps) {
super(props)
this.state = {
data: fakeData,
}
}
onMoveEnd = ({ data }: OnMoveEndInfo<Language>) => {
this.setState({ data: data ? [...data] : [] })
}
toggleFavorite = (value: Language) => {
const data = this.state.data.map(item => (
item.id !== value.id ? item : { ...item, favorite: !item.favorite }
))
this.setState({ data })
}
render() {
return (
<View style={styles.root}>
<DraggableFlatList
data={this.state.data}
renderItem={this.renderItem}
keyExtractor={(item) => item.id.toString()}
scrollPercent={5}
onMoveEnd={this.onMoveEnd}
/>
</View>
)
}
renderItem = ({ item, move, moveEnd, isActive }: RenderItemInfo<Language>) => {
return (
<ListItem
item={item}
move={move}
moveEnd={moveEnd}
isActive={isActive}
onHeartPress={() => this.toggleFavorite(item)}
/>
)
}
}
export default App
Исходники проекта на GitHub.
Результат представлен ниже:
Автор: san-smith