LoginSignup
6
5

More than 5 years have passed since last update.

redux-saga で DOMからアニメーションを分離した話

Posted at

DOMアニメーションをredux-sagaに乗せる

React + Redux でアニメーションといえば CSSアニメーションをマルチクラスでトリガーするものが殆どかと思いますが、要件によってはそれだけでは十分ではない場合があります。jQuery.animateでやっていた様に、インラインスタイルをゴリゴリ書き換えるものを React + Redux でも実現したいです。

しかし、多くのアニメーションライブラリはDOM参照する実装都合上、ref参照が必要になりStatelessを諦めなければいけなくなります。また、Presentational層にアニメーション終了などの action を発行する様なロジックを記述しなければいけません。

そこで、 Redux の副作用を扱う redux-saga を用いて実装したいと思います。動かすDOMは Store.state を参照するアプローチ。アニメーションライブラリに gsap を使用します。

本稿サンプル通りの内容は、ひとつのコンポーネントをアニメーションさせるだけには少し大袈裟かも知れませんが、以下の得られる利点を考えると価値あるものだと考えています。

  • StatelessFunctionalComponentを維持出来る
  • 開始・終了をtaskで管理出来る
  • アニメーションの状態をApp内共有出来る
  • reducerの書き方によってはひとつのTweenで複数のコンポーネントを動かせる
  • 他の非同期処理と足並みを揃えることが出来る
  • 動作を分割して凝ったアニメーションを組み換え出来る

上記が全て不要な場合はCSSアニメーションで実装することをお勧めします。

アニメーション関数を用意

redux-saga に乗せるため、Promise に wrap されたアニメーション関数を用意し、callで利用します。下記の例ではアニメーションを走らせるためにCLICK_BUTTONが発行されるのを待ちます。

~/actions/creators/animate.js
import types from '~/actions/types/animate'

export function updateStyle(style) {
  return { type: types.UPDATE_STYLE, style }
}

export function clickButton() {
  return { type: types.CLICK_BUTTON }
}
~/sagas/animation.js
import types from '~/actions/types/animate'
import { updateStyle } from '~/actions/creators/animate'
import { dispatch } from  '~/store'

function someAnimate(duration) {
  return new Promise(resolve => {
    const currentStyle  = { opacity: 0 }
    const completeStyle = { opacity: 1 }
    TweenLite.to(currentStyle, duration / 1000,
      {
        ...completeStyle,
        onUpdate: () => { dispatch(updateStyle(currentStyle)) },
        onComplete: resolve,
        ease: Circ.easeOutBounce
      }
    )
  })
}

export function * someAnimateSaga() {
  while (true) {
    yield take(types.CLICK_BUTTON)
    yield call(someAnimate, 1000)
  }
}

~/main.js
import 'gsap'

TweenLite.to とあるところが gsap のトゥイーン関数ですが、こちらのライブラリは エントリーポイントで import しておきます。これだけでGLOBALに TweenLite TweenMax TweenPlugin が定義されてしまうのでちょっと行儀が悪いです。(他に良いライブラリがあるよ!webpackでこのやり方があるよ!という方、ご存知であればお知らせいただけると幸いです)

onCompletePromise.resolve された後、再度 types.CLICK_BUTTON を待ちます。

ライブラリの callback で Store.dispatch

redux-saga では ActionCreator を yield put すれば、よしなに Store.dispatch してくれるので大変便利ですが、今回の様にGenerator関数ではない外部ライブラリなどの callback からでは yield が使えず、単純に updateStyle(currentStyle) しても Store.dispatch されません。(onUpdateのところ)この対策として、Store.dispatch を Store定義しているファイルから export し、dispatch(updateStyle(currentStyle))とすれば無事 Store.dispatch されます。

~/store.js
const store = createStore(
  reducers,
  composeEnhancers(
    applyMiddleware(sagaMiddleware)
  )
)
export const dispatch = store.dispatch

Reducer でプロパティを組み立てる

Action が発行されたら他と同様に Reducer で処理します。不要な _gsTweenID は削除しておきます。(initialStateのスキーマはサンプルのため簡素化しています)

~/reducers/animate.js
export const initialState = {
  style: { opacity: 1 }
}

export default (state = initialState, action) => {
  switch (action.type) {
    ...
    case types.UPDATE_STYLE : {
      let { style } = action
      delete style['_gsTweenID']
      return Object.assign({}, state, { style })
    }
    ...
  }
}

直接opacityを操作する簡単なサンプルですが、冒頭で述べた通り、この実装でアニメーションの状態をApp内共有出来ます。Reducerの処理次第では複数のコンポーネントを同時に、かつ全く違うプロパティをTweenで動かせることが可能になります。

~/reducers/animate.js
case types.UPDATE_STYLE : {
  let { style } = action
  delete style['_gsTweenID']
  const offset = style.opacity
  style = Object.assign({}, style, { transform: `scale(${offset})` })
  return Object.assign({}, state, { style })
}

transform は文字列で指定します。

Component は Store.state を参照するのみ

いよいよComponentにアニメーションを適用します。以下の記述で、インラインスタイルでアニメーションするStatelessFunctionalComponentの完成です! それではみなさん、良いクリスマスを :tada:

~/components/SomeComponent.js
export default function SomeComponent(props) {
  const { clickButton, animate } = props
  const { style } = animate
  return (
    <div>
      <div style={ style } />
      <button onClick={ clickButton }>click</button>
    </div>
  )
}
6
5
6

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
5