Emulating CSS Timing Functions with JavaScript

Avatar of Ana Tudor
Ana Tudor on

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

CSS-Animationen und Übergänge sind großartig! Bei der kürzlichen Ausprobieren einer Idee war ich jedoch wirklich frustriert über die Tatsache, dass Farbverläufe nur in Edge (und IE 10+) animierbar sind. Ja, wir können alle möglichen Tricks mit background-position, background-size, background-blend-mode oder sogar opacity und transform auf einem Pseudo-Element/Kindelement machen, aber manchmal reicht das einfach nicht aus. Ganz zu schweigen davon, dass wir auf ähnliche Probleme stoßen, wenn wir SVG-Attribute ohne ein CSS-Korrelat animieren wollen.

Mithilfe vieler Beispiele erklärt dieser Artikel, wie man mit nur wenig JavaScript reibungslos von einem Zustand in einen anderen wechselt, ähnlich wie bei gängigen CSS-Timing-Funktionen, ohne auf eine Bibliothek zurückgreifen zu müssen, und somit ohne viel komplizierten und unnötigen Code einzubinden, der zukünftig eine große Last darstellen könnte.

Das ist nicht die Art und Weise, wie CSS-Timing-Funktionen funktionieren. Dies ist ein Ansatz, den ich einfacher und intuitiver finde als die Arbeit mit Bézier-Kurven. Ich werde zeigen, wie man mit JavaScript verschiedene Timing-Funktionen experimentiert und Anwendungsfälle analysiert. Es ist kein Tutorial, wie man schöne Animationen erstellt.

Ein paar Beispiele mit einer linear-Timing-Funktion

Beginnen wir mit einem von links nach rechts verlaufenden linear-gradient() mit einem scharfen Übergang, bei dem wir den ersten Stopp animieren möchten. Hier ist eine Möglichkeit, dies mit CSS-Custom-Properties auszudrücken.

