CSS Deep-Dive - matrix3d() voor een frame-perfecte aangepaste schuifbalk

Aangepaste schuifbalken zijn uiterst zeldzaam en dat komt vooral door het feit dat schuifbalken een van de overgebleven stukjes op internet zijn die vrijwel niet te stylen zijn (ik kijk naar jou, datumkiezer). Je kunt JavaScript gebruiken om er zelf een te bouwen, maar dat is duur, weinig betrouwbaar en kan traag aanvoelen. In dit artikel zullen we een aantal onconventionele CSS-matrices gebruiken om een ​​aangepaste scroller te bouwen die tijdens het scrollen geen JavaScript nodig heeft, alleen wat installatiecode.

TL; DR

Geeft u niets om de kleine dingen? Wil je alleen de Nyan Cat-demo bekijken en de bibliotheek aanschaffen? Je kunt de code van de demo vinden in onze GitHub-repository .

LAM;WRA (lang en wiskundig; zal toch lezen)

Een tijdje geleden hebben we een parallax-scroller gebouwd (heb je dat artikel gelezen? Het is echt goed, zeker de moeite waard!). Door elementen terug te duwen met behulp van CSS 3D-transformaties, bewogen elementen langzamer dan onze werkelijke scrollsnelheid.

Samenvatten

Laten we beginnen met een samenvatting van hoe de parallax-scroller werkte.

Zoals te zien is in de animatie, bereikten we het parallaxeffect door elementen “achteruit” te duwen in de 3D-ruimte, langs de Z-as. Het scrollen door een document is in feite een vertaling langs de Y-as. Dus als we naar beneden scrollen met bijvoorbeeld 100px, wordt elk element 100px naar boven vertaald. Dat geldt voor alle elementen, ook voor de elementen die ‘verder terug’ liggen. Maar omdat ze verder van de camera verwijderd zijn, zal hun waargenomen beweging op het scherm minder dan 100 px zijn, wat het gewenste parallax-effect oplevert.

Als je een element terug in de ruimte verplaatst, lijkt het natuurlijk ook kleiner, wat we corrigeren door het element weer omhoog te schalen. We hebben de exacte wiskunde ontdekt toen we de parallax-scroller bouwden, dus ik zal niet alle details herhalen.

Stap 0: Wat willen we doen?

Schuifbalken. Dat is wat wij gaan bouwen. Maar heb je ooit echt nagedacht over wat ze doen? Dat deed ik zeker niet. Schuifbalken zijn een indicatie van hoeveel van de beschikbare inhoud momenteel zichtbaar is en hoeveel vooruitgang u als lezer heeft geboekt. Als u naar beneden scrollt, doet de schuifbalk dat ook om aan te geven dat u vooruitgang boekt richting het einde. Als alle inhoud in de viewport past, is de schuifbalk meestal verborgen. Als de inhoud 2x de hoogte van de viewport heeft, vult de schuifbalk de helft van de hoogte van de viewport. Inhoud ter waarde van 3x de hoogte van de viewport schaalt de schuifbalk naar ⅓ van de viewport enz. Je ziet het patroon. In plaats van te scrollen, kunt u ook op de schuifbalk klikken en slepen om sneller door de site te bladeren. Dat is verrassend veel gedrag voor zo'n onopvallend element. Laten we strijd voor strijd strijden.

Stap 1: Zet het in omgekeerde volgorde

Oké, we kunnen elementen langzamer laten bewegen dan de scrollsnelheid met CSS 3D-transformaties, zoals beschreven in het artikel over parallax scrollen. Kunnen we de richting ook omkeren? Het blijkt dat we dat kunnen en dat is onze manier om een ​​frame-perfecte, aangepaste schuifbalk te bouwen. Om te begrijpen hoe dit werkt, moeten we eerst enkele basisprincipes van CSS 3D bespreken.

Om enige vorm van perspectiefprojectie in wiskundige zin te krijgen, zul je hoogstwaarschijnlijk homogene coördinaten gebruiken. Ik ga niet in detail in op wat ze zijn en waarom ze werken, maar je kunt ze zien als 3D-coördinaten met een extra, vierde coördinaat genaamd w . Deze coördinaat moet 1 zijn, behalve als je perspectiefvervorming wilt. We hoeven ons geen zorgen te maken over de details van w , aangezien we geen andere waarde dan 1 gaan gebruiken. Daarom zijn alle punten vanaf nu 4-dimensionale vectoren [x, y, z, w=1] en dus matrices moet ook 4x4 zijn.

