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

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

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

הגדרה

  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> הוא חלק משיטת טעינה של 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:

.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> הוא חלק משיטת טעינה של 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 באינטרנט שנקרא טעינת המצבה (טעינת המצבה):

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

JavaScript

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

הגדרה

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

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

מוסיפים את הקוד הבא בחלק התחתון של 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 מסך מלא.

סיכום

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