Motion × グローバルステート管理(Context/Redux)でExitアニメーションがパッと消えた件
はじめに
こんにちは!PortalKeyの渋谷です。
今回はMotion(旧Framer Motion)についてです。
Motionは簡単に言うとアニメーションが簡単につけられてすごい!というライブラリです。
今記事はMotionの詳しい使用方法等は省きます。
現在開発中のプロジェクトではReduxを用いて開発を行っています。
いい感じに機能が揃ってきたのでアニメーションを入れよう!という後づけの形でアニメーションを付ける際にMotionを使用したのですが、Exitアニメーションを設定してるのに「パッ」と消えて「???」となったので原因と改善策をまとめました。
Motionの説明
使用方法は省くとは言っても軽く説明はしたいと思います。
今回は簡単なサンプルを2つ用いて説明させていただきます。
■Visibleを切り替えてアニメーションを再生
普段使っている要素(div等)の前にmotion.
を付けるだけであら不思議…
animate
の中を切り替えるだけでサクッとアニメーションが実装できます。
initial
はマウント時にアニメーションを行うかどうか。
transition
はアニメーションの時間などを設定できます。
import { motion } from "framer-motion"
import { useCallback, useState } from "react"
export const ToggleVisibleTest = () => {
const [visible, setVisible] = useState(true)
const toggleVisible = useCallback(() => {
setVisible((prev) => !prev)
}, [])
return (
<div className="w-96 p-2 flex flex-col items-center justify-center gap-2 bg-gray-50">
<div className="w-full h-52 p-2 flex gap-1 items-center justify-center bg-white">
<motion.div
className="h-8 px-4 flex items-center justify-center rounded bg-gray-300 text-white"
initial={false}
animate={visible ? { opacity: 1 } : { opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
Test
</motion.div>
</div>
<button className="w-full h-8 bg-gray-100" onClick={toggleVisible}>
Toggle
</button>
</div>
)
}
■マウント<->アンマウントを切り替えてアニメーションを再生
これこそがMotionの真骨頂です!
アンマウント時にアニメーションを仕込むことが出来ます。
AnimatePresence
でmotion.div
を囲うとexit
に設定したアニメーションがアンマウント時に反応します。
AnimatePresence
のinitial
はマウント時にアニメーションを行うかどうか。
この場合のmotion.div
のinitial
は再マウント時の初期状態になるため、exit
と同じ値を入れるのが定石となります。
挙動は上と変わりませんが、アンマウントできるので処理負荷に優しかったりレイアウトの組みやすさが上がったりしますね!
import { AnimatePresence, motion } from "framer-motion"
import { useCallback, useState } from "react"
export const ToggleMountTest = () => {
const [visible, setVisible] = useState(true)
const toggleVisible = useCallback(() => {
setVisible((prev) => !prev)
}, [])
return (
<div className="w-96 p-2 flex flex-col items-center justify-center gap-2 bg-gray-50">
<div className="w-full h-52 p-2 flex gap-1 items-center justify-center bg-white">
<AnimatePresence initial={false}>
{visible && (
<motion.div
className="h-8 px-4 flex items-center justify-center rounded bg-gray-300 text-white"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
Test
</motion.div>
)}
</AnimatePresence>
</div>
<button className="w-full h-8 bg-gray-100" onClick={toggleVisible}>
Toggle
</button>
</div>
)
}
本題
Motionについて軽く分かっていただけたと思うので、本題に移ります。
筆者が今回「パッ」と消えたケースはリスト
にアニメーションを付けた時です。
まずは成功例をお見せします。
■成功例
リストが空っぽ
からリストに要素が1つ以上ある
になった時にanimate
が再生
リストに要素が1つ以上ある
からリストが空っぽ
になった時にexit
が再生
というアニメーションになります。
追加されたらリスト全体が下から現れ、全て消えたらリスト全体が下に消えていく
という感じです。
import { AnimatePresence, motion } from "framer-motion"
import { useCallback, useState } from "react"
interface ItemProps {
id: string
name: string
}
const List = ({ items }: { items: ItemProps[] }) => {
return (
<div className="w-full h-full flex gap-1 items-center justify-center">
{items.map((item) => (
<div key={item.id} className="h-8 px-4 flex items-center justify-center rounded bg-gray-300 text-white">
{item.name}
</div>
))}
</div>
)
}
export const ListMountTest = () => {
const [items, setItems] = useState<ItemProps[]>([])
const add = useCallback(() => {
setItems((prev) => [...prev, { id: `${prev.length}`, name: `Item ${prev.length}` }])
}, [])
const reset = useCallback(() => {
setItems([])
}, [])
return (
<div className="w-96 p-2 flex flex-col items-center justify-center gap-2 bg-gray-50">
<div className="w-full h-52 p-2 flex gap-1 items-center justify-center bg-white">
<AnimatePresence initial={false} mode="wait">
{items.length > 0 && (
<motion.div
className="w-full h-full"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<List items={items} />
</motion.div>
)}
</AnimatePresence>
</div>
<div className="w-full h-8 flex gap-1">
<button className="w-full h-8 bg-gray-100" onClick={add}>
Add
</button>
<button className="w-full h-8 bg-gray-100" onClick={reset}>
Reset
</button>
</div>
</div>
)
}
■失敗例
同じアニメーションをContextを用いて実装してみます。
「パッ」と消えているのがおわかりいただけるだろうか…
import { AnimatePresence, motion } from "framer-motion"
import { createContext, useCallback, useContext, useState } from "react"
interface ItemProps {
id: string
name: string
}
const List = ({ items }: { items: ItemProps[] }) => {
return (
<div className="w-full h-full flex gap-1 items-center justify-center">
{items.map((item) => (
<div key={item.id} className="h-8 px-4 flex items-center justify-center rounded bg-gray-300 text-white">
{item.name}
</div>
))}
</div>
)
}
const CountContext = createContext<ItemProps[]>([])
const ContextList = () => {
const items = useContext(CountContext)
return <List items={items} />
}
const ContextListMountTest = () => {
const [items, setItems] = useState<ItemProps[]>([])
const add = useCallback(() => {
setItems((prev) => [...prev, { id: `${prev.length}`, name: `Item ${prev.length}` }])
}, [])
const reset = useCallback(() => {
setItems([])
}, [])
return (
<div className="w-96 p-2 flex flex-col items-center justify-center gap-2 bg-gray-50">
<div className="w-full h-52 p-2 flex gap-1 items-center justify-center bg-white">
<CountContext.Provider value={items}>
<AnimatePresence initial={false} mode="wait">
{items.length > 0 && (
<motion.div
className="w-full h-full"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<ContextList />
</motion.div>
)}
</AnimatePresence>
</CountContext.Provider>
</div>
<div className="w-full h-8 flex gap-1">
<button className="w-full h-8 bg-gray-100" onClick={add}>
Add
</button>
<button className="w-full h-8 bg-gray-100" onClick={reset}>
Reset
</button>
</div>
</div>
)
}
原因
一言で申しますとMotionの使い方を間違えているのが原因です。
Motionはexit
のタイミングでprops
をスナップショットして保持してくれます。
なので普通に使えば「アニメーションが終わるまで最後の状態を維持」できるはずなんです。
ところが今回の失敗例では、Context(=Reduxでも同じ)から直接items
を読みに行っていたため、グローバルステートが更新された瞬間にitems=[]
が反映されてしまう。
結果として「Exit中に中身が空になり → パッと消える」という挙動になってしまいました。
ただ、アニメーションの事を考えずにReduxなんか使ってたら内部でuseGet
して…というケース…容易に想像できますよね…?
改善策・設計のポイント
結論としては、MotionでExitを使うならprops
をきちんと受け渡して**「Exit用に凍結された値」**を使う設計にする必要があります。
少しでもアニメーションを付ける可能性があるのであれば最初からその設計を頭の片隅に入れておくだけで幸せになれると思います。
Motionを用いなかったとしてもアニメーションを付けたいケースであれば必要な設計になると思うので…
Component名の頭に「Animate」を付けるようにし、その内部のComponentでuse
という関数が無いかCopilot等にチェックさせるのも手かもしれません。
最後に
今回は「Motion × グローバルステート管理」でハマった話を紹介しました。
要するに「Exitアニメーションを使う予感が1%でもするならちゃんと設計しておけ」ってことです。
「パッ」と消えて困ってこの記事に来た人は…修正お互い頑張りましょう…!
ではまた!
Discussion