Eén gelegenheid waarbij je kunt zien dat CSS onder de motorkap homogene coördinaten gebruikt, is wanneer je je eigen 4x4-matrices definieert in een transform-eigenschap met behulp van de matrix3d() functie. matrix3d ​​heeft 16 argumenten (omdat de matrix 4x4 is), waarbij de ene kolom na de andere wordt gespecificeerd. We kunnen deze functie dus gebruiken om handmatig rotaties, vertalingen, enz. op te geven. Maar wat we er ook mee kunnen doen, is rommelen met die w- coördinaat!

Voordat we matrix3d() kunnen gebruiken, hebben we een 3D-context nodig – want zonder een 3D-context zou er geen perspectiefvervorming zijn en geen behoefte aan homogene coördinaten. Om een ​​3D-context te creëren, hebben we een container nodig met een perspective en enkele elementen daarin die we kunnen transformeren in de nieuw gecreëerde 3D-ruimte. Bijvoorbeeld :

Een stukje CSS-code dat een div vervormt met behulp van het perspectiefattribuut van de CSS.

De elementen in een perspectiefcontainer worden als volgt door de CSS-engine verwerkt:

  • Verander elke hoek (vertex) van een element in homogene coördinaten [x,y,z,w] , relatief ten opzichte van de perspectiefcontainer.
  • Pas alle transformaties van het element toe als matrices van rechts naar links .
  • Als het perspectiefelement schuifbaar is, pas dan een schuifmatrix toe.
  • Pas de perspectiefmatrix toe.

De scrollmatrix is ​​een vertaling langs de y-as. Als we 400px naar beneden scrollen , moeten alle elementen 400px naar boven worden verplaatst . De perspectiefmatrix is ​​een matrix die punten dichter bij het verdwijnpunt ‘trekt’ naarmate ze zich verder terug in de 3D-ruimte bevinden. Hierdoor worden zowel de effecten bereikt dat dingen kleiner lijken als ze zich verder naar achteren bevinden, als dat ze ook “langzamer bewegen” wanneer ze worden vertaald. Dus als een element wordt teruggeduwd, zal een vertaling van 400 px ervoor zorgen dat het element slechts 300 px op het scherm beweegt.

Als je alle details wilt weten, moet je de specificaties van het CSS-transformatieweergavemodel lezen, maar omwille van dit artikel heb ik het bovenstaande algoritme vereenvoudigd.

Ons vak bevindt zich in een perspectiefcontainer met waarde p voor het perspective , en laten we aannemen dat de container schuifbaar is en met n pixels naar beneden wordt gescrolld.

Perspectiefmatrix maal scrollmatrix maal elementtransformatiematrix is ​​gelijk aan vier bij vier identiteitsmatrix met minus één gedeeld door p in de vierde rij derde kolom maal vier bij vier identiteitsmatrix met min n in de tweede rij vierde kolom maal elementtransformatiematrix.

De eerste matrix is ​​de perspectiefmatrix, de tweede matrix is ​​de scrollmatrix. Om samen te vatten: het is de taak van de scrollmatrix om een ​​element naar boven te laten bewegen als we naar beneden scrollen , vandaar het negatieve teken.

Voor onze schuifbalk willen we echter het tegenovergestelde : we willen dat ons element naar beneden beweegt als we naar beneden scrollen. Hier kunnen we een truc gebruiken: de w- coördinaat van de hoeken van onze doos omkeren. Als de w- coördinaat -1 is, zullen alle vertalingen in de tegenovergestelde richting van kracht zijn. Dus hoe doen we dat? De CSS-engine zorgt ervoor dat de hoeken van ons vak worden omgezet in homogene coördinaten, en stelt w in op 1. Het is tijd dat matrix3d() gaat schitteren!

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

Deze matrix zal niets anders doen dan w ontkennen. Dus wanneer de CSS-engine elke hoek in een vector van de vorm [x,y,z,1] heeft veranderd, zal de matrix deze omzetten in [x,y,z,-1] .

