Making Sense of react-spring

Avatar of Adam Rackis
Adam Rackis on

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

Animation ist eine der kniffligeren Dinge, die man mit React richtig hinbekommen muss. In diesem Beitrag werde ich versuchen, die Einführung in react-spring zu geben, die ich mir gewünscht hätte, als ich gerade erst angefangen habe, und dann einige interessante Anwendungsfälle zu behandeln. Obwohl react-spring nicht die einzige Animationsbibliothek für React ist, gehört sie zu den beliebteren (und besseren).

Ich werde die neueste Version 9 verwenden, die zum Zeitpunkt der Erstellung dieses Beitrags im Release-Candidate-Status ist. Wenn sie zum Zeitpunkt des Lesens noch nicht vollständig veröffentlicht ist, stellen Sie sicher, dass Sie sie mit react-spring@next installieren. Soweit ich das beurteilen kann und was mir der Hauptwartungsbeauftragte gesagt hat, ist der Code unglaublich stabil. Das einzige Problem, das ich gesehen habe, ist ein kleiner Fehler bei der Verwendung *mit dem Concurrent Mode*, der im GitHub-Repository verfolgt werden kann.

react-spring redux

Bevor wir zu einigen interessanten Anwendungsfällen kommen, hier eine kurze Einführung. Wir behandeln Springs, Höhenanimationen und dann Übergänge. Am Ende dieses Abschnitts werde ich eine funktionierende Demo bereitstellen, also machen Sie sich keine Sorgen, wenn es unterwegs etwas verwirrend wird. 

springs

Betrachten wir das kanonische "Hallo Welt" der Animation: Inhalte ein- und auszublenden. Halten wir einen Moment inne und überlegen wir, wie wir die Opazität ohne jede Animation umschalten würden. Das würde ungefähr so aussehen

export default function App() {
  const [showing, setShowing] = useState(false);
  return (
    <div>
      <div style={{ opacity: showing ? 1 : 0 }}>
        This content will fade in and fade out
      </div>
      <button onClick={() => setShowing(val => !val)}>Toggle</button>
      <hr />
    </div>
  );
}

Einfach, aber langweilig. Wie *animieren* wir die wechselnde Opazität? Wäre es nicht schön, wenn wir die Opazität, die wir basierend auf dem Zustand wollen, deklarativ einstellen könnten, wie wir es oben tun, nur dass diese Werte sanft animiert werden? Das leistet react-spring. Denken Sie an react-spring als unseren Mittelsmann, der unsere wechselnden Stilwerte wäscht, damit er den sanften Übergang zwischen animierten Werten erzeugen kann, den wir wollen. So

const [showA, setShowA] = useState(false);


const fadeStyles = useSpring({
  config: { ...config.stiff },
  from: { opacity: 0 },
  to: {
    opacity: showA ? 1 : 0
  }
});

Wir definieren unsere anfänglichen Stilwerte mit from und den aktuellen Wert im Abschnitt to, basierend auf unserem aktuellen Zustand. Der Rückgabewert, fadeStyles, enthält die tatsächlichen Stilwerte, die wir auf unsere Inhalte anwenden. Es bleibt nur noch eine letzte Sache...

Sie denken vielleicht, dass Sie einfach so machen können

<div style={fadeStyles}>

...und fertig. Aber anstatt eines normalen Divs müssen wir ein react-spring-Div verwenden, das aus dem animierten Export erstellt wird. Das mag verwirrend klingen, aber alles, was es wirklich bedeutet, ist dies

<animated.div style={fadeStyles}>

Und das ist alles. 

Höhe animieren 

Je nachdem, was wir animieren, möchten wir vielleicht, dass der Inhalt von einer Höhe von null auf seine volle Größe nach oben und unten gleitet, damit sich der umgebende Inhalt anpasst und reibungslos an seinen Platz fließt. Sie würden sich vielleicht wünschen, dass wir das obige einfach kopieren könnten, wobei die Höhe von null auf auto geht, aber leider können Sie nicht zu auto-Höhe animieren. Das funktioniert weder mit Vanilla CSS noch mit react-spring. Stattdessen müssen wir die tatsächliche Höhe unseres Inhalts kennen und diese im to-Abschnitt unserer Feder angeben.

Wir müssen die Höhe beliebigem Inhalt im laufenden Betrieb haben, damit wir diesen Wert an react-spring übergeben können. Es stellt sich heraus, dass die Webplattform dafür etwas speziell entwickelt hat: einen ResizeObserver. Und die Unterstützung ist tatsächlich ziemlich gut! Da wir React verwenden, werden wir diese Verwendung natürlich in einen Hook packen. So sieht meiner aus

export function useHeight({ on = true /* no value means on */ } = {} as any) {
  const ref = useRef<any>();
  const [height, set] = useState(0);
  const heightRef = useRef(height);
  const [ro] = useState(
    () =>
      new ResizeObserver(packet => {
        if (ref.current && heightRef.current !== ref.current.offsetHeight) {
          heightRef.current = ref.current.offsetHeight;
          set(ref.current.offsetHeight);
        }
      })
  );
  useLayoutEffect(() => {
    if (on && ref.current) {
      set(ref.current.offsetHeight);
      ro.observe(ref.current, {});
    }
    return () => ro.disconnect();
  }, [on, ref.current]);
  return [ref, height as any];
}

