CSS Deep-Dive – matrix3d() לסרגל גלילה מותאם אישית עם פריים מושלם

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

אמ;לק

אתם לא מתעניינים בפרטים הקטנים? רוצים רק לצפות בהדגמה של Nyan cat ולקבל את הספרייה? הקוד של הדמו זמין במאגר שלנו ב-GitHub.

LAM;WRA (Long and mathematical; will read anyways)

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

Recap

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

כפי שמוצג באנימציה, השגנו את אפקט התלת-ממד על ידי דחיפת הרכיבים 'לאחור' במרחב תלת-ממדי, לאורך ציר Z. גלילה של מסמך היא למעשה תרגום לאורך ציר ה-Y. לכן, אם גוללים למטה, למשל ב-100px, כל רכיב יועבר למעלה ב-100px. זה רלוונטי לכל הרכיבים, גם לאלה שנמצאים 'מאחור יותר'. אבל כי הם רחוקים יותר מהמצלמה, התנועה שלהם שנצפית במסך תהיה פחות מ-100px, וכך יתקבל אפקט הפרלקס הרצוי.

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

שלב 0: מה אנחנו רוצים לעשות?

סרגלי גלילה. זה מה שאנחנו הולכים ליצור. אבל האם אי פעם חשבתם מה הם עושים? בהחלט לא. סרגל הגלילה הוא אינדיקטור של כמה מהתוכן הזמין גלוי כרגע ושל ההתקדמות שלכם כקוראים. אם תגללו למטה, גם פס הגלילה יתקדם כדי להראות שאתם מתקרבים לסוף. אם כל התוכן נכנס לאזור התצוגה, פס ההזזה בדרך כלל מוסתר. אם גובה התוכן הוא פי 2 מגובה אזור התצוגה, פס ההזזה ממלא מחצית מגובה אזור התצוגה. תוכן שגובהו פי 3 מגובה אזור התצוגה גורם לסרגל הגלילה להתכוונן ל-⅓ מאזור התצוגה וכו'. אתם מבינים את התמונה. במקום לגלול, אפשר גם ללחוץ ולגרור את פס ההזזה כדי לעבור באתר מהר יותר. זו כמות מפתיעה של התנהגויות לאלמנט לא בולט כזה. נלחם במאבק אחד בכל פעם.

שלב 1: העברת הרכב למצב נסיעה לאחור

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

כדי ליצור כל סוג של הקרנה פרופרקטיבית במובן המתמטי, סביר להניח שתצטרכו להשתמש בקואורדינטות הומוגניות. לא אכנס לפרטים לגבי מהן וכיצד הן פועלות, אבל אפשר לחשוב עליהן כעל קואורדינטות תלת-ממדיות עם קואורדינטה רביעית נוספת שנקראת w. הערך של הקואורדינטה הזו צריך להיות 1, אלא אם רוצים שיהיה עיוות פרספקטיבה. אין לנו מה לדאוג לגבי הפרטים של w כי לא נשתמש בערך אחר מלבד 1. לכן, מעכשיו כל הנקודות הן וקטורים 4-ממדיים [x, y, z, w=1], ולכן גם המטריצות צריכות להיות בגודל 4x4.

מקרה אחד שבו אפשר לראות ש-CSS משתמש בקואורדינטות הומוגניות מתחת לפני השטח הוא כשמגדירים מטריצות 4x4 משלכם במאפיין טרנספורמציה באמצעות הפונקציה matrix3d(). matrix3d מקבל 16 ארגומנטים (כי המטריצה היא 4x4), ומציין עמודה אחת אחרי השנייה. כך אפשר להשתמש בפונקציה הזו כדי לציין באופן ידני רוטציות, תרגומים וכו'. אבל היא גם מאפשרת לנו להתעסק בקואורדינטה w.

כדי שנוכל להשתמש ב-matrix3d(), אנחנו צריכים הקשר תלת-ממדי – כי בלי הקשר תלת-ממדי לא תהיה עיוות פרספקטיבה ולא תהיה צורך בקואורדינטות הומוגניות. כדי ליצור הקשר תלת-ממדי, אנחנו צריכים קונטיינר עם perspective ורכיבים מסוימים בתוכו שאפשר לבצע בהם טרנספורמציה במרחב התלת-ממדי החדש שנוצר. לדוגמה:

קטע של קוד CSS שמעוות div באמצעות מאפיין הפרספקטיבה של ה-CSS.

