Xây dựng thành phần câu chuyện bằng phần tử lit

1. Giới thiệu

Stories là một thành phần giao diện người dùng phổ biến hiện nay. Các ứng dụng mạng xã hội và tin tức đang tích hợp các tính năng này vào nguồn cấp dữ liệu của họ. Trong lớp học lập trình này, chúng ta sẽ xây dựng một thành phần story bằng phần tử lit-element và TypeScript.

Đây là giao diện của thành phần câu chuyện ở cuối:

Một thành phần hoàn chỉnh dành cho người xem câu chuyện, cho thấy 3 hình ảnh về cà phê

Chúng ta có thể coi "story" (tin bài) trên mạng xã hội hoặc tin tức là một tập hợp các thẻ được phát tuần tự, giống như một trình chiếu. Thực ra, câu chuyện chính là bản trình chiếu. Thẻ thường có hình ảnh hoặc video tự động phát ở phần trên cùng và có thể có thêm văn bản ở phía trên. Sau đây là những gì chúng ta sẽ xây dựng:

Danh sách tính năng

  • Thẻ có nền hình ảnh hoặc video.
  • Vuốt sang trái hoặc sang phải để di chuyển trong câu chuyện.
  • Tự động phát video.
  • Có thể thêm văn bản hoặc tuỳ chỉnh thẻ.

Theo kinh nghiệm của nhà phát triển đối với thành phần này, bạn nên chỉ định thẻ tin bài bằng mã đánh dấu HTML thuần tuý, ví dụ như sau:

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

Hãy thêm câu lệnh đó vào danh sách tính năng.

Danh sách tính năng

  • Chấp nhận một loạt thẻ bằng mã đánh dấu HTML.

Bằng cách này, bất kỳ ai cũng có thể sử dụng thành phần tin bài của chúng tôi chỉ bằng cách viết HTML. Điều này rất hữu ích cho cả lập trình viên và người không lập trình, đồng thời hoạt động ở mọi nơi HTML hoạt động: hệ thống quản lý nội dung, khung, v.v.

Điều kiện tiên quyết

  • Một shell nơi bạn có thể chạy gitnpm
  • Trình chỉnh sửa văn bản

2. Thiết lập

Bắt đầu bằng cách sao chép kho lưu trữ này: story-viewer-starter

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

Môi trường đã được thiết lập bằng lit-element và TypeScript. Bạn chỉ cần cài đặt các phần phụ thuộc:

npm i

Đối với người dùng VS Code, hãy cài đặt tiện ích lit-plugin để có tính năng tự động hoàn thành, kiểm tra kiểu và tìm lỗi mã nguồn của các mẫu lit-html.

Bắt đầu môi trường phát triển bằng cách chạy:

npm run dev

Bạn đã sẵn sàng bắt đầu lập trình!

3. Thành phần <story-card>

Khi xây dựng các thành phần phức hợp, đôi khi sẽ dễ dàng hơn để bắt đầu với các thành phần phụ đơn giản hơn rồi bắt đầu xây dựng. Hãy bắt đầu bằng cách tạo <story-card>. Khung đó phải có thể hiển thị hình ảnh hoặc video tràn lề. Người dùng nên có thể tuỳ chỉnh thêm, chẳng hạn như bằng văn bản lớp phủ.

Bước đầu tiên là xác định lớp thành phần, lớp này mở rộng LitElement. Trình trang trí customElement sẽ giúp chúng ta đăng ký phần tử tuỳ chỉnh. Giờ là thời điểm thích hợp để đảm bảo rằng bạn bật trình trang trí trong tsconfig bằng cờ experimentalDecorators (nếu bạn đang sử dụng kho lưu trữ khởi đầu thì kho lưu trữ này đã được bật).

Đặt mã sau vào story-card.ts:

import { LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';

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

<story-card> hiện là phần tử tuỳ chỉnh có thể sử dụng, nhưng chưa có gì để hiển thị. Để xác định cấu trúc bên trong của phần tử, hãy xác định phương thức thực thể render. Đây là nơi chúng ta sẽ cung cấp mẫu cho phần tử bằng cách sử dụng thẻ html của lit-html.

Cần có gì trong mẫu của thành phần này? Người dùng cần cung cấp được 2 nội dung: thành phần nội dung đa phương tiện và lớp phủ. Vì vậy, chúng ta sẽ thêm một <slot> cho mỗi phần tử đó.

Vị trí là cách chúng ta chỉ định phần tử con của một phần tử tuỳ chỉnh. Để biết thêm thông tin, đây là hướng dẫn từng bước tuyệt vời về cách sử dụng vị trí.

import { html } from 'lit';

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

Việc tách phần tử nội dung nghe nhìn thành một khe riêng sẽ giúp chúng ta nhắm mục tiêu đến phần tử đó cho các thao tác như thêm kiểu tràn lề và tự động phát video. Đặt vị trí thứ hai (vị trí cho các lớp phủ tuỳ chỉnh) bên trong một phần tử vùng chứa để chúng ta có thể cung cấp một số khoảng đệm mặc định sau này.

Thành phần <story-card> hiện có thể được sử dụng như sau:

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

Nhưng giao diện này trông rất tệ:

một nhân viên xem câu chuyện không theo phong cách, đang chiếu bức ảnh về cà phê

Thêm kiểu

Hãy thêm kiểu nào đó. Với thành phần lit-element, chúng ta thực hiện việc đó bằng cách xác định thuộc tính styles tĩnh và trả về một chuỗi mẫu được gắn thẻ css. Mọi CSS được viết ở đây chỉ áp dụng cho phần tử tuỳ chỉnh của chúng ta! CSS với shadow DOM thực sự rất hữu ích theo cách này.

Hãy tạo kiểu cho phần tử nội dung nghe nhìn được đặt trong khe để bao phủ <story-card>. Trong khi chúng ta ở đây, chúng ta có thể cung cấp một số định dạng đẹp cho các phần tử trong ô thứ hai. Bằng cách đó, người dùng thành phần có thể thả một số <h1>, <p> hoặc bất kỳ thành phần nào khác và thấy một thành phần đẹp mắt theo mặc định.

import { css } from 'lit';

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

một trình xem Story được tạo kiểu hiển thị hình ảnh cà phê

Giờ đây, chúng ta đã có thẻ tin bài với nội dung nghe nhìn nền và có thể đặt bất kỳ nội dung nào chúng ta muốn lên đầu. Tuyệt vời! Chúng ta sẽ quay lại lớp StoryCard sau giây lát để triển khai tính năng tự động phát video.

4. Thành phần <story-viewer>

Phần tử <story-viewer> là phần tử mẹ của <story-card>. Nó sẽ chịu trách nhiệm bố trí thẻ theo chiều ngang và cho phép chúng ta vuốt giữa các thẻ. Chúng ta sẽ bắt đầu chương trình này giống như cách đã làm cho StoryCard. Chúng ta muốn thêm các thẻ câu chuyện làm phần tử con của phần tử <story-viewer>, vì vậy, hãy thêm một ô cho các phần tử con đó.

Đặt mã sau vào story-viewer.ts:

import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';

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

Tiếp theo là bố cục ngang. Chúng ta có thể giải quyết vấn đề này bằng cách cung cấp vị trí tuyệt đối cho tất cả <story-card> được đặt trong khe và dịch các <story-card> đó theo chỉ mục của chúng. Chúng ta có thể nhắm mục tiêu chính phần tử <story-viewer> bằng cách sử dụng bộ chọn :host.

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

Người dùng có thể kiểm soát kích thước của thẻ câu chuyện chỉ bằng cách ghi đè chiều cao và chiều rộng mặc định trên máy chủ lưu trữ. Chẳng hạn như:

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

Để theo dõi thẻ đang xem, hãy thêm biến thực thể index vào lớp StoryViewer. Việc trang trí bằng @property của LitElement sẽ khiến thành phần hiển thị lại bất cứ khi nào giá trị của thành phần đó thay đổi.

import { property } from 'lit/decorators.js';

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

Mỗi thẻ cần được dịch theo chiều ngang vào vị trí. Hãy áp dụng các bản dịch này trong phương thức vòng đời update của phần tử lit. Phương thức cập nhật sẽ chạy bất cứ khi nào một thuộc tính được quan sát của thành phần này thay đổi. Thông thường, chúng ta sẽ truy vấn cho vị trí và lặp lại trên slot.assignedElements(). Tuy nhiên, vì chúng ta chỉ có một khung giờ chưa đặt tên, nên việc này cũng giống như việc sử dụng this.children. Để thuận tiện, hãy sử dụng this.children.

import { PropertyValues } from 'lit';

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

Giờ đây, tất cả <story-card> của chúng ta đã ở trong một hàng. Phương thức này vẫn hoạt động với các phần tử con khác, miễn là chúng ta cẩn thận tạo kiểu cho các phần tử đó một cách phù hợp:

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

Chuyển đến phần build/index.html rồi huỷ nhận xét phần còn lại của các thành phần trong thẻ story. Bây giờ, hãy cùng bắt đầu khám phá!

5. Thanh tiến trình và điều hướng

Tiếp theo, chúng ta sẽ thêm cách di chuyển giữa các thẻ và thanh tiến trình.

Hãy thêm một số chức năng trợ giúp vào StoryViewer để di chuyển trong câu chuyện. Các hàm này sẽ đặt chỉ mục cho chúng ta trong khi cố định chỉ mục đó trong một phạm vi hợp lệ.

Trong story-viewer.ts, trong lớp StoryViewer, hãy thêm:

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

Để hiển thị thành phần điều hướng cho người dùng cuối, chúng ta sẽ thêm các nút "trước" và "tiếp theo" vào <story-viewer>. Khi người dùng nhấp vào một trong hai nút, chúng ta muốn gọi hàm trợ giúp next hoặc previous. lit-html giúp bạn dễ dàng thêm trình nghe sự kiện vào các phần tử; chúng ta có thể hiển thị các nút và thêm trình nghe lượt nhấp cùng một lúc.

Cập nhật phương thức render như sau:

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

Hãy xem cách chúng ta có thể thêm trình nghe sự kiện cùng dòng trên các nút svg mới, ngay trong phương thức render. Điều này áp dụng cho mọi sự kiện. Bạn chỉ cần thêm liên kết của biểu mẫu @eventname=${handler} vào một phần tử.

Thêm các dòng sau vào thuộc tính static styles để tạo kiểu cho các nút:

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

Đối với thanh tiến trình, chúng ta sẽ sử dụng lưới CSS để tạo kiểu cho các hộp nhỏ, mỗi hộp cho một thẻ câu chuyện. Chúng ta có thể sử dụng thuộc tính index để thêm có điều kiện các lớp vào hộp nhằm cho biết liệu các lớp đó đã được "xem" hay chưa. Chúng ta có thể sử dụng một biểu thức có điều kiện như i <= this.index : 'watched': '', nhưng mọi thứ có thể trở nên dài dòng nếu chúng ta thêm nhiều lớp hơn. May mắn thay, lit-html bán một lệnh có tên là classMap để giúp đỡ. Trước tiên, hãy nhập classMap:

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

Và thêm mã đánh dấu sau vào cuối phương thức render:

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

Chúng tôi cũng bổ sung thêm một số trình xử lý lượt nhấp để người dùng có thể chuyển thẳng đến thẻ tin bài cụ thể nếu muốn.

Sau đây là các kiểu mới cần thêm vào 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;
}

