1. 簡介
目前,Stories 是相當受歡迎的 UI 元件。社群平台和新聞應用程式會將它們整合至動態消息。在本程式碼研究室中,我們將使用 lit-element 和 TypeScript 建構 Story 元件。
故事元件最終會如下所示:
可視為社群媒體或新聞「故事」,也就是需要依序播放的資訊卡組合,就像投影播放一樣。其實短片故事就是幻燈片。資訊卡通常以圖片或自動播放的影片為主,頂端可能會顯示其他文字。即將建構的項目如下:
功能清單
- 含有圖片或影片背景的資訊卡。
- 向左或向右滑動即可瀏覽動態消息。
- 自動播放影片。
- 可新增文字或以其他方式自訂資訊卡。
就這個元件的開發人員體驗而言,最好能以純 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 程式碼 (內容管理系統、架構等) 的所有平台。
必要條件
- 可執行
git
和npm
的殼層 - 文字編輯器
2. 設定
首先請複製這個存放區:story-viewer-starter
git clone git@github.com:PolymerLabs/story-viewer-starter.git
環境已設定 lit-element 和 TypeScript。只要安裝依附元件即可:
npm i
若是 VS Code 使用者,請安裝 lit-plugin 擴充功能,取得自動完成、輸入檢查和 lit-html 範本的程式碼檢查功能。
執行下列指令,啟動開發環境:
npm run dev
您現在可以開始寫程式了!
3. <story-card> 元件
建構複合元件時,有時會比較容易從較簡單的子元件開始,然後逐步建構。因此,我們先來建立 <story-card>
。應能顯示全出血影片或圖片。使用者應能進一步自訂,例如顯示重疊文字。
第一步是定義元件的類別,該類別擴充 LitElement
。customElement
裝飾器會替我們註冊自訂元素。請務必使用 experimentalDecorators
標記在 tsconfig 中啟用修飾符 (如果您使用的是 Starter 存放區,則已啟用修飾符)。
將下列程式碼放入 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>
但看起來很糟糕:
新增樣式
讓我們新增一些樣式。在 lit-element 中,我們會定義靜態 styles
屬性,並傳回標記為 css
的範本字串。在這裡撰寫的 CSS 都只適用於我們的自訂元素!在這種情況下,CSS 搭配 shadow DOM 會非常實用。
讓我們來設定版位媒體元素的樣式,以涵蓋 <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;
}
每張資訊卡都必須水平移動到適當位置。我們將在 lit-element 的 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>
中新增「上一個」和「下一個」按鈕。點選任一按鈕時,我們想要呼叫 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 格線為小方塊設定樣式,每個 Story 資訊卡一個。我們可以使用 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 手勢控制程式庫。Hammer 會偵測滑動等特殊手勢,並調度事件,提供我們可使用的相關資訊 (例如 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
中,我們會接收這些事件,並播放或暫停任何現有的影片。為什麼選擇在子項上調度事件,而不是呼叫 StoryCard 上定義的「entered」和「exited」例項方法?使用方法時,元件使用者無法選擇,而必須編寫自訂元素,才能利用自訂動畫自行編寫故事資訊卡。有了事件,他們只要附加事件監聽器即可!
讓我們重構 StoryViewer
的 index
屬性以使用 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 或 null 上呼叫 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. 傾斜天平
我們現在已完成所有必要的功能,接下來再新增一個:精美的縮放效果。我們再回顧一次 StoryViewer
的 update
方法。完成一些數學了,即可取得 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。