Takahiro Octopress Blog

-1から始める情弱プログラミング

iOSエンジニアが苦しんだReactアニメーション

はじめに

少し期間が空いてしまいましたが、ブログを更新します。
今回は理解できていれば簡単なのに、なかなか実装できなかったReactアニメーションについてです。
これが何でかすご〜く苦労したんですね…
筆者自身が詰まったところから紐解く形で解説を載せていきたいと思います。

Counterサンプルにアニメーションを追加しよう!

まずはネタとして前回まで基礎を学ぶのに利用したCounterサンプルを利用します。
元々のCounterサンプルとは以下のものになります。

Counter Example画面

実装されている機能としては下記の4つになります。

  • 「+」ボタンを選択するとClick数が+1される
  • 「-」ボタンを選択するとClick数が-1される
  • 「Increment if odd」ボタンを選択するとClick数が奇数のときのみ+1される
  • 「Increment async」ボタンを選択すると1秒後にClick数が+1される

これに次の機能を1つ追加します。

  • 「Show Toast」ボタンを選択するとToastが表示される
  • 「Show Toast」ボタンを連打すると追加でToastが表示される

Toast って何だろう…!?」と思う人もいるかもしれませんので、念のため説明します。
簡単に言うと、「ユーザにポップアップのような形で知らせるもの」です。
Androidユーザなら必ず見たことがあるはずです。(Android Developers Toasts)

今回目指す完成図は下記になります。

機能を追加したCounter Example画面

フォルダ構成

まずは例によってフォルダ構成を見ていきます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
counter
├── public
     └── index.html
└── src
     ├── index.js
     ├── actions
          └── index.js
     ├── reducers
          └── index.js
     ├── components
          ├── Counter.js
          └── Toast
                ├── Toast.js
                └── Toast.css
     └── node_modules
Toast表示のActionを追加

さて、まずはToastを表示するためのActionを追加しましょう。

1
2
3
4
5
6
7
8
9
10
// actions/index.js
import { createAction } from 'redux-actions';

const INCREMENT = ('INCREMENT');
const DECREMENT = ('DECREMENT');
const SHOW_TOAST = ('SHOW_TOAST');  // 追加

export const increment = createAction(INCREMENT);
export const decrement = createAction(DECREMENT);
export const showToast = createAction(SHOW_TOAST);  // 追加

おわかりの通り、showToastがそのActionに該当します。

Toast表示Action発行後のReducer処理

続いて、Reducerの処理を書いていきます。
機能要件にあった通り、ボタンを連打することで複数のToastを表示します。
後で詳細を説明しますが、個々のToastを区別する必要があるため、固有値を付与します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// reducers/index.js
import { handleActions } from 'redux-actions'

const initialState = {
  value: 0,
  num: 0    // 追加 (1)
};

export default handleActions({
  INCREMENT: (state, action) => {
    const newState = {
      ...state,   // 追加 (2)
      value: state.value + 1
    };
    return newState;
  },
  DECREMENT: (state, action) => {
    const newState = {
      ...state,   // 追加 (2)
      value: state.value - 1
    };
    return newState;
  },
  // 以下が追加 (3)
  SHOW_TOAST: (state, action) => {
    const newState = {
      ...state,
      num: state.num + 1
    };
    return newState;
  }
}, initialState)

それぞれ説明します。

追加(1):
今回は簡単のため各Toastに与えるために固有値をnumとして定義し、初期値を0とします。
これをボタンタップ時にカウントアップして、そのnumを付与してToastを作成することで個々を区別することができます。

追加(2):
他のボタンをタップしたときにstate.numがリセットされてしまわないように、...stateを追加して、変化のない値も丸々返すようにしています。

追加(3):
Toast表示Actiondispatchされた後に検知して、新たなnum値を返却するために追加しました。

Toast Componentの作成

今回、最も重要なComponentであるToast Componentを作成していきましょう。
Reactで簡単にアニメーションを作成可能なreact-addons-css-transition-groupReactCSSTransitionGroupを使います。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// ./components/Toast/Toast.js
import React, { Component } from 'react';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
import './Toast.css';

class Toast extends Component {

  render() {
    const { num } = this.props;
    const toast = <div key={num} className="toast">Get Wild</div>
    return (
      <div className="toast-group">
        <ReactCSSTransitionGroup
          transitionName="example"
          transitionEnterTimeout={1500}
          transitionLeaveTimeout={1000}
        >
        {toast}
        </ReactCSSTransitionGroup>
      </div>
    )
  }
}

Toast.propTypes = {
  num: React.PropTypes.number.isRequired
}

export default Toast

ReactCSSTransitionGroup Componentの各パラメータは次の通りの意味です。

  • transitionName: 基本となるアニメーション対象要素の名称
  • transitionEnterTimeout: 要素が追加された後のタイムアウト時間
  • transitionLeaveTimeout: 要素が消えた後のタイムアウト時間?

CSSでもアニメーションを追加するので、transitionEnterTimeouttransitionLeaveTimeoutが不要なんじゃないかと思ったりしたのですが、これらをつけないとエラーが出ます。

ReactCSSTransitionGroupの子要素としてToast表示したいDOM要素を追加します。
この子要素にkeyパラメータとしてnumを渡しています。
これによってボタン連打で追加される各DOM要素が固有の要素であることを区別しています。
keyの重要性については、React.jsの地味だけど重要なkeyについてを見ることをオススメします。

