Kapitel 4. Interaktionsdesign

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

In diesem Kapitel schauen wir uns einige Rezepte an, die eine Reihe von typischen Schnittstellenproblemen lösen. Wie gehst du mit Fehlern um? Wie hilfst du den Leuten, dein System zu benutzen? Wie kannst du komplexe Eingabesequenzen erstellen, ohne einen Haufen Spaghetti-Code zu schreiben?

Dies ist eine Sammlung von Tipps, die wir immer wieder als nützlich empfunden haben. Am Ende des Kapitels sehen wir uns verschiedene Möglichkeiten an, wie du Animationen in deine Anwendung einbauen kannst. Wo immer es möglich ist, wählen wir einen Low-Tech-Ansatz. Im Idealfall verleihen die Rezepte deinen Benutzeroberflächen mit einem Minimum an Aufwand mehr Bedeutung.

4.1 Erstellen einer zentralen Fehlerbehandlung

Problem

Es ist schwer, genau zu definieren, was gute Software ausmacht. Aber eines haben die meisten guten Programme gemeinsam: Wie sie auf Fehler und Ausnahmen reagieren. Es wird immer wieder unerwartete Ausnahmesituationen geben, wenn Menschen deinen Code ausführen: Das Netzwerk kann ausfallen, der Server kann abstürzen, die Speicherung kann beschädigt werden. Es ist wichtig, dass du dir überlegst, wie du mit diesen Situationen umgehst, wenn sie auftreten.

Ein Ansatz, der mit ziemlicher Sicherheit fehlschlägt, ist die Tatsache zu ignorieren, dass ein Fehler auftritt, und die blutigen Details des Fehlers zu verschweigen. Irgendwo, irgendwie musst du eine Spur von Beweisen hinterlassen, die du verwenden kannst, um zu verhindern, dass der Fehler erneut auftritt.

Wenn wir Servercode schreiben, können wir die Fehlerdetails protokollieren und eine entsprechende Meldung an eine Anfrage zurückgeben. Wenn wir aber Client-Code schreiben, brauchen wir einen Plan, wie wir mit lokalen Fehlern umgehen wollen. Vielleicht zeigen wir dem Benutzer die Details des Absturzes an und bitten ihn, einen Fehlerbericht einzureichen. Wir könnten einen Drittanbieterdienst wie Sentry.io nutzen, um die Details aus der Ferne zu protokollieren.

Was auch immer unser Code tut, er sollte konsistent sein. Aber wie können wir Ausnahmen in einer React-Anwendung konsistent behandeln?

Lösung

In diesem Rezept sehen wir uns eine Möglichkeit an, einen zentralen Error-Handler zu erstellen. Um es klar zu sagen: Dieser Code fängt nicht automatisch alle Ausnahmen ab. Er muss immer noch explizit zu den JavaScript-Blöcken catch hinzugefügt werden. Er ist auch kein Ersatz für die Behandlung von Fehlern, von denen wir uns auf andere Weise erholen können. Wenn eine Bestellung fehlschlägt, weil der Server wegen Wartungsarbeiten nicht erreichbar ist, ist es viel besser, den Nutzer zu bitten, es später noch einmal zu versuchen.

Aber diese Technik hilft, Fehler aufzuspüren, die wir vorher nicht eingeplant haben.

Generell gilt: Wenn etwas schief läuft, gibt es drei Dinge, die du dem/der Nutzer/in mitteilen solltest:

  • Was geschah

  • Warum es geschah

  • Was sie dagegen tun sollten

In dem Beispiel, das wir hier zeigen, werden wir Fehler behandeln, indem wir ein Dialogfeld anzeigen, das die Details eines JavaScript ErrorObjekts anzeigt und den Benutzer auffordert, den Inhalt per E-Mail an den Systemsupport zu senden. Wir wollen eine einfache Fehlerbehandlungsfunktion, die wir aufrufen können, wenn ein Fehler auftritt:

setVisibleError('Cannot do that thing', errorObject)

Wenn wir die Funktion in der gesamten Anwendung verfügbar machen wollen, ist der übliche Weg die Verwendung eines Kontexts. Ein Kontext ist eine Art Bereich, den wir um eine Reihe von React-Komponenten wickeln können. Alles, was wir in diesen Kontext einfügen, ist für alle untergeordneten Komponenten verfügbar. Wir werden unseren Kontext verwenden, um die Error-Handler-Funktion zu speichern, die wir im Falle eines Fehlers ausführen können.

Wir nennen unseren Kontext ErrorHandlerContext:

import React from 'react'

const ErrorHandlerContext = React.createContext(() => {})

export default ErrorHandlerContext

Damit wir den Kontext für eine Reihe von Komponenten verfügbar machen können, erstellen wir eineErrorHandlerProvider Komponente, die eine Instanz des Kontexts erstellt und ihn für alle untergeordneten Komponenten verfügbar macht, die wir ihr übergeben:

import ErrorHandlerContext from './ErrorHandlerContext'

let setError = () => {}

const ErrorHandlerProvider = (props) => {
  if (props.callback) {
    setError = props.callback
  }

  return (
    <ErrorHandlerContext.Provider value={setError}>
      {props.children}
    </ErrorHandlerContext.Provider>
  )
}

export default ErrorHandlerProvider

Jetzt brauchen wir einen Code, der uns sagt, was wir tun sollen, wenn wir die Error-Handler-Funktion aufrufen. In unserem Fall brauchen wir einen Code, der auf eine Fehlermeldung reagiert, indem er ein Dialogfeld mit allen Fehlerdetails anzeigt. Wenn du Fehler anders behandeln willst, musst du diesen Code ändern:

import { useCallback, useState } from 'react'
import ErrorHandlerProvider from './ErrorHandlerProvider'
import ErrorDialog from './ErrorDialog'

const ErrorContainer = (props) => {
  const [error, setError] = useState()
  const [errorTitle, setErrorTitle] = useState()
  const [action, setAction] = useState()

  if (error) {
    console.error(
      'An error has been thrown',
      errorTitle,
      JSON.stringify(error)
    )
  }

  const callback = useCallback((title, err, action) => {
    console.error('ERROR RAISED ')
    console.error('Error title: ', title)
    console.error('Error content', JSON.stringify(err))
    setError(err)
    setErrorTitle(title)
    setAction(action)
  }, [])
  return (
    <ErrorHandlerProvider callback={callback}>
      {props.children}

      {error && (
        <ErrorDialog
          title={errorTitle}
          onClose={() => {
            setError(null)
            setErrorTitle('')
          }}
          action={action}
          error={error}
        />
      )}
    </ErrorHandlerProvider>
  )
}

export default ErrorContainer

ErrorContainer zeigt die Details mit Hilfe von ErrorDialog an. Wir werden hier nicht auf die Details des Codes für ErrorDialogeingehen, da dies der Code ist, den du höchstwahrscheinlich durch deine Implementierung ersetzen wirst.1

Wir müssen den größten Teil unserer Anwendung in eine ErrorContainer verpacken. Alle Komponenten innerhalb der ErrorContainer können den Error Handler aufrufen:

import './App.css'
import ErrorContainer from './ErrorContainer'
import ClockIn from './ClockIn'

function App() {
  return (
    <div className="App">
      <ErrorContainer>
        <ClockIn />
      </ErrorContainer>
    </div>
  )
}

