Design System 101 - Animation Presence
- 文章發表於
前言
作為前端工程師,處理動畫效果是日常工作中常見的需求。但 React 並沒有提供一個生命週期方法,讓我們可以在組件被卸載 (unmount) 之前進行邏輯處理。這也導致我們在處理動畫效果時,即使加了退場動畫的邏輯,在組件被卸載時的動畫效果根本沒開始,組件就已經被卸載了。
舉一個淡出/淡入 (fade out/fade in) 的例子,當點擊 "toggle" 時,可以看到 "Content" 淡入,但是當點擊 "toggle" 時,"Content" 沒有淡出,而是直接消失了。
如果我們希望在組件消失之前進行淡出效果,就需要在組件被卸載之前,將組件執行完動畫邏輯,再改變組件的狀態,讓組件消失。
這時我們可以將上面的例子改寫成如下:
import React from 'react' import useStateMachine from './useStateMachine' import './style.css' const machine = { mounted: { UNMOUNT: 'unmounted', ANIMATION_OUT: 'unmountSuspended', }, unmountSuspended: { MOUNT: 'mounted', ANIMATION_END: 'unmounted', }, unmounted: { MOUNT: 'mounted', }, } export default () => { // 如果是 false,則會禁用在組件第一次渲染時存在的子組件上的任何初始動畫 const present = false const [open, setOpen] = React.useState(present) const [animationState, send] = useStateMachine(!present ? 'unmounted' : 'mounted', machine) // 處理當 animation name 為 none 時,直接將組件從 DOM 中移除 const [node, setNode] = React.useState(null) const stylesRef = React.useRef({}) React.useEffect(() => { if (node) { stylesRef.current = getComputedStyle(node) } setNode(node) }, [open]) React.useEffect(() => { const styles = stylesRef?.current if (open) { send('MOUNT') } else if (styles?.animationName === 'none') { send('UNMOUNT') } else { send('ANIMATION_OUT') } }, [open, send]) return ( <> <button className="btn" onClick={() => setOpen(!open)}> toggle </button> {['mounted', 'unmountSuspended'].includes(animationState) && ( <div ref={setNode} data-state={open} className="content-animation" onAnimationEnd={() => { if (!open) { send('ANIMATION_END') } }} > Content </div> )} </> ) }
上面的實作主要有幾個重點:
- 實作出一個 狀態機 (State Machine),用來管理組件的狀態 - 這個狀態機有三個狀態,分別是 - mounted、- unmountSuspended、- unmounted:- mounted:組件已經被掛載 (mount),並且正在顯示中,下一個狀態會是- unmounted或- unmountSuspended。
- unmountSuspended:組件已經被掛載 (mount),但是正在進行退場動畫,下一個狀態會是- unmounted或- mounted。
- unmounted:組件已經被卸載 (unmount),下一個狀態會是- mounted。
 
- 首先一開始如果將 - present設為- false,則會禁用在組件第一次渲染時存在的子組件上的任何初始動畫
- 當點擊 "toggle" 時, - open會改變成- true,則將狀態機的狀態會從- unmounted改為- mounted,這樣組件本身的 animtaion 就會開始執行。
- 當再次點擊 "toggle" 時, - open會改變成- false,則將狀態機的狀態會從- mounted改為- unmountSuspended,這樣組件本身的 animtaion 就會開始執行。直到執行結束,才會將狀態機的狀態從- unmountSuspended改為- unmounted,這樣組件就會被卸載。
什麼是 Presence?
Presence 能夠讓組件在從 React 樹狀結構 (DOM Tree) 中移除之前進行動畫處理,進而實現更好的用戶體驗。
為什麼需要 Presence?
如前面所提到 React 本身並沒有提供一個生命週期方法,可以讓在組件被卸載 (unmount) 之前進行邏輯處理。如果我們希望在組件消失之前進行動畫效果,像是淡出 (fade out) 效果,就需要 Presence 這樣的工具。
Presence 就是將上面的實作進行模組化,其功能就是在當如果有動畫時,在動畫完成之前,讓組件保持在 DOM 中,直到動畫完成之後再將其從 DOM 中移除。
使用方式
const App = () => {const [open, setOpen] = React.useState(true)return (<><button onClick={() => setOpen(!open)}>toggle</button><Presence present={open}><div>Content</div></Presence></>)}
實作
與上面實作不同的是,我們將 Presence 進行一定程度的模組化,讓其自身達到開箱及用的效果。不用讓使用 <Presence> 的人,需要去實作狀態機。
所以將 animation 的狀態改用 addEventListener 進行監聽,並在動畫結束時,透過 ReactDOM.flushSync 來強制更新狀態機的狀態。
而同時會監聽 present 的變化,如果 present 的值改變,則會根據 present 的值,來決定狀態機的狀態。
import React from 'react' import useStateMachine from './useStateMachine' import { Presence } from './presence' import './styles.css' export default () => { const [open, setOpen] = React.useState(false) return ( <> <button className="btn" onClick={() => setOpen(!open)}> toggle </button> <Presence present={open}> <div data-state={open} className="content-animation"> Content </div> </Presence> </> ) }
