Codelab: 스토리 구성요소 빌드

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

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

설정

  1. Remix to Edit를 클릭하여 프로젝트를 수정할 수 있도록 합니다.
  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-x를 auto로 설정합니다. 사용자가 스크롤할 때 구성요소가 다음 스토리에 부드럽게 안착하도록 하려면 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-size를 cover로 설정하면 이미지가 표시 영역을 채우므로 표시 영역에 빈 공간이 없습니다. 배경 이미지 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의 Easing 가이드에서 맞춤 이징 함수(cubic-bezier(0.4, 0.0, 1,1))를 가져왔습니다(Accerlerated easing 섹션으로 스크롤).

눈썰미가 좋다면 pointer-events: none 선언을 발견하고 지금쯤 머리를 긁적이고 있을 것입니다. 지금까지 이 솔루션의 유일한 단점이라고 할 수 있습니다. .seen.story 요소가 상단에 있고 보이지 않더라도 탭을 수신하므로 이 작업이 필요합니다. pointer-events를 none로 설정하면 글래스 스토리가 창으로 바뀌고 더 이상 사용자 상호작용을 가로채지 않습니다. 나쁘지 않은 절충안이며 현재 CSS에서 관리하기가 그리 어렵지 않습니다. z-index을(를) 저글링하지 않습니다. 여전히 기분이 좋습니다.

JavaScript

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

설정

먼저 최대한 많은 정보를 계산하고 저장해 보겠습니다. 다음 코드를 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에서 작업하므로 친구(사용자) 또는 스토리(스토리)가 있는지 쿼리합니다.

이러한 변수는 '스토리 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
    }
  }
}

사용해 보기

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

결론

구성요소에 필요한 사항은 여기까지입니다. 이를 기반으로 데이터를 활용하고 전반적으로 나만의 것으로 만들어 보세요.