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

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

מידע על Codelab זה

subjectהעדכון האחרון: אוק׳ 1, 2021
account_circleנכתב על ידי Steven Traversi

1.‏ מבוא

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

דרישות מוקדמות

  • מעטפת שבה אפשר להריץ את git ואת npm
  • כלי לעריכת טקסט

2.‏ הגדרה

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

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

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

npm i

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

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

npm run dev

עכשיו אפשר להתחיל לתכנת.

3.‏ <כרטיס המידע> רכיב

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

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

אם מפרידים את רכיב המדיה למיקום נפרד, נוכל לטרגט את האלמנט הזה לדברים כמו הוספת עיצוב ב-Full-Bגל והפעלה אוטומטית של סרטונים. צריך למקם את המשבצת השנייה (האחת לשכבות-על בהתאמה אישית) בתוך רכיב קונטיינר כדי שנוכל לספק מרווח פנימי שמוגדר כברירת מחדל מאוחר יותר.

עכשיו ניתן להשתמש ברכיב <story-card> כך:

<story-card>
 
<img slot="media" src="some/image.jpg" />
 
<h1>My Title</h1>
 
<p>my description</p>
</story-card>

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

צופה לא מסוגנן עם תמונה של קפה

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

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

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

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

ניישם זאת באמצעות שליחת הערך 'Entered' וגם 'יציאה' אירועים מותאמים אישית בחשבונות הצאצא המתאימים בכל פעם שהאינדקס משתנה. בחודש StoryCard, נקבל את האירועים האלה ונפעיל או נשהה סרטונים קיימים. למה לבחור בשיגור אירועים על הילדים במקום לקרוא ל'הוזנו' וגם 'יציאה' methods של מכונות שמוגדרות ב-StoryCard? עם methods, למשתמשים ברכיב לא תהיה אפשרות לבחור אלא לכתוב רכיב מותאם אישית אם הם ירצו לכתוב כרטיס סיפור משלהם עם אנימציות בהתאמה אישית. כשמשתמשים באירועים, הם יכולים פשוט לצרף האזנה לאירועים!

מגדירים מחדש את נכס 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 בתמונה או ב-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.