export default App

Wie verwendet eine Komponente den Error-Handler? Wir erstellen einen benutzerdefinierten Hook namens useErrorHandler(), der die Error-Handler-Funktion aus dem Kontext holt und sie zurückgibt:

import ErrorHandlerContext from './ErrorHandlerContext'
import { useContext } from 'react'

const useErrorHandler = () => useContext(ErrorHandlerContext)

export default useErrorHandler

Das ist ein ziemlich komplexer Code, aber jetzt kommen wir zur Verwendung des Error-Handlers; er ist sehr einfach. Dieser Beispielcode stellt eine Netzwerkanfrage, wenn ein Nutzer auf eine Schaltfläche klickt. Wenn die Netzwerkanfrage fehlschlägt, werden die Details des Fehlers an den Error-Handler weitergegeben:

import useErrorHandler from './useErrorHandler'
import axios from 'axios'

const ClockIn = () => {
  const setVisibleError = useErrorHandler()

  const doClockIn = async () => {
    try {
      await axios.put('/clockTime')
    } catch (err) {
      setVisibleError('Unable to record work start time', err)
    }
  }

  return (
    <>
      <h1>Click Button to Record Start Time</h1>
      <button onClick={doClockIn}>Start work</button>
    </>
  )
}

export default ClockIn

In Abbildung 4-1 kannst du sehen, wie die App aussieht.

Abbildung 4-1. Die App für die Zeiterfassung

Wenn du auf die Schaltfläche klickst, schlägt die Netzwerkanfrage fehl, weil der Servercode nicht existiert. Abbildung 4-2 zeigt den Fehlerdialog, der erscheint. Er zeigt an, was schief gelaufen ist, warum es schief gelaufen ist und was der Benutzer tun sollte.

Abbildung 4-2. Wenn die Netzwerkanfrage eine Ausnahme auslöst, übergeben wir sie an denError-Handler

Diskussion

Von allen Rezepten, die wir im Laufe der Jahre entwickelt haben, hat dieses am meisten Zeit gespart. Während der Entwicklung kommt es häufig zu Codefehlern, und wenn der einzige Hinweis auf einen Fehler ein in der JavaScript-Konsole versteckter Stack-Trace ist, verpasst du ihn wahrscheinlich.

Wenn ein Teil der Infrastruktur (Netzwerke, Gateways, Server, Datenbanken) fehlschlägt, kann dir dieser kleine Code unzählige Stunden ersparen, um die Ursache zu finden.

Du kannst den Quellcode für dieses Rezept von der GitHub-Seite herunterladen.

4.2 Eine interaktive Hilfe erstellen

Problem

Tim Berners-Lee hat das Web absichtlich so gestaltet, dass es nur wenige Funktionen hat. Es verfügt über ein einfaches Protokoll (HTTP) und hatte ursprünglich eine einfache Auszeichnungssprache (HTML). Der Mangel an Komplexität bedeutete, dass neue Nutzer/innen von Websites sofort wussten, wie sie zu benutzen waren. Wenn du etwas sahst, das wie ein Hyperlink aussah, konntest du darauf klicken und zu einer anderen Seite gehen.

Aber Rich-JavaScript-Anwendungen haben das alles verändert. Webanwendungen sind nicht mehr nur eine Ansammlung von Webseiten mit Hyperlinks, sondern sie ähneln den alten Desktop-Anwendungen; sie sind leistungsfähiger und funktionsreicher, aber die Kehrseite ist, dass sie jetzt viel komplexer zu bedienen sind.

Wie kannst du einen interaktiven Leitfaden in deine Anwendung einbauen?

Lösung

Wir werden ein einfaches Hilfesystem erstellen, das du über eine bestehende Anwendung legen kannst. Wenn der Nutzer die Hilfe öffnet, sieht er eine Reihe von Pop-up-Hinweisen, die beschreiben, wie er die verschiedenen Funktionen auf der Seite nutzen kann (siehe Abbildung 4-3).

Abbildung 4-3. Eine Folge von Hilfemeldungen anzeigen, wenn der Benutzer fragt

Wir wollen etwas, das einfach zu pflegen ist und nur für sichtbare Komponenten Hilfe bietet. Das hört sich nach einer ziemlich großen Aufgabe an, also fangen wir damit an, eine Komponente zu konstruieren, die eine Pop-up-Hilfe anzeigt:

import { Popper } from '@material-ui/core'
import './HelpBubble.css'

const HelpBubble = (props) => {
  const element = props.forElement
    ? document.querySelector(props.forElement)
    : null

  return element ? (
    <Popper
      className="HelpBubble-container"
      open={props.open}
      anchorEl={element}
      placement={props.placement || 'bottom-start'}
    >
      <div className="HelpBubble-close" onClick={props.onClose}>
        Close [X]
      </div>
      {props.content}
      <div className="HelpBubble-controls">
        {props.previousLabel ? (
          <div
            className="HelpBubble-control HelpBubble-previous"
            onClick={props.onPrevious}
          >
            &lt; {props.previousLabel}
          </div>
        ) : (
          <div>&nbsp;</div>
        )}
        {props.nextLabel ? (
          <div
            className="HelpBubble-control HelpBubble-next"
            onClick={props.onNext}
          >
            {props.nextLabel} &gt;
          </div>
        ) : (
          <div>&nbsp;</div>
        )}
      </div>
    </Popper>
  ) : null
}

export default HelpBubble

Wir verwenden die Komponente Popper aus der Bibliothek @material-ui. Die Komponente Popper kann auf der Seite verankert werden, neben einer anderen Komponente. Unser Help​Bub⁠ble nimmt einen forElement String entgegen, der einen CSS-Selektor darstellt, wie z. B..class-name oder #some-id. Wir werden Selektoren verwenden, um Dinge auf dem Bildschirm mit Pop-up-Meldungen zu verknüpfen.

Da wir nun eine Komponente für Pop-up-Nachrichten haben, brauchen wir etwas, das eine Folge von HelpBubbles koordiniert. Wir nennen dasHelpSequence:

import { useEffect, useState } from 'react'

import HelpBubble from './HelpBubble'

function isVisible(e) {
  return !!(
    e.offsetWidth ||
    e.offsetHeight ||
    e.getClientRects().length
  )
}

const HelpSequence = (props) => {
  const [position, setPosition] = useState(0)
  const [sequence, setSequence] = useState()

  useEffect(() => {
    if (props.sequence) {
      const filter = props.sequence.filter((i) => {
        if (!i.forElement) {
          return false
        }
        const element = document.querySelector(i.forElement)
        if (!element) {
          return false
        }
        return isVisible(element)
      })
      setSequence(filter)
    } else {
      setSequence(null)
    }
  }, [props.sequence, props.open])

  const data = sequence && sequence[position]

  useEffect(() => {
    setPosition(0)
  }, [props.open])

  const onNext = () =>
    setPosition((p) => {
      if (p === sequence.length - 1) {
        props.onClose && props.onClose()
      }
      return p + 1
    })

  const onPrevious = () =>
    setPosition((p) => {
      if (p === 0) {
        props.onClose && props.onClose()
      }
      return p - 1
    })

  return (
    <div className="HelpSequence-container">
      {data && (
        <HelpBubble
          open={props.open}
          forElement={data.forElement}
          placement={data.placement}
          onClose={props.onClose}
          previousLabel={position > 0 && 'Previous'}
          nextLabel={
            position < sequence.length - 1 ? 'Next' : 'Finish'
          }
          onPrevious={onPrevious}
          onNext={onNext}
          content={data.text}
        />
      )}
    </div>
  )
}

