Next.js에서 View Transitions API 적용해보기

xoxristine·2024년 4월 8일
12

FE-ninjas

목록 보기
2/2

인턴할 때 애니메이션 구현할 페이지들이 있어서 View Transitions API에 대해 접하게 되어 적용해보고 싶었는데 프로젝트를 진행하며 InsightList와 Calendar에 적용하면 딱~ 좋겠다고 생각해서 정리 & 적용해보았다.

✏️ View Transitions API란?

View Transitions API는 작년 3월에 나온 기능으로 다른 DOM 상태 간에 애니메이션 전환을 쉽게 만드는 기능이다.

이미 웹에도 여러 애니메이션 툴이 있지만 아래와 같은 문제가 발생할 수 있고, 이를 해결하기 어렵다.

  • 간단한 cross-fade 효과에도 두 state가 동시에 존재할 수 있어 추가적인 상호작용을 처리해야 한다.
  • 또한 보조 장치를 사용하는 경우, 전/후 상태가 DOM에 동시에 있는 경우가 있을 수 있고, reading position과 focus를 잃어버리기 쉽다.

따라서 View Transitions API를 사용하면 상태 간 중복 없이 DOM을 변경할 수 있고, 두 상태 간 전환 애니메이션을 쉽게 만들 수 있다.

현재는 아쉽게도 아직은 Firefox, Safari는 지원하지 않는다.

예시: Basic View Transitions demo, HTTP 203 playlist, Smooth and simple transitions with the View Transitions API

그럼 먼저 어떻게 적용하는지 알아보자.

1. startViewTransition()

document.startViewTransition(() => {
	setisSmall(!isSmall);
});

상태 변화를 담당하는 콜백 함수만 startViewTransition()로 감싸주면 fade-in/out 효과가 디폴트로 적용된다. 와우.

startViewTransition 이 실행되면 setisSmall으로 상태 값이 변경되기 전 화면을 캡처하고, 상태 값이 변경된 이후 화면도 캡처한다.
이후 두 화면을 브라우저가 기본으로 제공하는 fade-in/out으로 Transition 하는 화면을 보여준다.

2. Transition 이름 정의

.left {
	view-transition-name: left-board;
}

만약 다른 요소를 기본 root 요소와 다르게 애니메이션을 적용하려면 view-transition-name 속성을 사용해야한다. 애니메이션 적용할 부분을 CSS 파일에서 id나 className을 통해 속성을 지정해줘야 한다.

::view-transition
├─ ::view-transition-group(root)
│ └─ ::view-transition-image-pair(root)
│     ├─ ::view-transition-old(root)
│     └─ ::view-transition-new(root)
└─ ::view-transition-group(left-board)
  └─ ::view-transition-image-pair(left-board)		// 이전, 이후 페이지 화면
      ├─ ::view-transition-old(left-board)		// 이전 페이지 화면
      └─ ::view-transition-new(left-board)		// 새로운 페이지 화면

그럼 위와 같이 CSS pseudo-element tree가 생성된다.

3. keyframes, 동작 애니메이션 지정

@keyframes slideOut {
  to { opacity: 0; transform: translateX(15px); }
}
::view-transition-old(left-board) {
  animation-name: slideOut;
}

적용할 keyframes를 작성하고, 위에서 생성된 CSS pseudo-element tree를 통해 동작할 애니메이션을 지정한다.

4. Instance properties

startViewTransition()이 리턴하는 ViewTransition 상태에 따라 구현하고 싶은게 있다면 아래 상태에 따라 fulfilled 되는 Promise를 사용하면 된다.

ViewTransition.finished: 애니메이션이 끝나고 사용자에게 새로운 페이지 뷰가 보여졌을 때 fulfilled 되는 Promise
ViewTransition.ready: pseudo-element tree가 생성되고 애니메이션이 시작되기 직전에 fulfilled 되는 Promise
ViewTransition.updateCallbackDon: startViewTransition()의 콜백이 실행될 때 fulfilled 되는 Promise
skipTransition(): view transition의 애니메이션은 skip 하지만 DOM을 업데이트하는 startViewTransition()의 콜백은 skip하지 않는 함수

