КатегорииReactУроки

React урок 4 Сложные взаимодействия

Имея отлаженную функциональность, быстрое время загрузки и хорошую скорость работы, на сегодняшний день может быть не достаточным. Интерфейсы популярных проектов, становятся все более утонченными, добавляется анимация, перемещения элементов (drag and drop) и т.д. Что в целом можно назвать сложными взаимодействиями про них мы и поговорим в этой статье.

Анимация в React

React обеспечивает высокоуровневые инструменты для работы с анимацией на пример ReactCSSTransitionGroup (часть модуля дополнения). ReactCSSTransitionGroup это далеко не полный стек библиотеки анимации. Это функция интерполяции значений, управление временной шкалой или формирование цепочек. Все это работает при помощи CSS переходов (transitions) и анимации доступной в обычном браузере. Далее мы рассмотрим как использовать ReactCSSTransitionGroup для анимации компонентов.

CSS Анимация

Чтобы использовать ReactCSSTransitionGroup, вы должны быть знакомы с настройкой CSS переходов и анимации, вы должны знать, как вызвать их из JS. Давайте кратко рассмотрим эту тему. Прежде чем перейдем к применению этого добра к компонентам. Если вы уже все знаете, можете смело листать дальше, а кому интересно давайте продолжим.

Есть две категории анимаций в CSS: CSS переходы (transitions) и CSS ключевые кадры (keyframe).

  • CSS переходы — это анимация перехода между двумя разными значениями CSS.
  • CSS ключевые кадры — эта анимация позволяет более сложные варианты анимации с контролем над промежуточными шагами, а так же началом и концом, используя ключевые кадры.

CSS переходы

CSS переходы обеспечивают возможность анимировать (или интерполировать, что было бы более точным) переход между значениями CSS свойств. Например, если изменить цвет элемента от серого до красного, как правило, изменение происходит мгновенно. Если добавить CSS переход, изменение происходит плавно в течении определенного периода времени.

CSS переходы управляются свойствами. Они сообщают браузеру, что свойств в пределах этого селектора измениться в течении времени, создавая анимационный эффект. Свойства CSS переходов принимает до четырех атрибутов:

  • Имя свойства элемента для анимации (например, цвет или ширина). Если этот параметр опущен, в анимации будут участвовать все возможные свойства
  • Продолжительность анимации.
  • Опционально функция синхронизации для управления кривой ускорения.
  • Дополнительная задержка перед началом анимации.

Давайте создадим кнопку, которая будет изменять цвет фона при наведении курсора.

...
<style media="screen">
  a{
    font-family: Helvetica, Arial, sans-serif;
    text-decoration: none;
    color:#ffffff;
  }
  .button{
    padding: 0.75rem 1rem;
    border-radius: 0.3rem;
    box-shadow: 0;
    background-color: #bbbbbb;
  }
  .button:hover{
    background-color: #ee2222;
    box-shadow: 0 4px #990000;
    transition: 0.5s;
  }
</style>

<a href="#" class="button"> Hover Me! </a>
...

css_transitions_button

Примечание о префиксах

По состоянию на момент написания, некоторые WebKit-браузеры по прежнему требуют использовать префикса при использовании обоих видов анимации. Например для кнопки в предыдущем пример, надо добавить:

.button:hover{
  background-color: #ee2222;
  box-shadow: 0 4px #990000;
  webkit-transition: 0.5s;
  transition: 0.5s;
}

Но для простоты мы не будем включать их в код наших примеров, но при настоящей разработке, следует их добавлять.

Ключевые кадры

Анимация на основе переходов обеспечивает контроль только надо началом и конечным состоянием. Все промежуточные шаги контролируются браузером. Анимация на основе ключевых кадров позволяет контролировать промежуточные шаги. Чтобы использовать ключевые кадры, надо указать все шаги анимации в отдельном блоке CSS, с правилом @keyframes и имя, например:

...
@keyframes pulsing-heart {
  0% { transform: none; }
  50% { transform: scale(1.4); }
  100% { transform: none; }
}
...

Это блок определяет набор основных кадров, с процентным соотношением позиции кадра.

Определив ключевые кадры на них можно ссылать позже через свойство animation. Свойство animation принимает имя набора ключевых кадров, продолжительность анимации, и другие необязательные свойства (например, повторения). В качестве примера, давайте создадим простое сердце, пульсирующее, когда мышь находиться над ним.

<style media="screen">
  body{
    text-align: center;
  }
  @keyframes pulsing-heart {
    0% { transform: none; }
    50% { transform: scale(1.4); }
    100% { transform: none; }
  }
  .heart {
    font-size: 10rem;
    color: #FF0000;
  }
  .heart:hover {
    animation: pulsing-heart .5s infinite;
    transform-origin: center;
  }
</style>

<div class="heart">&hearts; </div>

Программный запуск CSS переходов и анимации

Давайте попробуем разобраться на примере бокового меню который будет запускаться по нажатию на кнопку в верхнем меню:

run_animations

Начнем с создания класса CSS определяющего стиль бокового меню:

/* Sidebar default style */
.sidebar{
  background-color:#eee;
  box-shadow: 1px 0 3px #888888;
  position:absolute;
  width: 15rem;
  height: 100%;
}

Далее, создадим два класса с теми же свойствами и различными значениями. Первый класс (.sidebar-transition) устанавливает боковую панель прозрачной и за границами экрана, а второй (.sidebar-transition-active) делает ее видимой и позиционирует внутри границ экрана. Обратите внимание на то, что .sidebar-transition-active имеет свойство CSS перехода 0,5 секунд.

.sidebar-transition{
  opacity: 0;
  left: -15rem;
}
.sidebar-transition-active{
  opacity: 1;
  left: 0;
  transition: ease-in-out 0.5s;
}

