Codelab: יצירת רכיב של סטוריז

בשיעור הזה תלמדו איך ליצור חוויה כמו Instagram Stories באינטרנט. נבנה את הרכיב תוך כדי תנועה, נתחיל ב-HTML, אחר כך ב-CSS ואז ב-JavaScript.

בפוסט שלי בבלוג יצירת רכיב של סטוריז תוכלו לקרוא על השיפורים המתמשכים שבוצעו במהלך פיתוח הרכיב הזה.

הגדרה

  1. לוחצים על Remix to Edit כדי לאפשר עריכה של הפרויקט.
  2. פתיחת app/index.html.

HTML

אני תמיד שואפת להשתמש בHTML סמנטי. מכיוון שלכל חבר יכולים להיות כמה סיפורים, חשבתי שכדאי להשתמש באלמנט <section> לכל חבר וברכיב <article> לכל סיפור. אבל נתחיל מההתחלה. קודם כול, אנחנו צריכים מאגר לרכיב הסטוריז.

מוסיפים רכיב <div> ל-<body>:

<div class="stories">

</div>

מוסיפים כמה רכיבים של <section> שמייצגים את החברים:

<div class="stories">
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
</div>

מוסיפים כמה רכיבים מסוג <article> כדי לייצג סיפורים:

<div class="stories">
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/480/841);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/481/840);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/481/841);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/482/840);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/482/843);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/482/844);"></article>
  </section>
</div>
  • אנחנו משתמשים בשירות תמונות (picsum.com) כדי ליצור אב-טיפוס של כתבות.
  • המאפיין style בכל <article> הוא חלק מטעינה של placeholder, ועל כך מפורט מידע נוסף בקטע הבא.

CSS

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

.stories

בקונטיינר <div class="stories"> אנחנו רוצים קונטיינר עם גלילה אופקית. כדי לעשות את זה:

  • הפיכת הקונטיינר למרקע
  • הגדרת כל ילד או ילדה כך שיתמלאו את הטראק של השורה
  • קביעת הרוחב של כל ילד לרוחב של אזור תצוגה של מכשיר נייד

המערכת תמשיך להוסיף עמודות חדשות ברוחב 100vw שמימין לעמודה הקודמת, עד שהיא תוסיף את כל רכיבי ה-HTML בסימון.

Chrome וכלי הפיתוח נפתחים עם רשת חזותית שמוצגת בה הפריסה ברוחב מלא
כלי פיתוח ל-Chrome, שבהם מוצגת גלישת עמודות ברשת, שהופכת לגלילה אופקית.

מוסיפים את הקוד הבא לקובץ ה-CSS app/css/index.css:

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
}

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

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  overscroll-behavior: contain;
  touch-action: pan-x;
}

אנחנו רוצים גלילה אופקית, ולכן נגדיר את overflow-x ל-auto. כשהמשתמש גולל, אנחנו רוצים שהרכיב יישב בעדינות על הסיפור הבא, ולכן נשתמש ב-scroll-snap-type: x mandatory. מידע נוסף על ה-CSS הזה זמין בקטעים CSS Scroll Snap Points ו-overscroll-behavior בפוסט בבלוג שלי.

כדי להשתמש ב-Snapping בזמן גלילה, צריך להסכים לכך גם בקונטיינר ההורה וגם בקונטיינרים הצאצאים. עכשיו נראה איך עושים את זה. מוסיפים את הקוד הבא לתחתית הקובץ app/css/index.css:

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

האפליקציה עדיין לא פועלת, אבל בסרטון הבא אפשר לראות מה קורה כשהאפשרות scroll-snap-type מופעלת ומושבתת. כשהתכונה הזו מופעלת, כל גלילה אופקית מובילה לסיפור הבא. כשהתכונה מושבתת, הדפדפן משתמש בהתנהגות ברירת המחדל שלו לגלילה.

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

.user

בואו ניצור פריסה בקטע .user שמרכזת את הרכיבים של סיפור הילד. כדי לפתור את הבעיה, נשתמש בטריק נוח של יצירת סטאק. בעצם, אנחנו יוצרים רשת 1x1 שבה לשורה ולעמודה יש את אותו כינוי רשת, [story], וכל פריט ברשת הסיפורים ינסה לטעון לאותו מקום, וכתוצאה מכך נוצר סטאק.