1번: startViewTransition() 기본적으로 사용
2번 & 3번: 특정 애니메이션 효과를 주고싶을 때 사용
4번: startViewTransition()가 리턴하는 Promise에 따라 코드를 작성할 일이 있다면 사용

1️⃣ InsightList 적용

먼저 간단하게 fade-in/out 애니메이션을 적용해보자.

1. 적용 전 (애니메이션 X)

//ReminderCalendar.tsx
const [isSmall, setisSmall] = useState<boolean>(false);
const onClickView = () => {
	setisSmall(!isSmall);
};

클릭 시 isSmall state 변화로 인해 보여주는 컴포넌트가 큰 카드 ↔️ 작은 카드로 바뀐다.

2. startViewTransition 적용 후

//global.d.ts
declare global {
  export interface Document {
    startViewTransition(callback: () => void): void;
  }
}

//ReminderCalendar.tsx
const onClickView = () => {
    document.startViewTransition(() => {
      setisSmall(!isSmall);
	});
};
  • global.d.ts
    View Transition API는 아직 실험 기능이기 때문에 Document에 startViewTransition이 없다는 에러가 발생해 type을 지정해주었다.

  • ReminderCalendar.tsx

  1. 호출 시 document.startViewTransition()API가 현재 페이지의 스크린샷을 찍는다.
  2. setisSmall(!isSmall)이 성공적으로 실행되면 startViewTransition이 리턴하는 ViewTransition.updateCallbackDone Promise가 fulfilled로 바뀌어 DOM 업데이트에 응답할 수 있게 된다.
  3. API는 페이지의 새로운 상태를 라이브 표현으로 캡처한다.
  4. API는 아래 구조를 사용하여 pseudo-element tree를 구성한다.
::view-transition
└─ ::view-transition-group(root)
   └─ ::view-transition-image-pair(root)
      ├─ ::view-transition-old(root)
      └─ ::view-transition-new(root)

::view-transition: 모든 뷰 전환을 포함, 다른 모든 페이지 콘텐츠의 상위에 위치
::view-transition-old: 이전 페이지 화면 (state 변경 전)
::view-transition-new: 새로운 페이지 화면 (state 변경 후)

  1. transition animation이 거의 실행되기 직전에 ViewTransition.ready가 fulfilled 상태로 바뀌어 custom JavaScript animation 실행이 가능하게 된다.

  2. 애니메이션이 종료 상태에 도달하면 ViewTransition.finished Promise가 fulfilled 된다.

2️⃣ Calendar에 적용

Framer motion으로 구현한 슬라이드 애니메이션을 View Transitions API로 구현해보자.

1. 적용 전 (Framer Motion 사용)

// Calendar.tsx
const variants = {
  enter: (direction: number) => {
    return {
      zIndex: 2,
      x: direction > 0 ? 1000 : -1000,
      // opacity: 1,
    };
  },
  center: {
    zIndex: 1,
    x: 0,
    // opacity: 1,
  },
  exit: (direction: number) => {
    return {
      zIndex: 2,
      x: direction < 0 ? 1000 : -1000,
      // opacity: 1,
    };
  },
};

const swipeConfidenceThreshold = 10000;
const swipePower = (offset: number, velocity: number) => {
  return Math.abs(offset) * velocity;
};

const Calendar = ({ onClickModal, selectedDate, setSelectedDate }: Props) => {
  
  const [[page, direction], setPage] = useState([0, 0]);

  const paginate = (newDirection: number) => {
    setPage([page + newDirection, newDirection]);
  };

  return (
    <Wrapper>
      <Head>
		<Left
			onClick={() => {
				handlePrevWeek();
				paginate(-1);
            }}
		/>
		<Right
			onClick={() => {
              handleNextWeek();
              paginate(1);
            }}
        />
      </Head>
      <Board>
        <AnimatePresence initial={false} custom={direction}>
          <BoardContainer
            as={motion.div}
            custom={direction}
            key={page}
            variants={variants}
            initial="enter"
            animate="center"
            exit="exit"
            transition={{
              x: { duration: 0.25 },
              // opacity: { duration: 0.2 },
            }}
            drag="x"
            dragConstraints={{ left: 0, right: 0 }}
            dragElastic={1}
            onDragEnd={(e, { offset, velocity }) => {
              const swipe = swipePower(offset.x, velocity.x);

              if (swipe < -swipeConfidenceThreshold) {
                paginate(1);
              } else if (swipe > swipeConfidenceThreshold) {
                paginate(-1);
              }
            }}
          >
            {board}
          </BoardContainer>
        </AnimatePresence>
      </Board>
    </Wrapper>
  );
};

