使用 lit-element 构建故事组件

故事是如今的热门界面组件。社交应用和新闻应用都在将故事组件集成到自己的 Feed 中。在此 Codelab 中,我们将使用 lit-element 和 TypeScript 构建一个故事组件。

最终完成的故事组件将如下所示:

一个完成的 story-viewer 组件,其中显示了三张咖啡图片

我们可以将社交媒体或新闻“故事”视为依序播放的卡片集合,有些像幻灯片。实际上,故事就是幻灯片。卡片通常以图片或自动播放的视频为主,并可以在上方添加其他文字。以下是我们将构建的内容:

功能列表

  • 具有图片或视频背景的卡片。
  • 向左或向右滑动可浏览故事。
  • 自动播放的视频。
  • 可添加文字或通过其他方式自定义卡片。

根据此组件开发者的经验,最好用纯 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 的 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>。它应该能显示全出血视频或图片。用户应该能进一步对它进行自定义,例如,添加叠加文字。

第一步是定义组件的类,此类会扩展 LitElementcustomElement 修饰器负责为我们注册自定义元素。最好趁现在使用 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>

不过,这看起来很糟糕:

一个未设置样式的 story-viewer,其中显示了一张咖啡图片

添加样式

我们来添加一些样式。为了实现这一目的,借助 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;
    }
  `;
}

一个设置样式的 story-viewer,其中显示了一张咖啡图片

现在,我们有了带背景媒体的故事卡片,我们可以将任何想要的内容放在上方。太棒了!我们稍后将返回 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> 添加“上一个”按钮和“下一个”按钮。当用户点击任一按钮时,我们希望调用 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-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”实例方法?使用方法时,如果组件用户想自己编写带自定义动画的故事卡片,则不得不编写自定义元素。而使用事件时,他们只附加事件监听器即可!

我们来重构 StoryViewerindex 属性以使用 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;
}

自动播放完成。✅

我们实现了所有基本功能,接下来我们再添加一项功能:炫酷的伸缩效果。我们将再次返回到 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