Wir können optional einen on-Wert angeben, der das Messen ein- und ausschaltet (was sich später als nützlich erweisen wird). Wenn on true ist, weisen wir unseren ResizeObserver an, unseren Inhalt zu beobachten. Wir geben eine ref zurück, die auf den zu messenden Inhalt angewendet werden muss, sowie die aktuelle Höhe.

Sehen wir es uns in Aktion an.

const [heightRef, height] = useHeight();
const slideInStyles = useSpring({
  config: { ...config.stiff },
  from: { opacity: 0, height: 0 },
  to: {
    opacity: showB ? 1 : 0,
    height: showB ? height : 0
  }
});

useHeight gibt uns eine ref und einen Höhenwert des Inhalts, den wir messen, den wir an unsere Feder weitergeben. Dann wenden wir die ref an und wenden die Höhenstile an.

<animated.div style={{ ...slideInStyles, overflow: "hidden" }}>
  <div ref={heightRef}>
    This content will fade in and fade out with sliding
  </div>
</animated.div>

Oh, und vergessen Sie nicht, overflow: hidden zum Container hinzuzufügen. Das ermöglicht es uns, unsere sich anpassenden Höhenwerte ordnungsgemäß zu umschließen.

Übergänge animieren

Schließlich sehen wir uns an, wie man animierte Elemente zum DOM hinzufügt und daraus entfernt. Wir wissen bereits, wie man die wechselnden Werte eines vorhandenen und im DOM verbleibenden Elements animiert, aber um das Hinzufügen oder Entfernen von Elementen zu animieren, benötigen wir einen neuen Hook: useTransition.

Wenn Sie react-spring schon einmal verwendet haben, ist dies einer der wenigen Bereiche, in denen Version 9 einige große Änderungen an der API vorgenommen hat. Werfen wir einen Blick darauf.

Um eine Liste von Elementen zu animieren, wie diese

const [list, setList] = useState([]);

...deklarieren wir unsere Übergangsfunktion wie folgt

const listTransitions = useTransition(list, {
  config: config.gentle,
  from: { opacity: 0, transform: "translate3d(-25%, 0px, 0px)" },
  enter: { opacity: 1, transform: "translate3d(0%, 0px, 0px)" },
  leave: { opacity: 0, height: 0, transform: "translate3d(25%, 0px, 0px)" },
  keys: list.map((item, index) => index)
});

Wie ich bereits angedeutet habe, ist der Rückgabewert, listTransitions, eine Funktion. react-spring verfolgt das Listenarray und behält den Überblick über die hinzugefügten und entfernten Elemente. Wir rufen die Funktion listTransitions auf, übergeben eine Callback-Funktion, die ein einzelnes styles-Objekt und ein einzelnes Element akzeptiert, und react-spring ruft sie für jedes Element in der Liste mit den richtigen Stilen auf, je nachdem, ob es neu hinzugefügt, neu entfernt oder einfach nur in der Liste vorhanden ist.

Beachten Sie den Abschnitt keys: Dies ermöglicht es uns, react-spring mitzuteilen, wie Objekte in der Liste identifiziert werden sollen. In diesem Fall habe ich mich entschieden, react-spring mitzuteilen, dass der Index eines Elements im Array dieses Element eindeutig definiert. Normalerweise wäre das eine schreckliche Idee, aber vorerst können wir damit die Funktion in Aktion sehen. In der folgenden Demo fügt die Schaltfläche "Element hinzufügen" am Ende der Liste ein Element hinzu, wenn sie geklickt wird, und die Schaltfläche "Letztes Element entfernen" entfernt das zuletzt hinzugefügte Element aus der Liste. Wenn Sie also in das Eingabefeld tippen und dann schnell die Hinzufügen-Schaltfläche und dann die Entfernen-Schaltfläche drücken, sehen Sie, wie dasselbe Element sanft einzutreten beginnt und dann sofort, in welchem Animationsstadium auch immer, beginnt zu verlassen. Umgekehrt, wenn Sie ein Element hinzufügen und dann schnell die Entfernen-Schaltfläche und die Hinzufügen-Schaltfläche drücken, wird dasselbe Element abrupt gestoppt und gleitet wieder dorthin zurück, wo es war.

Hier ist die Demo

Puh, das waren eine Menge Worte! Hier ist eine funktionierende Demo, die alles zeigt, was wir gerade behandelt haben.

Kleinigkeiten

Haben Sie bemerkt, wie der Inhalt in der Demo beim Heruntergleiten so etwas wie... eine *Feder* in Position federt? Daher kommt der Name: react-spring interpoliert unsere wechselnden Werte mithilfe der Federphysik. Es zerhackt die Wertänderung nicht einfach in N gleiche Deltas, die es über N gleiche Verzögerungen anwendet. Stattdessen verwendet es einen ausgefeilteren Algorithmus, der diesen federähnlichen Effekt erzeugt, der natürlicher wirkt.

