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

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

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

הגדרה

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

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 בפוסט בבלוג שלי.

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

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

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

קודם כל, נעדכן את קבוצת הכללים של .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 תתווסף לסטורי שצריך יציאה. פונקציית ה-easing המותאמת אישית (cubic-bezier(0.4, 0.0, 1,1)) נלקחה מהמדריך Easing של Material Design (צריך לגלול לקטע Accelerated easing).

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

JavaScript

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

הגדרה

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

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

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

מדינה (State)

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

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

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

מאזינים

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

עכבר

נתחיל בהאזנה לאירוע '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
    }
  }
}

רוצה לנסות?

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

סיכום

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