Framer Motion을 사용하여 일주일 캘린더가 왼/오로 이동하는 애니메이션을 적용하였다.

2. startViewTransition 적용 후

// Calendar2.tsx
const Calendar2 = ({ onClickModal, selectedDate, setSelectedDate }: Props) => {

  return (
    <Wrapper>
      <Head>
		<Left
			onClick={() => {
				document.startViewTransition(() => {
                	handlePrevWeek();
            	});
          	}}
        />
        <Right
          	onClick={() => {
              	document.startViewTransition(() => {
                	handleNextWeek();
            	});
           	}}
         />
        </div>
      </Head>
      <div>{board}</div> // 일주일 달력이 그려지는 곳
    </Wrapper>
  );
};

바로 위 화면처럼 일주일 캘린더가 왼쪽/오른쪽으로 이동하는 애니메이션은 아니지만 확연하게 간단한 코드로 fade-in/out 효과가 적용되었다.

3. slide 애니메이션 추가

// Calendar2.tsx
<div className={direction}>{board}</div>
<style jsx>{`
	.left {
		view-transition-name: left-board;
	}
	.right {
		view-transition-name: right-board;
	}
`}</style>

board를 담은 div의 className은 사용자가 Left / Right 아이콘을 눌렀을 때 left / right로 설정되도록 했다.

/* animation.css -> _app.tsx에서 import */
@keyframes slideTo {
  to {
    opacity: 0;
    transform: translateX(var(--slide-distance, 0));
  }
}
@keyframes slideFrom {
  from {
    opacity: 0;
    transform: translateX(var(--slide-distance, 0));
  }
}

::view-transition-old(left-board) {
  animation-name: slideTo;
  --slide-distance: -150px;
}
::view-transition-new(right-board) {
  animation-name: slideFrom;
  --slide-distance: -150px;
}

::view-transition-new(left-board) {
  animation-name: slideFrom;
  --slide-distance: 150px;
}
::view-transition-old(right-board) {
  animation-name: slideTo;
  --slide-distance: 150px;
}

위 CSS 코드를 Left 아이콘 작동 순서대로 설명해보면,

  1. Left 아이콘 클릭
  2. setDirection('left')로 className 변경됨
  3. .left의 view-transition-name: left-board로 설정
  4. left-board의 transition 이전의 스크린샷은 slideTo 애니메이션
::view-transition-old(left-board) {
  animation-name: slideTo;
  --slide-distance: -150px;
}
  1. left-board의 transition 이후의 스크린샷은 slideFrom 애니메이션
::view-transition-new(left-board) {
  animation-name: slideFrom;
  --slide-distance: 150px;
}
  1. 따라서 Left 아이콘 클릭 시 board가 왼쪽으로 이동하는 애니메이션 보임

참고로 view-transition-name을 설정하는 부분은 Calendar2.tsx 의 style jsx 태그에서,
keyframes애니메이션 설정하는 부분은 animation.css에서 설정한 뒤 _app.tsx에서 import 했다.

그 이유는

  • view-transition-name ➡️ style jsx
    styled-compoent에는 view-transition-name 속성이 없기 때문에 style jsx을 사용했다.

  • ::view-transition-new/old ➡️ _app.tsx
    Next.js는 CSS 모듈에서 className, id만 정의 가능하며 공통 속성은 무조건 global로 선언해야 한다.
    구조적으로 CSS 모듈에서 일반 태그의 속성을 설정했을 때, 각각 다른 페이지에서 같은 요소에 대해 다른 속성을 설정하면 우선순위를 정할 수 없기 때문이다.
    따라서 sudo element ::view-transition-new::view-transition-old는 _app.tsx에서 import 해서 해결했다.