Der Federalgorithmus ist vollständig konfigurierbar und kommt mit einer Reihe von Standard-Presets. Die obige Demo verwendet die Presets stiff und gentle. Weitere Informationen finden Sie in der Dokumentation.

Beachten Sie auch, wie ich Werte innerhalb von translate3d-Werten animiere. Wie Sie sehen können, ist die Syntax nicht die kürzeste, und so bietet react-spring einige Abkürzungen. Es gibt Dokumentation dazu, aber für den Rest dieses Beitrags werde ich weiterhin die vollständige, nicht abgekürzte Syntax verwenden, um die Dinge so klar wie möglich zu halten. 

Ich schließe diesen Abschnitt, indem ich darauf hinweise, dass, wenn Sie den Inhalt in der obigen Demo *nach oben* schieben, Sie wahrscheinlich sehen werden, wie der darunterliegende Inhalt am Ende ein wenig zappelt. Dies ist das Ergebnis desselben Federeffekts. Er sieht scharf aus, wenn der Inhalt nach unten federt und in Position geht, aber weniger, wenn wir den Inhalt nach oben schieben. Bleiben Sie dran, um zu sehen, wie wir ihn ausschalten können. (Spoiler: Es ist die clamp-Eigenschaft).

Einige Dinge, die man bei diesen Sandboxes beachten muss

Code Sandbox verwendet Hot Reloading. Wenn Sie den Code ändern, werden die Änderungen normalerweise sofort übernommen. Das ist cool, kann aber Animationen durcheinanderbringen. Wenn Sie mit dem Tüfteln beginnen und dann seltsames, scheinbar falsches Verhalten sehen, versuchen Sie, die Sandbox zu aktualisieren.

Die anderen Sandboxes in diesem Beitrag verwenden ein Modal. Aus Gründen, die ich noch nicht ganz herausfinden konnte, können Sie bei geöffnetem Modal keinen Code ändern – das Modal weigert sich, den Fokus abzugeben. Schließen Sie das Modal also unbedingt, bevor Sie Änderungen vornehmen. 

Lassen Sie uns nun etwas Reales bauen

Das sind die grundlegenden Bausteine von react-spring. Nutzen wir sie, um etwas Interessanteres zu bauen. Sie denken vielleicht, angesichts allem oben Genannten, dass react-spring ziemlich einfach zu verwenden ist. Leider kann es in der Praxis schwierig sein, einige subtile Dinge herauszufinden, die man richtig machen muss. Der Rest dieses Beitrags wird sich mit vielen dieser Details befassen. 

Frühere Blogbeiträge, die ich geschrieben habe, standen in irgendeiner Weise im Zusammenhang mit meinem Seitenprojekt Buchliste. Dieses hier wird keine Ausnahme sein – es ist keine Obsession, es ist nur so, dass dieses Projekt zufällig einen öffentlich zugänglichen GraphQL-Endpunkt und viel bestehenden Code hat, der genutzt werden kann, was es zu einem offensichtlichen Ziel macht.

Bauen wir eine Benutzeroberfläche, mit der Sie ein Modal öffnen und nach Büchern suchen können. Wenn die Ergebnisse eintreffen, können Sie sie zu einer laufenden Liste ausgewählter Bücher hinzufügen, die unter dem Modal angezeigt werden. Wenn Sie fertig sind, können Sie das Modal schließen und auf eine Schaltfläche klicken, um ähnliche Bücher zur Auswahl zu finden.

Wir beginnen mit einer funktionierenden Benutzeroberfläche und animieren die Teile dann Schritt für Schritt, einschließlich interaktiver Demos zwischendurch.

Wenn Sie wirklich neugierig sind, wie das Endergebnis aussehen wird, oder wenn Sie bereits mit react-spring vertraut sind und sehen möchten, ob ich etwas behandle, das Sie noch nicht kennen, hier ist es (es wird keine Designpreise gewinnen, dessen bin ich mir wohl bewusst). Der Rest dieses Beitrags behandelt die Reise zum Endzustand, Schritt für Schritt.

Unser Modal animieren

Beginnen wir mit unserem Modal. Bevor wir irgendwelche Daten hinzufügen, lassen Sie uns unser Modal schön animieren. Hier ist, wie ein grundlegendes, nicht animiertes Modal aussieht. Ich verwende Ryan Florence's Reach UI (speziell die Modal-Komponente), aber die Idee ist die gleiche, egal was Sie zum Erstellen Ihres Modals verwenden. Wir möchten, dass unser Backdrop ein- und ausfadet und auch unser Modal-Inhalt übergreift.

Da ein Modal bedingt basierend auf einer Art "offen"-Eigenschaft gerendert wird, verwenden wir den Hook useTransition. Ich habe das Reach UI Modal bereits mit meiner eigenen Modal-Komponente umwickelt und entweder nichts oder das eigentliche Modal gerendert, basierend auf der Eigenschaft isOpen. Wir müssen nur noch den Übergangs-Hook durchlaufen, um es zu animieren.

So sieht der Übergangs-Hook aus

