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

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. הגדרה

מתחילים בהעתקה (cloning) של המאגר הזה: 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 דואג לרשום עבורנו את הרכיב המותאם אישית. עכשיו כדאי לוודא שהפעלתם את ה-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> הוא רכיב מותאם אישית שניתן להשתמש בו, אבל עוד אין שום דבר להציג. כדי להגדיר את המבנה הפנימי של הרכיב, מגדירים את ה-method של המכונה render. כאן נספק את התבנית של האלמנט באמצעות התג 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>

אבל, זה נראה נורא:

חלון צפייה בסטורי ללא עיצוב שבו מוצגת תמונה של קפה

הוספת הסגנון מתבצעת

עכשיו נוסיף קצת סטייל. בעזרת הרכיב light, אפשר לעשות זאת על ידי הגדרת נכס styles סטטי והחזרת מחרוזת תבנית שמתויגת ב-css. כל קובץ CSS שייכתב כאן יחול רק על הרכיב המותאם אישית שלנו. CSS עם 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>s. באחריותו לפרוס את הכרטיסים לרוחב ולאפשר לנו להחליק ביניהם. נתחיל את התהליך בדיוק כמו שעשינו עבור 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> המתאימים, ולתרגם אותם לפי האינדקס שלהם. אנחנו יכולים לטרגט את הרכיב <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 לרכיבים. אנחנו יכולים לעבד את הלחצנים ולהוסיף אוזן קליקים בו-זמנית.

מעדכנים את השיטה 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';

מוסיפים את תגי העיצוב הבאים בחלק התחתון של ה-method render:

<div id="progress">
  ${Array.from(this.children).map((_, i) => html`
    <div
      class=${classMap({watched: i <= this.index})}
      @click=${() => this.index = i}
    ></div>`
  )}
</div>

הוספנו גם עוד כמה רכיבי handler של קליקים, כדי שהמשתמשים יוכלו לדלג ישירות לכרטיס של סיפור ספציפי אם הם רוצים.

אלה הסגנונות החדשים שאפשר להוסיף ל-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. פטיש מזהה תנועות מיוחדות כמו מחבתות ושולח אירועים עם מידע רלוונטי (כמו דלתא X) שאנחנו יכולים לצרוך.

כך אנחנו יכולים להשתמש בפטיש כדי לזהות מחבתות ולעדכן את הרכיב שלנו באופן אוטומטי בכל פעם שמתרחש אירוע תנועה:

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

ה-constructor של מחלקה של LitElement הוא מקום נהדר נוסף לצירוף מאזינים לאירועים ברכיב המארח עצמו. ב-constructor של Hammer מוגדר רכיב שבו יתבצע זיהוי התנועות. במקרה שלנו, מדובר ב-StoryViewer עצמו, או this. לאחר מכן, באמצעות ה-API של האמר, אנחנו אומרים לו לזהות את תנועת "הזזה" ולהגדיר את פרטי התנועה בנכס _panData חדש.

כשמקשטים את המאפיין _panData באמצעות @state, המערכת של 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, נקבל את האירועים האלה ונפעיל או נשהה סרטונים קיימים. למה כדאי לשלוח אירועים לילדים במקום להפעיל את שיטות המכונה 'entered' ו-'exited' שהוגדרו ב-StoryCard? עם methods, למשתמשים ברכיב לא תהיה אפשרות לבחור והם יצטרכו לכתוב רכיב מותאם אישית אם הם ירצו לכתוב כרטיס סיפור משלהם עם אנימציות בהתאמה אישית. באמצעות אירועים, הם יכולים פשוט לצרף פונקציית event listener.

מגדירים מחדש את נכס index של StoryViewer כדי להשתמש ברכיב מגדיר, שמספק נתיב נוח לשליחת האירועים:

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

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

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

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