Thanh điều hướng và thanh tiến trình đã hoàn tất. Giờ hãy thêm chút tinh tế!

6. Vuốt

Để triển khai thao tác vuốt, chúng ta sẽ sử dụng thư viện điều khiển cử chỉ Hammer.js. Hammer phát hiện các cử chỉ đặc biệt như kéo và gửi các sự kiện kèm theo thông tin liên quan (như delta X) mà chúng ta có thể sử dụng.

Dưới đây là cách chúng ta có thể sử dụng Hammer để phát hiện hoạt động xoay và tự động cập nhật phần tử mỗi khi xảy ra sự kiện xoay:

import { state } from 'lit/decorators.js';
import 'hammerjs';

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

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

Hàm khởi tạo của lớp LitElement là một nơi tuyệt vời khác để đính kèm trình nghe sự kiện trên chính phần tử lưu trữ. Hàm khởi tạo Hammer lấy một phần tử để phát hiện cử chỉ. Trong trường hợp này, đó là chính StoryViewer hoặc this. Sau đó, bằng cách sử dụng API của Hammer, chúng ta sẽ yêu cầu API này phát hiện cử chỉ "vuốt" và đặt thông tin vuốt vào thuộc tính _panData mới.

Khi trang trí thuộc tính _panData bằng @state, LitElement sẽ ghi nhận các thay đổi đối với _panData và cập nhật, nhưng sẽ không có thuộc tính HTML liên kết cho thuộc tính này.

