📖

Framer Motion の AnimatePresense のコードリーディングしてみた

2021/08/15に公開

Framer Motion の AnimatePresense、コンポーネントが Unmount する時にアニメーションできる便利関数ですが、どうやって実現しているのか不思議だったのでコードリーディングしてみました。

https://github.com/framer/motion/blob/main/src/components/AnimatePresence/index.tsx

https://www.framer.com/docs/animate-presence/

TL;DR

  • children として渡ってきた Element の key を保存しておく
  • Unmount される時に key がなくなった Element たちをアニメーションさせてから、取り除かれた children を強制的に再描画する

コードリーディング

まずは最終的なゴール return されているものから見てみます。

return (
  <>
    {exiting.size
	? childrenToRender
	: childrenToRender.map((child) => cloneElement(child))}
  </>
)

exiting.size とやらがあれば childrenToRender を描画、そうでなければそれを clone したものを返していますね。

次に exiting が何かを見てみます。
名前が指す通り、いなくなるコンポーネントの key の集合のようです。

// A living record of all currently exiting components.
const exiting = useRef(new Set<ComponentKey>()).current

では次にこの exiting がどのように更新されているかを見てみます。

// Filter out any children that aren't ReactElements. We can only track ReactElements with a props.key
const filteredChildren = onlyElements(children)

// Keep a living record of the children we're actually rendering so we
// can diff to figure out which are entering and exiting
const presentChildren = useRef(filteredChildren)

// Diff the keys of the currently-present and target children to update our
// exiting list.
const presentKeys = presentChildren.current.map(getChildKey)
const targetKeys = filteredChildren.map(getChildKey)

// Diff the present children with our target children and mark those that are exiting
const numPresent = presentKeys.length
for (let i = 0; i < numPresent; i++) {
	const key = presentKeys[i]
	if (targetKeys.indexOf(key) === -1) {
		exiting.add(key)
	} else {
		// In case this key has re-entered, remove from the exiting list
		exiting.delete(key)
	}
}

長いですが、 children が更新された時に、 children の中に初期値(presentKeys)に含まれていない child があったらその key を exiting に追加します。逆に再び追加されたものは exiting から削除されています。
ここで Unmount されているコンポーネントの key を管理している訳ですね。

最後に、 exiting に追加されたコンポーネントにどんな処理がなされているかを見てみます。

// Loop through all currently exiting components and clone them to overwrite `animate`
// with any `exit` prop they might have defined.
exiting.forEach((key) => {
	// If this component is actually entering again, early return
	if (targetKeys.indexOf(key) !== -1) return

	const child = allChildren.get(key)
	if (!child) return

	const insertionIndex = presentKeys.indexOf(key)

	const onExit = () => {
	    ...
	}

        childrenToRender.splice(
            insertionIndex,
            0,
            <PresenceChild
                key={getChildKey(child)}
                isPresent={false}
                onExitComplete={onExit}
                custom={custom}
                presenceAffectsLayout={presenceAffectsLayout}
            >
                {child}
            </PresenceChild>
        )
    })

exiting に追加されたコンポーネントは PresenseChild というアニメーションを実行するコンポーネントで囲まれて置換されています。

そして、アニメーションが終了したら onExit 関数が実行されて exiting からそのコンポーネントの key を削除したり、初期値を保存している presentChildren から消すなどの後処理を行うと終わりです。

const onExit = () => {
    allChildren.delete(key)
    exiting.delete(key)

    // Remove this child from the present children
    const removeIndex = presentChildren.current.findIndex(
	(presentChild) => presentChild.key === key
    )
    presentChildren.current.splice(removeIndex, 1)

    // Defer re-rendering until all exiting children have indeed left
    if (!exiting.size) {
	presentChildren.current = filteredChildren
	forceRender()
	onExitComplete && onExitComplete()
    }
}

という感じで、魔法のように見えましたが読んでみると意外とシンプルなロジックでした。children の key を監視するテクニックはどこかで使い所あるかもしれないですね。

Discussion