Membuat Komponen Story dengan lit-element

Story adalah komponen UI yang populer saat ini. Aplikasi media sosial dan berita mengintegrasikannya ke dalam feed masing-masing. Dalam codelab ini, kita akan membuat komponen story dengan lit-element dan TypeScript.

Komponen story nantinya akan terlihat seperti berikut:

Komponen penampil story lengkap yang menampilkan tiga gambar kopi

Anggaplah "story" media sosial atau berita sebagai sekumpulan kartu untuk diputar secara berurutan, mirip seperti slideshow. Sebenarnya, story memang slideshow. Kartu tersebut biasanya didominasi dengan gambar atau video yang berputar otomatis, dan dapat berisi teks tambahan di bagian atas. Berikut yang akan kita buat:

Daftar Fitur

  • Kartu dengan latar belakang gambar atau video.
  • Geser ke kiri atau ke kanan untuk membuka story.
  • Video yang berputar otomatis.
  • Kemampuan untuk menambahkan teks atau menyesuaikan kartu.

Sejauh pengalaman developer komponen ini, sebaiknya tentukan kartu story dalam markup HTML biasa, seperti berikut:

<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>

Jadi, mari kita tambahkan ini ke daftar fitur.

Daftar Fitur

  • Menerima serangkaian kartu dalam markup HTML.

Dengan cara ini, siapa pun dapat menggunakan komponen story kami cukup dengan menulis HTML. Cara ini sangat bagus bagi programmer dan non-programmer, serta berfungsi di mana pun HTML tersebut tertulis: sistem pengelolaan konten, framework, dll.

Prasyarat

  • Shell tempat Anda dapat menjalankan git dan npm
  • Editor teks

Mulai dengan membuat clone repositori ini: story-viewer-starter

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

Lingkungan sudah disiapkan dengan elemen lit dan TypeScript. Cukup instal dependensi:

npm i

Bagi pengguna VS Code, instal ekstensi lit-plugin untuk mendapatkan pelengkapan otomatis, pemeriksaan jenis, dan analisis lint template lit-html.

Mulai lingkungan pengembangan dengan menjalankan:

npm run dev

Anda sudah siap memulai coding!

Saat membuat komponen gabungan, terkadang lebih mudah untuk memulai dengan sub-komponen yang lebih sederhana, dan melakukan build. Jadi, mari kita mulai dengan membuat <story-card>. Komponen tersebut dapat menampilkan video atau gambar penuh dari tepi ke tepi. Pengguna akan dapat menyesuaikannya lebih lanjut, misalnya, dengan teks overlay.

Langkah pertama adalah menentukan class komponen, yang akan memperluas LitElement. Dekorator customElement menangani pendaftaran elemen khusus untuk kita. Sekarang adalah waktu yang tepat untuk memastikan Anda mengaktifkan dekorator di tsconfig dengan flag experimentalDecorators (jika Anda menggunakan repositori awal, artinya dekor telah diaktifkan).

Masukkan kode berikut ke story-card.ts:

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

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

Sekarang, <story-card> adalah elemen kustom yang dapat digunakan, tetapi belum ada yang dapat ditampilkan. Untuk menentukan struktur internal elemen, tentukan metode instance render. Di sinilah kita akan menyediakan template untuk elemen, menggunakan tag html lit-html.

Apa yang harus ada di template komponen ini? Pengguna harus bisa menyediakan dua hal: elemen media dan overlay. Jadi, kita akan menambahkan masing-masing satu <slot> untuk keduanya.

Slot adalah cara kami menentukan turunan elemen khusus yang harus dirender. Untuk info selengkapnya, berikut adalah panduan penggunaan 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>
    `;
  }
}

Pemisahan elemen media ke dalam slotnya sendiri akan membantu menargetkan elemen tersebut untuk hal-hal seperti menambahkan gaya visual dan video yang berputar otomatis dalam tampilan penuh dari tepi ke tepi. Masukkan slot kedua (slot untuk overlay khusus) di dalam elemen container agar kita dapat memberikan beberapa padding default nanti.

Komponen <story-card> kini dapat digunakan seperti berikut:

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

Tetapi, ini tidak terlihat menarik:

penampil story tanpa gaya yang menampilkan gambar kopi

Menambahkan gaya

Mari menambahkan gaya. Dengan lit-element, kami melakukannya dengan menentukan properti styles statis dan menampilkan string template yang diberi tag dengan css. CSS apa pun yang ditulis di sini hanya berlaku untuk elemen khusus. CSS dengan DOM bayangan sangat bagus dengan cara ini.

Mari kita beri gaya elemen media yang diberi slot untuk mempercantik <story-card>. Sementara berada di langkah ini, kita bisa memberikan beberapa pembuatan format yang bagus untuk elemen di slot kedua. Dengan demikian, pengguna komponen dapat menambahkan <h1>, <p>, atau apa pun, dan melihat sesuatu yang menarik secara default.

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;
    }
  `;
}