В HTML коде, боковая панель объявлена с .sidebar-transition, то есть по умолчанию скрыта:

<div class='sidebar sidebar-transition'>
  <ul>
    <li>Some content</li>
    <li>Content B</li>
    ...
    <li>Concent X</li>
  </ul>
</div>

Для примера мы не будем использовать React или другую библиотеку, а сделаем на простом JS, который не претендует на оптимальное решения. Все таки нам это надо только для демонстрации:

<style media="screen">
  /* the sidebar will have a list of contents. Let's style them too */
  ul {
    list-style-type: none;
    padding: 0;
  }
  li{
    padding: 15px;
    border-bottom: solid 1px #eee;
    background-color: #ddd;
  }
  .sidebar{
    background-color:#eee;
    box-shadow: 1px 0 3px #888888;
    position:absolute;
    width: 15rem;
    height: 100%;
  }
  .sidebar-transition{
    opacity: 0;
    left: -15rem;
  }
  .sidebar-transition-active{
    opacity: 1;
    left: 0;
    transition: 0.5s;
  }
</style>
<header>
  <button 
          onclick="document.querySelector('.sidebar').classList.add('sidebar-transition-active');"> 
    &#9776; 
  </button>
  <!-- &#9776; is the HTML Entity for the ☰ utf-8 symbol (aka "Hamburger Menu") -->
</header>
<div class='sidebar sidebar-transition'>
  <ul>
    <li>Some content</li>
    <li>Content B</li>
    <li>Concent X</li>
  </ul>
</div>

React CSSTransitionGroup

ReactCSSTransitionGroup простой элемент, который оборачивает все интересующие компоненты и добавляет к ним CSS анимацию и переходы в определенные моменты связанные с жизненным циклом компонента. ReactCSSTransitionGroup поставляется в качестве дополнения, так что не забудьте установить его с помощью npm install —save react-addons-css-transition-group.

Пример анимация в React: Список покупок

В качестве примера давайте создадим базовый список покупок с анимацией.

Основные настройки приложения