background: linear-gradient(90deg, #ff9800 var(--stop, 0%), #3c3c3c 0);

Bei Klick soll der Wert dieses Stopps über NF Frames von 0% auf 100% (oder umgekehrt, je nachdem, in welchem Zustand er sich bereits befindet) gehen. Wenn eine Animation zum Zeitpunkt des Klicks bereits läuft, stoppen wir sie, ändern ihre Richtung und starten sie neu.

Wir benötigen auch einige Variablen wie die Request-ID (die von requestAnimationFrame zurückgegeben wird), den Index des aktuellen Frames (eine Ganzzahl im Intervall [0, NF], beginnend bei 0) und die Richtung, in die sich unser Übergang bewegt (1, wenn er sich in Richtung 100% bewegt, und -1, wenn er sich in Richtung 0% bewegt).

Solange sich nichts ändert, ist die Request-ID null. Wir setzen auch den Index des aktuellen Frames anfänglich auf 0 und die Richtung auf -1, als ob wir gerade von 100% auf 0% angekommen wären.

const NF = 80; // number of frames transition happens over

let rID = null, f = 0, dir = -1;

function stopAni() {
  cancelAnimationFrame(rID);
  rID = null;  
};

function update() {};

addEventListener('click', e => {
  if(rID) stopAni(); // if an animation is already running, stop it
  dir *= -1; // change animation direction
  update();
}, false);

Nun müssen wir nur noch die update()-Funktion füllen. Darin aktualisieren wir den Index des aktuellen Frames f. Dann berechnen wir eine Fortschrittsvariable k als Verhältnis zwischen diesem aktuellen Frame-Index f und der Gesamtzahl der Frames NF. Da f von 0 bis NF (einschließlich) geht, bedeutet dies, dass unser Fortschritt k von 0 bis 1 reicht. Multiplizieren Sie dies mit 100%, und wir erhalten den gewünschten Stopp.

Danach prüfen wir, ob wir einen der Endzustände erreicht haben. Wenn ja, stoppen wir die Animation und verlassen die update()-Funktion.

function update() {
  f += dir; // update current frame index
  
  let k = f/NF; // compute progress
  
  document.body.style.setProperty('--stop', `${+(k*100).toFixed(2)}%`);
  
  if(!(f%NF)) {
    stopAni();
    return
  }
  
  rID = requestAnimationFrame(update)
};

Das Ergebnis ist im folgenden Pen zu sehen (beachten Sie, dass wir bei einem zweiten Klick zurückgehen).

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

Die Art und Weise, wie das Pseudo-Element im Kontrast zum darunter liegenden background steht, wird in einem älteren Artikel erklärt.

Die obige Demo könnte so aussehen, als ob wir sie leicht mit einem Element und dem Verschieben eines Pseudo-Elements, das es vollständig bedecken kann, erreichen könnten. Aber die Dinge werden viel interessanter, wenn wir background-size einen Wert kleiner als 100% entlang der x-Achse geben, sagen wir 5em.

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

Das gibt uns eine Art "vertikalen Jalousien"-Effekt, der mit reinem CSS nicht sauber repliziert werden kann, wenn wir nicht mehr als ein Element verwenden wollen.

Eine andere Möglichkeit wäre, die Richtung nicht zu wechseln und immer von links nach rechts zu wischen, außer dass nur ungerade Wischvorgänge orange wären. Dies erfordert eine leichte Anpassung des CSS.

--c0: #ff9800;
--c1: #3c3c3c;
background: linear-gradient(90deg, 
    var(--gc0, var(--c0)) var(--stop, 0%), 
    var(--gc1, var(--c1)) 0)

Im JavaScript verwerfen wir die Richtungsvariable und fügen eine Typvariable (typ) hinzu, die am Ende jeder Überblendung zwischen 0 und 1 wechselt. Dann aktualisieren wir alle benutzerdefinierten Eigenschaften.

const S = document.body.style;

let typ = 0;

function update() {
  let k = ++f/NF;
  
  S.setProperty('--stop', `${+(k*100).toFixed(2)}%`);
  
  if(!(f%NF)) {
    f = 0;
    S.setProperty('--gc1', `var(--c${typ})`);
    typ = 1 - typ;
    S.setProperty('--gc0', `var(--c${typ})`);
    S.setProperty('--stop', `0%`);
    stopAni();
    return
  }
  
  rID = requestAnimationFrame(update)
};

Dies ergibt das gewünschte Ergebnis (klicken Sie mindestens zweimal, um zu sehen, wie sich der Effekt von dem der ersten Demo unterscheidet).

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

Wir könnten auch den Gradientenwinkel anstelle des Stopps ändern. In diesem Fall wird die background-Regel wie folgt aussehen:

background: linear-gradient(var(--angle, 0deg), 
    #ff9800 50%, #3c3c3c 0);

Im JavaScript-Code passen wir die update()-Funktion an.

function update() {
  f += dir;
	
  let k = f/NF;
  
  document.body.style.setProperty(
    '--angle', 
    `${+(k*180).toFixed(2)}deg`
  );
  
  if(!(f%NF)) {
    stopAni();
    return
  }
  
  rID = requestAnimationFrame(update)
};

Wir haben jetzt einen Gradientenwinkel-Übergang zwischen den beiden Zuständen (0deg und 180deg).

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

In diesem Fall möchten wir möglicherweise auch im Uhrzeigersinn weitergehen, um zum Zustand 0deg zurückzukehren, anstatt die Richtung zu ändern. Also verwerfen wir einfach die Variable dir, ignorieren Klicks, die während der Überblendung erfolgen, und erhöhen immer den Frame-Index f, wobei wir ihn auf 0 zurücksetzen, wenn wir eine volle Umdrehung um den Kreis abgeschlossen haben.

function update() {
  let k = ++f/NF;
  
  document.body.style.setProperty(
    '--angle', 
    `${+(k*180).toFixed(2)}deg`
  );
  
  if(!(f%NF)) {
    f = f%(2*NF);
    stopAni();
    return
  }
  
  rID = requestAnimationFrame(update)
};

addEventListener('click', e => {
  if(!rID) update()
}, false);

Der folgende Pen veranschaulicht das Ergebnis – unsere Rotation erfolgt jetzt immer im Uhrzeigersinn.

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

Etwas anderes, das wir tun könnten, ist die Verwendung eines radial-gradient() und das Animieren des radialen Stopps.

background: radial-gradient(circle, 
    #ff9800 var(--stop, 0%), #3c3c3c 0);

Der JavaScript-Code ist identisch mit dem der ersten Demo und das Ergebnis ist unten zu sehen.

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

Wir möchten möglicherweise auch nicht zurückgehen, wenn wir erneut klicken, sondern stattdessen einen weiteren Blob wachsen lassen und den gesamten Viewport abdecken. In diesem Fall fügen wir der CSS einige weitere Custom-Properties hinzu.

--c0: #ff9800;
--c1: #3c3c3c;
background: radial-gradient(circle, 
    var(--gc0, var(--c0)) var(--stop, 0%), 
    var(--gc1, var(--c1)) 0)

Der JavaScript-Code ist derselbe wie im Fall der dritten linear-gradient()-Demo. Dies ergibt das gewünschte Ergebnis.

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

Ein lustiger Kniff wäre, unseren Kreis vom Punkt des Klicks aus wachsen zu lassen. Dazu führen wir zwei weitere Custom-Properties ein: --x und --y.

background: radial-gradient(circle at var(--x, 50%) var(--y, 50%), 
    var(--gc0, var(--c0)) var(--stop, 0%), 
    var(--gc1, var(--c1)) 0)

Beim Klicken setzen wir diese auf die Koordinaten des Punkts, an dem der Klick stattfand.

addEventListener('click', e => {
  if(!rID) {
    S.setProperty('--x', `${e.clientX}px`);
    S.setProperty('--y', `${e.clientY}px`);
    update();
  }
}, false);

Dies ergibt das folgende Ergebnis, bei dem eine Scheibe von dem Punkt aus wächst, an dem wir geklickt haben.

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

Eine andere Option wäre die Verwendung eines conic-gradient() und das Animieren des Winkelstopps.

background: conic-gradient(#ff9800 var(--stop, 0%), #3c3c3c 0%)

Beachten Sie, dass **im Falle von conic-gradient() ein Einheiten für den Nullwert verwendet werden muss** (ob diese Einheit % oder eine Winkelangabe wie deg ist, spielt keine Rolle), sonst funktioniert unser Code nicht – conic-gradient(#ff9800 var(--stop, 0%), #3c3c3c 0) bedeutet nichts wird angezeigt.

Der JavaScript-Code ist derselbe wie für das Animieren des Stopps im linearen oder radialen Fall, aber bedenken Sie, dass dies derzeit nur in Chrome mit aktivierten experimentellen Webplattformfunktionen unter chrome://flags funktioniert.

Screenshot showing the Experimental Web Platform Features flag being enabled in Chrome Canary (62.0.3184.0).
Das experimentelle Webplattform-Flag in Chrome Canary (63.0.3210.0) ist aktiviert.

Nur zum Zweck der Anzeige von Konusverläufen im Browser gibt es einen Polyfill von Lea Verou, und dieser funktioniert browserübergreifend, erlaubt aber keine Verwendung von CSS-Custom-Properties.

Die untenstehende Aufnahme veranschaulicht, wie unser Code funktioniert.

Recording of how our first conic gradient demo works in Chrome with the flag enabled.
Aufnahme, wie unsere erste conic-gradient() Demo in Chrome mit aktiviertem Flag funktioniert (Live-Demo).

Dies ist eine weitere Situation, in der wir bei einem zweiten Klick nicht zurückgehen möchten. Dies bedeutet, dass wir die CSS ein wenig ändern müssen, auf die gleiche Weise, wie wir es für die letzte radial-gradient() Demo getan haben.

--c0: #ff9800;
--c1: #3c3c3c;
background: conic-gradient( 
    var(--gc0, var(--c0)) var(--stop, 0%), 
    var(--gc1, var(--c1)) 0%)

Der JavaScript-Code ist genau derselbe wie in den entsprechenden linear-gradient() oder radial-gradient() Fällen, und das Ergebnis ist unten zu sehen.

Recording of how our second conic gradient demo works in Chrome with the flag enabled.
Aufnahme, wie unsere zweite conic-gradient() Demo in Chrome mit aktiviertem Flag funktioniert (Live-Demo).

Bevor wir zu anderen Timing-Funktionen übergehen, gibt es noch eine Sache zu behandeln: den Fall, wenn wir nicht von 0% auf 100% gehen, sondern zwischen beliebigen zwei Werten. Wir nehmen das Beispiel unseres ersten linear-gradient, aber mit einem anderen Standardwert für --stop, sagen wir 85%, und wir setzen auch einen --stop-fin Wert – das wird der Endwert für --stop sein.

--stop-ini: 85%;
--stop-fin: 26%;
background: linear-gradient(90deg, #ff9800 var(--stop, var(--stop-ini)), #3c3c3c 0)

Im JavaScript lesen wir diese beiden Werte – den Anfangswert (Standard) und den Endwert – und berechnen eine Spanne als Differenz zwischen ihnen.

const S = getComputedStyle(document.body), 
      INI = +S.getPropertyValue('--stop-ini').replace('%', ''), 
      FIN = +S.getPropertyValue('--stop-fin').replace('%', ''), 
      RANGE = FIN - INI;

Schließlich berücksichtigen wir in der update()-Funktion den Anfangswert und die Spanne, wenn wir den aktuellen Wert für --stop festlegen.

document.body.style.setProperty(
  '--stop', 
  `${+(INI + k*RANGE).toFixed(2)}%`
);

Mit diesen Änderungen haben wir nun einen Übergang zwischen 85% und 26% (und umgekehrt bei geraden Klicks).

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

Wenn wir Einheiten für den Stoppwert mischen wollen, wird es komplizierter, da wir mehr Dinge berechnen müssen (Box-Dimensionen beim Mischen von % und px, Schriftgrößen, wenn wir em oder rem einbeziehen, Viewport-Dimensionen, wenn wir Viewport-Einheiten verwenden wollen, die Länge des Segments von 0% bis 100% auf der Gradientenlinie für Gradienten, die nicht horizontal oder vertikal sind), aber die grundlegende Idee bleibt dieselbe.

Emulieren von ease-in/ ease-out

Eine Funktion vom Typ ease-in bedeutet, dass die Wertänderung zuerst langsam erfolgt und dann beschleunigt. ease-out ist genau das Gegenteil – die Änderung erfolgt am Anfang schnell, verlangsamt sich aber dann zum Ende hin.

Illustration showing the graphs of the ease-in and ease-out timing functions, both defined on the [0,1] interval, taking values within the [0,1] interval. The ease-in function has a slow increase at first, the change in value accelerating as we get closer to 1. The ease-out function has a fast increase at first, the change in value slowing down as we get closer to 1.
Die Timing-Funktionen ease-in (links) und ease-out (rechts) (live).

Die Steigung der obigen Kurven gibt die Änderungsrate an. Je steiler sie ist, desto schneller erfolgt die Wertänderung.

Wir können diese Funktionen emulieren, indem wir die lineare Methode aus dem ersten Abschnitt anpassen. Da k Werte im Intervall [0, 1] annimmt, ergibt das Potenzieren mit jeder positiven Potenz ebenfalls eine Zahl innerhalb desselben Intervalls. Die interaktive Demo unten zeigt den Graphen einer Funktion f(k) = pow(k, p) (k hoch eine Potenz p) in Lila und den Graphen einer Funktion g(k) = 1 - pow(1 - k, p) in Rot im Intervall [0, 1] im Vergleich zur Identitätsfunktion id(k) = k (die einer linear-Timing-Funktion entspricht).

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

Wenn die Potenz p gleich 1 ist, sind die Graphen der Funktionen f und g identisch mit dem der Identitätsfunktion.

Wenn die Potenz p größer als 1 ist, liegt der Graph der Funktion f unter der Identitätslinie – die Änderungsrate steigt, wenn k steigt. Dies ist wie eine Funktion vom Typ ease-in. Der Graph der Funktion g liegt über der Identitätslinie – die Änderungsrate sinkt, wenn k steigt. Dies ist wie eine Funktion vom Typ ease-out.

Es scheint, dass eine Potenz p von etwa 2 uns ein f ergibt, das ease-in ziemlich ähnlich ist, während g ease-out ziemlich ähnlich ist. Mit etwas mehr Feintuning scheint der beste Näherungswert für einen p-Wert von etwa 1,675 zu sein.

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

In dieser interaktiven Demo möchten wir, dass die Graphen der Funktionen f und g so nah wie möglich an den gestrichelten Linien liegen, die die ease-in-Timing-Funktion (unter der Identitätslinie) und die ease-out-Timing-Funktion (über der Identitätslinie) darstellen.

Emulieren von ease-in-out

Die CSS-Timing-Funktion ease-in-out sieht aus wie in der folgenden Abbildung.

Illustration showing the graph of the ease-in-out timing function, defined on the [0,1] interval, taking values within the [0,1] interval. This function has a slow rate of change in value at first, then accelerates and finally slows down again such that it's symmetric with respect to the (½,½) point.
Die Timing-Funktion ease-in-out (live).

Wie können wir also so etwas erreichen?

Nun, dafür sind harmonische Funktionen da! Genauer gesagt, die Form von ease-in-out erinnert an die Form der sin()-Funktion im Intervall [-90°,90°].

The sin(k) function on the [-90°,90°] interval. At -90°, this function is at its minimum, which is -1. After that, it increases, at first slow, then accelerates and finally slows down again when k gets close to 90°. At 90°, the sin(k) function is at its maximum, which is 1. Its graph is symmetrical with respect to the (0°,0) point.
Die Funktion sin(k) im Intervall [-90°,90°] (live).

Wir wollen jedoch keine Funktion, deren Eingabe im Intervall [-90°,90°] liegt und deren Ausgabe im Intervall [-1,1] liegt. Machen wir das also richtig!

Das bedeutet, wir müssen das gehaschte Rechteck ([-90°,90°]x[-1,1]) in der obigen Abbildung auf das Einheitsquadrat ([0,1]x[0,1]) quetschen.

Zuerst nehmen wir die Domäne [-90°,90°]. Wenn wir unsere Funktion zu sin(k·180°) (oder sin(k·π) in Radiant) ändern, wird unsere Domäne zu [-.5,.5] (wir können überprüfen, dass -.5·180° = 90° und .5·180° = 90°).

The sin(k·π) function on the [-½,½] interval. At -½, the sin(k·π) function is at its minimum, which is -1. After that, it increases, at first slow, then accelerates and finally slows down again when k gets close to ½. At ½, the sin(k·π) function is at its maximum, which is 1. Its graph is symmetrical with respect to the (0,0) point.
Die Funktion sin(k·π) im Intervall [-.5,.5] (live).

Wir können diese Domäne um .5 nach rechts verschieben und erhalten das gewünschte Intervall [0,1], wenn wir unsere Funktion zu sin((k - .5)·π) ändern (wir können überprüfen, dass 0 - .5 = -.5 und 1 - .5 = .5).

The sin((k - ½)·π) function on the [0,1] interval. At 0, the sin(((k - ½)·π) function is at its minimum, which is -1. After that, it increases, at first slow, then accelerates and finally slows down again when k gets close to 1. At 1, the sin((k - ½)·π) function is at its maximum, which is 1. Its graph is symmetrical with respect to the (½,0) point.
Die Funktion sin((k - .5)·π) im Intervall [0,1] (live).

Nun holen wir die gewünschte Zielmenge. Wenn wir 1 zu unserer Funktion addieren und sie zu sin((k - .5)·π) + 1 machen, verschiebt sich unsere Zielmenge nach oben in das Intervall [0, 2].

The sin((k - ½)·π) + 1 function on the [0,1] interval. At 0, the sin(((k - ½)·π) + 1 function is at its minimum, which is 0. After that, it increases, at first slow, then accelerates and finally slows down again when k gets close to 1. At 1, the sin((k - ½)·π) + 1 function is at its maximum, which is 2. Its graph is symmetrical with respect to the (½,1) point.
Die Funktion sin((k - .5)·π) + 1 im Intervall [0,1] (live).

Wenn wir alles durch 2 teilen, erhalten wir die Funktion (sin((k - .5)·π) + 1)/2 und komprimieren die Zielmenge in unser gewünschtes Intervall [0,1].

The (sin((k - ½)·π) + 1)/2 function on the [0,1] interval. At 0, the (sin(((k - ½)·π) + 1)/2 function is at its minimum, which is 0. After that, it increases, at first slow, then accelerates and finally slows down again when k gets close to 1. At 1, the (sin((k - ½)·π) + 1)/2 function is at its maximum, which is 1. Its graph is symmetrical with respect to the (½,½) point.
Die Funktion (sin((k - .5)·π) + 1)/2 im Intervall [0,1] (live).

Dies erweist sich als gute Annäherung an die ease-in-out-Timing-Funktion (dargestellt durch eine orange gestrichelte Linie in der obigen Abbildung).

Vergleich aller dieser Timing-Funktionen

Nehmen wir an, wir möchten eine Reihe von Elementen mit einem linear-gradient() (wie in der dritten Demo) haben. Beim Klicken gehen ihre --stop-Werte von 0% auf 100%, aber mit einer anderen Timing-Funktion für jedes Element.

Im JavaScript erstellen wir ein Objekt mit Timing-Funktionen, das für jeden Art von Ease die entsprechende Funktion enthält.

tfn = {
  'linear': function(k) {
    return k;
  }, 
  'ease-in': function(k) {
    return Math.pow(k, 1.675);
  }, 
  'ease-out': function(k) {
    return 1 - Math.pow(1 - k, 1.675);
  }, 
  'ease-in-out': function(k) {
    return .5*(Math.sin((k - .5)*Math.PI) + 1);
  }
};

Für jede dieser Funktionen erstellen wir ein article-Element.

const _ART = [];

let frag = document.createDocumentFragment();

for(let p in tfn) {
  let art = document.createElement('article'), 
      hd = document.createElement('h3');

  hd.textContent = p;
  art.appendChild(hd);
  art.setAttribute('id', p);
  _ART.push(art);
  frag.appendChild(art);
}

n = _ART.length;
document.body.appendChild(frag);

Die update-Funktion ist im Grunde die gleiche, außer dass wir die benutzerdefinierte Eigenschaft --stop für jedes Element als den Wert festlegen, der von der entsprechenden Timing-Funktion zurückgegeben wird, wenn ihr der aktuelle Fortschritt k übergeben wird. Außerdem müssen wir beim Zurücksetzen von --stop auf 0% am Ende der Animation dies auch für jedes Element tun.

function update() {
  let k = ++f/NF;	
  
  for(let i = 0; i < n; i++) {
    _ART[i].style.setProperty(
      '--stop',
      `${+tfn[_ART[i].id](k).toFixed(5)*100}%`
    );
  }
  
  if(!(f%NF)) {
    f = 0;
		
    S.setProperty('--gc1', `var(--c${typ})`);
    typ = 1 - typ;
    S.setProperty('--gc0', `var(--c${typ})`);
		
    for(let i = 0; i < n; i++)
      _ART[i].style.setProperty('--stop', `0%`);
		
    stopAni();
    return;
  }
  
  rID = requestAnimationFrame(update)
};

Dies gibt uns einen schönen visuellen Vergleich dieser Timing-Funktionen.

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

Sie alle starten und enden zur gleichen Zeit, aber während der Fortschritt für das lineare Element konstant ist, startet das Ease-In-Element langsam und beschleunigt dann, das Ease-Out-Element startet schnell und verlangsamt sich dann, und schließlich startet das Ease-In-Out-Element langsam, beschleunigt und verlangsamt sich dann wieder am Ende.

Timing-Funktionen für Bouncing-Übergänge

Ich stieß zum ersten Mal vor Jahren auf das Konzept in Lea Verous CSS Secrets Talk. Diese treten auf, wenn die y-Werte (gerade) in einer cubic-bezier()-Funktion außerhalb des Bereichs [0, 1] liegen und der von ihnen erzeugte Effekt darin besteht, dass der animierte Wert außerhalb des Intervalls zwischen seinem Anfangs- und Endwert liegt.

Dieser Sprung kann direkt nach Beginn des Übergangs, kurz vor seinem Ende oder an beiden Enden auftreten.

Ein Sprung am Anfang bedeutet, dass wir uns zunächst nicht dem Endzustand nähern, sondern in die entgegengesetzte Richtung. Wenn wir beispielsweise einen Stopp von 43% auf 57% animieren wollen und einen Sprung am Anfang haben, dann nimmt unser Stoppwert zunächst nicht zu in Richtung 57%, sondern sinkt unter 43%, bevor er wieder zum Endzustand ansteigt. Ähnlich verhält es sich, wenn wir von einem anfänglichen Stoppwert von 57% zu einem endgültigen Stoppwert von 43% gehen und einen Sprung am Anfang haben, dann steigt der Stoppwert zunächst über 57%, bevor er zum Endwert abfällt.

Ein Sprung am Ende bedeutet, dass wir unseren Endzustand überschießen und erst dann dorthin zurückkehren. Wenn wir einen Stopp von 43% auf 57% animieren wollen und einen Sprung am Ende haben, dann beginnen wir normal vom Anfangszustand zum Endzustand, aber gegen Ende gehen wir über 57%, bevor wir wieder dorthin zurückkehren. Und wenn wir von einem anfänglichen Stoppwert von 57% zu einem endgültigen Stoppwert von 43% gehen und einen Sprung am Ende haben, dann gehen wir zunächst in Richtung des Endzustands, aber gegen Ende überschießen wir ihn und haben kurzzeitig Stoppwerte unter 43%, bevor unsere Animation dort endet.

Wenn es immer noch schwer zu verstehen ist, was sie tun, gibt es unten ein vergleichendes Beispiel aller drei in Aktion.

Screen capture of a demo showing the three cases described above.
Die drei Fälle.

Diese Art von Timing-Funktionen hat keine eigenen Schlüsselwörter, aber sie sehen cool aus und sind das, was wir in vielen Situationen wollen.

Genau wie im Fall von ease-in-out erhalten wir sie am schnellsten mithilfe von harmonischen Funktionen. Der Unterschied liegt darin, dass wir jetzt nicht mehr von der Domäne [-90°,90°] ausgehen.

Für einen Sprung am Anfang beginnen wir mit dem Teil [s, 0°] der sin()-Funktion, wobei s (der Startwinkel) im Intervall (-180°,-90°) liegt. Je näher er an -180° liegt, desto größer ist der Sprung und desto schneller wird er zum Endzustand nach ihm übergehen. Wir wollen also nicht, dass er sehr nahe an -180° liegt, da das Ergebnis zu unnatürlich aussehen würde. Wir wollen auch, dass er weit genug von -90° entfernt ist, damit der Sprung spürbar ist.

In der interaktiven Demo unten können Sie den Schieberegler ziehen, um den Startwinkel zu ändern, und dann auf den Streifen unten klicken, um den Effekt zu sehen.

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

In der interaktiven Demo oben ist der gehaschte Bereich ([s,0]x[sin(s),0]) der Bereich, den wir in den Bereich [0,1]x[0,1] verschieben und skalieren müssen, um unsere Timing-Funktion zu erhalten. Der Teil der Kurve, der unterhalb seiner unteren Kante liegt, ist dort, wo der Sprung stattfindet. Sie können den Startwinkel mit dem Schieberegler einstellen und dann auf die untere Leiste klicken, um zu sehen, wie der Übergang bei verschiedenen Startwinkeln aussieht.

Genau wie im ease-in-out-Fall quetschen wir zuerst die Domäne in das Intervall [-1,0], indem wir das Argument durch die Spanne dividieren (die maximal 0 abzüglich der minimalen s ist). Daher wird unsere Funktion zu sin(-k·s) (wir können überprüfen, dass -(-1)·s = s und -0·s = 0).

The sin(-k·s) function on the [-1,0] interval. At -1, the sin(-k·s) function is sin(s). After that, it decreases, reaches a minimum, then starts increasing again and keeps increasing until k gets to 0. At 0, the sin(-k·s) function is 0.
Die Funktion sin(-k·s) im Intervall [-1,0] (live).

Als Nächstes verschieben wir dieses Intervall nach rechts (um 1, in das Intervall [0,1]). Dies macht unsere Funktion sin(-(k - 1)·s) = sin((1 - k)·s) (es überprüft, dass 0 - 1 = -1 und 1 - 1 = 0).

The sin(-(k - 1)·s) function on the [0,1] interval. At 0, the sin(-(k - 1)·s) function is sin(s). After that, it decreases, reaches a minimum, then starts increasing again and keeps increasing until k gets to 1. At 1, the sin(-(k - 1)·s) function is 0.
Die Funktion sin(-(k - 1)·s) im Intervall [0,1] (live).

Wir verschieben dann die Zielmenge nach oben um ihren Wert bei 0 (sin((1 - 0)*s) = sin(s)). Unsere Funktion ist jetzt sin((1 - k)·s) - sin(s) und unsere Zielmenge ist [0,-sin(s)].

The sin(-(k - 1)·s) - sin(s) function on the [0,1] interval. At 0, the sin(-(k - 1)·s) - sin(s) function is 0. After that, it decreases, reaches a minimum, then starts increasing again and keeps increasing until k gets to 1. At 1, the sin(-(k - 1)·s) - sin(s) function is -sin(s).
Die Funktion sin(-(k - 1)·s) - sin(s) im Intervall [0,1] (live).

Der letzte Schritt ist die Erweiterung der Zielmenge in den Bereich [0,1]. Dies tun wir, indem wir durch die obere Grenze (die -sin(s) ist) dividieren. Das bedeutet, dass unsere endgültige Ease-Funktion 1 - sin((1 - k)·s)/sin(s) lautet.

The 1 - sin((1 - k)·s)/sin(s) function on the [0,1] interval. At 0, the 1 - sin((1 - k)·s)/sin(s) function is 0. After that, it decreases, reaches a minimum, then starts increasing again and keeps increasing until k gets to 1. At 1, the 1 - sin((1 - k)·s)/sin(s) function is 1.
Die Funktion 1 - sin((1 - k)·s)/sin(s) im Intervall [0,1] (live).

Für einen Sprung am Ende beginnen wir mit dem Teil [0°, e] der sin()-Funktion, wobei e (der Endwinkel) im Intervall (90°,180°) liegt. Je näher er an 180° liegt, desto größer ist der Sprung und desto schneller bewegt er sich vom Anfangszustand zum Endzustand, bevor er ihn überschießt und der Sprung stattfindet. Wir wollen also nicht, dass er sehr nahe an 180° liegt, da das Ergebnis zu unnatürlich aussehen würde. Wir wollen auch, dass er weit genug von 90° entfernt ist, damit der Sprung spürbar ist.

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

In der interaktiven Demo oben ist der gehaschte Bereich ([0,e]x[0,sin(e)]) der Bereich, den wir in das Quadrat [0,1]x[0,1] quetschen und verschieben müssen, um unsere Timing-Funktion zu erhalten. Der Teil der Kurve, der unterhalb seiner oberen Kante liegt, ist dort, wo der Sprung stattfindet.

Wir beginnen damit, die Domäne in das Intervall [0,1] zu quetschen, indem wir das Argument durch die Spanne dividieren (die maximal e abzüglich der minimalen 0 ist). Daher wird unsere Funktion zu sin(k·e) (wir können überprüfen, dass 0·e = 0 und 1·e = e).

The sin(k·e) function on the [0,1] interval. At 0, the sin(k·e) function is 0. After that, it increases, overshoots the final value, reaches a maximum of 1, then decreases back to sin(e) for a k equal to 1.
Die Funktion sin(k·e) im Intervall [0,1] (live).

Was noch zu tun ist, ist die Erweiterung der Zielmenge in den Bereich [0,1]. Dies tun wir, indem wir durch die obere Grenze (die sin(e) ist) dividieren. Das bedeutet, dass unsere endgültige Ease-Funktion sin(k·e)/sin(e) lautet.

The sin(k·e)/sin(e) function on the [0,1] interval. At 0, the sin(k·e)/sin(e) function is 0. After that, it increases, overshoots the final value, reaches a maximum, then decreases back to 1 for a k equal to 1.
Die Funktion sin(k·e)/sin(e) im Intervall [0,1] (live).

Wenn wir an jedem Ende einen Sprung wünschen, beginnen wir mit dem Teil [s, e] der sin()-Funktion, wobei s im Intervall (-180°,-90°) und e im Intervall (90°,180°) liegt. Je größer s und e in absoluten Werten sind, desto größer sind die entsprechenden Sprünge und desto mehr von der Gesamtübergangszeit wird allein für sie aufgewendet. Auf der anderen Seite, je näher ihre absoluten Werte an 90° kommen, desto weniger spürbar sind die entsprechenden Sprünge. Also, genau wie in den beiden vorherigen Fällen, geht es darum, die richtige Balance zu finden.

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

In der interaktiven Demo oben ist der gehaschte Bereich ([s,e]x[sin(s),sin(e)]) der Bereich, den wir in das Quadrat [0,1]x[0,1] verschieben und skalieren müssen, um unsere Timing-Funktion zu erhalten. Der Teil der Kurve, der jenseits seiner horizontalen Ränder liegt, ist dort, wo die Sprünge stattfinden.

Wir beginnen damit, die Domäne nach rechts in das Intervall [0,e - s] zu verschieben. Das bedeutet, unsere Funktion wird zu sin(k + s) (wir können überprüfen, dass 0 + s = s und dass e - s + s = e).

The sin(k + s) function on the [0,e - s] interval. At 0, the sin(k + s) function is sin(s). At first, it decreases, reaches a minimum of -1, then starts increasing and keeps increasing until it overshoots the final value, reaches a maximum of 1, then decreases back to sin(e) for a k equal to e - s.
Die Funktion sin(k + s) im Intervall [0,e - s] (live).

Dann schrumpfen wir die Domäne, damit sie in das Intervall [0,1] passt, was uns die Funktion sin(k·(e - s) + s) ergibt.

The sin(k·(e - s) + s) function on the [0,1] interval. At 0, the sin(k·(e - s) + s) function is sin(s). At first, it decreases, reaches a minimum of -1, then starts increasing and keeps increasing until it overshoots the final value, reaches a maximum of 1, then decreases back to sin(e) for a k equal to 1.
Die Funktion sin(k·(e - s) + s) im Intervall [0,1] (live).

Wenn wir zur Zielmenge übergehen, verschieben wir sie zuerst um ihren Wert bei 0 (sin(0·(e - s) + s)) nach oben, was bedeutet, dass wir jetzt sin(k·(e - s) + s) - sin(s) haben. Dies ergibt die neue Zielmenge [0,sin(e) - sin(s)].

The sin(k·(e - s) + s) - sin(s) function on the [0,1] interval. At 0, the sin(k·(e - s) + s) - sin(s) function is 0. At first, it decreases, reaches a minimum, then starts increasing and keeps increasing until it overshoots the final value, reaches a maximum, then decreases back to sin(e) - sin(s) for a k equal to 1.
Die Funktion sin(k·(e - s) + s) - sin(s) im Intervall [0,1] (live).

Schließlich schrumpfen wir die Zielmenge in das Intervall [0,1], indem wir durch die Spanne (sin(e) - sin(s)) dividieren, so dass unsere endgültige Funktion (sin(k·(e - s) + s) - sin(s))/(sin(e - sin(s)) lautet.

The (sin(k·(e - s) + s) - sin(s))/(sin(e - sin(s)) function on the [0,1] interval. At 0, the s(sin(k·(e - s) + s) - sin(s))/(sin(e - sin(s)) function is 0. At first it decreases, reaches a minimum, then starts increasing and keeps increasing until it overshoots the final value, reaches a maximum, then decreases back to 1 for a k equal to 1.
Die Funktion (sin(k·(e - s) + s) - sin(s))/(sin(e - sin(s)) im Intervall [0,1] (live).

Um eine ähnliche vergleichende Demo wie für die JS-Entsprechungen der CSS-linear, ease-in, ease-out, ease-in-out-Timing-Funktionen zu erstellen, wird unser Timing-Funktionen-Objekt zu

tfn = {
  'bounce-ini': function(k) {
    return 1 - Math.sin((1 - k)*s)/Math.sin(s);
  }, 
  'bounce-fin': function(k) {
    return Math.sin(k*e)/Math.sin(e);
  }, 
  'bounce-ini-fin': function(k) {
    return (Math.sin(k*(e - s) + s) - Math.sin(s))/(Math.sin(e) - Math.sin(s));
  }
};

Die Variablen s und e sind die Werte, die wir von den beiden Bereichseingaben erhalten, mit denen wir die Bounce-Menge steuern können.

Die interaktive Demo unten zeigt den visuellen Vergleich dieser drei Arten von Timing-Funktionen

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

Alternierende Animationen

In CSS kehrt das Setzen von animation-direction auf alternate auch die Timing-Funktion um. Um dies besser zu verstehen, betrachten wir ein .box-Element, dessen transform-Eigenschaft wir animieren, sodass es sich nach rechts bewegt. Das bedeutet, unsere @keyframes sehen wie folgt aus

@keyframes shift {
   0%,  10% { transform: none }
  90%, 100% { transform: translate(50vw) }
}

Wir verwenden eine benutzerdefinierte Timing-Funktion, die uns einen Bounce am Ende ermöglicht, und wir lassen diese Animation alternieren – das heißt, von ihrem Endzustand (translate(50vw)) zurück zum Anfangszustand (keine Übersetzung) für die geradzahligen Iterationen (zweite, vierte und so weiter).

animation: shift 1s cubic-bezier(.5, 1, .75, 1.5) infinite alternate

Das Ergebnis ist unten zu sehen.

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

Eine wichtige Sache, die man hier bemerken sollte, ist, dass bei den geradzahligen Iterationen unser Bounce nicht am Ende, sondern am Anfang stattfindet – die Timing-Funktion ist umgekehrt. Visuell bedeutet dies, dass sie sowohl horizontal als auch vertikal in Bezug auf den Punkt .5,.5 gespiegelt wird.

Illustration showing, using the previous example, how the reverse timing function (bounce at the start) is symmetrical to the normal one (having a bounce at the end in this case) with respect to the (.5,.5) point.
Die normale Timing-Funktion (f, in Rot, mit einem Bounce am Ende) und die symmetrisch umgekehrte (g, in Lila, mit einem Bounce am Anfang) (live)

In CSS gibt es keine Möglichkeit, eine andere Timing-Funktion als die symmetrische beim Zurückkehren zu haben, wenn wir diesen Satz von Keyframes und animation-direction: alternate verwenden möchten. Wir können den Rückweg in die Keyframes einbeziehen und die Timing-Funktion für jede Phase der animation steuern, aber das liegt außerhalb des Rahmens dieses Artikels.

Beim Ändern von Werten mit JavaScript auf die bisher in diesem Artikel vorgestellte Weise geschieht standardmäßig dasselbe. Betrachten wir den Fall, in dem wir die Stopp-Position eines linear-gradient() zwischen einer Anfangs- und einer Endposition animieren möchten und wir am Ende einen Bounce haben wollen. Dies ist ziemlich genau das letzte Beispiel, das im ersten Abschnitt mit einer Timing-Funktion vorgestellt wurde, die uns am Ende einen Bounce ermöglicht (eine aus der zuvor beschriebenen Kategorie bounce-fin) anstelle einer linearen.

Das CSS ist genau dasselbe und wir nehmen nur ein paar geringfügige Änderungen am JavaScript-Code vor. Wir setzen einen Grenzwinkel E und verwenden eine benutzerdefinierte Timing-Funktion vom Typ bounce-fin anstelle der linearen.

const E = .75*Math.PI;

/* same as before */

function timing(k) {
  return Math.sin(k*E)/Math.sin(E)
};

function update() {
  /* same as before */
	
  document.body.style.setProperty(
    '--stop', 
    `${+(INI + timing(k)*RANGE).toFixed(2)}%`
  );
  
  /* same as before */
};

/* same as before */

Das Ergebnis ist unten zu sehen.

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

Im Anfangszustand befindet sich der Stopp bei 85%. Wir animieren ihn auf 26% (was der Endzustand ist) unter Verwendung einer Timing-Funktion, die uns am Ende einen Bounce gibt. Das bedeutet, wir gehen über unsere End-Stopp-Position bei 26% hinaus, bevor wir wieder nach oben gehen und dort stoppen. Dies geschieht während der ungeradzahligen Iterationen.

Während der geradzahligen Iterationen verhält es sich genau wie im CSS-Fall, indem die Timing-Funktion umgekehrt wird, sodass der Bounce am Anfang und nicht am Ende stattfindet.

Aber was, wenn wir nicht möchten, dass die Timing-Funktion umgekehrt wird?

In diesem Fall müssen wir die symmetrische Funktion verwenden. Für jede Timing-Funktion f(k), die auf dem Intervall [0,1] (dies ist der Definitionsbereich) definiert ist und deren Werte im Intervall [0,1] (Bildbereich) liegen, ist die symmetrische Funktion, die wir wollen, 1 - f(1 - k). Beachten Sie, dass Funktionen, deren Form tatsächlich symmetrisch in Bezug auf den Punkt .5,.5 ist, wie linear oder ease-in-out, identisch mit ihren symmetrischen Funktionen sind.

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

Wir verwenden also unsere Timing-Funktion f(k) für die ungeradzahligen Iterationen und 1 - f(1 - k) für die geradzahligen. Ob eine Iteration ungerade oder gerade ist, erkennen wir an der Variablen Richtung (dir). Diese ist 1 für ungeradzahlige Iterationen und -1 für geradzahlige.

Das bedeutet, wir können unsere beiden Timing-Funktionen zu einer kombinieren: m + dir*f(m + dir*k).

Hier ist der Multiplikator m 0 für die ungeradzahligen Iterationen (wenn dir 1 ist) und 1 für die geradzahligen (wenn dir -1 ist), sodass wir ihn als .5*(1 - dir) berechnen können.

dir = +1 → m = .5*(1 - (+1)) = .5*(1 - 1) = .5*0 = 0
dir = -1 → m = .5*(1 - (-1)) = .5*(1 + 1) = .5*2 = 1

Auf diese Weise wird unser JavaScript

let m;

/* same as before */

function update() {
  /* same as before */
  
  document.body.style.setProperty(
    '--stop', 
    `${+(INI + (m + dir*timing(m + dir*k))*RANGE).toFixed(2)}%`
  );
  
  /* same as before */
};

addEventListener('click', e => {
  if(rID) stopAni();
  dir *= -1;
  m = .5*(1 - dir);
  update();
}, false);

Das Endergebnis kann in diesem Pen gesehen werden

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

Noch mehr Beispiele

Farbverlaufsstops sind nicht die einzigen Dinge, die nicht browserübergreifend nur mit CSS animierbar sind.

Farbverlauf-Ende von Orange zu Violett

Als erstes Beispiel für etwas anderes, nehmen wir an, wir möchten, dass das Orange in unserem Farbverlauf zu einer Art Violett animiert wird. Wir beginnen mit einem CSS, das ungefähr so aussieht

--c-ini: #ff9800;
--c-fin: #a048b9;
background: linear-gradient(90deg, 
  var(--c, var(--c-ini)), #3c3c3c)

Um zwischen den Anfangs- und Endwerten zu interpolieren, müssen wir wissen, welches Format wir erhalten, wenn wir sie über JavaScript lesen – wird es dasselbe Format sein, in dem wir sie gesetzt haben? Wird es immer rgb()/ rgba() sein?

Hier wird es ein bisschen knifflig. Betrachten wir den folgenden Test, bei dem wir einen Farbverlauf haben, bei dem wir jedes mögliche Format verwendet haben

--c0: hsl(150, 100%, 50%); // springgreen
--c1: orange;
--c2: #8a2be2; // blueviolet
--c3: rgb(220, 20, 60); // crimson
--c4: rgba(255, 245, 238, 1); // seashell with alpha = 1
--c5: hsla(51, 100%, 50%, .5); // gold with alpha = .5
background: linear-gradient(90deg, 
  var(--c0), var(--c1), 
  var(--c2), var(--c3), 
  var(--c4), var(--c5))

Wir lesen die berechneten Werte des Farbverlaufbilds und der einzelnen benutzerdefinierten Eigenschaften --c0 bis --c5 über JavaScript.

let s = getComputedStyle(document.body);

console.log(s.backgroundImage);
console.log(s.getPropertyValue('--c0'), 'springgreen');
console.log(s.getPropertyValue('--c1'), 'orange');
console.log(s.getPropertyValue('--c2'), 'blueviolet');
console.log(s.getPropertyValue('--c3'), 'crimson');
console.log(s.getPropertyValue('--c4'), 'seashell (alpha = 1)');
console.log(s.getPropertyValue('--c5'), 'gold (alpha = .5)');

Die Ergebnisse scheinen etwas inkonsistent zu sein.

Screenshots showing what gets logged in Chrome, Edge and Firefox.
Screenshots, die zeigen, was in Chrome, Edge und Firefox protokolliert wird (live).

Was auch immer wir tun, wenn wir einen Alphawert von strikt weniger als 1 haben, scheint das, was wir über JavaScript erhalten, immer ein rgba()-Wert zu sein, unabhängig davon, ob wir es mit rgba() oder hsla() gesetzt haben.

Alle Browser stimmen auch beim direkten Lesen der benutzerdefinierten Eigenschaften überein, allerdings scheint das, was wir erhalten, diesmal keinen großen Sinn zu ergeben: orange, crimson und seashell werden als Schlüsselwörter zurückgegeben, **unabhängig davon, wie sie gesetzt wurden**, aber wir erhalten Hex-Werte für springgreen und blueviolet. Abgesehen von orange, das in Level 2 hinzugefügt wurde, wurden all diese Werte in CSS in Level 3 hinzugefügt, also warum erhalten wir einige als Schlüsselwörter und andere als Hex-Werte?

Für das background-image gibt Firefox immer nur die vollständig opaken Werte als rgb() zurück, während Chrome und Edge sie entweder als Schlüsselwörter oder Hex-Werte zurückgeben, genau wie sie es tun, wenn wir die benutzerdefinierten Eigenschaften direkt lesen.

Na ja, zumindest wissen wir damit, dass wir verschiedene Formate berücksichtigen müssen.

Das Erste, was wir tun müssen, ist also, die Schlüsselwörter auf rgb()-Werte abzubilden. Das schreibe ich nicht alles manuell, also finde ich bei einer schnellen Suche dieses Repo – perfekt, es ist genau das, was wir wollen! Wir können das jetzt als Wert einer CMAP-Konstante festlegen.

Der nächste Schritt ist die Erstellung einer Funktion getRGBA(c), die einen String annimmt, der ein Schlüsselwort, einen Hex-Wert oder einen rgb()/ rgba()-Wert darstellt, und ein Array zurückgibt, das die RGBA-Werte ([rot, grün, blau, alpha]) enthält.

Wir beginnen mit dem Erstellen unserer regulären Ausdrücke für die Hex- und rgb()/ rgba()-Werte. Diese sind etwas locker und würden einige Fehlalarme einfangen, wenn wir Benutzereingaben hätten, aber da wir sie nur auf berechnete Stilwerte von CSS anwenden, können wir uns den schnellen und dreckigen Weg leisten.

let re_hex = /^\#([a-f\d]{1,2})([a-f\d]{1,2})([a-f\d]{1,2})$/i,
    re_rgb = /^rgba?\((\d{1,3},\s){2}\d{1,3}(,\s((0|1)?\.?\d*))?\)/;

Dann behandeln wir die drei Arten von Werten, die wir beim Lesen der berechneten Stile erhalten könnten

if(c in CMAP) return CMAP[c]; // keyword lookup, return rgb
	
if([4, 7].indexOf(c.length) !== -1 && re_hex.test(c)) {
  c = c.match(re_hex).slice(1); // remove the '#'
  if(c[0].length === 1) c = c.map(x => x + x);
	// go from 3-digit form to 6-digit one
  c.push(1); // add an alpha of 1

  // return decimal valued RGBA array
  return c.map(x => parseInt(x, 16)) 
}
	
if(re_rgb.test(c)) {
  // extract values
  c = c.replace(/rgba?\(/, '').replace(')', '').split(',').map(x => +x.trim());
  if(c.length === 3) c.push(1); // if no alpha specified, use 1

  return c // return RGBA array
}

Nachdem wir die Zuordnung von Schlüsselwörtern zu RGBA (CMAP) und die Funktion getRGBA() hinzugefügt haben, ändert sich unser JavaScript-Code nicht wesentlich von den vorherigen Beispielen.

const INI = getRGBA(S.getPropertyValue('--c-ini').trim()), 
      FIN = getRGBA(S.getPropertyValue('--c-fin').trim()), 
      RANGE = [], 
      ALPHA = 1 - INI[3] || 1 - FIN[3];

/* same as before */

function update() {
  /* same as before */
  
  document.body.style.setProperty(
    '--c', 
    `rgb${ALPHA ? 'a' : ''}(
      ${INI.map((c, i) => Math.round(c + k*RANGE[i])).join(',')})`
  );
  
  /* same as before */
};

(function init() {
  if(!ALPHA) INI.pop(); // get rid of alpha if always 1
  RANGE.splice(0, 0, ...INI.map((c, i) => FIN[i] - c));
})();

/* same as before */

Dies gibt uns eine lineare Farbverlaufsanimation

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

Wir können auch eine andere, nicht-lineare Timing-Funktion verwenden, zum Beispiel eine, die einen Bounce am Ende ermöglicht.

const E = .8*Math.PI;

/* same as before */

function timing(k) {
  return Math.sin(k*E)/Math.sin(E)
}

function update() {
  /* same as before */
  
  document.body.style.setProperty(
    '--c', 
    `rgb${ALPHA ? 'a' : ''}(
      ${INI.map((c, i) => Math.round(c + timing(k)*RANGE[i])).join(',')})`
  );
  
  /* same as before */
};

/* same as before */

Das bedeutet, wir gehen ganz zu einer Art Blau, bevor wir zurück zu unserem endgültigen Violett gehen.

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

Beachten Sie jedoch, dass RGBA-Übergänge im Allgemeinen nicht die besten Beispiele für Bounces sind. Das liegt daran, dass die RGB-Kanäle strikt auf den Bereich [0,255] und der Alpha-Kanal auf den Bereich [0,1] beschränkt sind. rgb(255, 0, 0) ist so rot, wie Rot nur sein kann, es gibt kein röteres Rot mit einem Wert über 255 für den ersten Kanal. Ein Wert von 0 für den Alpha-Kanal bedeutet vollständig transparent, es gibt keine größere Transparenz mit einem negativen Wert.

Bis jetzt sind Sie wahrscheinlich schon gelangweilt von Farbverläufen, also wechseln wir zu etwas anderem!

Sanft ändernde SVG-Attributwerte

Zu diesem Zeitpunkt können wir die Geometrie von SVG-Elementen nicht über CSS ändern. Laut SVG2-Spezifikation sollten wir das können, und Chrome unterstützt einige dieser Dinge, aber was, wenn wir die Geometrie von SVG-Elementen jetzt, auf eine browserübergreifendere Weise, animieren wollen?

Nun, Sie haben es wahrscheinlich erraten, JavaScript zur Rettung!

Wachsender Kreis

Unser erstes Beispiel ist das eines circle, dessen Radius von nichts (0) bis zu einem Viertel der minimalen viewBox-Dimension reicht. Wir halten die Dokumentenstruktur einfach, ohne zusätzliche Elemente.

<svg viewBox='-100 -50 200 100'>
  <circle/>
</svg>

Für den JavaScript-Teil ist der einzige bemerkenswerte Unterschied zu den vorherigen Demos, dass wir die SVG viewBox-Dimensionen lesen, um den maximalen Radius zu erhalten, und wir setzen nun das r-Attribut innerhalb der update()-Funktion, nicht eine CSS-Variable (es wäre immens nützlich, wenn CSS-Variablen als Werte für solche Attribute zugelassen wären, aber leider leben wir nicht in einer idealen Welt).

const _G = document.querySelector('svg'), 
      _C = document.querySelector('circle'), 
      VB = _G.getAttribute('viewBox').split(' '), 
      RMAX = .25*Math.min(...VB.slice(2)), 
      E = .8*Math.PI;

/* same as before */

function update() {
  /* same as before */
  
  _C.setAttribute('r', (timing(k)*RMAX).toFixed(2));
  
  /* same as before */
};

/* same as before */

Unten sehen Sie das Ergebnis bei Verwendung einer Timing-Funktion vom Typ bounce-fin.

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

Schwenk- und Zoom-Karte

Ein weiteres SVG-Beispiel ist eine flüssige Schwenk- und Zoom-Karten-Demo. In diesem Fall nehmen wir eine Karte wie die von amCharts, bereinigen das SVG und erzeugen dann diesen Effekt, indem wir eine lineare viewBox-Animation auslösen, wenn die +/ --Tasten (Zoom) und die Pfeiltasten (Schwenken) gedrückt werden.

Das Erste, was wir im JavaScript tun, ist die Erstellung einer Navigationskarte, bei der wir die interessanten Tastencodes nehmen und Informationen darüber anhängen, was wir tun, wenn die entsprechenden Tasten gedrückt werden (beachten Sie, dass wir aus irgendeinem Grund unterschiedliche Tastencodes für + und - in Firefox benötigen).

const NAV_MAP = {
  187: { dir:  1, act: 'zoom', name: 'in' } /* + */, 
   61: { dir:  1, act: 'zoom', name: 'in' } /* + Firefox ¯\_(ツ)_/¯ */, 
  189: { dir: -1, act: 'zoom', name: 'out' } /* - */, 
  173: { dir: -1, act: 'zoom', name: 'out' } /* - Firefox ¯\_(ツ)_/¯ */, 
   37: { dir: -1, act: 'move', name: 'left', axis: 0 } /* ⇦ */, 
   38: { dir: -1, act: 'move', name: 'up', axis: 1 } /* ⇧ */, 
   39: { dir:  1, act: 'move', name: 'right', axis: 0 } /* ⇨ */, 
   40: { dir:  1, act: 'move', name: 'down', axis: 1 } /* ⇩ */
}

Wenn die Taste + gedrückt wird, wollen wir hineinzoomen. Die ausgeführte Aktion ist 'zoom' in positiver Richtung – wir gehen 'in'. Ebenso, wenn die Taste - gedrückt wird, ist die Aktion ebenfalls 'zoom', aber in negativer (-1) Richtung – wir gehen 'out'.

Wenn die Pfeiltaste nach links gedrückt wird, ist die ausgeführte Aktion 'move' entlang der x-Achse (was die erste Achse ist, an Index 0) in negativer (-1) Richtung – wir gehen 'left'. Wenn die Pfeiltaste nach oben gedrückt wird, ist die ausgeführte Aktion 'move' entlang der y-Achse (was die zweite Achse ist, an Index 1) in negativer (-1) Richtung – wir gehen 'up'.

Wenn die Pfeiltaste nach rechts gedrückt wird, ist die ausgeführte Aktion 'move' entlang der x-Achse (was die erste Achse ist, an Index 0) in positiver Richtung – wir gehen 'right'. Wenn die Pfeiltaste nach unten gedrückt wird, ist die ausgeführte Aktion 'move' entlang der y-Achse (was die zweite Achse ist, an Index 1) in positiver Richtung – wir gehen 'down'.

Wir holen uns dann das SVG-Element, seinen anfänglichen viewBox, setzen die maximale Auszoomebene auf diese anfänglichen viewBox-Dimensionen und setzen die kleinstmögliche viewBox-Breite auf einen viel kleineren Wert (sagen wir 8).

const _SVG = document.querySelector('svg'), 
      VB = _SVG.getAttribute('viewBox').split(' ').map(c => +c), 
      DMAX = VB.slice(2), WMIN = 8;

Wir erstellen auch ein leeres aktuelles Navigationsobjekt, um die aktuellen Navigationsaktionsdaten zu speichern, und ein Ziel-viewBox-Array, um den Endzustand zu enthalten, zu dem wir die viewBox für die aktuelle Animation animieren.

let nav = {}, tg = Array(4);

Bei 'keyup', wenn wir noch keine Animation laufen haben und die gedrückte Taste eine von Interesse ist, holen wir das aktuelle Navigationsobjekt aus der Anfangs erstellten Navigationskarte. Danach behandeln wir die beiden Aktionsfälle ('zoom'/ 'move') und rufen die Funktion update() auf.

addEventListener('keyup', e => {	
  if(!rID && e.keyCode in NAV_MAP) {
    nav = NAV_MAP[e.keyCode];
		
    if(nav.act === 'zoom') {
      /* what we do if the action is 'zoom' */
    }
		
    else if(nav.act === 'move') {
      /* what we do if the action is 'move' */
    }
		
    update()
  }
}, false);

Sehen wir uns nun an, was wir tun, wenn wir zoomen. Zuerst, und das ist eine sehr nützliche Programmierstrategie im Allgemeinen, nicht nur hier im Besonderen, kümmern wir uns um die Grenzfälle, die uns veranlassen, die Funktion zu verlassen.

Was sind also unsere Grenzfälle hier?

Der erste ist, wenn wir herauszoomen wollen (ein Zoom in negativer Richtung), wenn unsere gesamte Karte bereits sichtbar ist (die aktuellen viewBox-Dimensionen sind größer oder gleich den maximalen). In unserem Fall sollte dies passieren, wenn wir ganz am Anfang herauszoomen wollen, weil wir mit der gesamten Karte in Sicht beginnen.

Der zweite Grenzfall ist, wenn wir das andere Limit erreichen – wir wollen hineinzoomen, aber wir sind auf der maximalen Detailstufe (die aktuellen viewBox-Dimensionen sind kleiner oder gleich den minimalen).

Wenn wir das oben Genannte in JavaScript-Code umsetzen, erhalten wir

if(nav.act === 'zoom') {
  if((nav.dir === -1 && VB[2] >= DMAX[0]) || 
     (nav.dir ===  1 && VB[2] <= WMIN)) {
    console.log(`cannot ${nav.act} ${nav.name} more`);
    return
  }

  /* main case */
}

Nachdem wir nun die Grenzfälle behandelt haben, kommen wir zum Hauptfall. Hier legen wir die Ziel-viewBox-Werte fest. Wir verwenden bei jedem Schritt einen 2x-Zoom, was bedeutet, dass beim Hineinzoomen die Ziel-viewBox-Dimensionen halb so groß sind wie zu Beginn der aktuellen Zoom-Aktion, und beim Herauszoomen sind sie doppelt so groß. Die Ziel-Offsets sind die Hälfte der Differenz zwischen den maximalen viewBox-Dimensionen und den Ziel-Dimensionen.

if(nav.act === 'zoom') {
  /* edge cases */
			
  for(let i = 0; i < 2; i++) {
    tg[i + 2] = VB[i + 2]/Math.pow(2, nav.dir);
    tg[i] = .5*(DMAX[i] - tg[i + 2]);
  }
}

Als Nächstes sehen wir uns an, was wir tun, wenn wir stattdessen verschieben möchten.

In ähnlicher Weise kümmern wir uns zuerst um die Grenzfälle, die uns veranlassen, die Funktion zu verlassen. Hier treten sie auf, wenn wir uns am Rand der Karte befinden und weiter in diese Richtung gehen wollen (egal welche Richtung). Da ursprünglich die obere linke Ecke unserer viewBox bei 0,0 liegt, bedeutet dies, dass wir nicht unter 0 oder über die maximale viewBox-Größe minus die aktuelle gehen können. Beachten Sie, dass dies, da wir anfangs vollständig ausgezoomt sind, auch bedeutet, dass wir keine Bewegung in irgendeine Richtung ausführen können, bis wir hineingezoomt haben.

else if(nav.act === 'move') {
  if((nav.dir === -1 && VB[nav.axis] <= 0) || 
     (nav.dir ===  1 && VB[nav.axis] >= DMAX[nav.axis] - VB[2 + nav.axis])) {
    console.log(`at the edge, cannot go ${nav.name}`);
    return
  }

  /* main case */

Für den Hauptfall bewegen wir uns in die gewünschte Richtung um die Hälfte der viewBox-Größe entlang dieser Achse.

else if(nav.act === 'move') {
  /* edge cases */
			
  tg[nav.axis] = VB[nav.axis] + .5*nav.dir*VB[2 + nav.axis]
}

Sehen wir uns nun an, was wir innerhalb der Funktion update() tun müssen. Dies wird den vorherigen Demos sehr ähnlich sein, außer dass wir nun die Fälle 'move' und 'zoom' separat behandeln müssen. Wir erstellen auch ein Array, um die aktuellen viewBox-Daten darin zu speichern (cvb).

function update() {	
  let k = ++f/NF, j = 1 - k, cvb = VB.slice();
	
  if(nav.act === 'zoom') {		
    /* what we do if the action is zoom */
  }
	
  if(nav.act === 'move') {		
    /* what we do if the action is move */
  }
	
  _SVG.setAttribute('viewBox', cvb.join(' '));
	
  if(!(f%NF)) {
    f = 0;
    VB.splice(0, 4, ...cvb);
    nav = {};
    tg = Array(4);
    stopAni();
    return
  }
  
  rID = requestAnimationFrame(update)
};

Im 'zoom'-Fall müssen wir alle viewBox-Werte neu berechnen. Wir tun dies mit linearer Interpolation zwischen den Werten zu Beginn der Animation und den zuvor berechneten Zielwerten.

if(nav.act === 'zoom') {		
  for(let i = 0; i < 4; i++)
    cvb[i] = j*VB[i] + k*tg[i];
}

Im 'move'-Fall müssen wir nur einen viewBox-Wert neu berechnen – den Offset für die Achse, entlang derer wir uns bewegen.

if(nav.act === 'move')	
  cvb[nav.axis] = j*VB[nav.axis] + k*tg[nav.axis];

Und das ist es! Wir haben jetzt eine funktionierende Pan- und Zoom-Demo mit sanften linearen Übergängen zwischen den Zuständen.

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.

Vom traurigen Quadrat zum glücklichen Kreis

Ein weiteres Beispiel wäre das Morphing eines traurigen Quadrat-SVGs in einen glücklichen Kreis. Wir erstellen ein SVG mit einer quadratischen viewBox, deren 0,0-Punkt genau in der Mitte liegt. Symmetrisch zum Ursprung des SVG-Koordinatensystems haben wir ein Quadrat (ein rect-Element), das 80% des SVG bedeckt. Das ist unser Gesicht. Wir erstellen die Augen mit einer ellipse und einer Kopie davon, symmetrisch zur vertikalen Achse. Der Mund ist eine kubische Bézierkurve, erstellt mit einem path-Element.

- var vb_d = 500, vb_o = -.5*vb_d;
- var fd = .8*vb_d, fr = .5*fd;

svg(viewBox=[vb_o, vb_o, vb_d, vb_d].join(' '))
  rect(x=-fr y=-fr width=fd height=fd)
  ellipse#eye(cx=.35*fr cy=-.25*fr
              rx=.1*fr ry=.15*fr)
  use(xlink:href='#eye'
      transform='scale(-1 1)')
  path(d=`M${-.35*fr} ${.35*fr}
          C${-.21*fr} ${.13*fr}
           ${+.21*fr} ${.13*fr}
           ${+.35*fr} ${.35*fr}`)

Im JavaScript holen wir die Gesicht- und Mundelemente. Wir lesen die width des Gesichts, die gleich der height ist, und verwenden sie, um die maximale Eckenrundung zu berechnen. Dies ist der Wert, für den wir einen Kreis erhalten, und er entspricht der Hälfte der Quadratkante. Wir holen auch die Mundpfaddaten, aus denen wir die anfängliche y-Koordinate der Kontrollpunkte extrahieren und die endgültige y-Koordinate derselben Kontrollpunkte berechnen.

const _FACE = document.querySelector('rect'), 
      _MOUTH = document.querySelector('path'), 
      RMAX = .5*_FACE.getAttribute('width'), 
      DATA = _MOUTH.getAttribute('d').slice(1)
                   .replace('C', '').split(/\s+/)
                   .map(c => +c), 
      CPY_INI = DATA[3], 
      CPY_RANGE = 2*(DATA[1] - DATA[3]);

Der Rest ist sehr ähnlich wie bei allen anderen Übergängen per Klick-Demos bisher, mit nur wenigen geringfügigen Unterschieden (beachten Sie, dass wir eine Ease-Out-Timing-Funktion verwenden).

/* same as before */

function timing(k) { return 1 - Math.pow(1 - k, 2) };

function update() {
  f += dir;
	
  let k = f/NF, cpy = CPY_INI + timing(k)*CPY_RANGE;	
  
  _FACE.setAttribute('rx', (timing(k)*RMAX).toFixed(2));
  _MOUTH.setAttribute(
    'd', 
    `M${DATA.slice(0,2)}
     C${DATA[2]} ${cpy} ${DATA[4]} ${cpy} ${DATA.slice(-2)}`
  );
  
  /* same as before */
};

/* same as before */

Und so haben wir unser albernes Ergebnis.

Siehe den Pen von thebabydino (@thebabydino) auf CodePen.