export default HelpSequence

Die HelpSequence nimmt ein Array von JavaScript-Objekten wie dieses:

[
    {forElement: "p",
        text: "This is some introductory text telling you how to start"},
    {forElement: ".App-link", text: "This will show you how to use React"},
    {forElement: ".App-nowhere", text: "This help text will never appear"},
]

und wandelt sie in eine dynamische Sequenz von HelpBubbles um. HelpBubble wird nur angezeigt, wenn ein Element gefunden wird, das dem Selektor forElement entspricht. Dann platziert es das HelpBubble neben dem Element und zeigt den Hilfetext an.

Fügen wir eine HelpSequence zum Standard App.js Code hinzu, der von create-react-app generiert wurde:

import { useState } from 'react'
import logo from './logo.svg'
import HelpSequence from './HelpSequence'
import './App.css'

function App() {
  const [showHelp, setShowHelp] = useState(false)

  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
      <button onClick={() => setShowHelp(true)}>Show help</button>
      <HelpSequence
        sequence={[
          {
            forElement: 'p',
            text: 'This is some introductory text telling you how to start',
          },
          {
            forElement: '.App-link',
            text: 'This will show you how to use React',
          },
          {
            forElement: '.App-nowhere',
            text: 'This help text will never appear',
          },
        ]}
        open={showHelp}
        onClose={() => setShowHelp(false)}
      />
    </div>
  )
}

export default App

Zunächst einmal sehen wir nichts anderes als eine Hilfe-Schaltfläche (siehe Abbildung 4-4).

Abbildung 4-4. Die Anwendung, wenn sie zum ersten Mal geladen wird

Wenn der Benutzer auf die Schaltfläche "Hilfe" klickt, wird das erste Hilfethema angezeigt, wie in Abbildung 4-5 dargestellt.

Abbildung 4-5. Wenn der Benutzer auf die Hilfe-Schaltfläche klickt, erscheint die Hilfe-Blase für den ersten Treffer

Abbildung 4-6 zeigt, wie sich die Hilfe zum nächsten Element bewegt, wenn der Nutzer auf Weiter klickt. Der Nutzer kann sich so lange von Element zu Element bewegen, bis keine passenden Elemente mehr sichtbar sind.

Abbildung 4-6. Das letzte Element hat eine Schaltfläche Fertigstellen

Diskussion

Wenn du deiner Anwendung eine interaktive Hilfe hinzufügst, wird deine Benutzeroberfläche auffindbar. Entwicklerinnen und Entwickler verbringen einen Großteil ihrer Zeit damit, Funktionen zu Anwendungen hinzuzufügen, die die Menschen vielleicht nie nutzen, weil sie nicht wissen, dass sie da sind.

Die Implementierung in diesem Rezept zeigt die Hilfe als einfachen Text an. Du könntest in Erwägung ziehen, Markdown zu verwenden, da dies eine umfangreichere Darstellung ermöglicht und die Hilfethemen dann Links zu anderen, umfangreicheren Hilfeseiten enthalten können.2

Die Hilfethemen werden automatisch auf die Elemente beschränkt, die auf der Seite sichtbar sind. Du kannst entweder für jede Seite eine eigene Hilfesequenz erstellen oder eine einzige große Hilfesequenz, die sich automatisch an die aktuelle Ansicht der Benutzeroberfläche anpasst.

Schließlich eignet sich ein solches Hilfesystem ideal für die Speicherung in einem Headless CMS, das es dir ermöglicht, die Hilfe dynamisch zu aktualisieren, ohne jedes Mal eine neue Bereitstellung erstellen zu müssen.

Du kannst den Quellcode für dieses Rezept von der GitHub-Seite herunterladen.

4.3 Reducers für komplexe Interaktionen verwenden

Problem

Bei Anwendungen müssen die Benutzer häufig eine Reihe von Aktionen ausführen. Vielleicht müssen sie die Schritte eines Assistenten ausführen oder sich anmelden und einen gefährlichen Vorgang bestätigen (siehe Abbildung 4-7).

Abbildung 4-7. Dieser Löschvorgang erfordert eine Anmeldung und eine anschließende Bestätigung der Löschung

Der/die Nutzer/in muss nicht nur eine Reihe von Schritten ausführen, sondern diese Schritte können auch an Bedingungen geknüpft sein. Wenn der Nutzer sich kürzlich angemeldet hat, muss er sich vielleicht nicht erneut anmelden. Es kann sein, dass er auf halbem Weg abbrechen möchte.

Wenn du die komplexen Abläufe innerhalb deiner Komponenten modellierst, kannst du bald feststellen, dass deine Anwendung voller Spaghetti-Code ist.

Lösung

Wir werden einen Reducer verwenden, um eine komplexe Abfolge von Operationen zu verwalten. In Kapitel 3 haben wir Reducer für die Verwaltung von Zuständen eingeführt. EinReducer ist eine Funktion, die ein Zustandsobjekt und eine Aktion annimmt. Der Reducer verwendet die Aktion, um zu entscheiden, wie der Zustand geändert werden soll, und darf keine Nebeneffekte haben.

Da Reduzierer keinen Code für die Benutzeroberfläche haben, sind sie perfekt geeignet, um komplizierte, zusammenhängende Zustände zu verwalten, ohne sich um das Aussehen zu kümmern. Sie eignen sich besonders gut für Unit-Tests.

Nehmen wir zum Beispiel an, wir implementieren die zu Beginn dieses Rezepts erwähnte Löschsequenz. Wir können im klassischen testgetriebenen Stil beginnen, indem wir einen Unit-Test schreiben:

import deletionReducer from './deletionReducer'

describe('deletionReducer', () => {
  it('should show the login dialog if we are not logged in', () => {
    const actual = deletionReducer({}, { type: 'START_DELETION' })
    expect(actual.showLogin).toBe(true)
    expect(actual.message).toBe('')
    expect(actual.deleteButtonDisabled).toBe(true)
    expect(actual.loginError).toBe('')
    expect(actual.showConfirmation).toBe(false)
  })
})

Hier heißt unsere Reduzierfunktion deletionReducer. Wir übergeben ihr ein leeres Objekt ({}) und eine Aktion, die besagt, dass wir den Löschvorgang starten wollen ({type: 'START_DELETION'}). Dann sagen wir, dass wir erwarten, dass die neue Version des Zustands einenshowLogin Wert von true, einen showConfirmation Wert von false und so weiter hat.

Wir können dann den Code für einen Reducer implementieren, der genau das tut:

function deletionReducer(state, action) {
  switch (action.type) {
    case 'START_DELETION':
      return {
        ...state,
        showLogin: true,
        message: '',
        deleteButtonDisabled: true,
        loginError: '',
        showConfirmation: false,
      }
    default:
      return null // Or anything
  }
}

Zunächst setzen wir die Statusattribute nur auf Werte, die den Test bestehen. Wenn wir mehr und mehr Tests hinzufügen, verbessert sich unser Reducer, da er mehr Situationen bewältigen kann.