4. 재렌더링으로 인한 오류 해결

Calendar의 Left, Right를 눌렀을 때만 일주일 달력 board가 움직여야 하는데 small/large view 아이콘을 클릭했을 때도 움직이는 오류가 있었다.

따라서 아래 코드처럼 root에 animation duration을 5초로 주고 개발자도구의 애니메이션 탭으로 확인해보았다.

::view-transition-new(root),
::view-transition-old(root) {
  animation-duration: 5s;
}
  • 캘린더의 Left 아이콘 클릭 시 (제대로 동작 O)
  • View 아이콘 클릭 시 (root만 동작해야함, 제대로 동작 X)

View 아이콘을 클릭 했을 때 root와 left-board의 애니메이션이 같이 동작하면 안되는데 현재 그렇게 동작하는 이유는

  1. 아이콘 클릭 -> setisSmall 실행으로 인해 state 변경됨
  2. state 변경되니까 컴포넌트 전체 재렌더링 됨
  3. View 아이콘을 다루는 컴포넌트의 하위 컴포넌트가 캘린더임
  4. 따라서 하위 컴포넌트도 같이 렌더링 되면서 캘린더에서도 transition 이전 / 이후 스크린샷이 다르다고 판단해 남아있던 direction을 바탕으로 애니메이션 실행됨

따라서 내가 채택한 해결 방법은 캘린더의 transition이 끝나면 바로 direction을 ''로 설정해 애니메이션이 동작하지 않도록 하는 것이다.
둘 다 독립적으로 재렌더링 안되게 하고 싶었으나 서로 state가 엉켜있어서 어쩔 수 없었음..

// global.d.ts 수정
export interface ViewTransition {
    finished: Promise;
  }
export interface Document {
    startViewTransition(callback: () => void): ViewTransition;
  }

TypeScript를 사용 중이기 때문에 startViewTransition이 return 하는 ViewTransition 타입을 인터페이스로 선언하여 startViewTransition 리턴 타입을 수정하였다.
(참고로 finished만 사용할거라 필요한 속성만 선언하였다.)

//Calendar2.tsx
<Left
	onClick={() => {
	setDirection('left');
	document
		.startViewTransition(() => {
			handlePrevWeek();
		})
		.finished.finally(() => {
			setDirection('');
		});
	}}
/>
<Right
	onClick={() => {
	setDirection('right');
	document
		.startViewTransition(() => {
			handleNextWeek();
		})
		.finished.finally(() => {
 			setDirection('');
		});
	}}
/>

2번 - startViewTransition 적용 후 코드에서 달라진 점은 startViewTransition에서 Left, Right transition 애니메이션이 끝났을 때 리턴되는 Promise를 통해 setDirection(''); 이 실행된다는 점이다.

따라서 같은 컴포넌트에 위치하는 isSmall state가 바뀌어도 direction state가 ''이고, 그에 따라 className도 지정되지 않아 렌더링은 발생하지만 transition animation이 적용되지 않는다! 얏호!

  • 추가적으로 사실 Left 아이콘 - 과거 일자 표시 - 그럼 슬라이드가 오른쪽으로 되는게 더 자연스럽고, 오른쪽도 마찬가지로 왼쪽으로 이동하는게 더 적절하다고 생각해서 슬라이드 방향만 수정해주었다. 수정한 화면은 다음과 같다.

🏁 결론

아직은 모든 브라우저에서 지원하지 않고, firefox & safari를 제외한 모든 브라우저에서 동작한다고 한다.
위에서 언급한 장점과 라이브러리를 사용하지 않아도 된다는 점이 큰 장점인 것 같다.
언젠가 View Transition API가 표준 스펙이 되어 모든 환경을 지원했을 때, 다른 state 변화에 의한 재렌더링과 같은 문제만 유의해서 사용하면 편리할 것 같다.

참고한 링크

https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API
https://developer.chrome.com/docs/web-platform/view-transitions/
https://fe-developers.kakaoent.com/2023/230403-view-transitions-api/

profile
🔥🦊

0개의 댓글