Vier bij vier identiteitsmatrix met min één boven p in de vierde rij derde kolom maal vier bij vier identiteitsmatrix met min n in de tweede rij vierde kolom maal vier bij vier identiteitsmatrix met min één in de vierde rij vierde kolom maal vierdimensionale vector x, y, z, 1 is gelijk aan vier bij vier identiteitsmatrix met min één gedeeld door p in de vierde rij derde kolom, minus n in de tweede rij vierde kolom en min één in de vierde rij vierde kolom gelijk aan vierdimensionale vector x, y plus n, z, min z gedeeld door p min 1.

Ik heb een tussenstap opgesomd om het effect van onze elementtransformatiematrix te laten zien. Als u zich niet op uw gemak voelt met matrixwiskunde, is dat geen probleem. Het Eureka-moment is dat we in de laatste regel uiteindelijk de scroll-offset n optellen bij onze y-coördinaat in plaats van deze af te trekken. Het element wordt naar beneden vertaald als we naar beneden scrollen.

Als we deze matrix echter alleen in ons voorbeeld plaatsen, wordt het element niet weergegeven. Dit komt omdat de CSS-specificatie vereist dat elk hoekpunt met w < 0 de weergave van het element blokkeert. En aangezien onze z-coördinaat momenteel 0 is, en p 1 is, zal w -1 zijn.

Gelukkig kunnen we de waarde van z kiezen! Om er zeker van te zijn dat we eindigen op w=1, moeten we z = -2 instellen.

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

Kijk eens aan, onze box is terug !

Stap 2: Laat het bewegen

Nu is onze doos er en ziet er hetzelfde uit als zonder enige transformatie. Op dit moment is de perspectiefcontainer niet schuifbaar, dus we kunnen deze niet zien, maar we weten dat ons element de andere kant op zal gaan als er wordt gescrolld. Dus laten we de container laten scrollen, oké? We kunnen gewoon een afstandselement toevoegen dat ruimte in beslag neemt:

<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>

En blader nu door het vakje ! Het rode vak beweegt naar beneden.

Stap 3: Geef het een maat

We hebben een element dat naar beneden beweegt als de pagina naar beneden scrollt. Dat is eigenlijk het moeilijkste uit de weg. Nu moeten we het zo opmaken dat het op een schuifbalk lijkt en het een beetje interactiever maken.

Een scrollbar bestaat doorgaans uit een “duim” en een “track”, terwijl de track niet altijd zichtbaar is. De hoogte van de duim is recht evenredig met hoeveel van de inhoud zichtbaar is.

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

scrollerHeight is de hoogte van het schuifbare element, terwijl scroller.scrollHeight de totale hoogte van de schuifbare inhoud is. scrollerHeight/scroller.scrollHeight is het gedeelte van de inhoud dat zichtbaar is. De verhouding van de verticale ruimte tussen de duimafdekkingen moet gelijk zijn aan de verhouding van de inhoud die zichtbaar is:

punthoogte van de duimpuntstijl over scrollerHeight is gelijk aan de hoogte van de scroller over de scrollhoogte van de scrollerpunt als en alleen als de punthoogte van de duimpuntstijl gelijk is aan de scrollerhoogte maal de scrollerhoogte over de scrollhoogte van de scrollerpunt.
<script>
    // …
    thumb.style.height =
    scrollerHeight * scrollerHeight / scroller.scrollHeight + 'px';
    // Accommodate for native scrollbars
    thumb.style.right =
    (scroller.clientWidth - scroller.getBoundingClientRect().width) + 'px';
</script>

De grootte van de duim ziet er goed uit , maar hij beweegt veel te snel. Dit is waar we onze techniek uit de parallax-scroller kunnen halen. Als we het element verder naar achteren verplaatsen, beweegt het langzamer tijdens het scrollen. We kunnen de grootte corrigeren door deze op te schalen. Maar hoeveel moeten we precies terugdringen? Laten we wat – je raadt het al – wiskunde doen! Dit is de laatste keer, dat beloof ik.