const modalTransition = useTransition(!!isOpen, {
  config: isOpen ? { ...config.stiff } : { duration: 150 },
  from: { opacity: 0, transform: `translate3d(0px, -10px, 0px)` },
  enter: { opacity: 1, transform: `translate3d(0px, 0px, 0px)` },
  leave: { opacity: 0, transform: `translate3d(0px, 10px, 0px)` }
});

Es gibt nicht zu viele Überraschungen hier. Wir möchten Dinge einblenden und einen leichten vertikalen Übergang basierend darauf bereitstellen, ob das Modal aktiv ist oder nicht. Das Seltsame ist dies

config: isOpen ? { ...config.stiff } : { duration: 150 },

Ich möchte die Federphysik nur dann verwenden, wenn das Modal geöffnet wird. Der Grund dafür ist – zumindest meiner Erfahrung nach – dass, wenn Sie das Modal schließen, der Backdrop zu lange braucht, um vollständig zu verschwinden, was die zugrunde liegende Benutzeroberfläche zu lange unbedienbar lässt. Wenn das Modal also geöffnet wird, federt es schön mit Federphysik an seinen Platz, und wenn es geschlossen wird, verschwindet es schnell in 150 ms.

Und natürlich rendern wir unseren Inhalt über die von unserem Hook zurückgegebene Übergangsfunktion. Beachten Sie, dass ich den Opazitätsstil aus dem Styles-Objekt herausziehe, um ihn auf den Backdrop anzuwenden, und dann alle animierenden Stile auf den eigentlichen Modal-Inhalt anwende.

return modalTransition(
  (styles, isOpen) =>
    isOpen && (
      <AnimatedDialogOverlay
        allowPinchZoom={true}
        initialFocusRef={focusRef}
        onDismiss={onHide}
        isOpen={isOpen}
        style={{ opacity: styles.opacity }}
      >
      <AnimatedDialogContent
        style={{
          border: "4px solid hsla(0, 0%, 0%, 0.5)",
          borderRadius: 10,
          maxWidth: "400px",
          ...styles
        }}
      >
        <div>
          <div>
            <StandardModalHeader caption={headerCaption} onHide={onHide} />
            {children}
          </div>
        </div>
      </AnimatedDialogContent>
    </AnimatedDialogOverlay>
  )
);

Basis-Setup

Beginnen wir mit dem oben beschriebenen Anwendungsfall. Wenn Sie die Demos verfolgen, hier ist eine vollständige Demo von allem, aber ohne Animation. Öffnen Sie das Modal und suchen Sie nach etwas (fühlen Sie sich frei, einfach Enter in das leere Textfeld zu drücken). Sie sollten auf meinen GraphQL-Endpunkt treffen und Suchergebnisse aus meiner persönlichen Bibliothek erhalten.

Der Rest dieses Beitrags konzentriert sich auf das Hinzufügen von Animationen zur Benutzeroberfläche, was uns die Möglichkeit gibt, ein Vorher und Nachher zu sehen und (hoffentlich) zu beobachten, wie viel schöner einige subtile, gut platzierte Animationen eine Benutzeroberfläche machen können. 

Modalgröße animieren

Lassen Sie uns mit dem Modal selbst beginnen. Öffnen Sie es und suchen Sie z. B. nach "jefferson". Beachten Sie, wie das Modal abrupt größer wird, um den neuen Inhalt aufzunehmen. Können wir das Modal größer (und kleiner) animieren? Natürlich. Graben wir unseren treuen useHeight Hook aus und sehen wir, was wir tun können.

Leider können wir den Höhen-Ref nicht einfach an einen Wrapper in unserem Inhalt anheften und dann die Höhe in eine Feder stecken. Wenn wir das täten, würden wir sehen, wie das Modal auf seine anfängliche Größe gleitet. Das wollen wir nicht; wir wollen, dass unser vollständig ausgebildetes Modal in der richtigen Größe erscheint und *von dort* seine Größe ändert.

Was wir tun wollen, ist zu warten, bis unser Modal-Inhalt im DOM gerendert wird, dann unseren Höhen-Ref setzen und unseren useHeight Hook einschalten, um mit dem Messen zu beginnen. Oh, und wir wollen, dass unsere anfängliche Höhe sofort eingestellt wird und nicht erst an Ort und Stelle animiert wird. Es klingt nach viel, aber es ist nicht so schlimm, wie es klingt.

Beginnen wir mit diesem

const [heightOn, setHeightOn] = useState(false);
const [sizingRef, contentHeight] = useHeight({ on: heightOn });
const uiReady = useRef(false);

Wir haben einige Zustände dafür, ob wir die Höhe unseres Modals messen. Dies wird auf true gesetzt, wenn das Modal im DOM ist. Dann rufen wir unseren useHeight Hook mit der on-Eigenschaft auf, ob wir aktiv sind. Schließlich einige Zustände, um zu speichern, ob unsere Benutzeroberfläche bereit ist, und wir können mit dem Animieren beginnen.