Для начала, создадим новый проект (можно воспользоваться шаблоном от сюда https://github.com/pro-react/react-app-boilerplate) и создадим новый главный файл AnimatedShoppingList.

import React, {Component} from 'react';
import {render} from 'react-dom';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';

class AnimatedShoppingList extends Component {
    constructor() {
        super(...arguments);
        // Create an "items" state pre-populated with some shopping items
        this.state = {
            items: [
                {id: 1, name: 'Milk'},
                {id: 2, name: 'Yogurt'},
                {id: 3, name: 'Orange Juice'}
            ]
        }
    }

    // Called when the user changes the input field
    handleChange(evt) {
        if (evt.key === 'Enter') {
            // Create a new item and set the current time as it's id
            let newItem = {id: Date.now(), name: evt.target.value};
            // Create a new array with the previous items plus the value the user typed
            let newItems = this.state.items.concat(newItem);
            // Clear the text field
            evt.target.value = '';
            // Set the new state
            this.setState({items: newItems});
        }
    }

    // Called when the user Clicks on a shopping item
    handleRemove(i) {
        // Create a new array without the clicked item
        var newItems = this.state.items;
        newItems.splice(i, 1);
        // Set the new state
        this.setState({items: newItems});
    }

    render() {
        let shoppingItems = this.state.items.map((item, i) => (
            <div key={item.id}
                 className="item"
                 onClick={this.handleRemove.bind(this, i)}>
                {item.name}
            </div>
        ));
        return (
            <div>
                {shoppingItems}
                < input type="text" value={this.state.newItem} onKeyDown={this.handleChange.bind(this)}/>
            </div>
        );
    }
}

render(<AnimatedShoppingList />, document.getElementById('root'));

Вот что он умеет:

  • при нажатии удаляет элемент из списка
  • добавление при помощи ввода в текстовое поле
  • так же для каждого элемента задан key.

Давайте добавим некоторые правила CSS, пока без анимации:

input {
  padding: 5px;
  width: 120px;
  margin-top:10px;
}
.item {
  background-color: #efefef;
  cursor: pointer;
  display: block;
  margin-bottom: 1px;
  padding: 8px 12px;
  width: 120px;
}

Добавление элемента ReactCSSTransitionGroup

Компонент уже работает, можно добавлять и удалять пункты. Теперь давайте добавим анимацию добавления и удаления пунктов.

Элементы ReactCSSTransitionGroup должны быть родительскими по отношению к элементам к которым вы хотите применить анимацию. Он будет принимает три реквизита: transitionName (название класса CSS, содержащее определение анимации), transitionEnterTimeout длительность анимации появления и transitionLeaveTimeout длительность анимации удаления в миллисекундах. В нашем примере вставлять в ReactCSSTransitionGroup будет обернут shoppingItems:

...
render() {
        let shoppingItems = this.state.items.map((item, i) => (
            <div key={item.id}
                 className="item"
                 onClick={this.handleRemove.bind(this, i)}>
                {item.name}
            </div>
        ));
        return (
            <div>
                <ReactCSSTransitionGroup transitionName="example"
                                         transitionEnterTimeout={300}
                                         transitionLeaveTimeout={300}>
                    {shoppingItems}
                </ReactCSSTransitionGroup>
                <input type="text" value={this.state.newItem} onKeyDown={this.handleChange.bind(this)}/>
            </div>
        );
    }
...

С этого момента, каждый раз, когда новый элемент добавляется в состояние, React будет добавлять к нему дополнительный CSS класс example-enter и example-enter-active на 300 миллисекунд, а затем удалит. Так же будет работать и удаление, только классы будут example-leave и example-leave-active соответственно. Давайте из добавим их:

.example-enter {
  opacity: 0;
  transform: translateX(-250px);
}
.example-enter.example-enter-active {
  opacity: 1;
  transform: translateX(0);
  transition: 0.3s;
}

.example-leave {
  opacity: 1;
  transform: translateX(0);
}

.example-leave.example-leave-active {
  opacity: 0;
  transform: translateX(250px);
  transition: 0.3s;
}

Теперь если запустить приложение мы увидим симпатичную анимации удаления и добавления элемента.

Анимация появления

Уже имеющиеся в списке элементы появляются без анимации, давайте это исправим с помощью свойства transitionAppear компонента ReactCSSTransitionGroup:

...
<ReactCSSTransitionGroup transitionName="example"
                                         transitionEnterTimeout={300}
                                         transitionLeaveTimeout={300}
                                         transitionAppear={true}
                                         transitionAppearTimeout={300}>
   {shoppingItems}
</ReactCSSTransitionGroup>
...

И в CSS на надо добавить соответствующие классы:

...
.example-appear {
    opacity: 0;
    transform: translateX(-250px);
}
.example-appear.example-appear-active {
    opacity: 1;
    transform: translateX(0);
    transition: .5s;
}
....

Вот теперь список выезжает при загрузке страницы.

Drag and Drop

Drag & Drop (перемещение и падение) часто встречается в пользовательских интерфейсах и его реализация может быть сложной. В React есть библиотека которая предоставляет нам создавать подобные взаимодействия. Называется React DnD, будучи внешней библиотекой, что ее использовать нужно установить используя npm:

npm install –-save [email protected] [email protected]

Пример использования React DnD

Давайте создадим корзину покупок, товары будут представлять собой кружки, а корзиной будет прямоугольная область обведенная пунктиром, как на изображении ниже:

dndpng

Для этого создадим новый проект (надеюсь вы уже запомнили как это делать). Начнем с главного файла App:

import React, { Component } from 'react';
import {render} from 'react-dom';
import Container from './Container';

class App extends Component {
  render(){
    return (
        <Container />
    );
  }
}

render(<App />, document.getElementById('root'));

Как видите мы добавили главный компонент Container про него и поговорим.

Container

Теперь давайте его создадим:

import React, { Component } from 'react';
import ShoppingCart from './ShoppingCart';
import Snack from './Snack';
import { DragDropContext } from 'react-dnd';
import HTML5Backend from 'react-dnd-html5-backend';
class Container extends Component {
    render() {
        return (
            <div>
                <Snack name='Chips'/>
                <Snack name='Cupcake'/>
                <Snack name='Donut'/>
                <Snack name='Doritos'/>
                <Snack name='Popcorn'/>
                <ShoppingCart/>
            </div>
        );
    }
}
export default DragDropContext(HTML5Backend)(Container);

Обратите особое внимание на тот факт, что модуль экспортирует DragDropContext основанный на HTML5Backend и на нашем компоненте Container. На выходе получим Container, наделенный свойствами и методами для поддержки Drag & Drop. HTML5Backend — означает что для реализации Drag & Drop будет использоваться API HTML5 если он доступен, но есть и другие альтернативы Backend.

DragSource и DropTarget компоненты высшего порядка

Далее нам надо создать компоненты  Snack  и ShoppingCart, которые будут использовать DragSource и DropTarget. Оба компонента требуют некоторой настройки:

Type

Это имя компонента. В сложном интерфейсе, может быть несколько перемещающихся элементов и целевых тоже, поэтому важно идентифицировать их точно.

Spec Object

Это просто объект JS описывающий поведение компонента при событиях перетаскивания.

Collecting Function

Эта функция дает нам контроль надо тем, как и какие реквизиты получает компонент.

ShoppingCart Component

import React, { PropTypes, Component } from 'react';
import { DropTarget } from 'react-dnd';

class ShoppingCart extends Component {
    render() {
        const style = {
            backgroundColor: '#FFFFFF'
        };
        return (
            <div className='shopping-cart' style={style}>
                Drag here to order!
            </div>
        );
    }
}

Наш компонент ShoppingCart представляет простой div с белым фоном. И он должен реагировать на падение давайте добавим реакцию:

import React, { PropTypes, Component } from 'react';
import { DropTarget } from 'react-dnd';

// ShoppingCart DND Spec
// "A plain object implementing the drop target specification"
//
// - DropTarget Methods (All optional)
// - drop: Called when a compatible item is dropped.
// - hover: Called when an item is hovered over the component.
// - canDrop: Use it to specify whether the drop target is able to accept
// the item.
const ShoppingCartSpec = {
    drop() {
        return { name: 'ShoppingCart' };
    }
};

class ShoppingCart extends Component {
    render() {...}
}

Теперь давайте добавим Collect function:

// ShoppingCart DropTarget - collect
//
// - connect: An instance of DropTargetConnector.
// You use it to assign the drop target role to a DOM node.
//
// - monitor: An instance of DropTargetMonitor.
// You use it to connect state from the React DnD to props.
// Available functions to get state include canDrop(), isOver() and didDrop()
let collect = (connect, monitor) => {
    return {
        connectDropTarget: connect.dropTarget(),
        isOver: monitor.isOver(),
        canDrop: monitor.canDrop()
    };
};

Все эти реквизиты будут использованы в функции render:

import React, { PropTypes, Component } from 'react';
import { DropTarget } from 'react-dnd';

// ShoppingCart DND Spec
// "A plain object implementing the drop target specification"
//
// - DropTarget Methods (All optional)
// - drop: Called when a compatible item is dropped.
// - hover: Called when an item is hovered over the component.
// - canDrop: Use it to specify whether the drop target is able to accept
// the item.
const ShoppingCartSpec = {
    drop() {
        return { name: 'ShoppingCart' };
    }
};


// ShoppingCart DropTarget - collect
//
// - connect: An instance of DropTargetConnector.
// You use it to assign the drop target role to a DOM node.
//
// - monitor: An instance of DropTargetMonitor.
// You use it to connect state from the React DnD to props.
// Available functions to get state include canDrop(), isOver() and didDrop()
let collect = (connect, monitor) => {
    return {
        connectDropTarget: connect.dropTarget(),
        isOver: monitor.isOver(),
        canDrop: monitor.canDrop()
    };
};


class ShoppingCart extends Component {

    render() {
        const { canDrop, isOver, connectDropTarget } = this.props;
        const isActive = canDrop && isOver;
        let backgroundColor = '#FFFFFF';
        if (isActive) {
            backgroundColor = '#F7F7BD';
        } else if (canDrop) {
            backgroundColor = '#F7F7F7';
        }
        const style = {
            backgroundColor: backgroundColor
        };
        return connectDropTarget(
            <div className='shopping-cart' style={style}>
                {isActive ?
                    'Hummmm, snack!' :
                    'Drag here to order!'
                }
            </div>
        );
    }
}

ShoppingCart.propTypes = {
    connectDropTarget: PropTypes.func.isRequired,
    isOver: PropTypes.bool.isRequired,
    canDrop: PropTypes.bool.isRequired
};

export default DropTarget("snack", ShoppingCartSpec, collect)(ShoppingCart);

Snack Component

import React, { Component, PropTypes } from 'react';
import { DragSource } from 'react-dnd';
// snack Drag'nDrop spec
//
// - Required: beginDrag
// - Optional: endDrag
// - Optional: canDrag
// - Optional: isDragging
const snackSpec = {
    beginDrag(props) {
        return {
            name: props.name
        };
    },
    endDrag(props, monitor) {
        const dragItem = monitor.getItem();
        const dropResult = monitor.getDropResult();
        if (dropResult) {
            console.log(`You dropped ${dragItem.name} into ${dropResult.name}`);
        }
    }
};
// Snack DragSource collect collecting function.
// - connect: An instance of DragSourceConnector.
// You use it to assign the drag source role to a DOM node.
//
// - monitor: An instance of DragSourceMonitor.
// You use it to connect state from the React DnD to your component’s properties.
// Available functions to get state include canDrag(), isDragging(), getItemType(),
// getItem(), didDrop() etc.
let collect = (connect, monitor) => {
    return {
        connectDragSource: connect.dragSource(),
        isDragging: monitor.isDragging()
    };
};
class Snack extends Component {
    render() {
        const { name, isDragging, connectDragSource } = this.props;
        const opacity = isDragging ? 0.4 : 1;
        const style = {
            opacity: opacity
        };
        return (
            connectDragSource(
                <div className='snack' style={style}>
                    {name}
                </div>
            )
        );
    }
}
Snack.propTypes = {
    connectDragSource: PropTypes.func.isRequired,
    isDragging: PropTypes.bool.isRequired,
    name: PropTypes.string.isRequired
};
export default DragSource('snack', snackSpec, collect)(Snack);

Styling

body {
  font: 16px/1 sans-serif;
}
#root {
  height: 100%;
}
h1 {
  font-weight: 200;
  color: #3b414c;
  font-size: 20px;
}
.app {
  white-space: nowrap;
  height: 100%;
}
.snack {
  display: inline-block;
  padding: .5em;
  margin: 0 1em 1em 0.25em;
  border: 4px solid #d9d9d9;
  background: #f7f7f7;
  height: 5rem;
  width: 5rem;
  border-radius: 5rem;
  cursor: pointer;line-height: 5em;
  text-align: center;
  color: #333;
}
.shopping-cart {
  border: 5px dashed #d9d9d9;
  border-radius: 10px;
  padding: 5rem 2rem;
  text-align: center;
}