Tiếp theo, hãy mở rộng logic update để sử dụng dữ liệu kéo:

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

Bây giờ, chúng ta có thể kéo thẻ câu chuyện qua lại. Để mọi thứ diễn ra suôn sẻ, hãy quay lại static get styles và thêm transition: transform 0.35s ease-out; vào bộ chọn ::slotted(*):

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

Giờ đây, chúng ta sẽ có trải nghiệm vuốt mượt mà:

Di chuyển giữa các thẻ story bằng thao tác vuốt mượt mà

7. Tự động phát

Tính năng cuối cùng chúng ta sẽ thêm là tự động phát video. Khi thẻ câu chuyện được đưa vào tiêu điểm, chúng ta muốn video trong nền phát nếu có. Khi thẻ câu chuyện không còn là tâm điểm, chúng ta nên tạm dừng video của thẻ đó.

Chúng tôi sẽ triển khai việc này bằng cách gửi các sự kiện tuỳ chỉnh "đã nhập" và "đã thoát" trên các phần tử con thích hợp bất cứ khi nào chỉ mục thay đổi. Trong StoryCard, chúng ta sẽ nhận được các sự kiện đó và phát hoặc tạm dừng mọi video hiện có. Tại sao bạn nên chọn điều phối các sự kiện trên các thành phần con thay vì gọi các phương thức thực thể "đã nhập" và "đã thoát" được xác định trên StoryCard? Với các phương thức, người dùng thành phần sẽ không có lựa chọn nào khác ngoài việc viết một phần tử tuỳ chỉnh nếu họ muốn viết thẻ câu chuyện của riêng mình bằng ảnh động tuỳ chỉnh. Với các sự kiện, họ chỉ cần đính kèm trình nghe sự kiện!

Hãy tái cấu trúc thuộc tính index của StoryViewer để sử dụng phương thức setter. Phương thức này cung cấp một đường dẫn mã thuận tiện để điều phối các sự kiện:

class StoryViewer extends LitElement {
  @state() 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;
  }
}

Để hoàn tất tính năng tự động phát, chúng ta sẽ thêm trình nghe sự kiện cho trạng thái "đã nhập" và "đã thoát" trong hàm khởi tạo StoryCard có chức năng phát và tạm dừng video.

Hãy nhớ rằng người dùng thành phần có thể cung cấp hoặc không cung cấp phần tử video cho <story-card> trong khe phương tiện. Thậm chí, các thành phần này có thể không cung cấp phần tử nào trong khung nội dung đa phương tiện. Chúng ta phải cẩn thận để không gọi play trên một hình ảnh hoặc trên giá trị rỗng.

Quay lại story-card.ts, hãy thêm đoạn mã sau:

import { query } from 'lit/decorators.js';

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

Đã tự động phát xong. ✅

8. Cân bằng

Giờ đây, chúng ta đã có tất cả các tính năng cần thiết, hãy thêm một tính năng nữa: hiệu ứng điều chỉnh theo tỷ lệ. Hãy quay lại phương thức update của StoryViewer một lần nữa. Một số phép toán được thực hiện để lấy giá trị cho hằng số scale. Giá trị này sẽ bằng 1.0 đối với thành phần con đang hoạt động và minScale đối với thành phần con đang hoạt động, nếu không, cũng nội suy giữa hai giá trị này.

Thay đổi vòng lặp trong phương thức update trong story-viewer.ts thành:

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

Tất cả chỉ có thế! Trong bài đăng này, chúng ta đã đề cập đến nhiều nội dung, bao gồm một số tính năng LitElement và lit-html, phần tử khe HTML và tính năng điều khiển cử chỉ.

Để xem phiên bản hoàn chỉnh của thành phần này, hãy truy cập vào: https://github.com/PolymerLabs/story-viewer.