keynumを渡したいので、ToastpropTypesnum: React.PropTypes.number.isRequiredと定義しています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// ./components/Toast/Toast.css
.toast-group {
  position: fixed;
  top: 10px;
  right: 10px;
}
.toast {
  background-color: #f4df42;
  border-radius: 10px;
  width: 200px;
  height: 35px;
  line-height: 35px;
  margin: 10px;
  text-align: center;
}
.toast:first-child {
  display: none;
}

.example-enter {
  opacity: 0.01;
}

.example-enter.example-enter-active {
  opacity: 1;
  transition: opacity 500ms ease-in;
}

.example-leave {
  opacity: 1;
}

.example-leave.example-leave-active {
  opacity: 0.01;
  transition: opacity 300ms ease-in;
}

アニメーションを実行するにはcssファイルでスタイルをつけることも必要です。
それぞれ説明します。

  • .toats-group
    これは単にToast全体の表示位置を右上に固定したかったため追加したスタイルになります。
  • .toast
    これはToastのデザインです。
  • .toast:first-child
    Toast要素は必ず1つ残り続けてしまうため、1番目の要素は非表示としています。
  • .example-enter
    Toastの表示し始めの透明度を設定しています。
  • .example-enter.example-enter-active
    Toast表示アニメーションになります。
  • .example-leave
    Toastの消え初めの透明度を設定しています。
  • .example-leave-active
    Toastの非表示アニメーションになります。

Counter ComponentのボタンからToast表示までの流れ

Toast Componentの作成が完了したので、これをCounter Componentから呼び出すように実装していきましょう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// ./components/Counter.js
import React, { Component, PropTypes } from 'react';
import Toast from './Toast/Toast';

class Counter extends Component {
  static propTypes = {
    value: PropTypes.number,
    num: PropTypes.number.isRequired, // 追加 (1)
    onIncrement: PropTypes.func.isRequired,
    onDecrement: PropTypes.func.isRequired,
    onShowToast: PropTypes.func.isRequired  // 追加 (1)
  }

  // 省略...

  // 追加 (2)
  showToast = () => {
    this.props.onShowToast();
  }
  // 追加 (3)
  renderToast(num) {
    return (
      <Toast num={num} />
    );
  }
  render() {
    // 追加 (4)
    const { value, onIncrement, onDecrement, onShowToast, num } = this.props
    return (
      <p>
        Clicked: {value} times
        {' '}
        <button onClick={onIncrement}>
          +
        </button>
        {' '}
        <button onClick={onDecrement}>
          -
        </button>
        {' '}
        <button onClick={this.incrementIfOdd}>
          Increment if odd
        </button>
        {' '}
        <button onClick={this.incrementAsync}>
          Increment async
        </button>
        {' '}
        {/* 追加 (5) */}
        <button onClick={this.showToast}>
          Show Toast
        </button>
        {this.renderToast(num)}
      </p>
    )
  }
}

export default Counter

1つ1つ説明します。

追加 (1):
numReducerで変更された新しいstatenumを受け付けるためにpropTypesに追加しました。
onShowToastはボタンクリック時にToast表示アクションの実行を受け付けるためにpropTypesに追加しました。

追加 (2):
ボタンアクションで実行するための追加です。

追加 (3):
Toast Componentの描画メソッドです。
1つ目の要素は非表示にCSSで設定しているので、初めから描画してしまって問題ありません。

追加 (4):
新たにpropTypesとして追加したnumonShowToastを利用するために書いています。

追加 (5):
ボタンクリック処理とToast描画処理を書いています。
Toastので位置はCSSで右上固定にしてあるので、DOM位置がどこになろうと問題ありません。

Store周りの修正

最後にStore周りの修正です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// ./index.js
import React from 'react'
import ReactDOM from 'react-dom'
import { createStore, bindActionCreators } from 'redux'
import { Provider, connect } from 'react-redux'
import Counter from './components/Counter'
import { increment, decrement, showToast } from './actions/index' // 追加 (1)
import counter from './reducers'

const store = createStore(counter)
const rootEl = document.getElementById('root')

function mapStateToPropsContainer (state) {
  return {
    value: state.value,
    num: state.num    // 追加 (2)
  }
}

function mapDispatchToPropsContainer (dispatch) {
  return {
    onIncrement: () => dispatch(increment()),
    onDecrement: () => dispatch(decrement()),
    onShowToast: () => dispatch(showToast())    // 追加 (3)
  }
}

let AppContainer = connect(
  mapStateToPropsContainer,
  mapDispatchToPropsContainer
)(Counter)

const render = () => ReactDOM.render(
  <Provider store={store}>
    <AppContainer />
  </Provider>,
  rootEl
)
render()

ここではmapStateToPropsContainermapDispatchToPropsContainerを実装しているので、Reducerで新たに作成されたstateを受け取ります。
それを適切にCounter Componentに渡す必要があります。

追加 (1):
Toast表示アクションをdispatchに流し込む必要があるので、showToastaction.jsからimportしています。

追加 (2):
新たに生成されたstateの値を利用するためにmapStateToPropsContainerに追記しています。

追加 (3):
dispatchToast表示アクションを流し込む設定を追記しています。

まとめ

さて如何でしたでしょうか?
上記実装をすることで連打によるToast表示ができるようになったはずです。
ReactCSSTransitionGroup周りを新たに学ぶ必要があったものの、基本的なReduxのデータフローを理解していればさほど難しくないことがわかったのではないでしょうか。
筆者は結局のところ、Reduxのデータフロー周りで躓くことが多く、不必要に難しく感じてしまっていました。
アニメーションはWebサイト作りにおいて、なくてはならないものなので、これを機にReactでのアニメーション作りの基礎を身に着けたいと思います。
と言ったところで本日はここまで。

Comments