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

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

בפוסט בבלוג Building a Stories component אפשר לקרוא על השיפורים המתקדמים שביצעתי במהלך בניית הרכיב הזה.

הגדרה

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

HTML

אני תמיד משתדל להשתמש ב-HTML סמנטי. לכל חבר יכולות להיות כמה סטוריז, ולכן חשבתי שיהיה הגיוני להשתמש ברכיב <section> לכל חבר וברכיב <article> לכל סטורי. Let's start from the beginning though. קודם כל, צריך מאגר לרכיב stories.

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

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

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

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

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

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

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

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

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

.story

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

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

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

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

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

אם יש לכם עין חדה, סביר להניח ששמתם לב להצהרה 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, ולכן נחפש בו את הנוכחות של חברים (משתמשים) או סיפורים (story).

המשתנים האלה יעזרו לנו לענות על שאלות כמו: "בהינתן סיפור 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
    }
  }
}

רוצה לנסות?

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

סיכום

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