Am Ende erhalten wir etwas, das so aussieht:3

function deletionReducer(state, action) {
  switch (action.type) {
    case 'START_DELETION':
      return {
        ...state,
        showLogin: !state.loggedIn,
        message: '',
        deleteButtonDisabled: true,
        loginError: '',
        showConfirmation: !!state.loggedIn,
      }
    case 'CANCEL_DELETION':
      return {
        ...state,
        showLogin: false,
        showConfirmation: false,
        showResult: false,
        message: 'Deletion canceled',
        deleteButtonDisabled: false,
      }
    case 'LOGIN':
      const passwordCorrect = action.payload === 'swordfish'
      return {
        ...state,
        showLogin: !passwordCorrect,
        showConfirmation: passwordCorrect,
        loginError: passwordCorrect ? '' : 'Invalid password',
        loggedIn: true,
      }
    case 'CONFIRM_DELETION':
      return {
        ...state,
        showConfirmation: false,
        showResult: true,
        message: 'Widget deleted',
      }
    case 'FINISH':
      return {
        ...state,
        showLogin: false,
        showConfirmation: false,
        showResult: false,
        deleteButtonDisabled: false,
      }
    default:
      throw new Error('Unknown action: ' + action.type)
  }
}

export default deletionReducer

Obwohl dieser Code kompliziert ist, kannst du ihn schnell schreiben, wenn du zuerst die Tests erstellst.

Jetzt, wo wir den Reducer haben, können wir ihn in unserer Anwendung verwenden:

import { useReducer, useState } from 'react'
import './App.css'
import deletionReducer from './deletionReducer'

function App() {
  const [state, dispatch] = useReducer(deletionReducer, {})
  const [password, setPassword] = useState()

  return (
    <div className="App">
      <button
        onClick={() => {
          dispatch({ type: 'START_DELETION' })
        }}
        disabled={state.deleteButtonDisabled}
      >
        Delete Widget!
      </button>
      <div className="App-message">{state.message}</div>
      {state.showLogin && (
        <div className="App-dialog">
          <p>Enter your password</p>
          <input
            type="password"
            value={password}
            onChange={(evt) => setPassword(evt.target.value)}
          />
          <button
            onClick={() =>
              dispatch({ type: 'LOGIN', payload: password })
            }
          >
            Login
          </button>
          <button
            onClick={() => dispatch({ type: 'CANCEL_DELETION' })}
          >
            Cancel
          </button>
          <div className="App-error">{state.loginError}</div>
        </div>
      )}
      {state.showConfirmation && (
        <div className="App-dialog">
          <p>Are you sure you want to delete the widget?</p>
          <button
            onClick={() =>
              dispatch({
                type: 'CONFIRM_DELETION',
              })
            }
          >
            Yes
          </button>
          <button
            onClick={() =>
              dispatch({
                type: 'CANCEL_DELETION',
              })
            }
          >
            No
          </button>
        </div>
      )}
      {state.showResult && (
        <div className="App-dialog">
          <p>The widget was deleted</p>
          <button
            onClick={() =>
              dispatch({
                type: 'FINISH',
              })
            }
          >
            Done
          </button>
        </div>
      )}
    </div>
  )
}

export default App

Der größte Teil dieses Codes besteht darin, die Benutzeroberfläche für die einzelnen Dialoge in der Sequenz zu erstellen. Es gibt praktisch keine Logik in dieser Komponente. Sie tut einfach, was der Reducer ihr sagt. Sie führt den Benutzer durch den glücklichen Weg der Anmeldung und der Bestätigung der Löschung (siehe Abbildung 4-8).

Abbildung 4-8. Das Endergebnis

Aber Abbildung 4-9 zeigt, dass es auch alle Kanten behandelt, wie z. B. ungültige Passwörter und Stornierungen.

Abbildung 4-9. Die Kantenfälle werden alle vom Reducer behandelt

Diskussion

Es gibt Fälle, in denen Reducer deinen Code unübersichtlich machen können. Wenn du nur wenige Zustandsgrößen mit wenigen Interaktionen zwischen ihnen hast, brauchst du wahrscheinlich keinen Reducer. Wenn du aber ein Flussdiagramm oder ein Zustandsdiagramm zeichnest, um eine Abfolge von Benutzerinteraktionen zu beschreiben, ist das ein Zeichen dafür, dass du vielleicht einen Reducer brauchst.

Du kannst den Quellcode für dieses Rezept von der GitHub-Seite herunterladen.

4.4 Tastaturinteraktion hinzufügen

Problem

Power-User benutzen gerne die Tastatur für häufig verwendete Funktionen. React-Komponenten können auf Tastaturereignisse reagieren, aber nur, wenn sie (oder ihre Kinder) den Fokus haben. Was tust du, wenn du möchtest, dass deine Komponente auf Ereignisse auf Dokumentenebene reagiert?

Lösung

Wir werden einen Key-Listener-Hook erstellen, um auf keydown Ereignisse auf der Ebene document zu hören. Er kann aber auch leicht geändert werden, um auf jedes andere JavaScript-Ereignis im DOM zu warten. Das ist der Hook:

import { useEffect } from 'react'

const useKeyListener = (callback) => {
  useEffect(() => {
    const listener = (e) => {
      e = e || window.event
      const tagName = e.target.localName || e.target.tagName
      // Only accept key-events that originated at the body level
      // to avoid key-strokes in e.g. text-fields being included
      if (tagName.toUpperCase() === 'BODY') {
        callback(e)
      }
    }
    document.addEventListener('keydown', listener, true)
    return () => {
      document.removeEventListener('keydown', listener, true)
    }
  }, [callback])
}

export default useKeyListener

Der Hook nimmt eine Callback-Funktion an und registriert sie für keydown Ereignisse auf demdocument Objekt. Am Ende der useEffect gibt er eine Funktion zurück, die die Registrierung des Rückrufs aufhebt. Wenn sich die übergebene Callback-Funktion ändert, heben wir zuerst die Registrierung der alten Funktion auf, bevor wir die neue Funktion registrieren.

Wie verwenden wir den Haken? Hier ist ein Beispiel. Schau mal, ob du den kleinen Programmierfehler bemerkst, mit dem wir umgehen müssen:

import { useCallback, useState } from 'react'
import './App.css'
import useKeyListener from './useKeyListener'

const RIGHT_ARROW = 39
const LEFT_ARROW = 37
const ESCAPE = 27

function App() {
  const [angle, setAngle] = useState(0)
  const [lastKey, setLastKey] = useState('')

  let onKeyDown = useCallback(
    (evt) => {
      if (evt.keyCode === LEFT_ARROW) {
        setAngle((c) => Math.max(-360, c - 10))
        setLastKey('Left')
      } else if (evt.keyCode === RIGHT_ARROW) {
        setAngle((c) => Math.min(360, c + 10))
        setLastKey('Right')
      } else if (evt.keyCode === ESCAPE) {
        setAngle(0)
        setLastKey('Escape')
      }
    },
    [setAngle]
  )
  useKeyListener(onKeyDown)

  return (
    <div className="App">
      <p>
        Angle: {angle} Last key: {lastKey}
      </p>
      <svg
        width="400px"
        height="400px"
        title="arrow"
        fill="none"
        strokeWidth="10"
        stroke="black"
        style={{
          transform: `rotate(${angle}deg)`,
        }}
      >
        <polyline points="100,200 200,0 300,200" />
        <polyline points="200,0 200,400" />
      </svg>
    </div>
  )
}

