Codelab: 스토리 구성요소 빌드

이 Codelab에서는 웹에서 Instagram 스토리와 같은 환경을 빌드하는 방법을 알아봅니다. HTML, CSS, JavaScript 순으로 진행하면서 구성요소를 빌드합니다.

이 구성요소를 빌드하는 동안 이루어진 점진적인 개선사항에 대해 알아보려면 블로그 게시물 스토리 구성요소 빌드를 확인하세요.

설정

  1. 리믹스하여 수정을 클릭하여 프로젝트를 수정할 수 있도록 합니다.
  2. app/index.html를 엽니다.

HTML

저는 항상 시맨틱 HTML을 사용하려 합니다. 각 친구는 스토리를 무제한으로 보유할 수 있으므로 친구별로 <section> 요소를 사용하고 스토리별로 <article> 요소를 사용하는 것이 의미 있다고 생각했습니다. 처음부터 시작해 보겠습니다. 먼저 스토리 구성요소의 컨테이너가 필요합니다.

<body><div> 요소를 추가합니다.

<div class="stories">

</div>

친구를 나타내는 <section> 요소를 추가합니다.

<div class="stories">
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
</div>

스토리를 나타내는 <article> 요소를 추가합니다.

<div class="stories">
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/480/841);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/481/840);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/481/841);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/482/840);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/482/843);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/482/844);"></article>
  </section>
</div>
  • 스토리 프로토타입을 만드는 데 이미지 서비스 (picsum.com)를 사용합니다.
  • <article>style 속성은 자리표시자 로드 기법의 일부이며 이에 관해서는 다음 섹션에서 자세히 알아봅니다.

CSS

YouTube 콘텐츠에 스타일을 적용할 준비가 되었습니다. 이제 이러한 뼈대를 사용자가 상호작용하고 싶어 하는 것으로 바꿔 보겠습니다. 오늘은 모바일을 우선으로 작업할 예정입니다.

.stories

<div class="stories"> 컨테이너의 경우 가로 스크롤 컨테이너가 필요합니다. 다음과 같이 할 수 있습니다.

  • 컨테이너를 그리드로 설정
  • 행 트랙을 채우도록 각 하위 요소 설정
  • 각 하위 요소의 너비를 휴대기기 뷰포트의 너비로 설정

그리드는 마크업에 모든 HTML 요소를 배치할 때까지 이전 열의 오른쪽에 계속해서 새 100vw 너비 열을 배치합니다.

전체 너비 레이아웃을 보여주는 그리드 시각화로 Chrome 및 DevTools가 열림
그리드 열 오버플로를 보여주는 Chrome DevTools로, 가로 스크롤러가 생성됩니다.

app/css/index.css 하단에 다음 CSS를 추가합니다.

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
}

이제 뷰포트 너머로 확장되는 콘텐츠가 있으므로 컨테이너에 이를 처리하는 방법을 알려야 합니다. 강조 표시된 코드 줄을 .stories 규칙 집합에 추가합니다.

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  overscroll-behavior: contain;
  touch-action: pan-x;
}

가로 스크롤을 사용하려면 overflow-xauto로 설정합니다. 사용자가 스크롤할 때 구성요소가 다음 스토리에 부드럽게 멈추도록 scroll-snap-type: x mandatory를 사용합니다. 이 CSS에 관한 자세한 내용은 블로그 게시물의 CSS Scroll Snap Pointsoverscroll-behavior 섹션을 참고하세요.

스크롤 스냅에 동의하려면 상위 컨테이너와 하위 요소 모두 필요하므로 이제 이를 처리해 보겠습니다. 다음 코드를 app/css/index.css 하단에 추가합니다.

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

앱이 아직 작동하지 않지만 아래 동영상에서는 scroll-snap-type를 사용 설정 및 사용 중지할 때 어떤 일이 일어나는지 보여줍니다. 이 기능을 사용 설정하면 각 가로 스크롤이 다음 스토리로 이동합니다. 사용 중지하면 브라우저에서 기본 스크롤 동작을 사용합니다.

이렇게 하면 친구를 스크롤할 수 있지만 스토리와 관련된 문제가 아직 해결되지 않았습니다.

.user

.user 섹션에서 이러한 하위 스토리 요소를 제자리에 배치하는 레이아웃을 만들어 보겠습니다. 이 문제를 해결하기 위해 편리한 비슷한 사진 겹치기 트릭을 사용합니다. 기본적으로 행과 열의 그리드 별칭이 모두 [story]인 1x1 그리드를 만들고 각 스토리 그리드 항목이 해당 공간을 소유하려고 시도하여 스택이 생성됩니다.

강조 표시된 코드를 .user 규칙 집합에 추가합니다.

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
  display: grid;
  grid: [story] 1fr / [story] 1fr;
}

app/css/index.css 하단에 다음 규칙 집합을 추가합니다.