Канбан доска: Анимация и Drag & Drop

Давайте вернемся к приложению канбан и добавим анимацию и возможно перемещать карточки между статусами.

Анимация раскрытия Card

Для начало нам надо установить ReactCSSTransitionGroup:

npm i —save react-addons-css-transition-group

Теперь давайте импортируем и используем компонент ReactCSSTransitionGroup, а затем добавим необходимые css стили:

import React, {Component, PropTypes} from 'react';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
import CheckList from './CheckList';
import marked from 'marked';

let titlePropType = (props, propName, componentName) => {
    if (props[propName]) {
        let value = propName[propName];
        if (typeof value !== 'string' || value.length > 80) {
            return new Error(
                `${propName} in ${componentName} is longer than 80 characters`
            );
        }
    }
};

class Card extends Component {
    constructor() {
        super(...arguments);
        this.state = {
            showDetails: false
        };
    }

    toggleDetails() {
        this.setState({showDetails: !this.state.showDetails});
    }

    render() {
        let cardDetails;
        if (this.state.showDetails) {
            cardDetails = (
                <div className="card__details">
                    <span dangerouslySetInnerHTML={{__html:marked(this.props.description)}} />
                    <CheckList cardId={this.props.id} tasks={this.props.tasks} taskCallbacks={this.props.taskCallbacks}/>
                </div>
            )
        }

        let sideColor = {
            position: 'absolute',
            zIndex: -1,
            top: 0,
            bottom: 0,
            left: 0,
            width: 7,
            backgroundColor: this.props.color
        };

        return (
            <div className="card">
                <div style={sideColor}/>
                <div
                    className={
                        this.state.showDetails ? "card__title card__title--is-open" : "card__title"
                    }
                    onClick={this.toggleDetails.bind(this)}
                >
                    {this.props.title}
                </div>
                <ReactCSSTransitionGroup transitionName="toggle"
                                         transitionEnterTimeout={250}
                                         transitionLeaveTimeout={250} >
                    {cardDetails}
                </ ReactCSSTransitionGroup>
            </div>
        )
    }
}