מוסיפים את הקוד המודגש לכלל .user:

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
  display: grid;
  grid: [story] 1fr / [story] 1fr;
}

מוסיפים את קבוצת הכללים הבאה לתחתית הקובץ app/css/index.css:

.story {
  grid-area: story;
}

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

.story

עכשיו רק צריך להוסיף סגנון לפריט הסיפור עצמו.

קודם הזכרנו שהמאפיין style בכל אלמנט <article> הוא חלק מטכניקת טעינת placeholder:

<article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>

נשתמש במאפיין background-image של CSS, שמאפשר לציין יותר מתמונת רקע אחת. אנחנו יכולים להציג אותם בסדר כך שתמונת המשתמש תופיע בחלק העליון ותוצג באופן אוטומטי בסיום הטעינה. כדי לעשות זאת, נוסיף את כתובת ה-URL של התמונה לנכס מותאם אישית (--bg) ונשתמש בה ב-CSS כדי ליצור שכבה עם placeholder של טעינת התמונה.

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

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));
}

הגדרת background-size כ-cover מבטיחה שלא יהיה מקום ריק בחלון התצוגה, כי התמונה שלנו תמלא אותו. הגדרת 2 תמונות רקע מאפשרת לנו להשתמש בטריק נחמד ב-CSS לאינטרנט שנקרא loading tombstone:

  • תמונת הרקע 1 (var(--bg)) היא כתובת ה-URL שהעברנו בתוך ה-HTML
  • תמונת רקע 2 (linear-gradient(to top, lch(98 0 0), lch(90 0 0)) היא תמונה הדרגתית שתוצג בזמן שכתובת ה-URL נטענת

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

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

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));

  user-select: none;
  touch-action: manipulation;
}
  • user-select: none מונע ממשתמשים לבחור טקסט בטעות
  • touch-action: manipulation מורה לדפדפן להתייחס לאינטראקציות האלה כאירועי מגע, וכך הדפדפן לא צריך לנסות להחליט אם אתם לוחצים על כתובת URL או לא

לסיום, נוסיף מעט CSS כדי להנפיש את המעבר בין הסיפורים. מוסיפים את הקוד המודגש לכלל .story:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));

  user-select: none;
  touch-action: manipulation;

  transition: opacity .3s cubic-bezier(0.4, 0.0, 1, 1);

  &.seen {
    opacity: 0;
    pointer-events: none;
  }
}

הכיתה .seen תתווסף לסטורי שצריך לו יציאה. קיבלתי את פונקציית ההתאמה בהתאמה אישית (cubic-bezier(0.4, 0.0, 1,1)) במדריך ההתאמה של Material Design (גוללים לקטע Accerleated הישות).

אם יש לכם עין חדה, סביר להניח שראיתם את ההצהרה pointer-events: none ועכשיו אתם תוהים מה המשמעות שלה. זהו, לדעתי, החיסרון היחיד של הפתרון עד עכשיו. אנחנו צריכים את זה כי רכיב .seen.story יופיע בחלק העליון ויקבל הקשות, למרות שהוא לא גלוי. כשמגדירים את pointer-events כ-none, הופכים את הסיפור בחלון הזכוכית לחלון, ולא גונבים יותר אינטראקציות של משתמשים. לא עסקה גרועה מדי, לא קשה לנהל אותה כרגע ב-CSS שלנו. אנחנו לא מבצעים ריקוד עם z-index. אני עדיין מרוצה מהמצב.

JavaScript

האינטראקציות של רכיב ב-Stories פשוטות למשתמש: מקישים על ימין כדי לעבור קדימה, ומקישים על ימין כדי לחזור אחורה. דברים פשוטים למשתמשים הם בדרך כלל קשים למפתחים. עם זאת, אנחנו נטפל בחלק גדול מהם.

הגדרה

כדי להתחיל, נחשב ונשמור כמה שיותר מידע. מוסיפים את הקוד הבא לקובץ app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

השורה הראשונה של JavaScript אוספת ומאחסנת הפניה לשורש של רכיב ה-HTML הראשי. השורה הבאה מחשבת את מרכז הרכיב שלנו, כך שאנחנו יכולים להחליט אם הקשה היא קדימה או אחורה.

מדינה

