使用光照元素建構精選故事元件

使用光照元素建構精選故事元件

程式碼研究室簡介

subject上次更新時間:10月 1, 2021
account_circle作者:Steven Traversi

1. 簡介

近年來,故事是熱門的 UI 元件。社群平台和新聞應用程式會將它們整合至動態消息。在本程式碼研究室中,我們會使用 lit-element 和 TypeScript 建構故事元件。

故事元件最後的呈現方式如下:

完整的故事觀眾元件,顯示三張咖啡圖片

我們可以視為社群媒體或新聞「報導」為一組資訊卡進行依序播放,類似投影播放。其實故事就是投影播放。資訊卡通常以圖片或自動播放影片為主,上方可以顯示額外文字。即將建構的項目如下:

功能清單

  • 含有圖片或影片背景的資訊卡。
  • 向左或向右滑動即可瀏覽故事。
  • 自動播放影片。
  • 可新增文字或自訂資訊卡。

截至此元件的開發人員體驗為止,建議您以純 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 程式碼 (內容管理系統、架構等) 的所有平台。

必要條件

  • 可執行 gitnpm 的殼層
  • 文字編輯器

2. 設定

首先請複製這個存放區:story-viewer-starter

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

環境已經設定到 Li-element 和 TypeScript。只要安裝依附元件:

npm i

若是 VS Code 使用者,請安裝 lit-plugin 擴充功能,取得自動完成、輸入檢查和 lit-html 範本的程式碼檢查功能。

執行下列指令以啟動開發環境:

npm run dev

您現在可以開始寫程式了!

3. <故事資訊卡>元件

建構複合元件時,最好先使用較簡單的子元件,再逐步建構。首先,讓我們建構 <story-card>。應該能夠顯示滿版影片或圖片。使用者應能進一步自訂,例如顯示重疊文字。

第一步是定義元件的類別,該類別擴充 LitElementcustomElement 裝飾器會替我們註冊自訂元素。現在,請確實使用 experimentalDecorators 旗標在 tsconfig 中啟用裝飾器 (如果您使用了範例程式碼存放區,則已經啟用)。

將下列程式碼放入 Story-card.ts:

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

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

現在 <story-card> 是可使用自訂元素,但尚未顯示任何內容。如要定義元素的內部結構,請定義 render 執行個體方法。我們會在此使用 lit-html 的 html 標記提供元素範本。

這個元件的範本應包含什麼?使用者應該要能夠提供兩件事:媒體元素和疊加層。因此,我們會分別為每個欄位新增一個 <slot>

運算單元是我們指定自訂元素「子項」應呈現的方式。詳情請參閱使用運算單元的詳細逐步操作說明

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

將媒體元素分割到其專屬版位,可協助我們指定該元素,例如新增全出血樣式和自動播放影片。將第二個版位 (用於自訂疊加層) 放入容器元素中,以便我們稍後提供預設的邊框間距。

<story-card> 元件現在可以如下所示:

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

但這看起來很糟糕:

單純呈現咖啡相片的故事觀者

正在新增樣式

新增一些樣式。方法是透過定義靜態 styles 屬性,並傳回標有 css 的範本字串。在這裡撰寫的 CSS 都只適用於我們的自訂元素!搭配 shadow DOM 的 CSS 就很不錯。

讓我們來設定版位媒體元素的樣式,以涵蓋 <story-card>。當我們在此時,我們可以為第二個版位中的元素提供不錯的格式。這樣一來,元件使用者就能捨棄某些 <h1><p> 或任何內容,這樣一來,畫面會預設好笑。

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

品味故事的觀眾,展示咖啡相片

現在我們有包含背景媒體的故事資訊卡,我們可以將自己想做的都放到頭上。太好了!我們會在稍後返回 StoryCard 類別,實作自動播放影片。

4. <story-viewer>元件

我們的 <story-viewer> 元素是 <story-card> 的父項,我們將負責水平排列資訊卡,並在資訊卡之間滑動。我們將以 StoryCard 的方法開始。我們想將故事資訊卡新增為 <story-viewer> 元素的子項,因此請為這些子項新增版位。

將下列程式碼放入 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>`;
 
}
}

接下來是橫向版面配置我們可以給予所有運算單元的 <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/decorators.js';

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

每張資訊卡都需要水平轉譯為位置。現在,讓我們在 Li-Fi 元素的 update 生命週期方法中套用這些轉譯。只要此元件觀察到的屬性有所變更,就會執行更新方法。一般而言,我們會查詢運算單元並在 slot.assignedElements() 上迴圈。不過,由於我們只有一個未命名的版位,因此這與使用 this.children 相同。為了方便起見,讓我們使用 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);
 
}
}

我們的 <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,將故事資訊卡的其他元素取消註解。我們來前往這些位置吧!

5. 進度列和導覽

接下來,我們要新增在資訊卡和進度列之間瀏覽的方法。

讓我們在 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>。當使用者點選任一按鈕時,我們要呼叫 nextprevious 輔助函式。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/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;
}

導覽和進度列完成。現在讓我們來增加一些功能!

6. 滑動

如要實作滑動手勢,我們會使用 Hammer.js 手勢控製程式庫。鎚子會偵測平移等特殊手勢,然後利用我們能取用的相關資訊 (例如 Delta X) 派遣活動。

以下說明我們如何使用 Hammer 偵測平移,並在發生平移事件時自動更新元素:

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

LitElement 類別的建構函式,是另一個在主要元素本身附加事件監聽器的絕佳位置。Hammer 建構函式使用元素偵測手勢。在本例中為 StoryViewer 本身,或 this。接著使用 Hammer API 來偵測「平移」手勢,並將平移資訊設為新的 _panData 屬性。

使用 @state 裝飾 _panData 屬性後,LitElement 會觀察 _panData 的變更並執行更新,但不會為該屬性提供相關聯的 HTML 屬性。

接下來,讓我們擴充 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;
}

現在我們新增了平滑的滑動手勢:

透過流暢滑動瀏覽故事資訊卡

7. 自動播放

最後一項功能是自動播放影片。焦點資訊卡進入焦點時,我們會希望有背景影片 (如果有的話) 播放。焦點卡離開焦點時,我們應暫停其影片。

我們會分派「已進入」且「已結束」每次索引變更時,在相應子項上執行的自訂事件。我們會在 StoryCard接收事件,並播放或暫停任何現有影片。為何選擇調度子項事件,而不是呼叫「已輸入」且「已結束」樣本方法?使用方法時,元件使用者無法選擇,而必須編寫自訂元素,才能利用自訂動畫自行編寫故事資訊卡。使用事件時,他們可以只附加事件監聽器!

讓我們重構 StoryViewerindex 屬性以使用 setter,提供用於分派事件的便利程式碼路徑:

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

為完成自動播放功能,我們會為「已進入」「結束」播放及暫停影片的 StoryCard 建構函式中。

請注意,元件使用者不一定能給 <story-card> 播放媒體版位的影片元素。甚至不一定會在媒體版位內提供元素。我們必須小心,不要在 img 或空值上呼叫 play

返回 Story-card.ts,新增以下內容:

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

自動播放完成。✅

8. 提示體重計

現在我們已具備所有基本功能,接下來要新增更多功能:可完美的縮放效果。讓我們再返回 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})`;
  });
  // ...
}

就這樣,大家好!在這篇文章中,我們討論了許多內容,包括一些 LitElement 和 lit-html 功能、HTML 版位元素,以及手勢控制。

如需此元件的完成版本,請造訪:https://github.com/PolymerLabs/story-viewer