Animate Presence란?
Presence의 사전적 정의는 "존재" 입니다.
Framer Motion과 같은 애니메이션 라이브러리에서는 AnimatePresence 기능을 제공하는 경우가 많은데요
이 기능은 리액트의 동작 방식에서의 한계와 밀접한 관련이 있습니다.
Dialog , Toast 등의 UI Interaction을 수행하는 컴포넌트를 직접 작성해본 개발자라면
이런 경험을 해봤을 것입니다.
등장 애니메이션은 문제없이 줄 수 있는데 퇴장 애니메이션을 주는게 불가능한 현상을 말입니다.
Animate Presence는 이러한 문제를 해결하기 위해
퇴장 애니메이션을 적절히 줄 수 있게 만들어주는 기능입니다.
본 포스트에서는 Animate Presence가 필요했던 리액트 생태계의 배경과
Radix , FramerMotion이 이 문제를 해결하는 방식에 대하여 코드레벨로 접근하며 구현 방법을 탐구합니다.
왜 퇴장애니메이션을 주는게 문제가 있을까요?
리액트 컴포넌트의 라이프사이클을 생각해보면 이해가 쉽습니다.
예컨대 이러한 코드를 통하여 어떤 컴포넌트의 마운트 여부를 컨트롤하는 코드를 만들었다고 가정해봅시다.
const Example = ({}: ExampleProps) => {
const [isOpen, setIsOpen] = React.useState(false);
return (
<div>
<button onClick={() => setIsOpen((s) => !s)}>
dialog의 등장 여부를 컨트롤합니다.
</button>
{isOpen ? <div className="">i am Dialog</div> : null}
</div>
);
};
이 i am Dialog 를 가지고있는 div 요소는 isOpen의 상태에 따라
실제 DOM에 마운트 되었다가 언마운트되기도 합니다.
중요한 것은 isOpen의 상태가 변경된 뒤 그것이 실제 DOM에 반영되는 순간
div 요소는 html 트리에서 제거된다는 것입니다.
즉 퇴장 애니메이션이 미처 동작하지 못한 상태임에도 불구하고 이미 DOM 트리에서 제거되었기 때문에
퇴장 애니메이션을 보여주지 못하고 사라지게 되는 결과를 낳습니다.
이것이 AnimatePresence가 필요했던 기본 배경이라고 생각할 수 있습니다.
그럼 기존 라이브러리들은 이 문제를 어떻게 해결하고 있을까요?
퇴장 애니메이션은 매우 "일상적인" 요구사항 중 하나입니다.
그렇기 때문에 대부분의 라이브러리는 퇴장 애니메이션을 적절히 수행할 수 있도록
기능을 제공해주고 있습니다.
대표적으로 FramerMotion 역시 퇴장애니메이션이 필요한 경우
AnimatePresence라는 래퍼 컴포넌트를 제공합니다.
import { motion, AnimatePresence } from "framer-motion"
export const MyComponent = ({ isVisible }) => (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
)}
</AnimatePresence>
)
사용 예시는 다음과 같으며 이 AnimatePresence 컴포넌트에는 제약조건이 존재합니다.
바로 내부에 들어가는 컴포넌트의 ref가 열려있어야한다는 것인데요.
리액트에서는 forwardRef를 통하여 직접 만든 컴포넌트에도
ref를 전달받을 수 있도록 컴포넌트를 구성할 수 있습니다.
그렇다면 왜 AnimatePresence는 forwardRef로 래핑된 컴포넌트를 요구하는 것일까요?
이는 퇴장 애니메이션을 적절히 보여주기 위한 방법으로 ref를 활용하는 전략을 취하고 있기 때문입니다.
이러한 FramerMotion의 구현을 살펴보기에 앞서
먼저 종료 애니메이션을 보여주기 위한 간단한 방법을 사용하여
종료 애니메이션을 적절히 보여주는 아이디어를 구현해보겠습니다.
아래 예제는 tailwindcss와 React를 이용하여 구현하였습니다.
const Example = ({}: ExampleProps) => {
const [isOpen, setIsOpen] = React.useState({
isAnimate: false,
isMount: false,
});
return (
<div className=" w-[768px]">
<button
onClick={() => {
if (!isOpen.isMount) {
setIsOpen({ isMount: true, isAnimate: false });
}
if (isOpen.isMount) {
setIsOpen({ isMount: true, isAnimate: true });
setTimeout(() => {
setIsOpen({ isMount: false, isAnimate: false });
}, 500);
}
}}
>
dialog의 등장 여부를 컨트롤합니다.
</button>
{isOpen.isMount ? (
<div
data-state={isOpen.isAnimate ? 'closed' : 'open'}
className={`
w-[200px] h-[200px] bg-purple-100
transition-all duration-500
data-[state=closed]:opacity-0
`}
>
i am Dialog
</div>
) : null}
</div>
);
};
이 예시의 아이디어는 간단합니다.
컴포넌트가 퇴장 애니메이션을 미처 보여주지 못하고 DOM Tree에서 Unmount 되는것이
퇴장 애니메이션을 보여줄 수 없는 것이 원인이기 때문에
상태를 두가지로 나누는 것을 통하여 문제를 해결합니다.
1. 퇴장 애니메이션을 보여주어야하는지에 대한 여부를 담은 isAnimate 상태
2. 실제 DOM 트리에서의 마운트,언마운트를 제어하는 isMount 상태
두가지 상태를 두는 것을 통하여 문제 해결을 시도합니다.
만약 컴포넌트가 보여지고 있는 상황에서 컴포넌트를 퇴장시켜야하는 상황이라면
우선 isMount는 true로 유지한 채로 isAnimate를 true로 전환하는 것을 통하여
퇴장 애니메이션을 보여줍니다.
duration을 500으로 잡았기 때문에 500 ms 동안 퇴장애니메이션을 보여준 뒤
setTimeout 을 이용하여 500 ms 이후에 실제 DOM Tree에서 컴포넌트를 제거합니다.
이 예시를 통해서 얻을 수 있는 퇴장 애니메이션에 대한 핵심적인 아이디어는 다음과 같습니다.
" 퇴장 애니메이션을 다 보여줄 때 까지 Unmount를 지연시키면 퇴장 애니메이션을 보여줄 수 있다. "
통상적인 라이브러리에서 Animate Presence를 구현하는 아이디어 자체도 위 틀을 벗어나지 않습니다.
그저 어떻게 Unmount를 지연시킬것인가? 에 대한 방법의 차이가 존재할 뿐이지요
그런데 여기까지 도달하고 난 뒤에는 약간의 의문이 생길 수 있습니다.
언마운트가 문제인 것 같은데 언마운트를 안시키면 퇴장애니메이션 문제도 안 겪는 것 아닌가?
개발자는 CSS를 통하여 DOM Tree 에는 존재하지만
실제 사용자에게는 보여지지 않도록 하는 트릭을 구사할 수 있습니다.
예를 들어 css의 visibility 속성을 사용하거나 예제에서처럼 opacity를 0으로 만드는 등
유저눈에서만 컴포넌트를 숨기고 실제 DOM Tree에는 유지시키는 것은 다양한 방법으로 가능합니다.
그러나 이 아이디어는 언뜻 생각하였을 때는 그럴듯해 보이지만 여러가지 문제를 내포합니다.
컴포넌트가 DOM에 표현되기 이전 새로 실행되어야 적절한 코드들이
한번 마운트 된 이후로 다시 언마운트 되지 않는 경우에는 실행되지 않는 문제가 발생할 수 있습니다.
예를 들어 닫고 나서 새로 열었을 때에는 초기값을 가진 화면이 나와야 적절한 상황임에도 불구하고
언마운트 되지 않았기 때문에 닫은 척했던 시점의 상태를 가진 화면이 등장할 수 있습니다.
이러한 동작은 대부분의 개발자들이 통상적으로 생각하는 동작방식과의 괴리를 낳게되며
이러한 동작 방식의 차이를 매번 생각해주어야한다는 문제가 발생합니다.
또한 실제로 언마운트 되어야하는 상황에도 언마운트가 되지 않는다면
스크린리더 등을 이용하는 사용자에게 불필요한 혼란을 줄 수 있으며
이 혼란을 피하기 위해 더 많은 처리가 필요하게 됩니다.
따라서 퇴장 애니메이션의 편의를 위해 언마운트를 안하겠다는 것은 또 다른 문제를 낳습니다.
Framer Motion의 Animate Presence 동작 원리
앞서 ref를 통하여 구현이 이루어진다고 살짝 언급을 해두었습니다.
Framer Motion에서 구현된 방식을 이해하기 위해선 사전에 러닝커브가 좀 있는 편이기 때문에
간단한 동작 원리만 다루고 코드 레벨에서의 자세한 구현은 다른 라이브러리를 참고하겠습니다.
아래는 Framer Motion의 AnimatePresence 컴포넌트의 구현입니다.
export const AnimatePresence: React.FunctionComponent<React.PropsWithChildren<AnimatePresenceProps>> = ({
children,
custom,
initial = true,
onExitComplete,
exitBeforeEnter,
presenceAffectsLayout = true,
}) => {
// We want to force a re-render once all exiting animations have finished. We
// either use a local forceRender function, or one from a parent context if it exists.
let [forceRender] = useForceUpdate()
const forceRenderLayoutGroup = useContext(LayoutGroupContext).forceRender
if (forceRenderLayoutGroup) forceRender = forceRenderLayoutGroup
const isMounted = useIsMounted()
// Filter out any children that aren't ReactElements. We can only track ReactElements with a props.key
const filteredChildren = onlyElements(children)
let childrenToRender = filteredChildren
const exiting = new Set<ComponentKey>()
// 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(childrenToRender)
// A lookup table to quickly reference components by key
const allChildren = useRef(
new Map<ComponentKey, ReactElement<any>>()
).current
// If this is the initial component render, just deal with logic surrounding whether
// we play onMount animations or not.
const isInitialRender = useRef(true)
useIsomorphicLayoutEffect(() => {
isInitialRender.current = false
updateChildLookup(filteredChildren, allChildren)
presentChildren.current = childrenToRender
})
useUnmountEffect(() => {
isInitialRender.current = true
allChildren.clear()
exiting.clear()
})
if (isInitialRender.current) {
return (
<>
{childrenToRender.map((child) => (
<PresenceChild
key={getChildKey(child)}
isPresent
initial={initial ? undefined : false}
presenceAffectsLayout={presenceAffectsLayout}
>
{child}
</PresenceChild>
))}
</>
)
}
// If this is a subsequent render, deal with entering and exiting children
childrenToRender = [...childrenToRender]
// 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)
}
}
// If we currently have exiting children, and we're deferring rendering incoming children
// until after all current children have exiting, empty the childrenToRender array
if (exitBeforeEnter && exiting.size) {
childrenToRender = []
}
// 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 = () => {
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
if (isMounted.current === false) return
forceRender()
onExitComplete && onExitComplete()
}
}
childrenToRender.splice(
insertionIndex,
0,
<PresenceChild
key={getChildKey(child)}
isPresent={false}
onExitComplete={onExit}
custom={custom}
presenceAffectsLayout={presenceAffectsLayout}
>
{child}
</PresenceChild>
)
})
// Add `MotionContext` even to children that don't need it to ensure we're rendering
// the same tree between renders
childrenToRender = childrenToRender.map((child) => {
const key = child.key as string | number
return exiting.has(key) ? (
child
) : (
<PresenceChild
key={getChildKey(child)}
isPresent
presenceAffectsLayout={presenceAffectsLayout}
>
{child}
</PresenceChild>
)
})
if (
process.env.NODE_ENV !== "production" &&
exitBeforeEnter &&
childrenToRender.length > 1
) {
console.warn(
`You're attempting to animate multiple children within AnimatePresence, but its exitBeforeEnter prop is set to true. This will lead to odd visual behaviour.`
)
}
return (
<>
{exiting.size
? childrenToRender
: childrenToRender.map((child) => cloneElement(child))}
</>
)
}
https://github.com/framer/motion/blob/85a60580d57650777871184561fc5e88508d7519/packages/framer-motion/src/components/AnimatePresence/index.tsx#L86-L90
해당 링크에서 확인이 가능하며 useForceUpdate , useIsMounted , useIsomorphicLayoutEffect 등
여러가지 유틸성 커스텀훅을 이용하여 구현되었으며 총 코드 라인은 주석포함 약 250줄입니다.
재밌는 코드들이 많지만 퇴장지연과 관련한 핵심적인 코드만 간략하게 살펴보고 넘어가도록 하겠습니다.
const filteredChildren = onlyElements(children)
let childrenToRender = filteredChildren
FramerMotion은 다음과 같이 유효한 ReactElements만 걸러낸뒤
이를 childrenToRender에 할당해둡니다
filteredChildren은 리액트 엘리먼트만 걸러내는 역할을 수행하고
childrenToRender는 이름에서 유추할 수 있듯이 렌더링 해야하는 대상을 저장해 두는 변수입니다.
const exiting = new Set<ComponentKey>()
// 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(childrenToRender)
// A lookup table to quickly reference components by key
const allChildren = useRef(
new Map<ComponentKey, ReactElement<any>>()
).current
// If this is the initial component render, just deal with logic surrounding whether
// we play onMount animations or not.
const isInitialRender = useRef(true)
1. 이후 exiting 변수를 초기화하고
2. 컴포넌트 라이프 사이클동안 유지되어야하는 children을 저장하는 presentChildren 변수를 만듭니다.
useIsomorphicLayoutEffect(() => {
isInitialRender.current = false
updateChildLookup(filteredChildren, allChildren)
presentChildren.current = childrenToRender
})
그리고 매 렌더마다 layoutEffect 이전에 동작하는 코드를 정의합니다.
코드를 한줄한줄 자세히 살펴보면 InitialRender인지를 보는 ref를 false로 돌려줍니다.
이는 이후 아래 코드를 살펴보면 알 수 있는 내용으로 첫렌더인지 아닌지에 따라
추가적인 로직 수행이 필요한가를 결정하기 때문에 첫렌더인지의 여부를 업데이트하는 것입니다.
또한 updateChildLookup 함수를 호출하는 모습을 볼 수 있는데요
이 코드로 인하여 위 allChildren 변수에 필터링된 children들이 키 : 칠드런 형태로 저장되게 됩니다.
그런 다음 위에서 언급한 presentChildren에 렌더해야하는 칠드런을 재할당하는 모습을 볼 수 있습니다.
위 사진은 FramerMotion의 AnimatePresence 문서의 일부를 발췌한 것입니다.
AnimatePresence는 React 트리에서 직접 자식이 제거되는 시점을 감지하여 작동하고
만약 제거된 자식에 exit property가 존재한다면
퇴장 애니메이션을 모두 수행할때까지 언마운트를 지연한다는 내용입니다.
const presentKeys = presentChildren.current.map(getChildKey);
const targetKeys = filteredChildren.map(getChildKey);
for (let i = 0; i < numPresent; i++) {
const key = presentKeys[i];
if (targetKeys.indexOf(key) === -1) {
exiting.add(key);
}
}
이후 이전 렌더링 단계에서 렌더링된 자식 컴포넌트들의 키 목록인 presentKeys와
현재 렌더링 단계에서 렌더링 되어야 할 자식 컴포넌트들의 키 목록인 targetKeys를 계산합니다.
이제 이 두 값을 비교하여 presentKeys에는 있고 targetKeys에는 없는 키를 가진 컴포넌트가
언마운트 되어야하는 컴포넌트라고 정의할 수 있습니다. 따라서 이런 컴포넌트는 exiting 변수에 추가합니다.
이후 더 자세한 구현이 남아있지만 여기까지의 흐름만 보고도 어느정도 큰 인사이트를 얻을 수 있습니다.
바로 FramerMotion은 useRef에 이전 렌더에 대한 정보를 저장해두고 이전 렌더와 현재 렌더를 비교하며
unmount를 지연해야할 필요성이 있는 경우 ref에 저장해둔 이전 컴포넌트를 통해
언마운트를 모든 퇴장 애니메이션들이 끝날때까지 지연시키는 일을 수행한다는 것입니다.
Radix Primitives의 Presence 구현
허나 framer motion은 자체적으로 애니메이션에 대한 다양한 기능들을 제공하다보니
그와 관련하여 여러가지 복잡한 처리들이 섞여있어
presence의 본질적인 부분을 이해하는데에는 조금 힘이 드는 것이 사실입니다.
그렇기 때문에 이번에는 Headless 라이브러리 중 하나인 radix primitives의 presence 구현을 참고하며
presence를 구현하는 모든 코드를 자세히 살펴보도록 하겠습니다.
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { useComposedRefs } from '@radix-ui/react-compose-refs';
import { useLayoutEffect } from '@radix-ui/react-use-layout-effect';
import { useStateMachine } from './useStateMachine';
다음 import 목록은 radix-primitives 라이브러리가 presence를 구현하는데 사용한 의존성의 전부입니다.
크게 어렵지 않으니 각 훅들이 하는 일을 먼저 짚어보고 가도록 하겠습니다.
import * as React from 'react';
/**
* On the server, React emits a warning when calling `useLayoutEffect`.
* This is because neither `useLayoutEffect` nor `useEffect` run on the server.
* We use this safe version which suppresses the warning by replacing it with a noop on the server.
*
* See: https://reactjs.org/docs/hooks-reference.html#uselayouteffect
*/
const useLayoutEffect = Boolean(globalThis?.document)
? React.useLayoutEffect
: () => {};
export { useLayoutEffect };
먼저 useLayoutEffect 입니다. server side에서의 처리를 추가한 layoutEffect이며
불필요하게 경고가 뜨는 것을 방지하기 위해 사용하는 코드일뿐입니다.
import * as React from 'react';
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === 'function') {
ref(value);
} else if (ref !== null && ref !== undefined) {
(ref as React.MutableRefObject<T>).current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]) {
return (node: T) => refs.forEach((ref) => setRef(ref, node));
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]) {
// eslint-disable-next-line react-hooks/exhaustive-deps
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };
다음은 useComposedRefs입니다. 이름에서 알 수 있듯이 ref들을 다루는 훅으로
setRef 함수는 주어진 ref에 주어진 value를 넣어주는 유틸성 함수라고 볼 수 있습니다.
만약 ref가 함수라면 value를 넣어서 호출하고 ref가 유효한 값이라면 value를 할당해주는것이지요
이를 통해서 여러개의 ref를 관리해야하는 상황을 좀 더 쉽게 해주는 훅입니다.
import * as React from 'react';
type Machine<S> = { [k: string]: { [k: string]: S } };
type MachineState<T> = keyof T;
type MachineEvent<T> = keyof UnionToIntersection<T[keyof T]>;
// 🤯 https://fettblog.eu/typescript-union-to-intersection/
type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (
x: infer R,
) => any
? R
: never;
export function useStateMachine<M>(
initialState: MachineState<M>,
machine: M & Machine<MachineState<M>>,
) {
return React.useReducer(
(state: MachineState<M>, event: MachineEvent<M>): MachineState<M> => {
const nextState = (machine[state] as any)[event];
return nextState ?? state;
},
initialState,
);
}
다음은 useStateMachine인데 상태머신을 쉽게 구현하기 위한 훅입니다.
각 상태에 따른 동작을 선언적으로 관리하기 위해 만든 훅으로 보여집니다.
interface PresenceProps {
children: React.ReactElement | ((props: { present: boolean }) => React.ReactElement);
present: boolean;
}
const Presence: React.FC<PresenceProps> = (props) => {
const { present, children } = props;
const presence = usePresence(present);
const child = (
typeof children === 'function'
? children({ present: presence.isPresent })
: React.Children.only(children)
) as React.ReactElement;
const ref = useComposedRefs(presence.ref, (child as any).ref);
const forceMount = typeof children === 'function';
return forceMount || presence.isPresent ? React.cloneElement(child, { ref }) : null;
};
Presence.displayName = 'Presence';
이제 Presence 컴포넌트의 구현을 보겠습니다.
핵심적인 로직은 usePresence 훅에 숨겨져있을 것이라는 게 예상이 됩니다.
아래 코드들을 해석해보기 전에 usePresence의 구현을 살펴봅시다.
function usePresence(present: boolean) {
const [node, setNode] = React.useState<HTMLElement>();
const stylesRef = React.useRef<CSSStyleDeclaration>({} as any);
const prevPresentRef = React.useRef(present);
const prevAnimationNameRef = React.useRef<string>('none');
const initialState = present ? 'mounted' : 'unmounted';
const [state, send] = useStateMachine(initialState, {
mounted: {
UNMOUNT: 'unmounted',
ANIMATION_OUT: 'unmountSuspended',
},
unmountSuspended: {
MOUNT: 'mounted',
ANIMATION_END: 'unmounted',
},
unmounted: {
MOUNT: 'mounted',
},
});
현재 처리 중인 DOM 요소를 저장해두기 위해 node state를 정의하고
현재 DOM 요소의 CSS style을 참조하기 위해 stylesRef를 정의해두는 모습입니다.
또 prevPresentRef / prevAnimationNameRef를 정의하여
이전 상태를 저장해두는 참조를 ref로 두는것을 볼 수 있습니다.
이전 FramerMotion 역시 이전 렌더의 컴포넌트 상태를 저장해두는 것을 통해
퇴장 애니메이션을 적절히 구현한 것을 생각하면 이해에 도움이 될 수 있을 듯 합니다.
const initialState = present ? 'mounted' : 'unmounted';
이후 인자로 전달받은 present를 통해 컴포넌트가 마운트로 시작하는지 언마운트로 시작하는지를 결정합니다.
const [state, send] = useStateMachine(initialState, {
mounted: {
UNMOUNT: 'unmounted',
ANIMATION_OUT: 'unmountSuspended',
},
unmountSuspended: {
MOUNT: 'mounted',
ANIMATION_END: 'unmounted',
},
unmounted: {
MOUNT: 'mounted',
},
});
그리고 이제 상태머신을 사용하는데요.
상태를 크게 세개로 정의해두는 것을 볼 수 있습니다.
1. 마운트된 상태
2. 언마운트를 진행중인 상태(퇴장 애니메이션이 진행중인 상태라고도 볼 수 있을 것입니다.)
3. 언마운트된 상태
이 역시 지금까지 계속 사용해왔던 개념을 좀 더 명확하게 정의한것에 지나지 않습니다.
React.useEffect(() => {
const currentAnimationName = getAnimationName(stylesRef.current);
prevAnimationNameRef.current = state === 'mounted' ? currentAnimationName : 'none';
}, [state]);
function getAnimationName(styles?: CSSStyleDeclaration) {
return styles?.animationName || 'none';
}
이제 useEffect 부분입니다.
상태가 바뀔때마다 현재 animationName을 가져오고 난 뒤
mounted state인 경우에는 현재 animationName을 할당하고 아니라면 none을 할당하는 코드입니다.
useLayoutEffect(() => {
const styles = stylesRef.current;
const wasPresent = prevPresentRef.current;
const hasPresentChanged = wasPresent !== present;
if (hasPresentChanged) {
const prevAnimationName = prevAnimationNameRef.current;
const currentAnimationName = getAnimationName(styles);
if (present) {
send('MOUNT');
} else if (currentAnimationName === 'none' || styles?.display === 'none') {
// If there is no exit animation or the element is hidden, animations won't run
// so we unmount instantly
send('UNMOUNT');
} else {
/**
* When `present` changes to `false`, we check changes to animation-name to
* determine whether an animation has started. We chose this approach (reading
* computed styles) because there is no `animationrun` event and `animationstart`
* fires after `animation-delay` has expired which would be too late.
*/
const isAnimating = prevAnimationName !== currentAnimationName;
if (wasPresent && isAnimating) {
send('ANIMATION_OUT');
} else {
send('UNMOUNT');
}
}
prevPresentRef.current = present;
}
}, [present, send]);
useLayoutEffect는 DOM 변경이 화면에 반영되기 전에 실행되는 훅입니다.
이 시점에서 현재의 animation과 예전 animation의 이름을 가져오고
둘 사이에 변경이 있었는지를 비교합니다.
만약 currentAnimationName이 none 이면서 styles?display도 none 이라면
종료애니메이션이 없거나 요소가 숨겨졌다는 것을 뜻하기 때문에 바로 언마운트를 시킵니다.
반면 present가 false이면서 애니메이션 이름이 다르다는 것은 애니메이션이 시작되었다는 것이기 때문에
animation out 이벤트를 전송하는 것을 통하여 퇴장 애니메이션이 진행 중이라는 것을 알립니다.
animation out의 밸류부분을 보면 unmountSuspended를 가지고 있다는 점을 참고해주세요
반면 애니메이션이 감지되지 않았다면 보여줘야할 퇴장 애니메이션이 없다는 것이니 Unmount해도 되겠지요
useLayoutEffect(() => {
if (node) {
/**
* Triggering an ANIMATION_OUT during an ANIMATION_IN will fire an `animationcancel`
* event for ANIMATION_IN after we have entered `unmountSuspended` state. So, we
* make sure we only trigger ANIMATION_END for the currently active animation.
*/
const handleAnimationEnd = (event: AnimationEvent) => {
const currentAnimationName = getAnimationName(stylesRef.current);
const isCurrentAnimation = currentAnimationName.includes(event.animationName);
if (event.target === node && isCurrentAnimation) {
// With React 18 concurrency this update is applied
// a frame after the animation ends, creating a flash of visible content.
// By manually flushing we ensure they sync within a frame, removing the flash.
ReactDOM.flushSync(() => send('ANIMATION_END'));
}
};
const handleAnimationStart = (event: AnimationEvent) => {
if (event.target === node) {
// if animation occurred, store its name as the previous animation.
prevAnimationNameRef.current = getAnimationName(stylesRef.current);
}
};
node.addEventListener('animationstart', handleAnimationStart);
node.addEventListener('animationcancel', handleAnimationEnd);
node.addEventListener('animationend', handleAnimationEnd);
return () => {
node.removeEventListener('animationstart', handleAnimationStart);
node.removeEventListener('animationcancel', handleAnimationEnd);
node.removeEventListener('animationend', handleAnimationEnd);
};
} else {
// Transition to the unmounted state if the node is removed prematurely.
// We avoid doing so during cleanup as the node may change but still exist.
send('ANIMATION_END');
}
}, [node, send]);
이제 node가 있는 경우 애니메이션 이벤트를 수신하는 것을 통해
애니메이션의 시작과 끝에서 일어날 일들을 적절히 정의합니다.
animationstart, animationend 이벤트를 통하여 애니메이션의 시작과 끝을 감지할 수 있습니다.
return {
isPresent: ['mounted', 'unmountSuspended'].includes(state),
ref: React.useCallback((node: HTMLElement) => {
if (node) stylesRef.current = getComputedStyle(node);
setNode(node);
}, []),
};
그러고 난 뒤 mounted, unmountSuspended인 경우는 퇴장애니메이션을 보여주는 중이거나
아니면 마운트된게 정상인 상태이니
이를 계산하는 불린값 변수와 ref 참조를 넘겨주는 것을 확인할 수 있습니다.
이게 presence 구현의 핵심이라고 볼 수 있겠네요
const Presence: React.FC<PresenceProps> = (props) => {
const { present, children } = props;
const presence = usePresence(present);
const child = (
typeof children === 'function'
? children({ present: presence.isPresent })
: React.Children.only(children)
) as React.ReactElement;
const ref = useComposedRefs(presence.ref, (child as any).ref);
const forceMount = typeof children === 'function';
return forceMount || presence.isPresent
? React.cloneElement(child, { ref })
: null;
};
이제 다시 Presence 컴포넌트로 돌아와보면 한결 이해가 쉽습니다.
만약 컴포넌트를 보여줘야하는 상황이라면 컴포징해준 ref를 담은 컴포넌트를 렌더하고
컴포넌트를 안보여줘도 되는 상황이라면 null을 반환하는 것을 통해 분기문도 알아서 처리해주고 있습니다.
const PresenceExmaple = ({}: PresenceExmapleProps) => {
const [isOpen, setIsOpen] = React.useState(true);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? 'Hide' : 'Show'}
</button>
<Presence present={isOpen}>
<div
data-state={isOpen ? 'open' : 'closed'}
className={`
translate-x-[-50%] translate-y-[-50%] fixed top-[50%] left-[50%] w-[300px] h-[300px] bg-purple-50
data-[state=open]:animate-toastShow data-[state=closed]:animate-toastDown
`}
></div>
</Presence>
</div>
);
};
그래서 실제로 Presence를 사용하는 입장에서는
이렇게 보여줘야하는 컴포넌트만 넣으면 되게 구성을 할 수 있는것이죠
mount , unmount에 대한 정보를 present 변수로 전달해주고 애니메이션 중인지 아닌지를
usePresence 훅에서 판단하기 때문에 애니메이션을 보여준 다음
언마운트 시키는 동작을 선언적으로 관리할 수 있습니다.
마치며
이번에는 퇴장 애니메이션을 적절히 구현하기 위한 Presence 기능의 개념과 세부구현을 다루었습니다.
저는 이번 포스트를 작성하면서 퇴장 애니메이션은 매우 일상적인 요구사항이지만
이를 적절히 구현하기 위해서는 생각보다 많은 기반지식이 필요하다는 것이 조금 놀라웠는데요
리액트 컴포넌트의 생명주기와 useRef를 prev 상태의 저장소로 활용, dom animation event 등
일반적인 경우에서는 별로 쓸 일이 많지 않았던 지식들을 적극적으로 활용하게 된다는 점에서
폭넓은 지식과 기본기는 이런 때에 큰 무기가 되어줄 수 있다는 생각이 들었습니다.
특히 presence를 구현하는 것의 핵심적인 부분은 애니메이션이 끝나기 전까지
적절하게 언마운트를 지연시키는 로직이라고 할 수 있는데요
제가 이번 포스트에서 다룬 기법 뿐만 아니라 다른 방식으로도 구현할 수 있는 여지들이 충분히 많다고 생각합니다.
예를 들어 https://developer.mozilla.org/en-US/docs/Web/API/Animation/finished
내부적으로 프로미스를 가지고 있는 animation.finished 프로퍼티를 통해
애니메이션이 끝났는지를 감지하는 방법도 존재하더라구요
글을 읽어주신 여러분의 방식대로 구현을 시도해보는 것도 뜻깊은 일이 되지 않을까하는 생각을 끝으로
이번 포스트를 마무리 하겠습니다.
읽어주셔서 감사합니다.
참고자료
https://itchallenger.tistory.com/911
https://www.framer.com/motion/animate-presence/
https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/composeRefs.tsx
'best' 카테고리의 다른 글
개발에 대한 나의 현재 생각 (2) | 2024.02.15 |
---|---|
사내 이벤트 로깅 시스템을 정비하고 패키지화 하기 (1) | 2024.01.25 |
High Order Component 를 아시나요? (1) | 2024.01.02 |
useFunnel을 제공하는 라이브러리 만들기 (2) | 2023.12.13 |
husky와 commitlint로 jira 이슈번호 자동화 시키기 (2) | 2023.11.27 |