Сегодня трудно кого-то удивить возможностью свайпать элементы списка в мобильных приложениях. В одном нашем 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.

Поскольку код компонента не очень большой, приведу его целиком:

код компонента App
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.

код компонента ListItem
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.

код компонента 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 рисуется картинка, определяющая, является ли язык любимым. При нажатии на неё значение должно меняться на противоположное, а так как данные находятся в родительском компоненте, то необходимо прокинуть сюда коллбэк, выполняющий это действие.

Внесём необходимые изменения в родительский компонент:

код компонента App
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.

Результат представлен ниже:

Комментарии (2)


  1. AlexZaharow
    20.07.2019 08:02

    Можете добавить какой-то признак, что элемент является сдвигаемым? Например, нарисовать справа в контроле левую стрелку?
    image


    1. san-smith Автор
      20.07.2019 09:16

      Сделать это, конечно, не проблема. Другое дело, что это скорее про дизайн, чем про решение поставленной задачи.

      Например, можно добавить стрелочку внутрь TouchableOpacity компонента ListItem:

      <TouchableOpacity
              activeOpacity={1}
              style={[styles.root, isActive && styles.active]}
              onLongPress={move}
              onPressOut={moveEnd}
            >
              <Text style={styles.text}>{item.name}</Text>
              <Text style={{
                  color: '#e57a44',
                  position: 'absolute',
                  right: 5,
                }}>{'<-'}</Text>
            </TouchableOpacity>

      Понятно дело, стили надо бы вынести, да и вместо текстовой стрелочки стоит использовать что-то другое. Это, скорее, демонстрация возможности.