export default App

Dieser Code wartet darauf, dass der Benutzer die Cursortasten links/rechts drückt. Unsere Funktion onKeyDown sagt, was passieren soll, wenn diese Tasten gedrückt werden, aber beachte, dass wir sie in eine useCallback verpackt haben. Wenn wir das nicht tun würden, würde der Browser die FunktiononKeyDown jedes Mal neu erstellen, wenn er die Komponente App rendert. Die neue Funktion würde dasselbe tun wie die alte onKeyDown Funktion, aber sie würde an einer anderen Stelle im Speicher liegen und useKeyListener würde sie immer wieder abmelden und neu anmelden.

Warnung

Wenn du vergisst, deine Callback-Funktion in ein useCallback zu verpacken, kann das zu einer Flut von Render-Aufrufen führen, die deine Anwendung verlangsamen.

Durch die Verwendung von useCallback können wir sicherstellen, dass wir die Funktion nur erstellen, wenn sich setAngle ändert.

Wenn du die Anwendung startest, siehst du einen Pfeil auf dem Bildschirm. Wenn du die Cursortasten links/rechts drückst(Abbildung 4-10), kannst du das Bild drehen. Wenn du die Escape-Taste drückst, kannst du es wieder in die Vertikale bringen.

Abbildung 4-10. Durch Drücken der Links/Rechts/Escape-Tasten wird der Pfeil gedreht

Diskussion

In der Funktion useKeyListener achten wir darauf, dass wir nur auf Ereignisse hören, die auf der Ebene body ausgelöst wurden. Wenn der Benutzer auf die Pfeiltasten in einem Textfeld klickt, wird der Browser diese Ereignisse nicht an deinen Code senden.

Du kannst den Quellcode für dieses Rezept von der GitHub-Seite herunterladen.

4.5 Markdown für umfangreiche Inhalte verwenden

Problem

Wenn deine Anwendung es den Nutzern erlaubt, große Textblöcke einzugeben, wäre es hilfreich, wenn dieser Inhalt auch formatierten Text, Links und so weiter enthalten könnte. Wenn du es den Nutzern jedoch erlaubst, solche schrecklichen Inhalte wie rohes HTML einzugeben, kann das zu Sicherheitslücken und unsäglichem Ärger für die Entwickler führen.

Wie kannst du Nutzern erlauben, umfangreiche Inhalte zu posten, ohne die Sicherheit deiner Anwendung zu untergraben?

Lösung

Markdown ist eine hervorragende Methode, um Nutzern die Möglichkeit zu geben, umfangreiche Inhalte sicher in deine Anwendung zu posten. Um zu sehen, wie du Markdown in deiner Anwendung nutzen kannst, schauen wir uns diese einfache Anwendung an, in der ein Nutzer eine Reihe von Nachrichten mit Zeitstempel in eine Liste posten kann:

import { useState } from 'react'
import './Forum.css'

const Forum = () => {
  const [text, setText] = useState('')
  const [messages, setMessages] = useState([])

  return (
    <section className="Forum">
      <textarea
        cols={80}
        rows={20}
        value={text}
        onChange={(evt) => setText(evt.target.value)}
      />
      <button
        onClick={() => {
          setMessages((msgs) => [
            {
              body: text,
              timestamp: new Date().toISOString(),
            },
            ...msgs,
          ])
          setText('')
        }}
      >
        Post
      </button>
      {messages.map((msg) => {
        return (
          <dl>
            <dt>{msg.timestamp}</dt>
            <dd>{msg.body}</dd>
          </dl>
        )
      })}
    </section>
  )
}

export default Forum

Wenn du die Anwendung startest(Abbildung 4-11), siehst du einen großen Textbereich. Wenn du eine Nachricht im Klartext schreibst, behält die App den Leerraum und die Zeilenumbrüche bei.

Abbildung 4-11. Ein Benutzer gibt Text in einen Textbereich ein, der als Klartextnachricht gepostet wird

Wenn deine Anwendung einen Textbereich enthält, ist es eine Überlegung wert, dem Benutzer die Eingabe von Markdown-Inhalten zu ermöglichen.

Es gibt viele, viele Markdown-Bibliotheken, aber die meisten von ihnen sind Wrapper für react-markdown oder einen Syntax-Highlighter wie PrismJS oder CodeMirror.

Wir schauen uns eine Bibliothek namens react-md-editor an, die react-markdown zusätzliche Funktionen hinzufügt und es dir ermöglicht, Markdown anzuzeigen und zu bearbeiten. Wir beginnen damit, die Bibliothek zu installieren:

$ npm install @uiw/react-md-editor

Jetzt wandeln wir unseren Nur-Text-Bereich in einen Markdown-Editor um und konvertieren die geposteten Nachrichten von Markdown in HTML:

import { useState } from 'react'
import MDEditor from '@uiw/react-md-editor'

const MarkdownForum = () => {
  const [text, setText] = useState('')
  const [messages, setMessages] = useState([])

  return (
    <section className="Forum">
      <MDEditor height={300} value={text} onChange={setText} />
      <button
        onClick={() => {
          setMessages((msgs) => [
            {
              body: text,
              timestamp: new Date().toISOString(),
            },
            ...msgs,
          ])
          setText('')
        }}
      >
        Post
      </button>
      {messages.map((msg) => {
        return (
          <dl>
            <dt>{msg.timestamp}</dt>
            <dd>
              <MDEditor.Markdown source={msg.body} />
            </dd>
          </dl>
        )
      })}
    </section>
  )
}

export default MarkdownForum

Die Umwandlung von einfachem Text in Markdown ist eine kleine Änderung mit einem großen Nutzen. Wie du in Abbildung 4-12 sehen kannst, kann der Nutzer eine Nachricht reichhaltig formatieren und sie vor dem Posten im Vollbildmodus bearbeiten.

Abbildung 4-12. Der Markdown-Editor zeigt eine Vorschau an, während du tippst, und ermöglicht es dir, im Vollbildmodus zu arbeiten

Diskussion

Das Hinzufügen von Markdown zu einer Anwendung geht schnell und verbessert die Nutzererfahrung mit minimalem Aufwand. Weitere Details zu Markdown findest du im Original-Leitfaden von John Gruber.

Du kannst den Quellcode für dieses Rezept von der GitHub-Seite herunterladen.

4.6 Animieren mit CSS-Klassen

Problem

Du möchtest deiner Anwendung ein paar einfache Animationen hinzufügen, aber du möchtest deine Anwendung nicht vergrößern, indem du eine Drittanbieter-Bibliothek installierst.

Lösung

Für die meisten Animationen, die du in einer React-Anwendung benötigen wirst, brauchst du wahrscheinlich keine Animationsbibliothek eines Drittanbieters. Das liegt daran, dass CSS-Animationen den Browsern jetzt die Möglichkeit geben, CSS-Eigenschaften mit minimalem Aufwand zu animieren. Es wird nur sehr wenig Code benötigt, und die Animation ist flüssig, weil die Grafikhardware sie erzeugt. Die GPU-Animation verbraucht weniger Strom und ist daher besser für mobileGeräte geeignet.

Tipp