.story {
  grid-area: story;
}

이제 절대 위치 지정, 플로트 또는 요소를 흐름에서 제외하는 기타 레이아웃 디렉티브가 없어도 여전히 흐름이 유지됩니다. 게다가 코드가 거의 없습니다. 이 내용은 동영상과 블로그 게시물에서 자세히 설명합니다.

.story

이제 스토리 항목 자체의 스타일을 지정하기만 하면 됩니다.

앞서 각 <article> 요소의 style 속성이 자리표시자 로드 기법의 일부라고 언급했습니다.

<article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>

CSS의 background-image 속성을 사용합니다. 이 속성을 사용하면 두 개 이상의 배경 이미지를 지정할 수 있습니다. 사용자 사진이 맨 위에 있고 로드가 완료되면 자동으로 표시되도록 순서를 지정할 수 있습니다. 이를 사용 설정하려면 이미지 URL을 맞춤 속성 (--bg)에 넣고 CSS 내에서 이를 사용하여 로드 자리표시자와 레이어링합니다.

먼저 로드가 완료되면 그라데이션을 배경 이미지로 바꾸도록 .story 규칙 집합을 업데이트합니다. 강조 표시된 코드를 .story 규칙 집합에 추가합니다.

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));
}

background-sizecover로 설정하면 이미지가 전체를 채우므로 뷰포트에 빈 공간이 없게 됩니다. 배경 이미지 2개를 정의하면 로드 툼스톤이라는 깔끔한 CSS 웹 트릭을 가져올 수 있습니다.

  • 배경 이미지 1 (var(--bg))은 HTML에서 인라인으로 전달한 URL입니다.
  • 배경 이미지 2 (linear-gradient(to top, lch(98 0 0), lch(90 0 0))는 URL이 로드되는 동안 표시되는 그라데이션입니다.

이미지 다운로드가 완료되면 CSS에서 그라데이션을 이미지로 자동 대체합니다.

다음으로 일부 동작을 삭제하는 CSS를 추가하여 브라우저가 더 빠르게 움직일 수 있도록 합니다. 강조 표시된 코드를 .story 규칙 집합에 추가합니다.

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));

  user-select: none;
  touch-action: manipulation;
}
  • user-select: none: 사용자가 실수로 텍스트를 선택하지 못하도록 합니다.
  • touch-action: manipulation는 이러한 상호작용을 터치 이벤트로 처리해야 한다고 브라우저에 지시하므로 브라우저가 URL을 클릭하는지 여부를 결정하지 않아도 됩니다.

마지막으로 스토리 간의 전환에 애니메이션을 적용하는 CSS를 추가해 보겠습니다. 강조 표시된 코드를 .story 규칙 집합에 추가합니다.

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));

  user-select: none;
  touch-action: manipulation;

  transition: opacity .3s cubic-bezier(0.4, 0.0, 1, 1);

  &.seen {
    opacity: 0;
    pointer-events: none;
  }
}

종료가 필요한 스토리에 .seen 클래스가 추가됩니다. Material Design의 이완 가이드 (가속된 이완 섹션으로 스크롤)에서 맞춤 이완 함수 (cubic-bezier(0.4, 0.0, 1,1))를 가져왔습니다.

눈이 밝은 독자라면 pointer-events: none 선언을 눈치채고 지금 당혹스러워하고 있을 것입니다. 지금까지 이 솔루션의 유일한 단점이라고 할 수 있습니다. .seen.story 요소가 보이지 않더라도 상단에 표시되고 탭을 수신하므로 이 작업이 필요합니다. pointer-eventsnone로 설정하면 글래스 스토리를 창으로 전환하고 더 이상 사용자 상호작용을 훔치지 않습니다. 나쁘지 않은 절충안이며 지금 CSS에서 관리하기에도 어렵지 않습니다. z-index을(를) 저글링하고 있지 않습니다. 여전히 괜찮습니다.

자바스크립트

스토리 구성요소의 상호작용은 사용자에게 매우 간단합니다. 오른쪽을 탭하여 앞으로 이동하고 왼쪽을 탭하여 뒤로 이동합니다. 사용자에게는 간단한 작업이 개발자에게는 어려운 작업일 수 있습니다. 하지만 많은 부분을 YouTube에서 처리합니다.

설정

먼저 최대한 많은 정보를 계산하고 저장해 보겠습니다. 다음 코드를 app/js/index.js에 추가합니다.

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

JavaScript의 첫 번째 줄은 기본 HTML 요소 루트에 대한 참조를 가져와 저장합니다. 다음 줄은 요소의 중앙 위치를 계산하여 탭이 앞으로 이동할지 뒤로 이동할지 결정할 수 있습니다.

다음으로 로직과 관련된 상태가 있는 작은 객체를 만듭니다. 이 경우 현재 스토리에만 관심이 있습니다. HTML 마크업에서는 첫 번째 친구와 가장 최근 스토리를 가져와 액세스할 수 있습니다. 강조 표시된 코드를 app/js/index.js에 추가합니다.

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

