View Transitions API 알아보기 및 실습

기운찬곰·2023년 5월 15일
1
post-thumbnail

💻 카카오엔터테이먼트 FE 블로그를 참조하여 실습 및 정리한 내용입니다.

🧑🏻‍💻 코드 참고 : https://github.com/ckstn0777/view-transitions-practice

Overview

여러분은 View Transitions API 에 대해 들어보셨나요? 나온지 얼마 안된 녀석이기 때문에 아직 모를 수 있을 거 같습니다. 참고로 저는 카카오엔터테이먼트 FE 기술 블로그를 보다가 알게되었습니다. 흥미로운 내용이길래 해당 블로그를 참고하여 실습을 진행해봤고 정말 감탄했습니다.

그래서 이번 시간에는 View Transitions API가 무엇이고 어떻게 사용하며, 사용해본 소감에 대해 말씀드리겠습니다.


View Transitions API 란?

MDN 소개

“The View Transitions API provides a mechanism for easily creating animated transitions between different DOM states, while also updating the DOM contents in a single step.” - MDN 참고

View Transitions API 는 서로 다른 DOM 상태 간에 애니메이션 전환을 쉽게 만들 수 있는 메커니즘을 제공하는 동시에 DOM 내용을 한 단계로 업데이트 합니다.

View transitions는 사용자의 인지 부하(cognitive load) 를 줄이고, 상황에 맞게 유지하며, 애플리케이션 상태나 뷰를 이동할 때 인식되는 로드 지연(loading latency) 를 줄이기 위해 널리사용되는 design 선택입니다.

그러나 웹에서 view transitions를 만드는 것은 역사적으로 어려웠습니다. 아래 상황을 고려해야 하기 때문이죠.

  • 이전과 새 콘텐츠의 로드 및 위치를 지정하는 처리를 해야 합니다.
  • transition을 위해 이전 상태와 새로운 상태를 애니메이션으로 만들어야 합니다.
  • 이전 콘텐츠와의 우발적인 사용자 인터렉션으로 문제가 발생하지 않도록 해야 합니다.
  • transtion이 완료되면 이전 내용을 제거해야 합니다.

또한, 새로운 콘텐츠와 오래된 콘텐츠가 동시에 DOM에 존재함으로써 발생하는 접근성 문제도 주요 이슈입니다.

결론적으로, View Transitions API는 필요한 DOM 변경 및 전환 애니메이션을 훨씬 쉽게 처리할 수 있는 방법을 제공하기 위해 만들어졌습니다.

글로만 설명하면 이해하기 어려울 거 같아서 📺 Demo - Chrome Developer, MDN demo 를 보시면 쉽게 이해가 가실 겁니다. 자연스러운 뷰 전환... 보이시나요?

간단 사용법

사용법은 진짜 매우 간단합니다. 이것이 끝입니다. 이 코드는 View Transitions을 처리하기에 충분합니다.

function updateView(event) {
  // Handle the difference in whether the event is fired on the <a> or the <img>
  const targetIdentifier = event.target.firstChild || event.target;

	// 이미지랑 텍스트 교체 함수
  const displayNewImage = () => {
    const mainSrc = `${targetIdentifier.src.split("_th.jpg")[0]}.jpg`;
    galleryImg.src = mainSrc;
    galleryCaption.textContent = targetIdentifier.alt;
  };

  // View Transitions을 지원하지 않는 브라우저라면 직접 함수를 실행시켜줌. 이 때는 전환 애니메이션 X
  if (!document.startViewTransition) {
    displayNewImage();
    return;
  }

  // View Transitions 사용 - 이게 끝입니다. 
  const transition = document.startViewTransition(() => displayNewImage());
}

이미지를 새 이미지로의 변화를 부드러운 cross-fade (the default view transition)으로 보여줄 것이며, 지원하지 않는 브라우저에서는 기본 작동은 하지만 멋진 애니메이션은 작동하지 않습니다.

Experimental API - 실험적인 기능

하지만 아쉽게도 Chrome 111 버전부터 사용가능합니다. Chrome 111버전은 2023년 3월 7일에 나왔으니까 정말 최신 기능이네요 (Chrome schedule 참고). 또한, Chrome, Edge 이외 브라우저에서는 지원하지 않습니다. 따라서 실무에서 사용할 수는 없을거 같습니다. 😂