Card.propTypes = {
    id: PropTypes.number,
    title: titlePropType,
    description: PropTypes.string,
    color: PropTypes.string,
    tasks: PropTypes.arrayOf(PropTypes.object),
    taskCallbacks: PropTypes.object
};

export default Card;
...
.toggle-enter {
    max-height: 0;
    overflow: hidden;
}
.toggle-enter.toggle-enter-active {
    max-height: 300px;
    overflow: hidden;
    transition: max-height .25s ease-in;
}
.toggle-leave {
    max-height: 300px;
    overflow: hidden;
}
.toggle-leave.toggle-leave-active {
    max-height: 0;
    overflow: hidden;
    transition: max-height .25s ease-out;
}
...

Перемещение Card

Перемещение по плану будет позволять менять положение карточек между столбцами, а так же изменять порядок в столбце. Для начала давайте установим необходимые библиотеки:

npm install —save react-dnd react-dnd-html5-backend

Далее, давайте создадим дав новых метода внутри компонента KanbanAppContainer, один для обновления статуса карты, другой для обновления положения:

import React, {Component} from 'react';
import KanbanBoard from './KanbanBoard';
import update from 'react-addons-update';
import 'whatwg-fetch';
import 'babel-polyfill';

// If you're running the server locally, the URL will be, by default, localhost:3000
// Also, the local server doesn't need an authorization header.
const API_URL = 'http://kanbanapi.pro-react.com';
const API_HEADERS = {
    'Content-Type': 'application/json',
    Authorization: 'any-string-you-like'// The Authorization is not needed for local server
};

class KanbanBoardContainer extends Component {
    constructor() {
        super(...arguments);
        this.state = {
            cards: []
        };
    }

    componentDidMount() {
        fetch(API_URL + '/cards', {headers: API_HEADERS})
            .then((response) => response.json())
            .then((responseData) => {
                this.setState({cards: responseData});
            })
            .catch((error) => {
                console.log('Error fetching and parsing data', error);
            });
    }

    addTask(cardId, taskName) {
        // Keep a reference to the original state prior to the mutations
        // in case you need to revert the optimistic changes in the UI
        let prevState = this.state;

        // Find the index of the card
        let cardIndex = this.state.cards.findIndex((card)=>card.id == cardId);
        // Create a new task with the given name and a temporary ID
        let newTask = {id: Date.now(), name: taskName, done: false};
        // Create a new object and push the new task to the array of tasks
        let nextState = update(this.state.cards, {
            [cardIndex]: {
                tasks: {$push: [newTask]}
            }
        });
        // set the component state to the mutated object
        this.setState({cards: nextState});
        // Call the API to add the task on the server
        fetch(`${API_URL}/cards/${cardId}/tasks`, {
            method: 'post',
            headers: API_HEADERS,
            body: JSON.stringify(newTask)
        })
            .then((response) => {
                if (!response.ok) {
                    throw new Error("Server response wasn't OK");
                }
                return response.json();
            })
            .then((responseData) => {
                // When the server returns the definitive ID
                // used for the new Task on the server, update it on React
                newTask.id = responseData.id;
                this.setState({cards: nextState});
            })
            .catch((error) => {
                console.error("Fetch error:", error);
                this.setState(prevState);
            });
    }

    deleteTask(cardId, taskId, taskIndex) {
        let prevState = this.state;
        // Find the index of the card
        let cardIndex = this.state.cards.findIndex((card)=>card.id == cardId);
        // Create a new object without the task
        let nextState = update(this.state.cards, {
            [cardIndex]: {
                tasks: {$splice: [[taskIndex, 1]]}
            }
        });
        // set the component state to the mutated object
        this.setState({cards: nextState});
        // Call the API to remove the task on the server
        fetch(`${API_URL}/cards/${cardId}/tasks/${taskId}`, {
            method: 'delete',
            headers: API_HEADERS
        })
            .then((response) => {
                if (!response.ok) {
                    throw new Error("Server response wasn't OK");
                }
            })
            .catch((error) => {
                console.error("Fetch error:", error);
                this.setState(prevState);
            });
    }

    toggleTask(cardId, taskId, taskIndex) {
        let prevState = this.state;
        // Find the index of the card
        let cardIndex = this.state.cards.findIndex((card)=>card.id == cardId);
        // Save a reference to the task's 'done' value
        let newDoneValue;
        // Using the $apply command, you will change the done value to its opposite
        let nextState = update(this.state.cards, {
            [cardIndex]: {
                tasks: {
                    [taskIndex]: {
                        done: {
                            $apply: (done) => {
                                newDoneValue = !done;
                                return newDoneValue;
                            }
                        }
                    }
                }
            }
        });
        // set the component state to the mutated object
        this.setState({cards: nextState});
        // Call the API to toggle the task on the server
        fetch(`${API_URL}/cards/${cardId}/tasks/${taskId}`, {
            method: 'put',
            headers: API_HEADERS,
            body: JSON.stringify({done: newDoneValue})
        })
            .then((response) => {
                if (!response.ok) {
                    throw new Error("Server response wasn't OK");
                }
            })
            .catch((error) => {
                console.error("Fetch error:", error);
                this.setState(prevState);
            });
    }

