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

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

אמ;לק

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

LAM;WRA (ארוך ומתמטי; אקרא בכל זאת)

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

Recap

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

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

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

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

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

שלב 1: שינוי המשפט

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

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

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

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

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

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

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

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

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

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

מטריצה של פרספקטיבה כפול מטריצת גלילה כפולה של מטריצת טרנספורמציה
  שווה מטריצה של 4 על 4 זהות עם מינוס 1 על p בשורה הרביעית
  בעמודה השלישית כפול מטריצה של 4 על 4 זהות עם מינוס 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 על ארבע

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

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

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

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

הנה הקופסה שלנו חוזרת!

שלב 2: מעבירים אותו

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

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

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

Factor שווה גובה הנקודה של הגלילה מינוס גובה נקודת הגלילה מעל הגלילה
  נקודה הגלילה מינוס גובה הנקודה של הגלילה.

זה הגורם לקביעת קנה המידה שלנו. עכשיו צריך להמיר את גורם קנה המידה לתרגום לאורך ציר ה-z, כפי שעשינו כבר במאמר הגלילה של פרלקס. על פי הקטע הרלוונטי במפרט: הגורם לקביעת קנה המידה שווה ל- p/(p – z). נוכל לפתור את המשוואה הזו עבור z כדי לראות עד כמה אנחנו צריכים לתרגם את האגודל לאורך ציר ה-z. אבל חשוב לזכור שבגלל תעלולי התיאום שלנו, אנחנו צריכים לתרגם עוד -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, אבל זה גורם לשטח בתלת-ממד וכל אפקט הגלילה מפסיק לפעול. פתרנו את הבעיה הזו בגלילת הפרלקס על ידי זיהוי של Safari ב-iOS והסתמכות על position: sticky כפתרון עקיף, ואנחנו נבצע בדיוק את אותו הדבר כאן. כדאי לקרוא את המאמר בנושא פרלקס (פרלקס) כדי לרענן את הזיכרון.

מה קורה בסרגל הגלילה של הדפדפן?

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

סנפיר

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

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

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