View Transitions API 없이 사용하기

정확한 차이를 느껴보려면 View Transitions API 없이 만들어보는 것입니다. 카카오엔터테이먼트 FE 블로그에 나와있는 화면과 동일하게 만들어봤습니다. 반응형으로도 만들어서 화면을 줄이면 오른쪽 이미지 처럼 됩니다.

이제 옆에 리스트를 클릭할 때 메인 썸네일이 바뀌어야 되는데, 지금은 아무 애니메이션 효과가 없어서 너무 밋밋합니다.

전환 트랜지션 효과를 통해 썸네일 이미지가 교체될 때 기존 썸네일은 자연스럽게 사라지면서 새로운 썸네일이 자연스럽게 나오도록 하고 싶습니다. View Transitions API 이전에는 어떻게 해야 할까요?

전체적인 소스코드는 카카오엔터테이먼트 FE 블로그를 참고했고, 나와있지 않은 부분은 제가 만들어서 했습니다.

export default function Example1() {
  const [currentViewItem, setCurrentViewItem] = useState(items[0]); // 현재 아이템
  const [futureViewItem, setFutureViewItem] = useState<ViewItem>(); // 바뀔 아이템

  const mainRef = useRef<HTMLImageElement>(null); // 현재(이제는 과거) 보이는 요소
  const futureMainRef = useRef<HTMLImageElement>(null); // 미래(이제는 현재) 보이는 요소

  const handleClick = (item: ViewItem) => {
    setFutureViewItem(item);
  };

  useEffect(() => {
    const main = mainRef.current;
    const futureMain = futureMainRef.current;

    if (futureViewItem && main && futureMain) {
      // 현재(이제는 과거) 보이는 요소는 fade-out 되어 화면에서 사라집니다.
      main.animate([{ opacity: 0 }], { duration: 1000 });
      // 미래(이제는 현재) 보이는 요소는 fade-in 되어 화면에 보여집니다.
      futureMain.animate([{ opacity: 1 }], { duration: 1000 });

      // 애니메이션이 종료될 타이밍에 맞춰서 상태 변경과 스타일 변경 (아하~)
      setTimeout(() => {
        setCurrentViewItem(futureViewItem);
        setFutureViewItem(undefined);

        main.style.opacity = "1";
        futureMain.style.opacity = "0";
      }, 1000);
    }
  }, [futureViewItem]);

  return (
    <main className="flex flex-col gap-6 md:w-[760px] md:flex-row">
      <ViewMain
        mainRef={mainRef}
        futureMainRef={futureMainRef}
        // 현재 데이터가 화면에 렌더링 됩니다.
        item={currentViewItem}
        // 미래에 보일 데이터가 있다면 화면에 보이진 않지만 렌더링 됩니다.
        futureItem={futureViewItem}
      />
      <ViewAsideList items={items} onClick={handleClick} />
    </main>
  );
}

import { ViewItem } from "../types";

type ViewMainProps = {
  mainRef: React.RefObject<HTMLImageElement>;
  futureMainRef: React.RefObject<HTMLImageElement>;
  item: ViewItem;
  futureItem: ViewItem | undefined;
};

export default function ViewMain({
  mainRef,
  futureMainRef,
  item,
  futureItem,
}: ViewMainProps) {
  return (
    <section className="basis-7/12">
      <div className="relative h-80">
        <img
          src={item.img}
          alt="view main img"
          className="absolute top-0 left-0 max-h-80 w-full object-cover"
          ref={mainRef}
        />

        <img
          src={futureItem?.img}
          alt="view main img"
          className="absolute top-0 left-0 max-h-80 w-full object-cover"
          ref={futureMainRef}
        />
      </div>

      <h3 className="text-xl my-4 text-left">{item.title}</h3>
      <hr />
      <p className="my-4 text-left">{item.desc}</p>
      <hr />
      <p className="my-4 text-left text-sm">
        Like {item.like} View {item.view}
      </p>
    </section>
  );
}
  • View Transitions API 없이는 현재 상태와 미래 상태를 가지고 있어야 합니다. HTML 요소 또한 마찬가지입니다. 그래서 MDN에서 접근성 문제도 주요 이슈라고 했던거군요. 새로운 콘텐츠와 오래된 콘텐츠가 동시에 DOM에 존재해야하니까요.
  • 그리고 각각에 대한 애니메이션을 만들어줘야 합니다.
  • 애니메이션이 끝나면 타이밍에 맞춰서 상태 변경을 해줘야 합니다.
  • 여기서는 고려되지 않았지만 뷰 전환 애니메이션 도중 우발적인 사용자 인터렉션으로 문제가 발생하지 않도록 해야 합니다.