    updateCardStatus(cardId, listId) {
        // Find the index of the card
        let cardIndex = this.state.cards.findIndex((card)=>card.id == cardId);
        // Get the current card
        let card = this.state.cards[cardIndex];
        // Only proceed if hovering over a different list
        if (card.status !== listId) {
            // set the component state to the mutated object
            this.setState(update(this.state, {
                cards: {
                    [cardIndex]: {
                        status: {$set: listId}
                    }
                }
            }));
        }
    }

    updateCardPosition(cardId, afterId) {
        // Only proceed if hovering over a different card
        if (cardId !== afterId) {
            // Find the index of the card
            let cardIndex = this.state.cards.findIndex((card)=>card.id == cardId);
            // Get the current card
            let card = this.state.cards[cardIndex];
            // Find the index of the card the user is hovering over
            let afterIndex = this.state.cards.findIndex((card)=>card.id == afterId);
            // Use splice to remove the card and reinsert it a the new index
            this.setState(update(this.state, {
                cards: {
                    $splice: [
                        [cardIndex, 1],
                        [afterIndex, 0, card]
                    ]
                }
            }));
        }
    }

    render() {
        return <KanbanBoard cards={this.state.cards}
                            taskCallbacks={{
                                toggle: this.toggleTask.bind(this),
                                delete: this.deleteTask.bind(this),
                                add: this.addTask.bind(this)
                            }}
                            cardCallbacks={{
                                updateStatus: this.updateCardStatus.bind(this),
                                updatePosition: this.updateCardPosition.bind(this)
                            }}
        />
    }
}
export default KanbanBoardContainer;

Обратите внимание у нас новый реквизит сardCallbacks, нам надо передать до компонента Card, начнем с  KanbanBoard:

import React, {Component, PropTypes} from 'react';
import List from './List';

class KanbanBoard extends Component {
    render() {
        return (
            <div className="app">
                <List
                    taskCallbacks={this.props.taskCallbacks}
                    cardCallbacks={this.props.cardCallbacks}
                    id='todo'
                    title="To Do"
                    cards={this.props.cards.filter((card) => card.status === "todo")}
                />
                <List
                    taskCallbacks={this.props.taskCallbacks}
                    cardCallbacks={this.props.cardCallbacks}
                    id='in-progress'
                    title="In Progress"
                    cards={this.props.cards.filter((card) => card.status === "in-progress")}
                />
                <List
                    taskCallbacks={this.props.taskCallbacks}
                    cardCallbacks={this.props.cardCallbacks}
                    id='done'
                    title='Done'
                    cards={this.props.cards.filter((card) => card.status === "done")}
                />
            </div>
        );
    }
}

KanbanBoard.propTypes = {
    cards: PropTypes.arrayOf(PropTypes.object),
    taskCallbacks: PropTypes.object,
    cardCallbacks: PropTypes.object
};

export default KanbanBoard;
import React, { Component, PropTypes } from 'react';
import Card from './Card';

class List extends Component {
    render() {
        var cards = this.props.cards.map((card) => {
            return <Card key={card.id}
                         id={card.id}
                         title={card.title}
                         description={card.description}
                         color={card.color}
                         tasks={card.tasks}
                         taskCallbacks={this.props.taskCallbacks}
                         cardCallbacks={this.props.cardCallbacks}
                    />
        });
        return (
            <div className="list">
                <h1>{this.props.title}</h1>
                {cards}
            </div>
        );
    }
}

List.propTypes = {
    title: PropTypes.string.isRequired,
    cards: PropTypes.arrayOf(PropTypes.object),
    taskCallbacks: PropTypes.object,
    cardCallbacks: PropTypes.object
};

export default List;

А еще мы сейчас создадим файл constants.js, с таким содержимым:

export default {
    CARD: 'card'
};

Перемещение между столбцами

Давайте приступим с изменения компонента Card:

import React, {Component, PropTypes} from 'react';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
import CheckList from './CheckList';
import marked from 'marked';
import { DragSource } from 'react-dnd';
import constants from './constants';

let titlePropType = (props, propName, componentName) => {
    if (props[propName]) {
        let value = propName[propName];
        if (typeof value !== 'string' || value.length > 80) {
            return new Error(
                `${propName} in ${componentName} is longer than 80 characters`
            );
        }
    }
};

const cardDragSpec = {
    beginDrag(props) {
        return {
            id: props.id
        };
    }
};

let collectDrag = (connect, monitor) => {
    return {
        connectDragSource: connect.dragSource()
    };
};

class Card extends Component {
    constructor() {
        super(...arguments);
        this.state = {
            showDetails: false
        };
    }

    toggleDetails() {
        this.setState({showDetails: !this.state.showDetails});
    }

    render() {

        const { connectDragSource } = this.props;

        let cardDetails;
        if (this.state.showDetails) {
            cardDetails = (
                <div className="card__details">
                    <span dangerouslySetInnerHTML={{__html:marked(this.props.description)}} />
                    <CheckList cardId={this.props.id} tasks={this.props.tasks} taskCallbacks={this.props.taskCallbacks}/>
                </div>
            )
        }

        let sideColor = {
            position: 'absolute',
            zIndex: -1,
            top: 0,
            bottom: 0,
            left: 0,
            width: 7,
            backgroundColor: this.props.color
        };

        return connectDragSource(
            <div className="card">
                <div style={sideColor}/>
                <div
                    className={
                        this.state.showDetails ? "card__title card__title--is-open" : "card__title"
                    }
                    onClick={this.toggleDetails.bind(this)}
                >
                    {this.props.title}
                </div>
                <ReactCSSTransitionGroup transitionName="toggle"
                                         transitionEnterTimeout={250}
                                         transitionLeaveTimeout={250} >
                    {cardDetails}
                </ ReactCSSTransitionGroup>
            </div>
        )
    }
}