בשלב הבא יוצרים אובייקט קטן עם מצב מסוים שרלוונטי ללוגיקת הקוד. במקרה הזה, אנחנו מתעניינים רק בסיפור הנוכחי. בתגי ה-HTML שלנו, אנחנו יכולים לגשת אליו על ידי אחזור החבר הראשון והסטוריה האחרונה שלו. מוסיפים את הקוד המודגש ל-app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

פונקציות מסוג Listener

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

עכבר

נתחיל בהאזנה לאירוע 'click' בקונטיינר הסיפורים שלנו. מוסיפים את הקוד המודגש אל app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

אם מתרחש קליק והוא לא על רכיב <article>, אנחנו יוצאים מהמצב ולא עושים דבר. אם מדובר בכתבה, אנחנו תופסים את המיקום האופקי של העכבר או האצבע באמצעות clientX. עדיין לא הטמענו את navigateStories, אבל הארגומנט שהוא מקבל מציין את הכיוון שאליו צריך לעבור. אם מיקום המשתמש גדול מהחציון, אנחנו יודעים שצריך לנווט אל next, אחרת prev (הקודם).

מקלדת

עכשיו נאזין ללחיצות על המקלדת. אם מקישים על החץ למטה, עוברים אל next. אם זה חץ למעלה, עוברים אל prev.

מוסיפים את הקוד המודגש אל app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

document.addEventListener('keydown', ({key}) => {
  if (key !== 'ArrowDown' || key !== 'ArrowUp')
    navigateStories(
      key === 'ArrowDown'
        ? 'next'
        : 'prev')
})

ניווט בסטוריז

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

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

המשתנים האלה יעזרו לנו לענות על שאלות כמו "בהתאם לסיפור x, האם 'הבא' פירושו מעבר לסיפור אחר של אותו חבר או לחבר אחר?" עשיתי זאת באמצעות מבנה העץ שיצרנו, שהגיע להורים ולילדים שלהם.

צריך להוסיף את הקוד הבא בחלק התחתון של app/js/index.js:

const navigateStories = direction => {
  const story = state.current_story
  const lastItemInUserStory = story.parentNode.firstElementChild
  const firstItemInUserStory = story.parentNode.lastElementChild
  const hasNextUserStory = story.parentElement.nextElementSibling
  const hasPrevUserStory = story.parentElement.previousElementSibling
}

זהו היעד של הלוגיקה העסקית שלנו, קרוב ככל האפשר לשפה טבעית:

  • בוחרים איך לטפל בהקשה
    • אם יש סטורי נוסף/קודם: הצגת הסטורי הזה
    • אם זה הסיפור האחרון או הראשון של החבר/ה: הצגת חבר/ה חדש/ה
    • אם אין סיפור אחר שאפשר ללכת אליו בכיוון הזה: לא לעשות כלום.
  • שמירת הכתבה הנוכחית החדשה ב-state

מוסיפים את הקוד המודגש לפונקציה navigateStories:

const navigateStories = direction => {
  const story = state.current_story
  const lastItemInUserStory = story.parentNode.firstElementChild
  const firstItemInUserStory = story.parentNode.lastElementChild
  const hasNextUserStory = story.parentElement.nextElementSibling
  const hasPrevUserStory = story.parentElement.previousElementSibling

  if (direction === 'next') {
    if (lastItemInUserStory === story && !hasNextUserStory)
      return
    else if (lastItemInUserStory === story && hasNextUserStory) {
      state.current_story = story.parentElement.nextElementSibling.lastElementChild
      story.parentElement.nextElementSibling.scrollIntoView({
        behavior: 'smooth'
      })
    }
    else {
      story.classList.add('seen')
      state.current_story = story.previousElementSibling
    }
  }
  else if(direction === 'prev') {
    if (firstItemInUserStory === story && !hasPrevUserStory)
      return
    else if (firstItemInUserStory === story && hasPrevUserStory) {
      state.current_story = story.parentElement.previousElementSibling.firstElementChild
      story.parentElement.previousElementSibling.scrollIntoView({
        behavior: 'smooth'
      })
    }
    else {
      story.nextElementSibling.classList.remove('seen')
      state.current_story = story.nextElementSibling
    }
  }
}

רוצה לנסות?

  • כדי לראות תצוגה מקדימה של האתר, לוחצים על View App (הצגת האפליקציה) ואז על Fullscreen מסך מלא (מסך מלא).

סיכום

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