인턴할 때 애니메이션 구현할 페이지들이 있어서 View Transitions API에 대해 접하게 되어 적용해보고 싶었는데 프로젝트를 진행하며 InsightList와 Calendar에 적용하면 딱~ 좋겠다고 생각해서 정리 & 적용해보았다.
View Transitions API는 작년 3월에 나온 기능으로 다른 DOM 상태 간에 애니메이션 전환을 쉽게 만드는 기능이다.
이미 웹에도 여러 애니메이션 툴이 있지만 아래와 같은 문제가 발생할 수 있고, 이를 해결하기 어렵다.
따라서 View Transitions API를 사용하면 상태 간 중복 없이 DOM을 변경할 수 있고, 두 상태 간 전환 애니메이션을 쉽게 만들 수 있다.
현재는 아쉽게도 아직은 Firefox, Safari는 지원하지 않는다.
예시: Basic View Transitions demo, HTTP 203 playlist, Smooth and simple transitions with the View Transitions API
그럼 먼저 어떻게 적용하는지 알아보자.
document.startViewTransition(() => {
setisSmall(!isSmall);
});
상태 변화를 담당하는 콜백 함수만 startViewTransition()
로 감싸주면 fade-in/out 효과가 디폴트로 적용된다. 와우.
startViewTransition 이 실행되면 setisSmall으로 상태 값이 변경되기 전 화면을 캡처
하고, 상태 값이 변경된 이후 화면도 캡처
한다.
이후 두 화면을 브라우저가 기본으로 제공하는 fade-in/out으로 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
가 생성된다.
@keyframes slideOut {
to { opacity: 0; transform: translateX(15px); }
}
::view-transition-old(left-board) {
animation-name: slideOut;
}
적용할 keyframes를 작성하고, 위에서 생성된 CSS pseudo-element tree
를 통해 동작할 애니메이션을 지정한다.
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에 따라 코드를 작성할 일이 있다면 사용
먼저 간단하게 fade-in/out 애니메이션을 적용해보자.
//ReminderCalendar.tsx
const [isSmall, setisSmall] = useState<boolean>(false);
const onClickView = () => {
setisSmall(!isSmall);
};
클릭 시 isSmall state 변화로 인해 보여주는 컴포넌트가 큰 카드 ↔️ 작은 카드
로 바뀐다.
//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
document.startViewTransition()API
가 현재 페이지의 스크린샷을 찍는다.setisSmall(!isSmall)
이 성공적으로 실행되면 startViewTransition이 리턴하는 ViewTransition.updateCallbackDone
Promise가 fulfilled로 바뀌어 DOM 업데이트에 응답할 수 있게 된다.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 변경 후)
transition animation이 거의 실행되기 직전에 ViewTransition.ready
가 fulfilled 상태로 바뀌어 custom JavaScript animation
실행이 가능하게 된다.
애니메이션이 종료 상태에 도달하면 ViewTransition.finished
Promise가 fulfilled 된다.
Framer motion으로 구현한 슬라이드 애니메이션을 View Transitions API로 구현해보자.
// 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을 사용하여 일주일 캘린더가 왼/오로 이동하는 애니메이션을 적용하였다.
// 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 효과
가 적용되었다.
// 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 아이콘 작동 순서대로 설명해보면,
className 변경됨
view-transition-name: left-board
로 설정::view-transition-old(left-board) {
animation-name: slideTo;
--slide-distance: -150px;
}
::view-transition-new(left-board) {
animation-name: slideFrom;
--slide-distance: 150px;
}
참고로
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 해서 해결했다.
Calendar의 Left, Right를 눌렀을 때만 일주일 달력 board가 움직여야 하는데 small/large view 아이콘을 클릭했을 때도 움직이는 오류가 있었다.
따라서 아래 코드처럼 root에 animation duration을 5초로 주고 개발자도구의 애니메이션 탭으로 확인해보았다.
::view-transition-new(root),
::view-transition-old(root) {
animation-duration: 5s;
}
View 아이콘을 클릭 했을 때 root와 left-board의 애니메이션이 같이 동작하면 안되는데 현재 그렇게 동작하는 이유는
- 아이콘 클릭 -> setisSmall 실행으로 인해 state 변경됨
- state 변경되니까 컴포넌트 전체 재렌더링 됨
- View 아이콘을 다루는 컴포넌트의 하위 컴포넌트가 캘린더임
- 따라서 하위 컴포넌트도 같이 렌더링 되면서 캘린더에서도 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이 적용되지 않는다! 얏호!
아직은 모든 브라우저에서 지원하지 않고, 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/