심지어 위 코드는 약간의 이슈가 더 있었습니다. 뷰 전환 애니메이션 마지막에 이전 이미지가 잠깐 깜빡하고 사라지는 이슈가 있습니다.

아무리 봐도 main이 좀 늦게 바뀌는거 같아서 1000ms(애니메이션 종료 시점)보다 좀 적은 시간으로 바꿔주니까 잘 되더군요. 그니까 상태 변경 전 main이 나타나기 전에 setCurrentViewItem으로 main 상태를 미리 바꾸는 겁니다. 시간은 어림잡아 50ms 더 빨리 변경되도록 구현했습니다.

useEffect(() => {
    const main = mainRef.current;
    const futureMain = futureMainRef.current;

    if (futureViewItem && main && futureMain) {
      // 현재(이제는 과거) 보이는 요소는 fade-out 되어 화면에서 사라집니다.
      main.animate([{ opacity: 0 }], { duration: 1000 });
      // 미래(이제는 현재) 보이는 요소는 fade-in 되어 화면에 보여집니다.
      futureMain.animate([{ opacity: 1 }], { duration: 1000 });

      setTimeout(() => {
        setCurrentViewItem(futureViewItem);
        main.style.opacity = "1";
      }, 950);

      setTimeout(() => {
        setFutureViewItem(undefined);
        futureMain.style.opacity = "0";
      }, 1000);
    }
  }, [futureViewItem]);

Animation Fill Modes

근데 여전히 좀 의아한 부분이 있습니다. 이는 제가 애니메이션을 잘 안 다뤄봤기 때문에 헷갈렸던 개념인데, 아래 코드를 보면 opacticy: 0으로 1초동안 바뀌는데, 그 이후에는 opacity가 0이 유지가 될까요?

main.animate([{ opacity: 0 }], { duration: 1000 });

이와 관련해서는 Animation Fill Modes 라는 개념에 대해 알고 있어야 합니다.

참고 : https://www.joshwcomeau.com/animation/keyframe-animations/#fill-modes-7

아래 예시 코드에서 저는 애니메이션이 끝나면 요소가 잘 사라지길 원하지만, 애니메이션이 끝나면 다시 불투명 상태로 돌아옵니다.

<style>
  @keyframes fade-out {
    from {
      opacity: 1; // 불투명
    }
    to {
      opacity: 0; // 투명 
    }
  }
  
  .box {
    animation: fade-out 1000ms;
  }
</style>

<div class="box">
  Hello World
</div>

이를 그림으로 표현하면 다음과 같습니다.

요소가 다시 full visibility로 되돌아가는 이유는 무엇일까요? 시작 및 종료 블록의 선언은 애니메이션이 실행되는 동안에만 적용됩니다. 1초(1000ms)가 경고하면 우리의 애니메이션 선언 블록은 소멸되어 우리의 요소는 다른 곳에서 정의된 CSS 선언과 함께 남게 됩니다.

다른 곳에서 이 요소의 불투명도를 설정하지 않았기 때문에 기본값(1)으로 스냅백되는 것입니다. (오호)

그래서 원하는 결과 수행대로 하려면 아래와 같이 default 값을 opacity: 0 으로 해주면 됩니다. 애니메이션이 실행되는 동안에는 @keyframes문의 선언이 더 우선이 된다.

<style>
  @keyframes fade-out {
    from {
      opacity: 1;
    }
    to {
      opacity: 0;
    }
  }
  
  .box {
    animation: fade-out 1000ms;
    /*
      Change the "default" value for opacity,
      so that it reverts to 0 when the
      animation completes.
    */
    opacity: 0;
  }
</style>

<div class="box">
  Hello World
</div>

이렇게 또 새로운 사실을 알아가게 되었습니다. ㅎㅎ