Wenn du deine React-Anwendung mit Animationen ausstatten möchtest, solltest du mit CSS-Animationen beginnen, bevor du dich anderweitig umsiehst.

Wie funktioniert die CSS-Animation? Sie verwendet eine CSS-Eigenschaft namens transition. Nehmen wir an, wir wollen ein ausklappbares Informationsfeld erstellen. Wenn der Nutzer auf die Schaltfläche klickt, öffnet sich das Panel sanft. Wenn der Nutzer erneut auf die Schaltfläche klickt, wird sie sanft geschlossen, wie in Abbildung 4-13 gezeigt.

Abbildung 4-13. Eine einfache CSS-Animation vergrößert und verkleinert das Panel fließend

Wir können diesen Effekt mit der CSS-Eigenschaft transition erzeugen:

.InfoPanel-details {
    height: 350px;
    transition: height 0.5s;
}

Dieses CSS legt eine height sowie eine transition Eigenschaft fest. Diese Kombination bedeutet: "Egal, wie hoch du gerade bist, animiere dich in der nächsten halben Sekunde auf meine bevorzugte Höhe."

Die Animation erfolgt immer dann, wenn sich die height des Elements ändert, z. B. wenn eine zusätzliche CSS-Regel gültig wird. Wenn wir zum Beispiel einen zusätzlichen CSS-Klassennamen mit einer anderen Höhe haben, wird die Übergangseigenschaft die Höhenänderung animieren, wenn ein Element zu einer anderen Klasse wechselt:

.InfoPanel-details {
    height: 350px;
    transition: height 0.5s;
}
.InfoPanel-details.InfoPanel-details-closed {
    height: 0;
}
Tipp

Diese Klassennamenstruktur ist ein Beispiel für die Block-Element-Modifikator (BEM)-Benennung. Der Block ist die Komponente (InfoPanel), dasElement ist eine Sache innerhalb des Blocks (details), und der Modifikator sagt etwas über den aktuellen Zustand des Elements aus (closed).Die BEM-Konvention verringert die Wahrscheinlichkeit von Namenskonflikten in deinem Code.

Wenn ein InfoPanel-details Element plötzlich eine zusätzliche .InfoPanel-details-closed Klasse erhält, ändert sich die height von350px zu 0, und die transition Eigenschaft lässt das Element sanft schrumpfen. Verliert die Komponente dagegen die Klasse.InfoPanel-details-closed, wird das Element wieder größer.

Das bedeutet, dass wir die harte Arbeit auf CSS verschieben können und alles, was wir in unserem React-Code tun müssen, ist, die Klasse zu einem Element hinzuzufügen oder zu entfernen:

import { useState } from 'react'

import './InfoPanel.css'

const InfoPanel = ({ title, children }) => {
  const [open, setOpen] = useState(false)

  return (
    <section className="InfoPanel">
      <h1>
        {title}
        <button onClick={() => setOpen((v) => !v)}>
          {open ? '^' : '​v'}
        </button>
      </h1>
      <div
        className={`InfoPanel-details ${
          open ? '' : 'InfoPanel-details-closed'
        }`}
      >
        {children}
      </div>
    </section>
  )
}

export default InfoPanel

Diskussion

Wir haben oft gesehen, dass viele Projekte Komponentenbibliotheken von Drittanbietern einbinden, um ein kleines Widget zu verwenden, das seinen Inhalt vergrößert oder verkleinert. Wie du siehst, ist es ganz einfach, eine solche Animation einzubinden.

Du kannst den Quellcode für dieses Rezept von der GitHub-Seite herunterladen.

4.7 Animieren mit React Animation

Problem

CSS-Animationen sind technisch sehr einfach und eignen sich für die meisten Animationen, die du wahrscheinlich brauchst.

Allerdings musst du dafür eine Menge über die verschiedenen CSS-Eigenschaften und die Auswirkungen ihrer Animation wissen. Wie stellst du dar, dass ein Gegenstand gelöscht wird, indem er sich schnell ausdehnt und transparent wird?

Bibliotheken wie Animate.css enthalten eine ganze Reihe vorgefertigter CSS-Animationen, aber sie erfordern oft fortgeschrittenere CSS-Animationskonzepte wie Keyframes und sind nicht besonders auf React abgestimmt. Wie können wir Animationen aus CSS-Bibliotheken in eine React-Anwendung einbauen?

Lösung

Die React Animations-Bibliothek ist ein React-Wrapper für die Animate.css-Bibliothek. Sie fügt effizient animiertes Styling zu deinen Komponenten hinzu, ohne unnötige Renderings zu erzeugen oder die Größe des generierten DOM deutlich zu erhöhen.

Das funktioniert so effizient, weil React Animations mit einer CSS-in-JS-Bibliothek arbeitet. CSS-in-JS ist eine Technik, mit der du deine Stilinformationen direkt in deinem JavaScript-Code kodieren kannst. Mit React kannst du deine Style-Attribute als React-Komponenten hinzufügen, aber CSS-in-JS macht das effizienter, indem es dynamisch gemeinsame Style-Elemente in der head der Seite erstellt.

Es gibt mehrere CSS-in-JS-Bibliotheken zur Auswahl, aber in diesem Rezept werden wir eine namensRadium verwenden.

Beginnen wir mit der Installation von Radium und React Animations:

$ npm install radium
$ npm install react-animations

In unserer Beispielanwendung(Abbildung 4-14) wird jedes Mal eine Animation ausgeführt, wenn wir ein Bild zur Sammlung hinzufügen.

Abbildung 4-14. Wenn du auf die Schaltfläche Hinzufügen klickst, wird ein neues Bild aus picsum.photos geladen

Wenn ein Nutzer auf ein Bild klickt, zeigt eine Ausblendanimation, bevor die Bilder aus der Liste entfernt werden, wie in Abbildung 4-15 gezeigt.4

Abbildung 4-15. Wenn wir auf das fünfte Bild klicken, wird es in der Liste ausgeblendet und verschwindet

Wir beginnen mit dem Import einiger Animationen und Hilfscodes aus Radium:

import { pulse, zoomOut, shake, merge } from 'react-animations'
import Radium, { StyleRoot } from 'radium'

const styles = {
  created: {
    animation: 'x 0.5s',
    animationName: Radium.keyframes(pulse, 'pulse'),
  },
  deleted: {
    animation: 'x 0.5s',
    animationName: Radium.keyframes(merge(zoomOut, shake), 'zoomOut'),
  },
}

Von React Animations erhalten wir pulse, zoomOut und shake Animationen. Wir werden die Animation pulse verwenden, wenn wir ein Bild hinzufügen. Wir verwenden eine kombinierte Animation aus zoomOut und shake, wenn wir ein Bild entfernen. Wir können Animationen mit der Funktion merge von React Animations kombinieren.

styles generiert alle CSS-Stile, die für die Ausführung jeder dieser Halbsekunden-Animationen benötigt werden. Der Aufruf von Radium.keyframes()kümmert sich um alle Details der Animationen für uns.

Wir müssen wissen, wann eine Animation vollständig beendet ist. Wenn wir ein Bild löschen, bevor die Lösch-Animation abgeschlossen ist, gibt es kein Bild mehr, das wir animieren können.