מנוע ה-CSS מעבד את הרכיבים בתוך קונטיינר תצוגה פרופורציונלית באופן הבא:

  • הופכים כל פינה (קודקוד) של רכיב לקואורדינטות הומוגניות [x,y,z,w], ביחס לקונטיינר התצוגה בפרספקטיבה.
  • מחילים את כל הטרנספורמציות של הרכיב כמטריצות מימין לשמאל.
  • אם אפשר לגלול ברכיב הפרספקטיבה, מחילים מטריצת גלילה.
  • מחילים את מטריצת הפרספקטיבה.

מטריצה הגלילה היא תרגום לאורך ציר ה-y. אם גוללים למטה ב-400 פיקסלים, צריך להזיז למעלה את כל הרכיבים ב-400 פיקסלים. מטריצת הפרספקטיבה היא מטריצת 'משיכה' של נקודות קרוב יותר לנקודת היעלמות ככל שהן נמצאות רחוק יותר במרחב תלת-ממדי. כך אפשר גם להציג דברים קטנים יותר כשהם רחוקים יותר, וגם לגרום להם "לנוע לאט יותר" כשהם מתרגמים. לכן, אם רכיב נדחף לאחור, תרגום של 400px יגרום לרכיב לזוז רק 300px במסך.

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

התיבה שלנו נמצאת בתוך מאגר תצוגה פרופרקטיבית עם הערך p למאפיין perspective, ונניח שהמאגר ניתן לגלילה ושהגלילה למטה היא ב-n פיקסלים.

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

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

עם זאת, בסרגל הגלילה שלנו אנחנו רוצים את ההפך – אנחנו רוצים שהרכיב יזוז למטה כשאנחנו גוללים למטה. כאן אפשר להשתמש בטריק: הפוך את הקואורדינטה w של הפינות של התיבה. אם הקואורדינטה w היא -1, כל התרגומים יתבצעו בכיוון ההפוך. איך עושים את זה? מנוע ה-CSS מטפל בהמרת הפינות של התיבה שלנו לקואורדינטות הומוגניות, ומגדיר את הערך של w כ-1. הגיע הזמן לתת ל-matrix3d() את הבמה!

.box {
  transform:
    matrix3d(
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, 0, 1, 0,
      0, 0, 0, -1
    );
}

המטריצה הזו פשוט תשנה את הסימן של w. לכן, כשמנוע ה-CSS הופך כל פינה לוקטור מהצורה [x,y,z,1], המטריצה ממירה אותו ל-[x,y,z,-1].

מטריצה זהות 4 על 4 עם מינוס אחד חלקי p בשורה הרביעית, עמודה שלישית, כפול מטריצה זהות 4 על 4 עם מינוס n בשורה השנייה, עמודה רביעית, כפול מטריצה זהות 4 על 4 עם מינוס אחד בשורה הרביעית, עמודה רביעית, כפול וקטור 4-ממדי x,‏ y,‏ z,‏ 1 שווה למטריצה זהות 4 על 4 עם מינוס אחד חלקי p בשורה הרביעית, עמודה שלישית, מינוס n בשורה השנייה, עמודה רביעית ומינוס אחד בשורה הרביעית, עמודה רביעית שווה לוקטור 4-ממדי x,‏ y,‏ z,‏ מינוס z חלקי p מינוס 1.

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

עם זאת, אם רק נוסיף את המטריצה הזו לדוגמה שלנו, הרכיב לא יוצג. הסיבה לכך היא שמפרט ה-CSS דורש שכל קודקוד עם w < 0 יחסום את העיבוד של האלמנט. מכיוון שהקואורדינטה z שלנו היא כרגע 0 ו-p הוא 1, הערך של w יהיה -1.

למזלנו, אנחנו יכולים לבחור את הערך של z. כדי לוודא ש-w=1, צריך להגדיר את z = -2.

.box {
  transform:
    matrix3d(
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, 0, 1, 0,
      0, 0, 0, -1
    )
    translateZ(-2px);
}

הקופסה חזרה!

שלב 2: גורמים לו לזוז

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

<div class="container">
    <div class="box"></div>
    <span class="spacer"></span>
</div>

<style>
/* … all the styles from the previous example … */
.container {
    overflow: scroll;
}
.spacer {
    display: block;
    height: 500px;
}
</style>

עכשיו אפשר לגלול את התיבה. התיבה האדומה תזוז למטה.

שלב 3: נותנים לו גודל

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

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

<script>
    const scroller = document.querySelector('.container');
    const thumb = document.querySelector('.box');
    const scrollerHeight = scroller.getBoundingClientRect().height;
    thumb.style.height = /* ??? */;
</script>