Zuerst einmal: Woher wissen wir überhaupt, wann unser Modal tatsächlich im DOM gerendert wurde? Es stellt sich heraus, dass wir eine ref verwenden können, die uns das mitteilt. Wir sind es gewohnt, <div ref={someRef} in React zu machen, aber Sie können tatsächlich eine Funktion übergeben, die React nach dem Rendern mit dem DOM-Knoten aufruft. Definieren wir diese Funktion jetzt.

const activateRef = ref => {
  sizingRef.current = ref;
  if (!heightOn) {
    setHeightOn(true);
  }
};

Das setzt unseren Höhen-Ref und schaltet unseren useHeight Hook ein. Wir sind fast fertig!

Wie bekommen wir nun diese anfängliche Animation, nicht sofort zu erfolgen? Der Hook useSpring hat zwei neue Eigenschaften, die wir uns jetzt ansehen werden. Er hat eine Eigenschaft immediate, die ihm sagt, Zustandsänderungen *sofort* auszuführen, anstatt sie zu animieren. Er hat auch einen Callback onRest, der ausgelöst wird, wenn eine Zustandsänderung abgeschlossen ist.

Nutzen wir beide. Hier ist, wie der endgültige Hook aussieht

const heightStyles = useSpring({
  immediate: !uiReady.current,
  config: { ...config.stiff },
  from: { height: 0 },
  to: { height: contentHeight },
  onRest: () => (uiReady.current = true)
});

Sobald jede Höhenänderung abgeschlossen ist, setzen wir den uiReady-Ref auf true. Solange er false ist, weisen wir react-spring an, sofort Änderungen vorzunehmen. Wenn also unser Modal zum ersten Mal gemountet wird, ist contentHeight null (useHeight gibt null zurück, wenn nichts zu messen ist) und die Feder chillt einfach, tut nichts. Wenn das Modal zu geöffnet wechselt und tatsächlicher Inhalt gerendert wird, wird unsere activateRef-Ref aufgerufen, unser useHeight wird eingeschaltet, wir erhalten einen tatsächlichen Höhenwert für unsere Inhalte, unsere Feder setzt ihn "sofort" und schließlich wird der onRest-Callback ausgelöst, und zukünftige Änderungen werden animiert. *Puh!*

Ich sollte darauf hinweisen, dass, wenn wir in einem alternativen Anwendungsfall bei der ersten Renderung sofort eine korrekte Höhe hätten, wir den obigen Hook zu diesem vereinfachen könnten

const heightStyles = useSpring({
  to: {
    height: contentHeight
  },
  config: config.stiff,
})

...was tatsächlich weiter vereinfacht werden kann zu diesem

const heightStyles = useSpring({
  height: contentHeight,
  config: config.stiff,
})

Unser Hook würde initial mit der richtigen Höhe gerendert, und alle Änderungen an diesem Wert würden animiert. Aber da unser Modal gerendert wird, bevor es tatsächlich angezeigt wird, können wir uns dieser Vereinfachung nicht bedienen. 

Scharfsinnige Leser fragen sich vielleicht, was passiert, wenn Sie das Modal schließen. Nun, der Inhalt wird ungerendert, und der Höhen-Hook bleibt bei der zuletzt gemeldeten Höhe, obwohl er immer noch einen DOM-Knoten "beobachtet", der nicht mehr im DOM ist. Wenn Sie sich darum Sorgen machen, können Sie die Dinge gerne besser bereinigen, als ich es hier getan habe, vielleicht mit etwas wie diesem

useLayoutEffect(() => {
  if (!isOpen) {
    setHeightOn(false);
  }
}, [isOpen]);

Das wird den ResizeObserver für diesen DOM-Knoten abbrechen und den Speicherleck beheben.

Ergebnisse animieren

Als Nächstes sehen wir uns an, wie wir die wechselnden Ergebnisse innerhalb des Modals animieren. Wenn Sie einige Suchen durchführen, sollten Sie sehen, wie die Ergebnisse sofort ein- und ausgewechselt werden.

Schauen Sie sich die Komponente SearchBooksContent in der Datei searchBooks.js an. Im Moment haben wir const booksObj = data?.allBooks;, die den entsprechenden Ergebnisdatensatz aus der GraphQL-Antwort extrahiert und ihn später rendert.

{booksObj.Books.map(book => (
  <SearchResult
    key={book._id}
    book={book}
    selected={selectedBooksMap[book._id]}
    selectBook={selectBook}
    dispatch={props.dispatch}
  />
))}

Wenn neue Ergebnisse von unserem GraphQL-Endpunkt zurückkommen, wird sich dieses Objekt ändern. Warum also nicht diese Tatsache nutzen und es dem useTransition Hook von zuvor übergeben und einige Übergangsanimationen definieren?

const resultsTransition = useTransition(booksObj, {
  config: { ...config.default },
  from: {
    opacity: 0,
    position: "static",
    transform: "translate3d(0%, 0px, 0px)"
  },
  enter: {
    opacity: 1,
    position: "static",
    transform: "translate3d(0%, 0px, 0px)"
  },
  leave: {
    opacity: 0,
    position: "absolute",
    transform: "translate3d(90%, 0px, 0px)"
  }
});