penampil story bergaya yang menampilkan gambar kopi

Sekarang kita memiliki kartu story dengan media latar belakang, dan kita dapat menempatkan apa pun yang kita inginkan di atasnya. Bagus! Kita akan kembali ke class StoryCard sebentar lagi untuk mengimplementasikan video yang berputar otomatis.

Elemen <story-viewer> kita adalah induk dari <story-card>. Elemen ini akan bertanggung jawab meletakkan kartu secara horizontal dan memungkinkan kita menggeser di antara kartu tersebut. Kita akan memulai dengan cara yang sama seperti yang dilakukan untuk StoryCard. Kita ingin menambahkan kartu story sebagai turunan dari elemen <story-viewer>, jadi tambahkan slot untuk turunan tersebut.

Masukkan kode berikut dalam story-viewer.ts:

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

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

Selanjutnya adalah tata letak horizontal. Kita dapat menerapkannya dengan memberikan semua positioning absolut <story-card> yang diberi slot, dan menerjemahkannya sesuai dengan indeksnya. Kita dapat menargetkan elemen <story-viewer> itu sendiri menggunakan pemilih :host.

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

Pengguna dapat mengontrol ukuran kartu story cukup dengan mengganti tinggi dan lebar default secara eksternal di host. Seperti ini:

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

Untuk melacak kartu yang saat ini dilihat, tambahkan variabel instance index ke class StoryViewer. Memberinya dekorator berupa @property LitElemen akan menyebabkan komponen dirender ulang setiap kali nilainya berubah.

import { property } from 'lit-element';

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

Setiap kartu perlu diterjemahkan secara horizontal ke posisinya. Mari kita terapkan terjemahan ini dalam metode siklus proses update lit-element. Metode update akan berjalan setiap kali properti yang diamati dari komponen ini berubah. Biasanya, kita melakukan kueri untuk slot dan menjalankan loop pada slot.assignedElements(). Namun, karena kita hanya memiliki satu slot tanpa nama, sama saja dengan menggunakan this.children. Mari kita gunakan this.children untuk memudahkan.

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> kita sekarang semuanya dalam satu baris. Fitur ini masih berfungsi dengan elemen lainnya sebagai turunan, asalkan kita menyesuaikan gayanya dengan tepat:

<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>

Buka build/index.html dan hapus tanda komentar elemen kartu story lainnya. Sekarang, mari kita lakukan agar bisa membukanya.

Selanjutnya, kita akan menambahkan cara untuk menjelajahi kartu-kartu dan status progres.

Mari tambahkan beberapa fungsi bantuan ke StoryViewer untuk membuka story. Fungsi bantuan tersebut akan menyetel indeks untuk kita selagi mencantumkannya ke rentang yang valid.

Di story-viewer.ts, dalam class StoryViewer, tambahkan:

