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 xã hội và tin tức đang tích hợp chúng 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ể hình dung một "câu chuyện" tin tức trên mạng xã hội dưới dạng một tập hợp các thẻ được phát tuần tự, giống như một bản trình chiếu. Thực ra, câu chuyện thực chất là những bản trình chiếu. Thẻ thường chiếm ưu thế trong hình ảnh hoặc video tự động phát và có thể có văn bản bổ sung ở trên cùng. Dưới đâ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 tuyệt vời cho cả lập trình viên lẫn người không lập trình, và hoạt động ở mọi nơi mà HTML thực hiện: hệ thống quản lý nội dung, khung, v.v.

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

  • Một shell để 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 phần tử lit-element và TypeScript. Chỉ cần cài đặt 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 để nhận tính năng tự động hoàn thành, kiểm tra loại và tìm lỗi mã nguồn của 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. <story-card> Thành phần

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ẽ đảm nhận việc đăng ký phần tử tuỳ chỉnh cho chúng ta. Giờ là thời điểm thích hợp để đảm bảo rằng bạn bật 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 giải pháp đó.

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 đa phương tiện vào vị trí riêng sẽ giúp chúng ta nhắm mục tiêu phần tử đó cho những việ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>

Tuy nhiên, số liệu 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ê

Đang thêm kiểu

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

Hãy tạo kiểu cho phần tử nội dung nghe nhìn có rãnh để 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ể bỏ vào một số <h1>, <p>, v.v. và thấy nội dung nào đó đẹ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 người xem câu chuyện theo phong cách giới thiệu bức ảnh quán cà phê

Giờ đây, chúng ta đã có thẻ tin bài với nội dung nghe nhìn ở chế độ 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. <story-viewer> (người xem câu chuyện>) Thành phần

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ể tiếp cận điều này bằng cách cung cấp vị trí tuyệt đối cho tất cả các <story-card> được phân vùng và dịch chúng 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ẻ story bằng cách ghi đè chiều cao và chiều rộng mặc định trên máy chủ lưu trữ bên ngoài. Chẳng hạn như:

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

Để theo dõi thẻ mà bạn đang xem, hãy thêm một biến thực thể index vào lớp StoryViewer. Việc trang trí thành phần này bằng @property của LitElement sẽ khiến thành phần này kết xuất 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-element. Phương thức cập nhật sẽ chạy bất cứ khi nào 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 vị trí và vòng lặp 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 đã ở trên một hàng. Nó vẫn hoạt động với các phần tử khác khi là phần tử con, miễn là chúng ta cẩn thận tạo kiểu cho chúng 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 bỏ nhận xét các yếu tố còn lại trong thẻ câu chuyện. Bây giờ, hãy cùng bắt đầu khám phá!

5. Thanh tiến trình và thành phần đ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. Họ sẽ thiết lập chỉ mục cho chúng tôi trong khi giới hạn 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ị điều hướng cho người dùng cuối, chúng tôi sẽ thêm "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 cầ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 tôi có thể hiển thị các nút và thêm một 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 tôi 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. Chế độ 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ể 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 người dùng đã "nhìn thấy" hay chưa hoặc không. Chúng ta có thể sử dụng biểu thức có điều kiện như i <= this.index : 'watched': '', nhưng mọi thứ có thể trở nên chi tiết nếu chúng ta thêm nhiều lớp hơn. May mắn thay, lit-html sẽ cung cấp một lệnh có tên là classMap để trợ 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.

Dưới đây là các kiểu mới để 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 tiến trình và thao tác đã 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 thao tác 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 vào 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 đó, chúng tôi yêu cầu Hammer phát hiện "di chuyển" bằng API của Hammer và thiết lập thông tin kéo 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 tăng cườ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);
}

Giờ đây, chúng ta có thể kéo thẻ tin bài qua lại. Để mọi việc 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 có thể vuốt mượt mà:

Vuốt nhẹ để di chuyển giữa các thẻ tin bài

7. Tự động phát

Tính năng cuối cùng chúng tôi sẽ thêm là tự động phát video. Khi thẻ story được đưa vào tiêu điểm, chúng ta sẽ 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 "đã nhập" và "đã thoát" các sự kiện tuỳ chỉnh trên các phần tử con thích hợp bất cứ khi nào chỉ mục thay đổi. Sau StoryCard, chúng tôi sẽ nhận được những 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 gửi các sự kiện trên thành phần con thay vì gọi "đã nhập" và "đã thoát" đã xác định phương thức thực thể trên StoryCard không? 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 một 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 một phương thức setter 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 tôi sẽ thêm trình nghe sự kiện cho cụm từ "đã 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 cho <story-card> một phần tử video trong vùng nội dung nghe nhìn. Các quảng cáo này thậm chí có thể hoàn toàn không cung cấp phần tử trong vùng 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 đối

Giờ đây, khi đã 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 chuyển tỷ lệ ngọt ngào. 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 rất nhiều thông tin, bao gồm cả một số tính năng LitElement và lit-html, các phần tử vị trí HTML cũng như chức năng điều khiển bằng 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.