בשיעור הזה תלמדו איך ליצור חוויה כמו Instagram Stories באינטרנט. נבנה את הרכיב תוך כדי תנועה, נתחיל ב-HTML, אחר כך ב-CSS ואז ב-JavaScript.
מומלץ לקרוא את הפוסט שלי בבלוג יצירת רכיב של סטוריז כדי לקבל מידע על השיפורים המתמשכים שבוצעו במהלך פיתוח הרכיב הזה.
הגדרה
- לוחצים על Remix to Edit כדי לאפשר עריכה של הפרויקט.
- פתיחת
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 בסימניית ה-HTML.
מוסיפים את הקוד הבא לקובץ ה-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 בנושא עקומות (גוללים לקטע עקומה מאיצה).
אם יש לכם עין חדה, סביר להניח שראיתם את ההצהרה 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
}
}
}
רוצה לנסות?
- כדי לראות תצוגה מקדימה של האתר, לוחצים על הצגת האפליקציה. לאחר מכן לוחצים על מסך מלא .
סיכום
זהו סיכום הצרכים שלי לגבי הרכיב. אתם מוזמנים להשתמש במודל, להוסיף לו נתונים ולהפוך אותו למודל משלכם.