Het cruciale stukje informatie is dat we willen dat de onderkant van de duim op één lijn ligt met de onderkant van het schuifbare element wanneer je helemaal naar beneden scrolt. Met andere woorden: als we scroller.scrollHeight - scroller.height pixels hebben gescrolld, willen we dat onze duim wordt vertaald door scroller.height - thumb.height . Voor elke pixel van de scroller willen we dat onze duim een ​​fractie van een pixel beweegt:

De factor is gelijk aan de hoogte van de scrollerpunt minus de hoogte van de duimpunt over de scrollhoogte van de scrollerpunt minus de hoogte van de scrollerpunt.

Dat is onze schaalfactor. Nu moeten we de schaalfactor omzetten in een vertaling langs de z-as, wat we al deden in het artikel over parallax scrollen. Volgens de relevante sectie in de specificatie : De schaalfactor is gelijk aan p/(p − z). We kunnen deze vergelijking voor z oplossen om erachter te komen hoeveel we nodig hebben om onze duim langs de z-as te verschuiven. Maar houd er rekening mee dat we vanwege onze w-coördinaat-shenanigans een extra -2px langs z moeten vertalen. Merk ook op dat de transformaties van een element van rechts naar links worden toegepast, wat betekent dat alle vertalingen vóór onze speciale matrix niet zullen worden omgekeerd, maar alle vertalingen na onze speciale matrix wel! Laten we dit codificeren!

<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>

We hebben een schuifbalk ! En het is gewoon een DOM-element dat we kunnen stylen zoals we willen. Een ding dat belangrijk is om te doen in termen van toegankelijkheid is om de duim te laten reageren op klikken en slepen, omdat veel gebruikers gewend zijn om op die manier met een schuifbalk te communiceren. Om deze blogpost niet nog langer te maken, ga ik de details voor dat deel niet uitleggen. Bekijk de bibliotheekcode voor meer informatie als je wilt zien hoe het werkt.

Hoe zit het met iOS?

Ah, mijn oude vriend iOS Safari. Net als bij parallax-scrollen komen we hier een probleem tegen. Omdat we op een element scrollen, moeten we -webkit-overflow-scrolling: touch specificeren, maar dat veroorzaakt 3D-afvlakking en ons hele scrolleffect werkt niet meer. We hebben dit probleem in de parallax-scroller opgelost door iOS Safari te detecteren en te vertrouwen op position: sticky als oplossing, en we zullen hier precies hetzelfde doen. Bekijk het parallaxende artikel eens om uw geheugen op te frissen.

Hoe zit het met de browserschuifbalk?

Op sommige systemen zullen we te maken krijgen met een permanente, native schuifbalk. Historisch gezien kan de schuifbalk niet worden verborgen (behalve met een niet-standaard pseudo-selector ). Om het te verbergen, moeten we dus onze toevlucht nemen tot (wiskundevrije) hackerij. We verpakken ons scrollelement in een container met overflow-x: hidden en maken het scrollelement breder dan de container. De eigen schuifbalk van de browser is nu buiten beeld.

Vin

Als we alles bij elkaar optellen, kunnen we nu een op maat gemaakte schuifbalk bouwen die perfect is voor het frame, zoals die in onze Nyan Cat-demo .

Als je Nyan cat niet kunt zien, heb je te maken met een bug die we hebben gevonden en ingediend tijdens het bouwen van deze demo (klik op de duim om Nyan cat te laten verschijnen). Chrome is erg goed in het vermijden van onnodig werk, zoals schilderen of animeren van dingen die buiten het scherm staan. Het slechte nieuws is dat onze matrix-shenanigans ervoor zorgen dat Chrome denkt dat de Nyan-kattengif eigenlijk buiten beeld is. Hopelijk wordt dit snel opgelost.

Daar heb je het. Dat was veel werk. Ik juich het toe dat je het hele stuk hebt gelezen. Dit is echt een trucje om dit werkend te krijgen en het is waarschijnlijk zelden de moeite waard, behalve wanneer een aangepaste schuifbalk een essentieel onderdeel van de ervaring is. Maar goed om te weten dat het mogelijk is, toch? Het feit dat het zo moeilijk is om een ​​aangepaste schuifbalk te maken, laat zien dat er aan de kant van CSS nog werk aan de winkel is. Maar vrees niet! In de toekomst zal Houdini 's AnimationWorklet dit soort frame-perfecte scroll-gekoppelde effecten een stuk eenvoudiger maken.