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