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
が発行されるのを待ちます。
import types from '~/actions/types/animate'
export function updateStyle(style) {
return { type: types.UPDATE_STYLE, style }
}
export function clickButton() {
return { type: types.CLICK_BUTTON }
}
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)
}
}
import 'gsap'
TweenLite.to
とあるところが gsap のトゥイーン関数ですが、こちらのライブラリは エントリーポイントで import しておきます。これだけでGLOBALに TweenLite
TweenMax
TweenPlugin
が定義されてしまうのでちょっと行儀が悪いです。(他に良いライブラリがあるよ!webpackでこのやり方があるよ!という方、ご存知であればお知らせいただけると幸いです)
onComplete
で Promise.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 されます。
const store = createStore(
reducers,
composeEnhancers(
applyMiddleware(sagaMiddleware)
)
)
export const dispatch = store.dispatch
Reducer でプロパティを組み立てる
Action が発行されたら他と同様に Reducer で処理します。不要な _gsTweenID
は削除しておきます。(initialStateのスキーマはサンプルのため簡素化しています)
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で動かせることが可能になります。
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の完成です! それではみなさん、良いクリスマスを
export default function SomeComponent(props) {
const { clickButton, animate } = props
const { style } = animate
return (
<div>
<div style={ style } />
<button onClick={ clickButton }>click</button>
</div>
)
}