리스너

이제 사용자 이벤트를 수신 대기하고 이를 전달하기에 충분한 로직이 있습니다.

먼저 스토리 컨테이너에서 'click' 이벤트를 리슨해 보겠습니다. 강조 표시된 코드를 app/js/index.js에 추가합니다.

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

클릭이 발생했지만 <article> 요소가 아닌 경우 종료하고 아무것도 하지 않습니다. 기사인 경우 clientX를 사용하여 마우스 또는 손가락의 가로 위치를 가져옵니다. 아직 navigateStories를 구현하지는 않았지만, 이 함수가 사용하는 인수는 이동해야 할 방향을 지정합니다. 사용자 위치가 중간값보다 크면 next로 이동해야 하고, 그렇지 않으면 prev (이전)로 이동해야 합니다.

키보드

이제 키보드 누르기를 수신 대기해 보겠습니다. 아래쪽 화살표를 누르면 next로 이동합니다. 위쪽 화살표인 경우 prev로 이동합니다.

강조 표시된 코드를 app/js/index.js에 추가합니다.

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

document.addEventListener('keydown', ({key}) => {
  if (key !== 'ArrowDown' || key !== 'ArrowUp')
    navigateStories(
      key === 'ArrowDown'
        ? 'next'
        : 'prev')
})

스토리 탐색

스토리의 고유한 비즈니스 로직과 유명해진 UX를 살펴볼 때입니다. 꽤 큼직하고 까다로워 보이지만 한 줄씩 살펴보면 꽤 쉽게 이해할 수 있을 것입니다.

먼저 친구에게 스크롤할지 또는 스토리를 표시/숨길지 결정하는 데 도움이 되는 일부 선택기를 저장합니다. HTML이 작업 공간이므로 HTML에서 친구 (사용자) 또는 스토리 (스토리)의 존재를 쿼리합니다.

이러한 변수는 '특정 스토리 x에서 '다음'은 같은 친구의 다른 스토리로 이동하는 것을 의미하나요? 아니면 다른 친구의 스토리로 이동하는 것을 의미하나요?'와 같은 질문에 답하는 데 도움이 됩니다. 이를 위해 빌드한 트리 구조를 사용하여 상위 요소와 하위 요소에 도달했습니다.

다음 코드를 app/js/index.js 하단에 추가합니다.

const navigateStories = direction => {
  const story = state.current_story
  const lastItemInUserStory = story.parentNode.firstElementChild
  const firstItemInUserStory = story.parentNode.lastElementChild
  const hasNextUserStory = story.parentElement.nextElementSibling
  const hasPrevUserStory = story.parentElement.previousElementSibling
}

다음은 최대한 자연어에 가까운 비즈니스 로직 목표입니다.

  • 탭을 처리하는 방법 결정
    • 다음/이전 스토리가 있는 경우: 해당 스토리 표시
    • 친구의 마지막/첫 번째 스토리인 경우: 새 친구를 표시합니다.
    • 해당 방향으로 진행할 스토리가 없는 경우: 아무것도 하지 않음
  • 새 현재 스토리를 state에 스테이시합니다.

강조 표시된 코드를 navigateStories 함수에 추가합니다.

const navigateStories = direction => {
  const story = state.current_story
  const lastItemInUserStory = story.parentNode.firstElementChild
  const firstItemInUserStory = story.parentNode.lastElementChild
  const hasNextUserStory = story.parentElement.nextElementSibling
  const hasPrevUserStory = story.parentElement.previousElementSibling

  if (direction === 'next') {
    if (lastItemInUserStory === story && !hasNextUserStory)
      return
    else if (lastItemInUserStory === story && hasNextUserStory) {
      state.current_story = story.parentElement.nextElementSibling.lastElementChild
      story.parentElement.nextElementSibling.scrollIntoView({
        behavior: 'smooth'
      })
    }
    else {
      story.classList.add('seen')
      state.current_story = story.previousElementSibling
    }
  }
  else if(direction === 'prev') {
    if (firstItemInUserStory === story && !hasPrevUserStory)
      return
    else if (firstItemInUserStory === story && hasPrevUserStory) {
      state.current_story = story.parentElement.previousElementSibling.firstElementChild
      story.parentElement.previousElementSibling.scrollIntoView({
        behavior: 'smooth'
      })
    }
    else {
      story.nextElementSibling.classList.remove('seen')
      state.current_story = story.nextElementSibling
    }
  }
}

사용해 보기

  • 사이트를 미리 보려면 앱 보기를 누른 다음 전체 화면 전체 화면을 누릅니다.

결론

구성요소와 관련된 요구사항을 마무리했습니다. 이 템플릿을 기반으로 하여 데이터로 구동하고 마음껏 활용해 보세요.