Card.propTypes = {
    id: PropTypes.number,
    title: titlePropType,
    description: PropTypes.string,
    color: PropTypes.string,
    tasks: PropTypes.arrayOf(PropTypes.object),
    taskCallbacks: PropTypes.object,
    connectDragSource: PropTypes.func.isRequired
};

export default DragSource(constants.CARD, cardDragSpec, collectDrag)(Card);

Теперь настало время подготовить списки:

import React, { Component, PropTypes } from 'react';
import { DropTarget } from 'react-dnd';
import Card from './Card';
import constants from './constants';


const listTargetSpec = {
    hover(props, monitor) {
        const draggedId = monitor.getItem().id;
        props.cardCallbacks.updateStatus(draggedId, props.id)
    }
};

function collect(connect, monitor) {
    return {
        connectDropTarget: connect.dropTarget()
    };
}


class List extends Component {
    render() {

        const { connectDropTarget } = this.props;

        var cards = this.props.cards.map((card) => {
            return <Card key={card.id}
                         id={card.id}
                         title={card.title}
                         description={card.description}
                         color={card.color}
                         tasks={card.tasks}
                         taskCallbacks={this.props.taskCallbacks}
                         cardCallbacks={this.props.cardCallbacks}
                    />
        });
        return connectDropTarget(
            <div className="list">
                <h1>{this.props.title}</h1>
                {cards}
            </div>
        );
    }
}

List.propTypes = {
    title: PropTypes.string.isRequired,
    cards: PropTypes.arrayOf(PropTypes.object),
    taskCallbacks: PropTypes.object,
    cardCallbacks: PropTypes.object,
    connectDropTarget: PropTypes.func.isRequired
};

export default DropTarget(constants.CARD, listTargetSpec, collect)(List);

И последнее надо изменить KanbanBoard:

import React, {Component, PropTypes} from 'react';
import { DragDropContext } from 'react-dnd';
import HTML5Backend from 'react-dnd-html5-backend';
import List from './List';

class KanbanBoard extends Component {
    render() {
        return (
            <div className="app">
                <List
                    taskCallbacks={this.props.taskCallbacks}
                    cardCallbacks={this.props.cardCallbacks}
                    id='todo'
                    title="To Do"
                    cards={this.props.cards.filter((card) => card.status === "todo")}
                />
                <List
                    taskCallbacks={this.props.taskCallbacks}
                    cardCallbacks={this.props.cardCallbacks}
                    id='in-progress'
                    title="In Progress"
                    cards={this.props.cards.filter((card) => card.status === "in-progress")}
                />
                <List
                    taskCallbacks={this.props.taskCallbacks}
                    cardCallbacks={this.props.cardCallbacks}
                    id='done'
                    title='Done'
                    cards={this.props.cards.filter((card) => card.status === "done")}
                />
            </div>
        );
    }
}

KanbanBoard.propTypes = {
    cards: PropTypes.arrayOf(PropTypes.object),
    taskCallbacks: PropTypes.object,
    cardCallbacks: PropTypes.object
};

export default DragDropContext(HTML5Backend)(KanbanBoard);

Если сейчас проверить, мы увидим как при перемещении карточки и наведении на другой список, она сразу же туда перемещается.

Сортировка Card

Теперь давайте сделаем сортировку:

import React, {Component, PropTypes} from 'react';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
import CheckList from './CheckList';
import marked from 'marked';
import { DragSource, DropTarget } from 'react-dnd';
import constants from './constants';

let titlePropType = (props, propName, componentName) => {
    if (props[propName]) {
        let value = propName[propName];
        if (typeof value !== 'string' || value.length > 80) {
            return new Error(
                `${propName} in ${componentName} is longer than 80 characters`
            );
        }
    }
};

const cardDropSpec = {
    hover(props, monitor) {
        const draggedId = monitor.getItem().id;
        props.cardCallbacks.updatePosition(draggedId, props.id);
    }
};

let collectDrop = (connect, monitor) => {
    return {
        connectDropTarget: connect.dropTarget()
    };
};


const cardDragSpec = {
    beginDrag(props) {
        return {
            id: props.id
        };
    }
};

let collectDrag = (connect, monitor) => {
    return {
        connectDragSource: connect.dragSource()
    };
};

class Card extends Component {
    constructor() {
        super(...arguments);
        this.state = {
            showDetails: false
        };
    }

    toggleDetails() {
        this.setState({showDetails: !this.state.showDetails});
    }

    render() {

        const { connectDragSource, connectDropTarget } = this.props;

        let cardDetails;
        if (this.state.showDetails) {
            cardDetails = (
                <div className="card__details">
                    <span dangerouslySetInnerHTML={{__html:marked(this.props.description)}} />
                    <CheckList cardId={this.props.id} tasks={this.props.tasks} taskCallbacks={this.props.taskCallbacks}/>
                </div>
            )
        }

        let sideColor = {
            position: 'absolute',
            zIndex: -1,
            top: 0,
            bottom: 0,
            left: 0,
            width: 7,
            backgroundColor: this.props.color
        };

        return connectDropTarget(connectDragSource(
            <div className="card">
                <div style={sideColor}/>
                <div
                    className={
                        this.state.showDetails ? "card__title card__title--is-open" : "card__title"
                    }
                    onClick={this.toggleDetails.bind(this)}
                >
                    {this.props.title}
                </div>
                <ReactCSSTransitionGroup transitionName="toggle"
                                         transitionEnterTimeout={250}
                                         transitionLeaveTimeout={250} >
                    {cardDetails}
                </ ReactCSSTransitionGroup>
            </div>
        ))
    }
}