/** 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));
}

Untuk menampilkan navigasi kepada pengguna akhir, kita akan menambahkan tombol "sebelumnya" dan "berikutnya" ke <story-viewer>. Saat salah satu tombol diklik, kita ingin memanggil fungsi bantuan next atau previous. lit-html memudahkan penambahan pemroses peristiwa ke elemen; kita dapat merender tombol dan menambahkan pemroses klik secara bersamaan.

Perbarui metode render menjadi yang berikut:

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>
    `;
  }
}

Lihat cara untuk menambahkan pemroses peristiwa secara inline pada tombol svg yang baru, langsung di metode render. Ini dapat digunakan untuk peristiwa apa pun. Cukup tambahkan binding formulir @eventname=${handler} ke elemen.

Tambahkan hal berikut ke properti static styles untuk mendesain tombol:

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

Untuk status progres, kita akan menggunakan petak CSS untuk mendesain kotak kecil, satu kotak untuk setiap kartu story. Kita dapat menggunakan properti index untuk menambahkan class, dengan ketentuan khusus, ke kotak tersebut guna menunjukkan apakah kotak telah "dilihat" atau belum. Kita dapat menggunakan ekspresi kondisional seperti i <= this.index : 'watched': '', tetapi berbagai hal dapat menjadi sangat panjang jika kita menambahkan lebih banyak class. Untungnya, lit-html menawarkan perintah yang disebut classMap untuk membantu. Impor classMap terlebih dahulu:

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

Lalu, tambahkan markup berikut ke bagian bawah metode render:

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

Kita juga menambahkan beberapa pengendali klik sehingga pengguna dapat langsung melompat ke kartu story tertentu jika mereka menginginkannya.

Berikut adalah gaya baru yang akan ditambahkan ke 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;
}

Navigasi dan status progres selesai. Sekarang, mari kita tambahkan kemampuan khusus.

Untuk menerapkan fitur geser, kita akan menggunakan library kontrol gestur Hammer.js. Hammer mendeteksi gestur khusus seperti geser, dan mengirimkan peristiwa dengan info yang relevan (seperti delta X) yang dapat kita pakai.

Berikut cara menggunakan Hammer untuk mendeteksi geser, dan secara otomatis memperbarui elemen setiap kali terjadi peristiwa geser:

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);
  }
}

Konstruktor class LitElement adalah tempat yang bagus untuk melampirkan pemroses peristiwa pada elemen host itu sendiri. Konstruktor Hammer mengambil elemen untuk mendeteksi gestur. Untuk kali ini, berupa StoryViewer itu sendiri, atau this. Kemudian, dengan API Hammer, kita memberi tahu agar mendeteksi gestur "geser", dan menyetel informasi geser ke properti _panData yang baru.

Dengan mendesain properti _panData menggunakan @internalProperty, LitElemen akan mengamati perubahan pada _panData dan melakukan pembaruan, tetapi properti tersebut TIDAK akan tercermin pada atribut.

Selanjutnya, mari kita tambahkan logika update untuk menggunakan data geser:

// 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);
}

Kita sekarang dapat menarik kartu story secara bolak-balik. Agar semuanya lancar, mari kembali ke static get styles dan tambahkan transition: transform 0.35s ease-out; ke pemilih ::slotted(*):

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

Kita sekarang memiliki fitur geser yang lancar

Menjelajahi kartu story dengan fitur geser yang lancar

Fitur terakhir yang akan kita tambahkan adalah video yang berputar otomatis. Saat kartu story memasuki fokus, kita ingin video latar belakang diputar, jika ada. Saat kartu story keluar dari fokus, kita harus menjeda videonya.

Kita akan mengimplementasikan ini dengan mengirimkan peristiwa khusus yang 'masuk' dan 'keluar' pada turunan yang sesuai setiap kali indeks berubah. Pada StoryCard, kita akan menerima peristiwa tersebut dan memutar atau menjeda setiap video yang ada. Mengapa memilih untuk mengirim peristiwa pada turunan bukan memanggil metode instance 'masuk dan 'keluar' yang ditentukan di StoryCard? Dengan metode, pengguna komponen tidak akan memiliki pilihan selain menulis elemen khusus jika mereka ingin menulis kartu story mereka sendiri menggunakan animasi kustom. Dengan peristiwa, pengguna cukup melampirkan pemroses peristiwa.

Mari kita faktorkan ulang properti index StoryViewer untuk menggunakan penyetel, yang menyediakan jalur kode yang mudah untuk mengirim peristiwa:

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;
  }
}

Untuk menyelesaikan fitur putar otomatis, kita akan menambahkan pemroses peristiwa untuk "masuk" dan "keluar" di konstruktor StoryCard yang memutar dan menjeda video.

Perlu diingat bahwa pengguna komponen dapat atau tidak dapat memberi <story-card> elemen video dalam slot media. Bahkan, mereka sama sekali tidak dapat menyediakan elemen dalam slot media. Kita harus berhati-hati agar tidak memanggil play di img, atau di null.

Kembali ke story-card.ts, tambahkan hal berikut:

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;
}

Fitur putar otomatis selesai. ✅

Setelah memiliki semua fitur penting, mari tambahkan satu lagi: efek penskalaan yang rapi. Mari kita kembali sekali lagi ke metode update dari StoryViewer. Beberapa perhitungan matematika dilakukan untuk mendapatkan nilai dalam konstanta scale. Nilai tersebut sama dengan 1.0 untuk turunan aktif atau minScale, yang melakukan interpolasi antara kedua nilai ini.

Ubah loop dalam metode update di story-viewer.ts menjadi:

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})`;
  });
  // ...
}

Sudah selesai. Dalam postingan ini, kita membahas banyak hal, termasuk beberapa fitur LitElement dan lit-html, elemen slot HTML, dan kontrol gestur.

Untuk versi lengkap komponen ini, buka: https://github.com/PolymerLabs/story-viewer.