리팩토링

사실 그래서 위 코드는 조금 수정을 해주면 좋을 거 같습니다. 처음부터 main은 opacity 1(불투명)로, future는 opacity 0(투명)으로 스타일을 설정해주면 됩니다.

<img
  src={item.img}
  alt="view main img"
  className="absolute top-0 left-0 max-h-80 w-full object-cover opacity-100"
  ref={mainRef}
/>

<img
  src={futureItem?.img}
  alt="view main img"
  className="absolute top-0 left-0 max-h-80 w-full object-cover opacity-0"
  ref={futureMainRef}
/>

그러면 setTimeout에 있는 style 코드는 필요가 없어집니다. 애니메이션이 끝나면 원래 스타일로 다시 되돌아갈테니까요.

setTimeout(() => {
  setCurrentViewItem(futureViewItem);
}, 950);

setTimeout(() => {
  setFutureViewItem(undefined);
}, 1000);

하지만 여전히 애니메이션이 끝나면 원래 스타일로 되돌아가기 전에 main 상태를 미리 변경 해줘야 합니다. 근데 문제가 이 시간차를 정확히 예측을 못하겠습니다. 50보다 작으면 깜빡거리기도 하고… 또 50보다 너무 크면 약간의 부자연스러움이 있습니다.

결국에 이런 부분에 있어서 정확히 예측할 수 없는 점, 구현 복잡도 등이 고려되어 View Transitions API가 나오게 된 게 아닐까 생각됩니다.


View Transitions API 사용해보기

document.startViewTransition이 가장 핵심입니다. 나머지는 다 필요없습니다. 현재 상태(요소)와 미래 상태(요소)를 만들어서 관리할 필요도 없습니다.

import { useState } from "react";
import ViewAsideList from "../../components/Example2/ViewAsideList";
import ViewMain from "../../components/Example2/ViewMain";
import { items } from "../../model/Items";
import { ViewItem } from "../../types";

export default function Example2() {
  const [currentViewItem, setCurrentViewItem] = useState(items[0]);

  const handleClickItem = (item: ViewItem) => {
    document.startViewTransition(() => {  // 핵심
      setCurrentViewItem(item);
    });
  };

  return (
    <main className="flex flex-col gap-6 md:w-[760px] md:flex-row">
      <ViewMain item={currentViewItem} />
      <ViewAsideList onClickItem={handleClickItem} />
    </main>
  );
}

아…근데 저는 타입스크립트를 사용하고 있는데 Document 타입에 startViewTransition 프로퍼티가 없나봅니다...

Property 'startViewTransition' does not exist on type 'Document'.ts(2339)

타입스크립트 버전이 좀 오래됐나싶어서 vscode 사용되는 타입스크립트 버전을 보니까 4.5.5 인듯합니다. 현재 5.0.4버전이니까 업데이트를 해봐야될거 같습니다.

참고 : https://stackoverflow.com/questions/39668731/what-typescript-version-is-visual-studio-code-using-how-to-update-it

하지만 그래도 타입스크립트 오류가 사라지질 않아서 document 타입스크립트를 확장해서 재정의하는 방법을 사용해야 겠습니다. index.d.ts를 만들어서 아래와 같이 작성하면 됩니다.

참고 : https://dev.to/khromov/extending-window-and-document-global-objects-in-typescript-30ia

export {};

declare global {
  interface Document {
    startViewTransition(callback: () => void): void;
  }
}

그리고 처음에 제대로 작동안하길래 봤더니 크롬에 Disable cache를 켜두고 있었습니다...해당 부분 꺼두고 결과를 보면 아주 자연스럽게 동작하는 것을 볼 수 있습니다. 심지어 이미지 뿐만 아니라 텍스트도 같이 적용이 되는 것을 알 수 있습니다.

만약 애니메이션 시간을 조정하고 싶다면 이런식으로 해주면 됩니다. 해당 의미는 밑에서 설명드리겠습니다.

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 1s;
}

작동 원리 알아보기

참고 : https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API#the_view_transition_process

