Design System 101 - Animation Presence

前言

作為前端工程師,處理動畫效果是日常工作中常見的需求。但 React 並沒有提供一個生命週期方法,讓我們可以在組件被卸載 (unmount) 之前進行邏輯處理。這也導致我們在處理動畫效果時,即使加了退場動畫的邏輯,在組件被卸載時的動畫效果根本沒開始,組件就已經被卸載了。

舉一個淡出/淡入 (fade out/fade in) 的例子,當點擊 "toggle" 時,可以看到 "Content" 淡入,但是當點擊 "toggle" 時,"Content" 沒有淡出,而是直接消失了。

import React from 'react';
import './style.css';

export default () => {
  const [open, setOpen] = React.useState(false);

  return (
    <>
      <button className="btn" onClick={() => setOpen(!open)}>
        toggle
      </button>
      <div data-state={open} className="content-animation">
        Content
      </div>
    </>
  );
};

如果我們希望在組件消失之前進行淡出效果,就需要在組件被卸載之前,將組件執行完動畫邏輯,再改變組件的狀態,讓組件消失。

這時我們可以將上面的例子改寫成如下:

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>
      )}
    </>
  );
};

上面的實作主要有幾個重點:

  1. 實作出一個 狀態機 (State Machine),用來管理組件的狀態

    這個狀態機有三個狀態,分別是 mountedunmountSuspendedunmounted

    • mounted:組件已經被掛載 (mount),並且正在顯示中,下一個狀態會是 unmountedunmountSuspended
    • unmountSuspended:組件已經被掛載 (mount),但是正在進行退場動畫,下一個狀態會是 unmountedmounted
    • unmounted:組件已經被卸載 (unmount),下一個狀態會是 mounted
  2. 首先一開始如果將 present 設為 false,則會禁用在組件第一次渲染時存在的子組件上的任何初始動畫

  3. 當點擊 "toggle" 時,open 會改變成 true,則將狀態機的狀態會從 unmounted 改為 mounted,這樣組件本身的 animtaion 就會開始執行。

  4. 當再次點擊 "toggle" 時, open 會改變成 false,則將狀態機的狀態會從 mounted 改為 unmountSuspended,這樣組件本身的 animtaion 就會開始執行。直到執行結束,才會將狀態機的狀態從 unmountSuspended 改為 unmounted,這樣組件就會被卸載。

什麼是 Presence?

Presence 能夠讓組件在從 React 樹狀結構 (DOM Tree) 中移除之前進行動畫處理,進而實現更好的用戶體驗。

為什麼需要 Presence?

如前面所提到 React 本身並沒有提供一個生命週期方法,可以讓在組件被卸載 (unmount) 之前進行邏輯處理。如果我們希望在組件消失之前進行動畫效果,像是淡出 (fade out) 效果,就需要 Presence 這樣的工具。

Presence 就是將上面的實作進行模組化,其功能就是在當如果有動畫時,在動畫完成之前,讓組件保持在 DOM 中,直到動畫完成之後再將其從 DOM 中移除。

使用方式

() => {
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>
    </>
  );
};

參考資料

  1. Framer Motion - Presence
  2. Radix UI - Presence