בונים רכיב של סטורי עם רכיב מואר

1. מבוא

סיפורים הם רכיב פופולרי בממשק המשתמש בימינו. אפליקציות חברתיות ואפליקציות חדשות משלבות אותן בפידים שלהן. בשיעור 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
  • כלי לעריכת טקסט

2. הגדרה

מתחילים בשיבוט המאגר הזה: story-viewer-starter

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

הסביבה כבר מוגדרת עם lit-element ו-TypeScript. פשוט מתקינים את יחסי התלות:

npm i

משתמשי VS Code יכולים להתקין את התוסף lit-plugin כדי לקבל השלמה אוטומטית, בדיקת סוגים ו-linting של תבניות lit-html.

מפעילים את סביבת הפיתוח באמצעות הפקודה:

npm run dev

עכשיו אפשר להתחיל לכתוב קוד!

3. הרכיב <story-card>

כשיוצרים רכיבים מורכבים, לפעמים קל יותר להתחיל עם רכיבי המשנה הפשוטים יותר, וליצור אותם בהדרגה. אז נתחיל ביצירה של <story-card>. הוא צריך להיות מסוגל להציג סרטון או תמונה ללא שוליים לבנים. המשתמשים צריכים להיות מסוגלים להתאים אותו אישית, למשל באמצעות טקסט שמוצג בשכבת-על.

השלב הראשון הוא להגדיר את המחלקה של הרכיב, שהיא הרחבה של LitElement. הדקורטור customElement דואג לרשום את האלמנט המותאם אישית בשבילנו. עכשיו כדאי לוודא שהפעלתם את התכונה decorators בקובץ tsconfig באמצעות הדגל experimentalDecorators (אם אתם משתמשים במאגר המתחילים, התכונה כבר מופעלת).

מכניסים את הקוד הבא לקובץ story-card.ts:

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

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

עכשיו <story-card> הוא רכיב מותאם אישית שאפשר להשתמש בו, אבל עדיין אין מה להציג. כדי להגדיר את המבנה הפנימי של הרכיב, מגדירים את render instance method. כאן נספק את התבנית של הרכיב, באמצעות התג html של lit-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 הוא פתרון מצוין.

בואו נעצב את רכיב המדיה עם ה-slot כדי שיכסה את <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>s slotted, ותיקון שלהם בהתאם לאינדקס שלהם. אפשר לטרגט את הרכיב <story-viewer> עצמו באמצעות הסלקטור :host.

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

כדי לעקוב אחרי הכרטיס שמוצג כרגע, נוסיף משתנה מופע index למחלקה StoryViewer. אם מוסיפים את הקישוט @property של LitElement, הרכיב יעבור עיבוד מחדש בכל פעם שהערך שלו ישתנה.

import { property } from 'lit/decorators.js';

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

כל כרטיס צריך להיות מתורגם אופקית למיקום. נשתמש בתרגומים האלה בשיטת מחזור החיים update של lit-element. שיטת העדכון תפעל בכל פעם שמאפיין שנצפה של הרכיב הזה משתנה. בדרך כלל, אנחנו שולחים שאילתה לגבי מיקום המודעה ומבצעים לולאה על 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 קל להוסיף רכיבי event listener לרכיבים. אנחנו יכולים לרנדר את הלחצנים ולהוסיף רכיב click listener בו-זמנית.

צריך לעדכן את שיטת התשלום 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>
    `;
  }
}

אפשר לראות איך אנחנו מוסיפים מאזיני אירועים בשורה ללחצני ה-SVG החדשים שלנו, ישירות בשיטה render. האפשרות הזו זמינה לכל האירועים. פשוט מוסיפים קישור של הטופס @eventname=${handler} לרכיב.

מוסיפים את הקוד הבא למאפיין static styles כדי להגדיר את הסגנון של הלחצנים:

svg {
  position: absolute;
  top: calc(50% - 25px);
  height: 50px;
  cursor: pointer;
}
#next {
  right: 0;
}

בסרגל ההתקדמות נשתמש ב-CSS grid כדי לעצב תיבות קטנות, אחת לכל כרטיס של סיפור. אנחנו יכולים להשתמש במאפיין 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 מזהה תנועות מיוחדות כמו תנועות פנורמה, ושולחת אירועים עם מידע רלוונטי (כמו 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 הוא עוד מקום מצוין לצרף פונקציות event listener לרכיב המארח עצמו. הבנאי Hammer מקבל רכיב לזיהוי תנועות. במקרה שלנו, זה StoryViewer עצמו, או this. לאחר מכן, באמצעות Hammer's API, אנחנו אומרים לו לזהות את תנועת ההזזה, ולהגדיר את פרטי ההזזה במאפיין _panData חדש.

אם מוסיפים את הקישוט @state למאפיין _panData, ‏ LitElement יעקוב אחרי שינויים ב-_panData ויבצע עדכון, אבל לא יהיה מאפיין HTML משויך למאפיין.

בשלב הבא, נרחיב את הלוגיקה של update כדי להשתמש בנתוני ה-PAN:

// 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. הפעלה אוטומטית

התכונה האחרונה שנוסיף היא הפעלה אוטומטית של סרטונים. כשכרטיס של סיפור נכנס למצב פוקוס, אנחנו רוצים שהסרטון ברקע יופעל, אם הוא קיים. כשכרטיס סטורי יוצא מהמיקוד, צריך להשהות את הסרטון שלו.

כדי ליישם את זה, נשלח אירועים מותאמים אישית מסוג 'entered' ו-'exited' אל רכיבי הצאצא המתאימים בכל פעם שהאינדקס משתנה. ב-StoryCard, האירועים האלה יתקבלו והסרטונים הקיימים יופעלו או יושהו. למה לבחור לשלוח אירועים לילדים במקום לקרוא לשיטות המופע 'entered' ו-'exited' שהוגדרו ב-StoryCard? בשיטות, למשתמשים ברכיב לא תהיה ברירה אלא לכתוב רכיב מותאם אישית אם הם ירצו לכתוב כרטיס סיפור משלהם עם אנימציות מותאמות אישית. עם אירועים, הם יכולים פשוט לצרף רכיב event listener.

בואו נשכתב את המאפיין StoryViewer's 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;
  }
}

כדי להשלים את התכונה של הפעלה אוטומטית, נוסיף event listeners לאירועים entered ו-exited ב-constructor של StoryCard, שיפעילו וישהו את הסרטון.

חשוב לזכור: יכול להיות שהמשתמש ברכיב ייתן או לא ייתן לרכיב <story-card> רכיב וידאו במשבצת המדיה. יכול להיות שהם אפילו לא יספקו רכיב במשבצת המדיה. צריך להיזהר ולא לקרוא לפונקציה play ב-img או ב-null.

חוזרים אל 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. הטיית הכף

עכשיו, אחרי שיש לנו את כל התכונות החיוניות, נוסיף עוד אחת: אפקט שינוי גודל מגניב. נחזור עוד פעם אחת לשיטה update של StoryViewer. מתבצעים חישובים מסוימים כדי לקבל את הערך בקבוע scale. הערך יהיה 1.0 עבור הילד הפעיל ו-minScale בכל מקרה אחר, והפונקציה תבצע אינטרפולציה גם בין שני הערכים האלה.

משנים את הלולאה בשיטה update בקובץ story-viewer.ts כך שתהיה:

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, רכיבי slot של HTML ושליטה באמצעות מחוות.

גרסה מלאה של הרכיב הזה זמינה בכתובת: https://github.com/PolymerLabs/story-viewer.