Beachten Sie die Änderung von position: static zu position: absolute. Ein ausgehender Ergebnisdatensatz mit absoluter Positionierung hat keinen Einfluss auf die Höhe seines Elternteils, was wir wollen. Unser Elternteil wird sich an die *neuen* Inhalte anpassen, und natürlich wird unser Modal schön an die neue Größe animiert, basierend auf unserer Arbeit von oben.

Wie zuvor verwenden wir unsere Übergangsfunktion, um unseren Inhalt zu rendern

<div className="overlay-holder">
  {resultsTransition((styles, booksObj) =>
    booksObj?.Books?.length ? (
      <animated.div style={styles}>
        {booksObj.Books.map(book => (
          <SearchResult
            key={book._id}
            book={book}
            selected={selectedBooksMap[book._id]}
            selectBook={selectBook}
            dispatch={props.dispatch}
          />
        ))}
      </animated.div>
    ) : null
  )}

Jetzt werden neue Ergebnisdatensätze eingeblendet, während ausgehende Ergebnisdatensätze ausgeblendet werden (und leicht gleiten), um dem Benutzer einen zusätzlichen Hinweis zu geben, dass sich etwas geändert hat.

Natürlich möchten wir auch alle Meldungen animieren, z. B. wenn keine Ergebnisse vorhanden sind oder wenn der Benutzer alles im Ergebnisdatensatz ausgewählt hat. Der Code dafür ist hier ziemlich repetitiv mit allem anderen, und da dieser Beitrag bereits lang wird, lasse ich den Code in der Demo.

Ausgewählte Bücher animieren (Aus)

Im Moment verschwindet ein ausgewähltes Buch sofort und abrupt aus der Liste. Lassen Sie uns unser übliches Ausblenden beim Ausblenden nach rechts anwenden. Und während das Element nach rechts gleitet (über Transform), möchten wir wahrscheinlich, dass seine Höhe auf null animiert wird, damit sich die Liste reibungslos an das austretende Element anpasst, anstatt eine leere Box zurückzulassen, die dann sofort verschwindet.

Bis jetzt denken Sie wahrscheinlich, dass das einfach ist. Sie erwarten etwas wie das hier