MDN 문서에서 프로세스 관련 부분을 살펴보겠습니다.

  1. startViewTransition이 실행되면, 현재 페이지에 대한 스냅샷을 찍습니다.
  2. 그리고 startViewTransition으로 전달된 콜백함수가 실행되면서, DOM 변경이 일어납니다. 콜백함수가 성공적으로 실행완료되면 ViewTransition.updateCallbackDone promise가 fulfiil 상태가 되어 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 transitions를 포함하고 다른 모든 페이지 콘텐츠의 맨 위에 있는 view transitions overlay 루트이다.
    • ::view-transition-old는 이전 페이지 뷰의 스크린샷이고, ::view-transition-new는 새 페이지 뷰의 라이브 표현이다.
      전환 애니메이션이 실행되려고 하면 ViewTransition.ready 프로미스 fulfills가 되므로, 기본값 대신 사용자 지정 JavaScript animation을 실행하여 응답할 수 있습니다.
  5. 이전 페이지 뷰는 opacity가 1에서 0으로 투명해지는 반면, 새 페이지 뷰는 opacity가 0에서 1로 불투명하게 애니메이션되므로 기본 cross-fade 가 된다.
  6. 전환 애니메이션이 종료 상태에 도달하면 ViewTransition.finished 프로미스가 fulfills가 됩니다.

실제로 확인해보면 ::view-transition이 생성된 것을 볼 수 있습니다. 그니까 내부적으로 가상요소를 만들어서 알아서 처리를 해준다는 거네요.

만약 특정 부분에만 다른 애니메이션 효과를 주려면?

다른 요소를 기본 “루트” 애니메이션과 다르게 애니메이션화하려면 view-transition-name 프로퍼티를 사용하여 요소를 구분할 수 있습니다.

figcaption {
  view-transition-name: figure-caption;
}

우리는 view transitions 측면에서 페이지의 나머지 부분과 분리하기 위해 figcaption 요소에 figure-caption이란 view-transition-name을 별도로 부여했습니다.

그러면 CSS pseudo-element tree 는 다음과 같이 생성됩니다. 아하. 완전 별개가 되는구나. 이러면 별도의 뷰 전환 스타일을 figcaption에 적용할 수 있습니다.

::view-transition
├─ ::view-transition-group(root)
│ └─ ::view-transition-image-pair(root)
│     ├─ ::view-transition-old(root)
│     └─ ::view-transition-new(root)
└─ ::view-transition-group(figure-caption)
  └─ ::view-transition-image-pair(figure-caption)
      ├─ ::view-transition-old(figure-caption)
      └─ ::view-transition-new(figure-caption)

참고로 view-transition-name은 none을 제외하고 원하는 모든 값이 가능합니다. 또한 유니크해야 합니다. 만약 똑같은게 여러개 있다면 ViewTransition.ready는 reject되어 transition을 건너뛴다고 하네요.

따라서 특정 부분에만 다른 애니메이션 효과를 주는 것도 충분히 가능합니다.

  1. 원하는 애니메이션을 위한 keyframes를 정의합니다.
  2. 그런 다음 트랜지션의 이름을 정의합니다.
  3. ::view-transition-old (이전 스냅샷), ::view-transition-new(미래 스냅샷) 에 애니메이션을 적용해줍니다.

이후 내용은 카카오엔터테이먼트 FE 블로그에 잘 나와있으니 생략하도록 하겠습니다.


마치면서

실습을 해보면서 정말 재미있는 시간이었습니다. 아주 유용한 Web API 인건 확실합니다. 조만간 실무에서도 사용할 수 있었으면 좋겠네요. 한 1~2년은 지나야 될까요...? 😂

참고 : https://http203-playlist.netlify.app/

아 그리고 이 사이트는 도대체 뭘까요? SPA인가요? SPA는 아닌거 같은데 어떻게 이렇게 자연스럽게 페이지 이동이 되면서 뷰 전환 애니메이션이 작동하는 걸까요...?

참고 : https://developer.chrome.com/docs/web-platform/navigation-api/

뭔가 Modern client-side routing: the Navigation API 이걸 추가적으로 사용한거 같은데… 나중에 이것도 한번 알아보고 실습해보면 좋을거 같네요. 정말 웹 브라우저와 프론트엔드 생태계는 발전 속도가 빠른거 같습니다. 앞으로는 또 어떤 재미있는게 나올까요?


참고 자료

profile
배움을 좋아합니다. 새로운 것을 좋아합니다.

0개의 댓글