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

스타일리시한 멋을 더하는 콘텐츠 그 뼈를 사람들이 상호작용하고 싶어 할 것으로 만들어 봅시다. 오늘은 모바일 중심으로 작업하겠습니다.

.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 스크롤 스냅 지점overscroll-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개를 정의하면 Tombstone 로드라는 깔끔한 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 클래스는 이탈이 필요한 스토리에 추가됩니다. 맞춤 이징 함수 (cubic-bezier(0.4, 0.0, 1,1))는 Material Design의 이징 가이드에서 가져왔습니다 (가속된 이징 섹션으로 스크롤).

관심이 있다면 pointer-events: none 선언을 알아차리고 지금 머리를 긁고 있을 수 있습니다. 지금까지 이 솔루션의 유일한 단점이라고 생각합니다. .seen.story 요소가 맨 위에 있고 보이지 않더라도 탭을 수신하기 때문에 이 작업이 필요합니다. pointer-eventsnone로 설정하면 유리 스토리를 창으로 바꾸고 더 이상 사용자 상호작용을 훔치지 않습니다. 절충이 그리 나쁘지 않으며 현재 CSS에서 관리하기도 너무 어렵지 않습니다. 우리는 z-index을(를) 저글링하는 게 아니에요. 아직도 기분이 좋습니다.

JavaScript

스토리 구성요소의 상호작용은 사용자에게 매우 간단합니다. 앞으로 이동하려면 오른쪽을 탭하고 뒤로 돌아가려면 왼쪽을 탭합니다. 사용자에게는 간단한 일도 개발자에게는 힘들게 마련입니다. 하지만 많은 부분은 Google에서 처리해 드리겠습니다.

설정

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

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

자바스크립트의 첫 번째 줄은 기본 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
}

Google의 비즈니스 로직 목표는 다음과 같습니다(자연어에 최대한 가까움).

  • 탭 처리 방법을 결정합니다.
    • 다음/이전 뉴스가 있는 경우 해당 스토리 표시
    • 친구의 마지막/첫 번째 이야기인 경우: 새 친구를 보여줍니다.
    • 해당 방향으로 진행할 스토리가 없는 경우: 아무 작업도 하지 않습니다.
  • 새로운 현재 스토리를 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
    }
  }
}

사용해 보기

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

결론

구성요소와 관련된 요구사항을 마치겠습니다. 자유롭게 이를 기반으로 빌드하고 데이터로 구동하고 일반적으로 나만의 것으로 만들 수 있습니다.