Card.propTypes = {
    id: PropTypes.number,
    title: titlePropType,
    description: PropTypes.string,
    color: PropTypes.string,
    tasks: PropTypes.arrayOf(PropTypes.object),
    taskCallbacks: PropTypes.object,
    connectDragSource: PropTypes.func.isRequired,
    connectDropTarget: PropTypes.func.isRequired
};

const dragHighOrderCard = DragSource(constants.CARD, cardDragSpec, collectDrag)(Card);
const dragDropHighOrderCard = DropTarget(constants.CARD, cardDropSpec, collectDrop)(dragHighOrderCard);
export default dragDropHighOrderCard

Вуаля! Теперь мы можем легко перемещать карточки.

Дроссельные обратные вызовы

Сейчас у нас при перемещении вызывается множество операций удаления добавление карточек и в следствии чего вызывается render очень много раз, что может быть проблемой производительности приложения. По этой причине давайте реализуем функцию дросселирования. Она получает два параметра, оригинальную функцию, которую вы хотите меньше вызывать и время ожидания, создадим ее в отдельном файле utils.js:

export const throttle = (func, wait) => {
    let context, args, prevArgs, argsChanged, result;
    let previous = 0;
    return function() {
        let now, remaining;
        if(wait){
            now = Date.now();
            remaining = wait - (now - previous);
        }
        context = this;
        args = arguments;
        argsChanged = JSON.stringify(args) != JSON.stringify(prevArgs);
        prevArgs = {...args};
        if (argsChanged || wait && (remaining <= 0 || remaining > wait)) {
            if(wait){
                previous = now;
            }
            result = func.apply(context, args);
            context = args = null;
        }
        return result;
    };
};

Теперь давайте ее используем:

 

import React, {Component} from 'react';
import KanbanBoard from './KanbanBoard';
import {throttle} from './utils';
import update from 'react-addons-update';
import 'whatwg-fetch';
import 'babel-polyfill';

// If you're running the server locally, the URL will be, by default, localhost:3000
// Also, the local server doesn't need an authorization header.
const API_URL = 'http://kanbanapi.pro-react.com';
const API_HEADERS = {
    'Content-Type': 'application/json',
    Authorization: 'any-string-you-like'// The Authorization is not needed for local server
};

class KanbanBoardContainer extends Component {
    constructor() {
        super(...arguments);
        this.state = {
            cards: []
        };
        this.updateCardStatus = throttle(this.updateCardStatus.bind(this));
        this.updateCardPosition = throttle(this.updateCardPosition.bind(this),500);
    }

    componentDidMount() {...}

    addTask(cardId, taskName) {...}

    deleteTask(cardId, taskId, taskIndex) {...}

    toggleTask(cardId, taskId, taskIndex) {...}

    updateCardStatus(cardId, listId) {...}

    updateCardPosition(cardId, afterId) {...}

    render() {
        return <KanbanBoard cards={this.state.cards}
                            taskCallbacks={{
                                toggle: this.toggleTask.bind(this),
                                delete: this.deleteTask.bind(this),
                                add: this.addTask.bind(this)
                            }}
                            cardCallbacks={{
                                updateStatus: this.updateCardStatus,
                                updatePosition: this.updateCardPosition
                            }}
        />
    }
}
export default KanbanBoardContainer;

Сохранение новой позиции и статуса карты

Первая мысль, чтобы реализовать сохранение на сервер — это использовать методы updateCardStatus и updateCardPosition. Но проблема здесь заключается в том, что во время перемещения карты, пользователь может навести на множество других карт и списков, прежде чем остановиться на конечном пункте назначения. Зачем сохранять промежуточные состояния перемещений? Плюс это усложнить реализацию отката в случае ошибки. И добавит не нужную нагрузку на сервер.

Стало быть нам надо сохранять только когда пользователь прекратил перетаскивание. Для этого мы создадим новый метод persistCardDrag, начнем с компонента KanbanBoardContainer:

...
persistCardDrag (cardId, status) {
        // Find the index of the card
        let cardIndex = this.state.cards.findIndex((card)=>card.id == cardId);
        // Get the current card
        let card = this.state.cards[cardIndex];
        fetch(`${API_URL}/cards/${cardId}`, {
            method: 'put',
            headers: API_HEADERS,
            body: JSON.stringify({status: card.status, row_order_position: cardIndex})
        })
            .then((response) => {
                if(!response.ok){
                // Throw an error if server response wasn't 'ok'
                // so you can revert back the optimistic changes
                // made to the UI.
                    throw new Error("Server response wasn't OK")
                }
            })
            .catch((error) => {
                console.error("Fetch error:",error);
                this.setState(
                    update(this.state, {
                        cards: {
                            [cardIndex]: {
                                status: { $set: status }
                            }
                        }
                    })
                );
            });
    }

    render() {
        return <KanbanBoard cards={this.state.cards}
                            taskCallbacks={{
                                toggle: this.toggleTask.bind(this),
                                delete: this.deleteTask.bind(this),
                                add: this.addTask.bind(this)
                            }}
                            cardCallbacks={{
                                updateStatus: this.updateCardStatus,
                                updatePosition: this.updateCardPosition,
                                persistCardDrag: this.persistCardDrag.bind(this)
                            }}
        />
    }
...

Далее, все что нам нужно, это использоваться cardDragSpec в Card:

...
onst cardDragSpec = {
    beginDrag(props) {
        return {
            id: props.id,
            status: props.status
        };
    },
    endDrag(props) {
        props.cardCallbacks.persistCardDrag(props.id, props.status);
    }
};
...

Итоги

Мы узнали как применить анимацию и Drag & Drop в React c использованием внешних библиотек.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *