lit-element로 스토리 구성요소 빌드

스토리는 오늘날 인기 있는 UI 구성요소입니다. 소셜 앱과 뉴스 앱에서는 피드에 스토리 구성요소를 통합하고 있습니다. 이 Codelab에서는 lit-element와 TypeScript를 사용해 스토리 구성요소를 빌드합니다.

스토리 구성요소가 완성된 모습은 다음과 같습니다.

세 장의 커피 이미지를 보여주는 완성된 story-viewer 구성요소

소셜 미디어나 뉴스 '스토리'를 일종의 슬라이드쇼와 같이 연속적으로 재생되는 카드 모음이라고 생각할 수 있습니다. 사실상 스토리는 말 그대로 슬라이드쇼입니다. 카드는 주로 이미지나 자동 재생 동영상이 주를 이루고 상단에 추가 텍스트가 있을 수 있습니다. 여기서 빌드할 항목은 다음과 같습니다.

기능 목록

  • 이미지 또는 동영상 배경이 포함된 카드
  • 왼쪽이나 오른쪽으로 스와이프하여 스토리 탐색
  • 자동재생 동영상
  • 텍스트를 추가하거나 카드를 맞춤설정할 수 있는 기능

이 구성요소의 개발자 환경에서는 다음과 같이 일반 HTML 마크업으로 스토리 카드를 지정하는 것이 좋습니다.

<story-viewer>
  <story-card>
    <img slot="media" src="some/image.jpg" />
    <h1>Title</h1>
  </story-card>
  <story-card>
    <video slot="media" src="some/video.mp4" loop playsinline></video>
    <h1>Whatever</h1>
    <p>I want!</p>
  </story-card>
</story-viewer>

따라서 HTML 마크업으로 된 카드도 특성 목록에 추가하겠습니다.

기능 목록

  • HTML 마크업으로 된 일련의 카드 허용

이렇게 하면 누구나 HTML을 작성하여 스토리 구성요소를 사용할 수 있습니다. 이 방법은 프로그래머이든 프로그래머가 아니든 다 좋습니다. 또한 콘텐츠 관리 시스템, 프레임워크 등과 같이 HTML이 작동하는 모든 곳에서 사용할 수 있습니다.

사전 준비 사항

  • gitnpm을 실행할 수 있는 셸
  • 텍스트 편집기

먼저 이 저장소 story-viewer-starter를 클론합니다.

git clone git@github.com:PolymerLabs/story-viewer-starter.git

이미 환경은 lit-element 및 TypeScript로 설정되어 있습니다. 종속 항목을 설치하기만 하면 됩니다.

npm i

VS Code 사용자의 경우 자동 완성, 유형 확인, lit-html 템플릿 린트 작업이 가능하도록 lit-plugin 확장 프로그램을 설치합니다.

다음을 실행하여 개발 환경을 시작합니다.

npm run dev

이제 코딩을 시작할 준비가 되었습니다.

복합 구성요소를 빌드할 때는 비교적 단순한 하위 구성요소로 빌드하는 것이 더 쉬울 때가 있습니다. 그럼 먼저 <story-card>를 빌드해 보겠습니다. 카드는 풀 블리드 동영상이나 이미지를 표시할 수 있어야 합니다. 사용자가 오버레이 텍스트 등을 사용하여 이를 추가로 맞춤설정할 수 있어야 합니다.

첫 번째 단계는 LitElement를 확장하는 구성요소의 클래스를 정의하는 것입니다. customElement 데코레이터는 맞춤 요소 등록을 자동으로 처리합니다. 지금 experimentalDecorators 플래그를 사용하여 tsconfig에 데코레이터가 사용 설정되었는지 확인하면 좋습니다(시작 저장소를 사용하는 경우 이미 사용 설정되어 있음).

story-card.ts에 다음 코드를 입력합니다.

import { LitElement, customElement } from 'lit-element';

@customElement('story-card')
export class StoryCard extends LitElement {
}

이제 <story-card>가 사용 가능한 맞춤 요소가 되었지만, 아직 표시되는 항목이 없습니다. 요소의 내부 구조를 정의하기 위해 render 인스턴스 메서드를 정의합니다. 이 메서드에서 lit-html의 html 태그를 사용하여 요소의 템플릿을 제공할 것입니다.

이 구성요소의 템플릿에는 무엇이 있어야 하나요? 사용자가 두 가지 항목 즉, 미디어 요소와 오버레이를 제공할 수 있어야 합니다. 그래서 이 두 가지 각각에 <slot>을 하나씩 추가합니다.

슬롯은 맞춤 요소의 하위 요소가 렌더링되는 방식을 지정한 것입니다. 자세한 내용은 유용한 슬롯 사용 방법 둘러보기에서 확인할 수 있습니다.

import { html } from 'lit-html';

export class StoryCard extends LitElement {
  render() {
    return html`
      <div id="media">
        <slot name="media"></slot>
      </div>
      <div id="content">
        <slot></slot>
      </div>
    `;
  }
}

미디어 요소를 자체 슬롯에 분리하면 그 요소를 풀 블리드 스타일 추가와 동영상 자동재생 같은 작업에 쉽게 타겟팅할 수 있습니다. 나중에 기본 패딩을 제공할 수 있도록 또 다른 슬롯(맞춤 오버레이용 슬롯)을 컨테이너 요소 내에 배치합니다.

이제 다음과 같은 <story-card> 구성요소를 사용할 수 있습니다.

<story-card>
  <img slot="media" src="some/image.jpg" />
  <h1>My Title</h1>
  <p>my description</p>
</story-card>

하지만 보기에 썩 좋지 않습니다.

한 장의 커피 사진을 표시하는, 스타일이 지정되지 않은 story-viewer

스타일 추가

스타일을 추가해 보겠습니다. 이 작업은 lit-element를 사용하여 정적 styles 속성을 정의하고 css로 태그가 지정된 템플릿 문자열을 반환하여 진행합니다. 여기서 작성된 어떤 CSS도 맞춤 요소에만 적용됩니다. 이렇게 하여 CSS를 Shadow DOM과 함께 사용하면 아주 좋습니다.

<story-card>를 포함하도록 슬롯이 있는 미디어 요소에 스타일을 지정해 보겠습니다. 이때 두 번째 슬롯의 요소에 멋진 포맷을 지정할 수 있습니다. 그렇게 하면 구성요소 사용자가 <h1>, <p> 등을 배치할 수 있고 기본적으로 보기에도 좋습니다.

import { css } from 'lit-element';

export class StoryCard extends LitElement {
  static styles = css`
    #media {
      height: 100%;
    }
    #media ::slotted(*) {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }

    /* Default styles for content */
    #content {
      position: absolute;
      top: 0;
      right: 0;
      bottom: 0;
      left: 0;
      padding: 48px;
      font-family: sans-serif;
      color: white;
      font-size: 24px;
    }
    #content > slot::slotted(*) {
      margin: 0;
    }
  `;
}

한 장의 커피 사진을 표시하는, 스타일이 지정된 story-viewer

이제 백그라운드 미디어가 있는 스토리 카드가 완성되었으며 상단에 원하는 모든 항목을 배치할 수 있습니다. 잘하셨습니다. 잠시 후에 StoryCard 클래스로 돌아가서 자동재생 동영상을 구현해 보겠습니다.

<story-viewer> 요소는 <story-card>의 상위 요소입니다. 카드를 가로로 펼치고 카드 사이를 스와이프할 수 있게 해 줍니다. StoryCard에 했던 방식과 동일하게 시작하겠습니다. 스토리 카드를 <story-viewer> 요소의 하위 요소로 추가하고자 하므로 그 하위 요소를 위한 슬롯을 추가합니다.

story-viewer.ts에 다음 코드를 삽입합니다.

import { LitElement, customElement, html } from 'lit-element';

@customElement('story-viewer')
export class StoryViewer extends LitElement {
  render() {
    return html`<slot></slot>`;
  }
}

다음은 가로 레이아웃입니다. 이에 접근하는 방법은 슬롯이 있는 <story-card>에 절대 위치를 모두 지정하고 이를 색인에 따라 변환하는 것입니다. :host 선택기를 사용하여 <story-viewer> 요소 자체를 타겟팅할 수 있습니다.

static styles = css`
  :host {
    display: block;
    position: relative;
    /* Default size */
    width: 300px;
    height: 800px;
  }
  ::slotted(*) {
    position: absolute;
    width: 100%;
    height: 100%;
  }`;

사용자는 외부에서 호스트의 기본 높이와 너비를 재정의하는 방식으로 스토리 카드 크기를 제어할 수 있습니다. 다음 라인을 추가하면 됩니다.

story-viewer {
  width: 400px;
  max-width: 100%;
  height: 80%;
}

현재 표시된 카드를 추적하기 위해 StoryViewer 클래스에 인스턴스 변수 index를 추가해 봅니다. LitElement의 @property로 이를 데코레이팅하면 값이 변할 때마다 구성요소가 다시 렌더링됩니다.

import { property } from 'lit-element';

export class StoryViewer extends LitElement {
  @property({type: Number}) index: number = 0;
}

각 카드를 가로 위치로 변환해야 합니다. 이러한 변환을 lit-element의 update 수명 주기 메서드에 적용해 봅니다. 이 구성요소의 관측된 속성이 변할 때마다 업데이트 메서드가 실행됩니다. 일반적으로는 슬롯에 관해 쿼리하고 slot.assignedElements()를 순환합니다. 하지만 명명되지 않은 슬롯이 하나만 있으므로 this.children을 사용하는 것과 같습니다. 편의상 this.children을 사용해 보겠습니다.

import { PropertyValues } from 'lit-element';

export class StoryViewer extends LitElement {
  update(changedProperties: PropertyValues) {
    const width = this.clientWidth;
    Array.from(this.children).forEach((el: Element, i) => {
      const x = (i - this.index) * width;
      (el as HTMLElement).style.transform = `translate3d(${x}px,0,0)`;
    });
    super.update(changedProperties);
  }
}

이제 <story-card>가 연이어 표시됩니다. 다른 요소에 적절히 스타일을 지정하는 한, 이 방식은 하위 요소 같은 다른 요소에도 사용할 수 있습니다.

<story-viewer>
  <!-- A regular story-card child... -->
  <story-card>
    <video slot="media" src="some/video.mp4"></video>
    <h1>This video</h1>
    <p>is so cool.</p>
  </story-card>
  <!-- ...and other elements work too! -->
  <img style="object-fit: cover" src="some/img.png" />
</story-viewer>

build/index.html로 이동하여 나머지 story-card 요소의 주석 처리를 삭제합니다. 이러한 요소로 이동할 수 있도록 지금 직접해 봅니다.

다음으로, 카드 간을 이동하는 방법과 진행률 표시줄을 추가해 보겠습니다.

스토리 탐색을 위해 StoryViewer에 도우미 함수를 추가해 봅니다. 도우미 함수는 자동으로 색인을 설정하면서 색인을 유효한 범위로 고정시킵니다.

story-viewer.ts의 StoryViewer 클래스에 다음을 추가합니다.

/** Advance to the next story card if possible **/
next() {
  this.index = Math.max(0, Math.min(this.children.length - 1, this.index + 1));
}

/** Go back to the previous story card if possible **/
previous() {
  this.index = Math.max(0, Math.min(this.children.length - 1, this.index - 1));
}

최종 사용자에게 이동을 노출하기 위해 <story-viewer>에 '이전' 및 '다음' 버튼을 추가하겠습니다. 두 버튼 중 하나가 클릭되면 next 또는 previous 도우미 함수를 호출하고자 합니다. lit-html을 사용하면 요소에 이벤트 리스너를 쉽게 추가할 수 있습니다. 또한 버튼을 렌더링하고 그와 동시에 클릭 리스너를 추가할 수 있습니다.

render 메서드를 다음과 같이 업데이트합니다.

export class StoryViewer extends LitElement {
  render() {
    return html`
      <slot></slot>

      <svg id="prev" viewBox="0 0 10 10" @click=${() => this.previous()}>
        <path d="M 6 2 L 4 5 L 6 8" stroke="#fff" fill="none" />
      </svg>
      <svg id="next" viewBox="0 0 10 10" @click=${() => this.next()}>
        <path d="M 4 2 L 6 5 L 4 8" stroke="#fff" fill="none" />
      </svg>
    `;
  }
}

render 메서드의 오른쪽에서 새 svg 버튼에 이벤트 리스너를 인라인으로 추가할 수 있는지 확인합니다. 이는 모든 이벤트에 적용됩니다. @eventname=${handler} 양식의 바인딩을 요소에 추가하기만 하면 됩니다.

static styles 속성에 다음을 추가하여 버튼에 스타일을 지정합니다.

svg {
  position: absolute;
  top: calc(50% - 25px);
  height: 50px;
  cursor: pointer;
}
#next {
  right: 0;
}

진행률 표시줄의 경우 CSS 그리드를 사용하여 작은 상자(스토리 카드마다 하나씩)에 스타일을 지정하겠습니다. 상자의 '표시' 여부를 나타낼 때 index 속성을 사용하여 상자에 조건부로 클래스를 추가할 수 있습니다. i <= this.index : 'watched': '' 같은 조건식을 사용할 수 있지만, 클래스를 추가하면 장황해질 수 있습니다. 다행히 lit-html은 도움이 되는 classMap이라는 지시어를 제공합니다. 먼저 classMap을 가져옵니다.

import { classMap } from 'lit-html/directives/class-map';

그리고 다음 마크업을 render 메서드 하단에 추가합니다.

<div id="progress">
  ${Array.from(this.children).map((_, i) => html`
    <div
      class=${classMap({watched: i <= this.index})}
      @click=${() => this.index = i}
    ></div>`
  )}
</div>

또한 사용자가 원하는 경우 특정 스토리 카드로 바로 건너뛸 수 있도록 몇 가지 클릭 핸들러를 추가했습니다.

다음은 static styles에 추가할 새로운 스타일입니다.

::slotted(*) {
  position: absolute;
  width: 100%;
  /* Changed this line! */
  height: calc(100% - 20px);
}

#progress {
  position: relative;
  top: calc(100% - 20px);
  height: 20px;
  width: 50%;
  margin: 0 auto;
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  grid-gap: 10px;
  align-content: center;
}
#progress > div {
  background: grey;
  height: 4px;
  transition: background 0.3s linear;
  cursor: pointer;
}
#progress > div.watched {
  background: white;
}

탐색 및 진행률 표시줄이 완성되었습니다. 이제 좀 더 꾸며보겠습니다.

스와이프를 구현하기 위해 Hammer.js 동작 제어 라이브러리를 활용할 것입니다. Hammer는 화면 이동 같은 특수 동작을 감지하고, 참고할 수 있는 관련 정보와 함께 이벤트(예: 델타 X)를 전달합니다.

다음은 Hammer를 사용하여 화면 이동을 감지하고 화면 이동 이벤트가 발생할 때마다 요소를 자동으로 업데이트하는 방법입니다.

import { internalProperty } from 'lit-element';
import 'hammerjs';

export class StoryViewer extends LitElement {
  // Data emitted by Hammer.js
  @internalProperty() _panData: {isFinal?: boolean, deltaX?: number} = {};

  constructor() {
    super();
    this.index = 0;
    new Hammer(this).on('pan', (e: HammerInput) => this._panData = e);
  }
}

LitElement 클래스의 생성자도 호스트 요소 자체에 이벤트 리스너를 연결하기에 적합합니다. Hammer 생성자는 동작을 감지할 요소를 취합니다. 여기서는 StoryViewer 자체이거나 this입니다. 그런 다음 Hammer의 API를 사용하여, '화면 이동' 동작을 감지하고 화면 이동 정보를 새 _panData 속성으로 설정하도록 이 생성자에 지시합니다.

LitElement는 _panData 속성을 @internalProperty로 데코레이팅하여 _panData 변경사항을 관측하고 업데이트를 수행합니다. 하지만 이 속성은 속성에 반영되지 않습니다.

다음으로, 화면 이동 데이터를 사용하도록 update 로직을 확장해 봅니다.

// Update is called whenever an observed property changes.
update(changedProperties: PropertyValues) {
  // deltaX is the distance of the current pan gesture.
  // isFinal is whether the pan gesture is ending.
  let { deltaX = 0, isFinal = false } = this._panData;
  // When the pan gesture finishes, navigate.
  if (!changedProperties.has('index') && isFinal) {
    deltaX > 0 ? this.previous() : this.next();
  }
  // We don't want any deltaX when releasing a pan.
  deltaX = isFinal ? 0 : deltaX;
  const width = this.clientWidth;
  Array.from(this.children).forEach((el: Element, i) => {
    // Updated this line to utilize deltaX.
    const x = (i - this.index) * width + deltaX;
    (el as HTMLElement).style.transform = `translate3d(${x}px,0,0)`;
  });

  // Don't forget to call super!
  super.update(changedProperties);
}

이제 스토리 카드를 앞뒤로 드래그할 수 있습니다. 부드럽게 움직이도록, static get styles로 돌아가서 transition: transform 0.35s ease-out;::slotted(*) 선택기에 추가해 봅니다.

::slotted(*) {
  ...
  transition: transform 0.35s ease-out;
}

이제 부드럽게 스와이프됩니다.

부드럽게 스와이프되는 스토리 카드 간 이동

마지막으로 추가할 기능은 자동재생 동영상입니다. 스토리 카드에 초점이 놓일 때 배경 동영상(있는 경우)이 재생되도록 하고 싶습니다. 스토리 카드에서 초점이 벗어나면 동영상을 일시중지해야 합니다.

이를 구현하는 방법은 색인이 변할 때마다 관련 하위 요소에 'entered' 및 'exited' 맞춤 이벤트를 전달하는 것입니다. StoryCard에서 이러한 이벤트가 수신되고 기존 동영상이 재생되거나 일시중지됩니다. 그렇다면 StoryCard에 정의된 'entered' 및 'exited' 인스턴스 메서드를 호출하지 않고 하위 요소에 이벤트를 전달하는 이유는 무엇일까요? 메서드를 사용하면 구성요소 사용자가 맞춤 애니메이션으로 자체 스토리 카드를 작성하고 싶을 때 맞춤 요소를 작성하는 것 외에는 선택권이 없습니다. 이벤트를 사용하면 구성요소 사용자가 이벤트 리스너를 연결할 수 있습니다.

이벤트 전달에 편리한 코드 경로를 제공하는 setter를 사용하도록 StoryViewerindex 속성을 리팩터링해 봅니다.

class StoryViewer extends LitElement {
  @internalProperty() private _index: number = 0
  get index() {
    return this._index
  }
  set index(value: number) {
    this.children[this._index].dispatchEvent(new CustomEvent('exited'));
    this.children[value].dispatchEvent(new CustomEvent('entered'));
    this._index = value;
  }
}

자동재생 기능을 중지시키기 위해 StoryCard 생성자에서 동영상을 재생 및 일시중지하는 'entered' 및 'exited'의 이벤트 리스너를 추가합니다.

구성요소 사용자가 <story-card>를 미디어 슬롯의 동영상 요소에 제공하거나 제공하지 않을 수 있다는 점을 기억하세요. 미디어 슬롯에 아무 요소도 제공하지 않을 수 있습니다. img 또는 null에서 play를 호출하지 않도록 주의해야 합니다.

story-card.ts로 돌아가서 다음을 추가합니다.

import { query } from 'lit-element';

class StoryCard extends LitElement {
  constructor() {
    super();

    this.addEventListener("entered", () => {
      if (this._slottedMedia) {
        this._slottedMedia.currentTime = 0;
        this._slottedMedia.play();
      }
    });

    this.addEventListener("exited", () => {
      if (this._slottedMedia) {
        this._slottedMedia.pause();
      }
    });
  }

 /**
  * The element in the "media" slot, ONLY if it is an
  * HTMLMediaElement, such as <video>.
  */
 private get _slottedMedia(): HTMLMediaElement|null {
   const el = this._mediaSlot && this._mediaSlot.assignedNodes()[0];
   return el instanceof HTMLMediaElement ? el : null;
 }

  /**
   * @query(selector) is shorthand for
   * this.renderRoot.querySelector(selector)
   */
  @query("slot[name=media]")
  private _mediaSlot!: HTMLSlotElement;
}

자동재생이 완성되었습니다. ✅

이제 모든 필수 기능을 갖추었습니다. 이제 보기 좋은 확장 효과를 추가해 보겠습니다. StoryViewerupdate 메서드로 한 번 더 돌아가겠습니다. scale 상수 값을 가져오기 위해 일부 계산이 진행됩니다. 값은 활성 하위 요소의 경우에는 1.0이고, 그 외에는 minScale이며, 이 두 값 사이에서 보간되기도 합니다.

story-viewer.ts의 update 메서드에서 루프를 다음과 같이 변경합니다.

update(changedProperties: PropertyValues) {
  // ...
  const minScale = 0.8;
  Array.from(this.children).forEach((el: Element, i) => {
    const x = (i - this.index) * width + deltaX;

    // Piecewise scale(deltaX), looks like: __/\__
    const u = deltaX / width + (i - this.index);
    const v = -Math.abs(u * (1 - minScale)) + 1;
    const scale = Math.max(v, minScale);
    // Include the scale transform
    (el as HTMLElement).style.transform = `translate3d(${x}px,0,0) scale(${scale})`;
  });
  // ...
}

이상으로 가이드를 마칩니다. 이 글에서는 LintElement 및 lit-html 기능, HTML 슬롯 요소, 동작 제어를 비롯한 많은 내용을 다루었습니다.

이 구성요소의 전체 버전은 https://github.com/PolymerLabs/story-viewer에서 확인하세요.