Wir können CSS-Animationen verfolgen, indem wir jedem Element, das wir animieren wollen, einen onAnimationEnd Callback übergeben. Für jedes Element in unserer Bildersammlung werden wir drei Dinge verfolgen:

  • Die URL des Bildes, das es repräsentiert

  • Ein boolescher Wert, der wahr ist, während die "erstellte" Animation läuft

  • Ein boolescher Wert, der wahr ist, während die "gelöschte" Animation läuft

Hier ist der Beispielcode, um Bilder in und aus der Sammlung zu animieren:

import { useState } from 'react'
import { pulse, zoomOut, shake, merge } from 'react-animations'
import Radium, { StyleRoot } from 'radium'

import './App.css'

const styles = {
  created: {
    animation: 'x 0.5s',
    animationName: Radium.keyframes(pulse, 'pulse'),
  },
  deleted: {
    animation: 'x 0.5s',
    animationName: Radium.keyframes(merge(zoomOut, shake), 'zoomOut'),
  },
}

function getStyleForItem(item) {
  return item.deleting
    ? styles.deleted
    : item.creating
    ? styles.created
    : null
}

function App() {
  const [data, setData] = useState([])

  let deleteItem = (i) =>
    setData((d) => {
      const result = [...d]
      result[i].deleting = true
      return result
    })
  let createItem = () => {
    setData((d) => [
      ...d,
      {
        url: `https://picsum.photos/id/${d.length * 3}/200`,
        creating: true,
      },
    ])
  }
  let completeAnimation = (d, i) => {
    if (d.deleting) {
      setData((d) => {
        const result = [...d]
        result.splice(i, 1)
        return result
      })
    } else if (d.creating) {
      setData((d) => {
        const result = [...d]
        result[i].creating = false
        return result
      })
    }
  }
  return (
    <div className="App">
      <StyleRoot>
        <p>
          Images from&nbsp;
          <a href="https://picsum.photos/">Lorem Picsum</a>
        </p>
        <button onClick={createItem}>Add</button>
        {data.map((d, i) => (
          <div
            style={getStyleForItem(d)}
            onAnimationEnd={() => completeAnimation(d, i)}
          >
            <img
              id={`image${i}`}
              src={d.url}
              width={200}
              height={200}
              alt="Random"
              title="Click to delete"
              onClick={() => deleteItem(i)}
            />
          </div>
        ))}
      </StyleRoot>
    </div>
  )
}

export default App

Diskussion

Wenn wir uns für eine Animation entscheiden, sollten wir uns zuerst fragen: Was soll diese Animation bedeuten?

Jede Animation sollte eine Bedeutung haben. Sie kann etwas Existentielles zeigen (Erstellung oder Löschung). Sie kann eine Zustandsänderung anzeigen (aktiviert oder deaktiviert werden). Sie kann heranzoomen, um ein Detail zu zeigen, oder herauszoomen, um einen größeren Zusammenhang zu zeigen. Oder sie kann eine Grenze darstellen (eine Rücksprunganimation am Ende einer langen Liste) oder es dem Nutzer ermöglichen, eine Präferenz auszudrücken (nach links oder rechts wischen).

Auch Animationen sollten kurz sein. Die meisten Animationen sollten wahrscheinlich in einer halben Sekunde vorbei sein, damit der Nutzer die Bedeutung der Animation erleben kann, ohne dass er sie bewusst wahrnimmt.

Eine Animation sollte niemals nur attraktiv sein.

Du kannst den Quellcode für dieses Rezept von der GitHub-Seite herunterladen.

4.8 Animieren von Infografiken mit TweenOne

Problem

CSS-Animationen sind flüssig und hocheffizient. Browser können CSS-Animationen in der Compositing-Phase an die Grafikhardware delegieren, was bedeutet, dass nicht nur die Animationen mit Maschinencode-Geschwindigkeit laufen, sondern auch der Maschinencode selbst nicht auf der CPU läuft.

Der Nachteil bei der Ausführung von CSS-Animationen auf Grafikhardware ist jedoch, dass dein Anwendungscode nicht weiß, waswährend einer Animation passiert. Du kannst zwar verfolgen, wann eine Animation beginnt, endet oder wiederholt wird (onAnimationStart, on​Ani⁠ma⁠tionEnd,onAnimationIteration), aber alles, was dazwischen passiert, bleibt einGeheimnis.

Wenn du eine Infografik animierst, möchtest du vielleicht die Zahlen in einem Balkendiagramm animieren, während die Balken wachsen oder schrumpfen. Oder wenn du eine Anwendung zur Verfolgung von Radfahrern schreibst, möchtest du vielleicht die aktuelle Höhe anzeigen, während das Fahrrad das Gelände auf und ab fährt.

Aber wie erstellt man Animationen, denen man zuhören kann, während sie passieren?

Lösung

Die TweenOne-Bibliothek erstellt Animationen mit JavaScript, was bedeutet, dass du sie Bild für Bild nachverfolgen kannst, während sie passieren.

Beginnen wir mit der Installation der TweenOne-Bibliothek:

$ npm install rc-tween-one

TweenOne arbeitet mit CSS, aber es verwendet keine CSS-Animationen. Stattdessen erzeugt es CSS-Transformationen, die es viele Male pro Sekunde aktualisiert.

Du musst den Gegenstand, den du animieren willst, in ein <TweenOne/> Element einpacken. Nehmen wir zum Beispiel an, wir wollen ein rect innerhalb eines SVG animieren:

<TweenOne component='g' animation={...details here}>
    <rect width="2" height="6" x="3" y="-3" fill="white"/>
</TweenOne>

TweenOne nimmt einen Elementnamen und ein Objekt, das die auszuführende Animation beschreibt. Wie dieses Animationsobjekt aussieht, werden wir gleich sehen.

TweenOne verwendet den Namen des Elements (in diesem Fallg ), um einen Wrapper um das animierte Element zu erstellen. Dieser Wrapper hat ein Style-Attribut, das eine Reihe von CSS-Transformationen enthält, um den Inhalt irgendwo zu bewegen und zu drehen.

In unserem Beispiel könnte das DOM zu einem bestimmten Zeitpunkt in der Animation so aussehen:

<g style="transform: translate(881.555px, 489.614px) rotate(136.174deg);">
  <rect width="2" height="6" x="3" y="-3" fill="white"/>
</g>

Obwohl du ähnliche Effekte wie mit CSS-Animationen erzeugen kannst, funktioniert die TweenOne-Bibliothek anders. Anstatt die Animation an die Hardware zu übergeben, verwendet die TweenOne-Bibliothek JavaScript, um jeden Frame zu erstellen, was zwei Konsequenzen hat. Erstens verbraucht sie mehr CPU-Leistung (schlecht), und zweitens können wir die Animation verfolgen, während sie abläuft (gut).

Wenn wir TweenOne einen onUpdate Callback übergeben, erhalten wir bei jedem einzelnen Frame Informationen über die Animation:

<TweenOne component='g' animation={...details here} onUpdate={info=>{...}}>
    <rect width="2" height="6" x="3" y="-3" fill="white"/>
</TweenOne>

Das info Objekt, das an onUpdate übergeben wird, hat einen ratio Wert zwischen 0 und 1, der den Anteil des Weges darstellt, den das TweenOne Element durch eine Animation geht. Wir können ratio verwenden, um Text zu animieren, der mit der Grafik verbunden ist.