const SearchResult = props => {
  let { book, selectBook, selected } = props;


  const initiallySelected = useRef(selected);
  const [sizingRef, currentHeight] = useHeight();


  const heightStyles = useSpring({
    config: { ...config.stiff, clamp: true },
    from: {
      opacity: initiallySelected.current ? 0 : 1,
      height: initiallySelected.current ? 0 : currentHeight,
      transform: "translate3d(0%, 0px, 0px)"
    },
    to: {
      opacity: selected ? 0 : 1,
      height: selected ? 0 : currentHeight,
      transform: `translate3d(${selected ? "25%" : "0%"},0px,0px)`
    }
  }); 

Dies verwendet unseren vertrauenswürdigen useHeight Hook, um unseren Inhalt zu messen, und verwendet den selected-Wert, um das austretende Element zu animieren. Wir verfolgen die selected-Eigenschaft und animieren zu oder beginnen mit einer Höhe von 0, wenn sie bereits ausgewählt ist, anstatt das Element einfach zu entfernen und einen Übergang zu verwenden. Dies ermöglicht es verschiedenen Ergebnisdatensätzen mit demselben Buch, es korrekt abzulehnen, wenn es ausgewählt ist.

Dieser Code funktioniert. Probieren Sie es in dieser Demo aus.

Aber es gibt einen Haken. Wenn Sie die meisten Bücher eines Ergebnisdatensatzes auswählen, gibt es eine Art hüpfende Animationskette, während Sie weiter auswählen. Das Buch beginnt, aus der Liste heraus zu animieren, und dann beginnt die Höhe des Modals selbst hinterherzuhinken.

Das sieht meiner Meinung nach albern aus, also sehen wir, was wir dagegen tun können. 

Wir haben bereits gesehen, wie wir die Eigenschaft immediate verwenden können, um alle Federanimationen auszuschalten. Wir haben auch den Callback onRest gesehen, der ausgelöst wird, wenn eine Animation abgeschlossen ist, und ich bin sicher, Sie werden nicht überrascht sein zu erfahren, dass es einen Callback onStart gibt, der das tut, was Sie erwarten würden. Verwenden wir diese Teile, um dem Inhalt unseres Modals zu ermöglichen, die Höhenanimation des Modals "auszuschalten", wenn der Inhalt selbst Höhen animiert.

Zuerst fügen wir unserem Modal einige Zustände hinzu, die die Animation ein- und ausschalten.

const animatModalSizing = useRef(true);
const modalSizingPacket = useMemo(() => {
  return {
    disable() {
      animatModalSizing.current = false;
    },
    enable() {
      animatModalSizing.current = true;
    }
  };
}, []);

Binden wir es nun in unseren Übergang von zuvor ein.

const heightStyles = useSpring({
  immediate: !uiReady.current || !animatModalSizing.current,
  config: { ...config.stiff },
  from: { height: 0 },
  to: { height: contentHeight },
  onRest: () => (uiReady.current = true)
});

Großartig. Nun, wie bekommen wir dieses modalSizingPacket in unseren Inhalt, damit das, was wir rendern, die Animation des Modals tatsächlich ausschalten kann, wenn nötig? Kontext natürlich! Erstellen wir ein Stück Kontext.

export const ModalSizingContext = createContext(null);

Dann wickeln wir unseren gesamten Modal-Inhalt damit ein

<ModalSizingContext.Provider value={modalSizingPacket}>

Jetzt kann unsere SearchResult-Komponente sie abgreifen

const { enable: enableModalSizing, disable: disableModalSizing } = useContext(
  ModalSizingContext
);

...und es direkt in ihre Feder einbinden

const heightStyles = useSpring({
  config: { ...config.stiff, clamp: true },
  from: {
    opacity: initiallySelected.current ? 0 : 1,
    height: initiallySelected.current ? 0 : currentHeight,
    transform: "translate3d(0%, 0px, 0px)"
  },
  to: {
    opacity: selected ? 0 : 1,
    height: selected ? 0 : currentHeight,
    transform: `translate3d(${selected ? "25%" : "0%"},0px,0px)`
  },
  onStart() {
    if (uiReady.current) {
      disableModalSizing();
    }
  },
  onRest() {
    uiReady.current = true;
    setTimeout(() => {
      enableModalSizing();
    });
  }
});

Beachten Sie das setTimeout ganz am Ende. Ich habe es für notwendig befunden, um sicherzustellen, dass die Animation des Modals wirklich ausgeschaltet ist, bis alles erledigt ist.

Ich weiß, das war viel Code. Wenn ich zu schnell vorgegangen bin, schauen Sie sich unbedingt die Demo an, um all das in Aktion zu sehen.

Ausgewählte Bücher animieren (Ein)

Schließen wir diesen Blogbeitrag ab, indem wir die ausgewählten Bücher animieren, die auf dem Hauptbildschirm unter dem Modal erscheinen. Lassen Sie neu ausgewählte Bücher beim Auswählen ein- und von links herein gleiten und dann nach rechts ausgleiten, während ihre Höhe auf null schrumpft, wenn sie entfernt werden.

Wir verwenden einen Übergang, aber es scheint bereits ein Problem zu geben, da wir die individuelle Höhe jedes ausgewählten Buches berücksichtigen müssen. Zuvor, als wir useTransition verwendeten, hatten wir ein einzelnes from- und to-Objekt, das auf eintretende und austretende Elemente angewendet wurde.

Hier verwenden wir eine alternative Form, die es uns erlaubt, eine Funktion für das to-Objekt bereitzustellen. Sie wird mit dem tatsächlich animierten Element – in diesem Fall einem Buchobjekt – aufgerufen, und wir geben das to-Objekt zurück, das die animierenden Werte enthält. Darüber hinaus behalten wir ein einfaches Nachschlageobjekt im Auge, das die Höhe jedes Buches einer ID zuordnet, und binden das dann in unseren Übergang ein.

Erstellen wir zunächst unsere Höhenwerte-Map

const [displaySizes, setDisplaySizes] = useState({});
const setDisplaySize = useCallback(
  (_id, height) => {
    setDisplaySizes(displaySizes => ({ ...displaySizes, [_id]: height }));
  },
  [setDisplaySizes]
);

Wir übergeben die setDisplaySizes-Update-Funktion an die SelectedBook-Komponente und verwenden sie mit useHeight, um die tatsächliche Höhe jedes Buches zurückzumelden.

const SelectedBook = props => {
  let { book, removeBook, styles, setDisplaySize } = props;
  const [ref, height] = useHeight();
  useLayoutEffect(() => {
    height && setDisplaySize(book._id, height);
  }, [height]);

Beachten Sie, wie wir prüfen, ob der Höhenwert mit einem tatsächlichen Wert aktualisiert wurde, bevor wir ihn aufrufen. Das verhindert, dass der Wert vor dem Einstellen der korrekten Höhe vorzeitig auf null gesetzt wird, was dazu führen würde, dass unser Inhalt nach unten animiert, anstatt vollständig ausgebildet hineingegleitet zu werden. Stattdessen wird anfangs keine Höhe eingestellt, sodass unser Inhalt standardmäßig auf height: auto gesetzt wird. Wenn unser Hook ausgelöst wird, wird die tatsächliche Höhe eingestellt. Wenn ein Element entfernt wird, animiert sich die Höhe auf null, während es ausblendet und gleitet.

Hier ist der Übergangs-Hook

const selectedBookTransitions = useTransition(selectedBooks, {
  config: book => ({
    ...config.stiff,
    clamp: !selectedBooksMap[book._id]
  }),
  from: { opacity: 0, transform: "translate3d(-25%, 0px, 0px)" },
  enter: book => ({
    opacity: 1,
    height: displaySizes[book._id],
    transform: "translate3d(0%, 0px, 0px)"
  }),
  update: book => ({ height: displaySizes[book._id] }),
  leave: { opacity: 0, height: 0, transform: "translate3d(25%, 0px, 0px)" }
});

Beachten Sie den update-Callback. Er passt unseren Inhalt an, wenn sich Höhen ändern. (Sie können dies in der Demo erzwingen, indem Sie den Ergebnisbereich vergrößern, nachdem Sie viele Bücher ausgewählt haben.)

Als kleines i-Tüpfelchen auf unserem Kuchen beachten Sie, wie wir die Eigenschaft clamp unseres Hook-Konfigs bedingt festlegen. Während der Inhalt eingeblendet wird, ist clamp ausgeschaltet, was einen schönen (zumindest meiner Meinung nach) Sprungeffekt erzeugt. Aber beim Ausblenden animiert es nach unten, bleibt aber verschwunden, ohne das Ruckeln, das wir zuvor bei ausgeschaltetem Clamping gesehen haben.

Bonus: Vereinfachung der Höhenanimation des Modals bei gleichzeitiger Behebung eines Fehlers

Nachdem ich diesen Beitrag fertiggestellt hatte, fand ich einen Fehler in der Modal-Implementierung, bei dem, wenn sich die Höhe des Modals ändert, während es **nicht** angezeigt wird, beim nächsten Öffnen des Modals die alte, nun falsche Höhe angezeigt wird, gefolgt von der Animation des Modals zur korrekten Höhe. Um zu sehen, was ich meine, werfen Sie einen Blick auf dieses Update der Demo. Sie werden neue Schaltflächen bemerken, um Ergebnisse im Modal zu löschen oder zu erzwingen, wenn es nicht sichtbar ist. Öffnen Sie das Modal, schließen Sie es, klicken Sie auf die Schaltfläche, um Ergebnisse hinzuzufügen, und öffnen Sie es erneut – Sie sollten sehen, wie es unbeholfen zur neuen, korrekten Höhe animiert.

Die Behebung dieses Problems ermöglicht es uns auch, den Code für die Höhenanimation von zuvor zu vereinfachen. Das Problem ist, dass unser Modal weiterhin im React-Komponentenbaum gerendert wird, auch wenn es nicht angezeigt wird. Der Hook für die Höhe läuft immer noch, wird aber erst beim nächsten Anzeigen des Modals aktualisiert, wodurch die Kinder gerendert werden. Was wäre, wenn wir die Kinder des Modals in eine eigene, dedizierte Komponente verschieben und den Höhenhook mitnehmen würden? Auf diese Weise werden der Hook und die Animationsfeder nur gerendert, wenn das Modal angezeigt wird, und können mit korrekten Werten beginnen. Es ist weniger kompliziert, als es scheint. Derzeit hat unsere Modal-Komponente dies:

<animated.div style={{ overflow: "hidden", ...heightStyles }}>
  <div style={{ padding: "10px" }} ref={activateRef}>
    <StandardModalHeader
      caption={headerCaption}
      onHide={onHide}
    />
    {children}
  </div>
</animated.div>

Erstellen wir eine neue Komponente, die dieses Markup rendert, einschließlich der benötigten Hooks und Refs

const ModalContents = ({ header, contents, onHide, animatModalSizing }) => {
  const [sizingRef, contentHeight] = useHeight();
  const uiReady = useRef(false);

  const heightStyles = useSpring({
    immediate: !uiReady.current || !animatModalSizing.current,
    config: { ...config.stiff },
    from: { height: 0 },
    to: { height: contentHeight },
    onRest: () => (uiReady.current = true)
  });

  return (
    <animated.div style={{ overflow: "hidden", ...heightStyles }}>
      <div style={{ padding: "10px" }} ref={sizingRef}>
        <StandardModalHeader caption={header} onHide={onHide} />
        {contents}
      </div>
    </animated.div>
  );
};

Dies ist eine deutliche Reduzierung der Komplexität im Vergleich zu dem, was wir zuvor hatten. Wir haben nicht mehr die Funktion activateRef und nicht mehr den Zustand heightOn, der in activateRef gesetzt wurde. Diese Komponente wird vom Modal nur dann gerendert, wenn es angezeigt wird, was bedeutet, dass wir garantiert Inhalt haben, sodass wir unserer Div einfach eine normale Ref hinzufügen können. Leider benötigen wir unseren uiReady-Status immer noch, da wir zu Beginn noch nicht unsere Höhe beim ersten Rendern haben; diese ist erst verfügbar, nachdem der useHeight-Layout-Effekt unmittelbar nach Abschluss des ersten Renderns ausgeführt wird.

Und natürlich löst dies das Problem von zuvor. Egal, was passiert, wenn das Modal geschlossen wird, wenn es wieder geöffnet wird, wird diese Komponente neu gerendert und unsere Feder startet mit einem neuen Wert für uiReady.

Abschließende Gedanken

Wenn Sie bis hierher mit mir durchgehalten haben, vielen Dank! Ich weiß, dass dieser Beitrag lang war, aber ich hoffe, Sie fanden ihn wertvoll.

react-spring ist ein unglaubliches Werkzeug, um robuste Animationen mit React zu erstellen. Es kann manchmal sehr niedrigschwellig sein, was es für nicht-triviale Anwendungsfälle schwierig zu verstehen macht. Aber gerade diese niedrige Schwelle macht es so flexibel.