scrollerHeight הוא הגובה של הרכיב שניתן לגלילה, ו-scroller.scrollHeight הוא הגובה הכולל של התוכן שניתן לגלילה. scrollerHeight/scroller.scrollHeight הוא החלק של התוכן שגלוי. היחס של המרחב האנכי שמוצג בסמן האצבע צריך להיות שווה ליחס של התוכן שגלוי:

היחס בין גובה הנקודה בסגנון של נקודת הסמן לבין scrollerHeight שווה לגובה scroller על גובה scroller dot scroll, אם ורק אם היחס בין גובה הנקודה בסגנון של נקודת הסמן לבין scrollerHeight שווה לגובה scroller כפול גובה scroller על גובה scroller dot scroll.
<script>
    // …
    thumb.style.height =
    scrollerHeight * scrollerHeight / scroller.scrollHeight + 'px';
    // Accommodate for native scrollbars
    thumb.style.right =
    (scroller.clientWidth - scroller.getBoundingClientRect().width) + 'px';
</script>

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

המידע החשוב הוא שאנחנו רוצים שקצה האגודל התחתון יתיישר עם הקצה התחתון של הרכיב שאפשר לגלול בו כשגוללים עד למטה. במילים אחרות: אם גוללים scroller.scrollHeight - scroller.height פיקסלים, רוצים שהאגודל יועבר ב-scroller.height - thumb.height. לכל פיקסל של גלילה, אנחנו רוצים שהאגודל יזוז בחלקיק של פיקסל:

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

זהו גורם ההתאמה שלנו. עכשיו צריך להמיר את גורם ההתאמה לערך תזוזה לאורך ציר z, כפי שכבר עשינו במאמר על גלילה בפרלקס. לפי הקטע הרלוונטי במפרט: גורם השינוי הוא p/(p − z). אפשר לפתור את המשוואה הזו עבור z כדי לחשב כמה צריך להזיז את האגודל לאורך ציר z. אבל חשוב לזכור שבגלל השטויות שעשינו עם קואורדינטת w, אנחנו צריכים לבצע תרגום נוסף של -2px לאורך z. חשוב גם לזכור שהטרנספורמציות של רכיב חלות מימין לשמאל, כלומר כל התרגומים לפני המטריצה המיוחדת שלנו לא יהיו הפוכים, אבל כל התרגומים אחרי המטריצה המיוחדת יהיו הפוכים. ננסה להפוך את זה לקוד.

<script>
    // ... code from above...
    const factor =
    (scrollerHeight - thumbHeight)/(scroller.scrollHeight - scrollerHeight);
    thumb.style.transform = `
    matrix3d(
        1, 0, 0, 0,
        0, 1, 0, 0,
        0, 0, 1, 0,
        0, 0, 0, -1
    )
    scale(${1/factor})
    translateZ(${1 - 1/factor}px)
    translateZ(-2px)
    `;
</script>

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

מה לגבי iOS?

אה, החבר הישן שלי, iOS Safari. כמו בגלילה בפרלקס, גם כאן נתקלנו בבעיה. מכיוון שאנחנו גוללים על רכיב, אנחנו צריכים לציין את הערך -webkit-overflow-scrolling: touch, אבל זה גורם לשטחת 3D וכל אפקט הגלישה שלנו מפסיק לפעול. פתרת את הבעיה בגלילה בפרלקס על ידי זיהוי של iOS Safari והסתמכת על position: sticky כפתרון זמני, ונעשה בדיוק את אותו הדבר כאן. כדאי לעיין במאמר בנושא תזוזת הפרספקטיבה כדי לרענן את הזיכרון.

מה קורה עם פס ההזזה בדפדפן?

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

סנפיר

עכשיו, כשאנחנו משלבים את כל הרכיבים, אנחנו יכולים ליצור פס גלילה בהתאמה אישית שמתאים לפריים – כמו זה שמוצג בהדגמה של Nyan cat.

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

זהו. זה היה הרבה עבודה. תודה שקראת את כל המאמר. זהו תהליך קצת מסובך, וכנראה שרק במקרים נדירים כדאי להשקיע בו את המאמץ, אלא אם סרגל גלילה מותאם אישית הוא חלק חיוני מחוויית השימוש. אבל טוב לדעת שאפשר, לא? העובדה שאי אפשר ליצור סרגל גלילה מותאם אישית בקלות כזו מראה שיש עבודה שצריך לעשות בצד ה-CSS. אבל אל דאגה! בעתיד, AnimationWorklet של Houdini יעזור לכם ליצור אפקטים מקושרים לגלילה כמו זה, עם תצוגה מושלמת בכל פריים.