Wenn wir zum Beispiel ein animiertes Dashboard erstellen, das Fahrzeuge auf einer Rennstrecke zeigt, können wir onUpdate verwenden, um die Geschwindigkeit und den Abstand jedes Autos anzuzeigen, während es sich bewegt.

Wir erstellen die Grafiken für dieses Beispiel in SVG. Zuerst erstellen wir einen String, der einen SVG-Pfad enthält, der die Strecke darstellt:

export default 'm 723.72379,404.71306 ...  -8.30851,-3.00521 z'

Dies ist eine stark verkürzte Version des tatsächlichen Pfads, den wir verwenden werden. Wir können den Pfadstring wie folgt aus track.js importieren:

import path from './track'

Um den Track in einer React-Komponente anzuzeigen, können wir ein svg Element rendern:

<svg height="600" width="1000" viewBox="0 0 1000 600"
     style={{backgroundColor: 'black'}}>
  <path stroke='#444' strokeWidth={10}
        fill='none' d={path}/>
</svg>

Wir können ein paar Rechtecke für das Fahrzeug hinzufügen - ein rotes für die Karosserie und ein weißes für die Windschutzscheibe:

<svg height="600" width="1000" viewBox="0 0 1000 600"
     style={{backgroundColor: 'black'}}>
  <path stroke='#444' strokeWidth={10}
        fill='none' d={path}/>
  <rect width={24} height={16} x={-12} y={-8} fill='red'/>
  <rect width={2} height={6} x={3} y={-3} fill='white'/>
</svg>

Abbildung 4-16 zeigt die Strecke mit dem Fahrzeug in der linken oberen Ecke.

Abbildung 4-16. Das statische Bild mit einem kleinen Fahrzeug oben links

Aber wie animieren wir das Fahrzeug auf der Strecke? Mit TweenOne ist das ganz einfach, denn es enthält ein Plugin, das Animationen erzeugt, die SVG-Pfadstrings folgen.

import PathPlugin from 'rc-tween-one/lib/plugin/PathPlugin'

TweenOne.plugins.push(PathPlugin)

Wir haben TweenOne für die Verwendung mit SVG-Pfad-Animationen konfiguriert. Das bedeutet, dass wir uns anschauen können, wie man eine Animation für TweenOne beschreibt. Wir machen das mit einem einfachen JavaScript-Objekt:

import path from './track'

const followAnimation = {
  path: { x: path, y: path, rotate: path },
  repeat: -1,
}

Mit diesem Objekt teilen wir TweenOne zwei Dinge mit: Erstens weisen wir es an, Translationen und Rotationen zu erzeugen, die dem pathString folgen, den wir aus track.js importiert haben. Zweitens sagen wir, dass die Animation in einer Endlosschleife laufen soll, indem wir denrepeat count auf -1 setzen.

Wir können dies als Grundlage für die Animation unseres Fahrzeugs verwenden:

<svg height="600" width="1000" viewBox="0 0 1000 600"
     style={{backgroundColor: 'black'}}>
  <path stroke='#444' strokeWidth={10}
        fill='none' d={path}/>
  <TweenOne component='g' animation={{...followAnimation, duration: 16000}}>
    <rect width={24} height={16} x={-12} y={-8} fill='red'/>
    <rect width={2} height={6} x={3} y={-3} fill='white'/>
  </TweenOne>
</svg>

Beachte, dass wir den Spread-Operator verwenden, um einen zusätzlichen Animationsparameter anzugeben: duration. Ein Wert von 16000 bedeutet, dass die Animation 16Sekunden dauern soll.

Wir können ein zweites Fahrzeug hinzufügen und die onUpdate Callback-Methode verwenden, um eine sehr rudimentäre Reihe von gefälschten Telemetriestatistiken für jedes Fahrzeug zu erstellen, während es sich auf der Strecke bewegt. Hier ist der fertige Code:

import { useState } from 'react'
import TweenOne from 'rc-tween-one'
import Details from './Details'
import path from './track'
import PathPlugin from 'rc-tween-one/lib/plugin/PathPlugin'
import grid from './grid.svg'

import './App.css'

TweenOne.plugins.push(PathPlugin)

const followAnimation = {
  path: { x: path, y: path, rotate: path },
  repeat: -1,
}

function App() {
  const [redTelemetry, setRedTelemetry] = useState({
    dist: 0,
    speed: 0,
    lap: 0,
  })
  const [blueTelemetry, setBlueTelemetry] = useState({
    dist: 0,
    speed: 0,
    lap: 0,
  })

  const trackVehicle = (info, telemetry) => ({
    dist: info.ratio,
    speed: info.ratio - telemetry.dist,
    lap:
      info.ratio < telemetry.dist ? telemetry.lap + 1 : telemetry.lap,
  })

  return (
    <div className="App">
      <h1>Nürburgring</h1>
      <Details
        redTelemetry={redTelemetry}
        blueTelemetry={blueTelemetry}
      />
      <svg
        height="600"
        width="1000"
        viewBox="0 0 1000 600"
        style={{ backgroundColor: 'black' }}
      >
        <image href={grid} width={1000} height={600} />
        <path stroke="#444" strokeWidth={10} fill="none" d={path} />
        <path
          stroke="#c0c0c0"
          strokeWidth={2}
          strokeDasharray="3 4"
          fill="none"
          d={path}
        />

        <TweenOne
          component="g"
          animation={{
            ...followAnimation,
            duration: 16000,
            onUpdate: (info) =>
              setRedTelemetry((telemetry) =>
                trackVehicle(info, telemetry)
              ),
          }}
        >
          <rect width={24} height={16} x={-12} y={-8} fill="red" />
          <rect width={2} height={6} x={3} y={-3} fill="white" />
        </TweenOne>

        <TweenOne
          component="g"
          animation={{
            ...followAnimation,
            delay: 3000,
            duration: 15500,
            onUpdate: (info) =>
              setBlueTelemetry((telemetry) =>
                trackVehicle(info, telemetry)
              ),
          }}
        >
          <rect width={24} height={16} x={-12} y={-8} fill="blue" />
          <rect width={2} height={6} x={3} y={-3} fill="white" />
        </TweenOne>
      </svg>
    </div>
  )
}

export default App

Abbildung 4-17 zeigt die Animation. Die Fahrzeuge folgen dem Verlauf der Rennstrecke und drehen sich dabei in Fahrtrichtung.

Abbildung 4-17. Unsere endgültige Animation mit Telemetrie, die aus dem aktuellen Animationsstatus generiert wird

Diskussion

Für die meisten UI-Animationen solltest du CSS-Animationen verwenden. Bei Infografiken musst du jedoch oft den Text und die Grafik synchronisieren. TweenOne macht das möglich, allerdings auf Kosten einer höheren CPU-Auslastung.

Du kannst den Quellcode für dieses Rezept von der GitHub-Seite herunterladen.

1 Du kannst den gesamten Quellcode für dieses Rezept auf dem GitHub-Repository herunterladen.

2 In Rezept 4.5 erfährst du, wie du Markdown in deiner Anwendung verwenden kannst.

3 Im GitHub-Repository findest du die Tests, mit denen wir diesen Code getestet haben.

4 Bücher aus Papier sind etwas Wunderschönes, aber um den Animationseffekt richtig zu erleben, solltest du dir den kompletten Code auf GitHub ansehen.

Get React Kochbuch now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.