故事是如今的热门界面组件。社交应用和新闻应用都在将故事组件集成到自己的 Feed 中。在此 Codelab 中,我们将使用 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 的任何地方使用,例如内容管理系统、框架等。
前提条件
- 一个可以运行
git
和npm
的 shell - 一个文本编辑器
首先克隆以下代码库:story-viewer-starter
git clone git@github.com:PolymerLabs/story-viewer-starter.git
此环境已设置 light-element 和 TypeScript。只需安装依赖项即可:
npm i
对于 VS Code 用户,应安装 lit-plugin 扩展程序,以便获取 lit-html 模板的自动补全、类型检查和执行 lint 请求功能。
通过运行以下命令启动开发环境:
npm run dev
您可以开始编码了!
构建复合组件时,有时候从较简单的子组件开始构建更容易。因此,我们先构建 <story-card>
。它应该能显示全出血视频或图片。用户应该能进一步对它进行自定义,例如,添加叠加文字。
第一步是定义组件的类,此类会扩展 LitElement
。customElement
修饰器负责为我们注册自定义元素。最好趁现在使用 experimentalDecorators
标记在 tsconfig 中启用修饰器(如果您使用的是起始代码库,则修饰器已启用)。
将以下代码添加到 story-card.ts:
import { LitElement, customElement } from 'lit-element';
@customElement('story-card')
export class StoryCard extends LitElement {
}
现在,<story-card>
是可用的自定义元素,但目前没有可显示的内容。要定义此元素的内部结构,应定义 render
实例方法。我们将在这里使用 lit-html 的 html
标记为此元素提供模板。
此组件模板应包含什么?用户应该能提供两个要素:一个媒体元素和一个叠加层。因此,我们将为每个要素添加一个 <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>
`;
}
}
通过将媒体元素分离到它自己的插槽,将有助于我们定位此元素来执行一些操作,例如添加全出血样式和自动播放的视频。将第二个插槽(用于自定义叠加层的插槽)放入一个容器元素中,以便我们稍后可以提供默认内边距。
<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 内容仅适用于我们的自定义元素!这样,包含 Shadow DOM 的 CSS 真的很棒。
我们将对放入插槽的媒体元素设置样式,以包含 <story-card>
。在这里,我们可以为第二个插槽中的元素提供不错的格式。这样一来,组件用户可以拖入 <h1>
、<p>
或任何内容,并会看到出色的默认显示效果。
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;
}
`;
}
现在,我们有了带背景媒体的故事卡片,我们可以将任何想要的内容放在上方。太棒了!我们稍后将返回 StoryCard
类,以便实现自动播放的视频。
我们的 <story-viewer>
元素是 <story-card>
的父元素。此元素负责水平布置卡片,并让我们能在这些卡片之间滑动切换。我们将按照处理 StoryCard
的方法着手。我们想将故事卡片添加为 <story-viewer>
元素的子元素,因此为这些子元素添加一个插槽。
将以下代码添加到 story-viewer.ts:
import { LitElement, customElement, html } from 'lit-element';
@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-element';
export class StoryViewer extends LitElement {
@property({type: Number}) index: number = 0;
}
每张卡片都需要水平移动到位。我们将在 lit-element 的 update
生命周期方法中应用这些平移样式。每当此组件的一个被观察属性发生变化时,此更新方法就会运行。通常,我们会通过 slot.assignedElements()
查询插槽和循环。但由于我们只有一个未命名的插槽,这与使用 this.children
一样。为方便起见,我们将使用 this.children
。
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>
全部位于一行中。它仍然适用于其他子元素,只要我们妥当设置这些子元素的样式即可:
<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
并取消注释其余故事卡片元素。现在,我们将着手添加导航功能!
接下来,我们将添加在卡片之间进行导航的方法和进度条。
我们将向 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 网格来设置小方框的样式,每个卡片对应一个小方框。我们可以使用 index
属性有条件地向此方框添加类,用来指示它们是否“被看到”。我们可以使用 i <= this.index : 'watched': ''
等条件表达式,但如果我们添加更多类,代码可能会变得冗长。还好 lit-html 提供一个名为 classMap
的指令,可以解决这个问题。首先,导入 classMap
:
import { classMap } from 'lit-html/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;
}
导航和进度条现已完成。现在,我们来添加一些装饰!
我们将使用 Hammer.js 手势控件库实现滑动操作。Hammer 会检测平移等特别手势,并分派包含可供我们使用的相关信息(例如 delta X)的事件。
以下代码演示了我们可以如何使用 Hammer 检测平移,并在发生平移事件时自动更新我们的元素:
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);
}
}
LitElement 类的构造函数也非常适合用来在托管元素本身上附加事件监听器。Hammer 构造函数采用一个元素来检测手势。在本例中就是 StoryViewer
本身或 this
。然后,通过使用 Hammer 的 API,我们会指示其检测“平移”手势,并将平移信息设置到新的 _panData
属性中。
通过使用 @internalProperty
修饰 _panData
属性,LitElement 将观察到 _panData
的变更并执行更新,但此属性不会反映到一个特性中。
接下来,我们将扩充 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;
}
现在,我们拥有顺畅的滑动操作:
我们要添加的最后一个功能是自动播放的视频。当故事卡片进入焦点时,我们希望播放背景视频(如果存在)。当故事卡片离开焦点时,则应暂停播放其视频。
为了实现此目的,我们将在索引变更时在相应的子元素上分派“entered”和“exited”自定义事件。在 StoryCard
中,我们将接收这些事件,并播放或暂停播放任何现有视频。为什么选择在子元素上分派事件,而不是调用在 StoryCard 上定义的“entered”和“exited”实例方法?使用方法时,如果组件用户想自己编写带自定义动画的故事卡片,则不得不编写自定义元素。而使用事件时,他们只附加事件监听器即可!
我们来重构 StoryViewer
的 index
属性以使用 setter,setter 为分派事件提供便捷的代码路径:
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;
}
}
要完成自动播放功能,我们将在 StoryCard
构造函数中添加分别用于播放和暂停视频的“entered”和“exited”事件监听器。
请注意,组件用户不一定会在媒体插槽中为 <story-card>
提供视频元素。他们甚至可能不会在媒体插槽中提供元素。我们必须小心,避免在 img 上或在 null 时调用 play
。
返回 story-card.ts,并添加以下内容:
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;
}
自动播放完成。✅
我们实现了所有基本功能,接下来我们再添加一项功能:炫酷的伸缩效果。我们将再次返回到 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。