Komplexe Interaktionen ganz ohne Bridge-Overhead bauen
Gesten allein liefern nur Rohdaten, echte Interaktionen entstehen erst, wenn diese Daten in Echtzeit in Animationen fließen. Dieser Artikel zeigt, wie react-native-gesture-handler und Reanimated über Shared Values so zusammenarbeiten, dass Pan- und Pinch-Gesten direkt und synchron auf dem UI-Thread in flüssige Animationen umgesetzt werden, am Beispiel einer Swipeable Card mit Rückfeder-Effekt.
Inhaltsverzeichnis
- 1. Warum Gesten und Animationen zusammen auf dem UI-Thread gehören
- 2. Die Gesture-API von react-native-gesture-handler im Überblick
- 3. Shared Values als Brücke zwischen Geste und Animation
- 4. Praxisbeispiel: eine Karte per Pan-Geste ziehen
- 5. Der Rückfeder-Effekt mit withSpring und Schwellenwerten
- 6. Pinch-Gesten für Zoom-Interaktionen kombinieren
- 7. Häufige Fallstricke bei der Kombination
- 8. Ein wiederverwendbarer Hook: useSwipeableCard()
- 9. Der Unterschied zur klassischen Animated-API mit useNativeDriver
- 10. Zusammenfassung
- 11. FAQ
1. Warum Gesten und Animationen zusammen auf dem UI-Thread gehören
Eine Geste ohne direkte Rückkopplung fühlt sich für Nutzer sofort falsch an. Wenn zwischen dem Finger auf dem Bildschirm und der visuellen Reaktion auch nur wenige Millisekunden Verzögerung liegen, etwa weil der Wert erst über die Bridge zum JS-Thread und wieder zurück muss, wirkt eine Karte beim Ziehen zäh statt direkt. Gesture Handler und Reanimated lösen dieses Problem gemeinsam, weil beide Bibliotheken auf demselben JSI-Fundament aufbauen.
Ohne diese Kombination müsste jede Fingerbewegung als Event über die Bridge zum JS-Thread geschickt, dort verarbeitet und als neuer Style-Wert zurückgeschickt werden, was bei 60 oder mehr Events pro Sekunde spürbar hinterherhinkt. Mit Gesture Handler und Reanimated bleibt die gesamte Kette von der Fingerbewegung bis zum gezeichneten Frame auf dem UI-Thread, ohne einen einzigen Bridge-Roundtrip pro Frame.
2. Die Gesture-API von react-native-gesture-handler im Überblick
Seit Version 2 bietet react-native-gesture-handler eine deklarative Gesture-API, bei der Gesten wie Gesture.Pan(), Gesture.Pinch() oder Gesture.Tap() über Callback-Methoden wie onStart, onUpdate und onEnd konfiguriert werden. Diese Callbacks werden automatisch als Worklets behandelt, sofern sie mit einem GestureDetector verbunden sind, wodurch sie direkt auf dem UI-Thread ausgeführt werden können.
Mehrere Gesten lassen sich über Gesture.Simultaneous(), Gesture.Race() oder Gesture.Exclusive() zu komplexeren Erkennungsregeln kombinieren, etwa um Pan- und Pinch-Gesten gleichzeitig zuzulassen, aber einen Tap nur dann zu erkennen, wenn keine der beiden anderen Gesten aktiv geworden ist.
const pan = Gesture.Pan()
.onStart(() => {
scale.value = withTiming(1.05);
})
.onUpdate((event) => {
translateX.value = event.translationX;
translateY.value = event.translationY;
})
.onEnd(() => {
scale.value = withTiming(1);
});
3. Shared Values als Brücke zwischen Geste und Animation
Der Kern der Kombination ist, dass eine Geste ihre Rohdaten, etwa translationX oder scale, direkt in einen Shared Value schreibt, ohne einen Umweg über React-State. Jede davon abhängige useAnimatedStyle-Berechnung reagiert dann im selben Frame, in dem sich der Shared Value ändert, weil beide Seiten auf demselben UI-Thread laufen.
Diese direkte Kopplung bedeutet auch, dass keine zusätzliche Synchronisationslogik nötig ist. Anders als bei einer klassischen Lösung mit PanResponder und Animated.Value muss hier kein useNativeDriver-Flag gesetzt und keine begrenzte Property-Liste beachtet werden, jede Eigenschaft, die über useAnimatedStyle gesteuert wird, profitiert automatisch von derselben Performance.
4. Praxisbeispiel: eine Karte per Pan-Geste ziehen
Das folgende Beispiel zeigt eine Karte, die per Pan-Geste horizontal verschoben werden kann und sich dabei leicht dreht, proportional zur horizontalen Verschiebung. Die Rotation wird über interpolate() direkt aus demselben Shared Value abgeleitet, der auch für die Verschiebung genutzt wird, sodass keine zweite Geste oder ein zweiter Listener nötig ist.
Wichtig ist, dass context-Werte, also der Zustand zu Beginn der Geste, über useSharedValue statt über normale Variablen gehalten werden, weil Worklets bei jedem Frame neu ausgewertet werden und lokale Variablen zwischen zwei Frames nicht automatisch erhalten bleiben.
const translateX = useSharedValue(0);
const startX = useSharedValue(0);
const pan = Gesture.Pan()
.onStart(() => {
startX.value = translateX.value;
})
.onUpdate((event) => {
translateX.value = startX.value + event.translationX;
})
.onEnd(() => {
translateX.value = withSpring(0);
});
const cardStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ rotateZ: `${interpolate(translateX.value, [-200, 200], [-15, 15])}deg` },
],
}));
5. Der Rückfeder-Effekt mit withSpring und Schwellenwerten
Ein Rückfeder-Effekt entsteht, wenn beim Loslassen der Geste über withSpring statt withTiming animiert wird, weil Spring-Animationen ein natürliches Überschwingen und Ausschwingen mitbringen, das sich physikalisch korrekt anfühlt. Über die Config-Parameter damping, stiffness und mass lässt sich das Verhalten von straff und schnell bis weich und langsam ausschwingend fein justieren.
In der Praxis wird zusätzlich meist ein Schwellenwert geprüft: Überschreitet die Verschiebung beim Loslassen einen bestimmten Wert, etwa ein Drittel der Kartenbreite, wird die Karte komplett aus dem Bildschirm hinaus animiert und ein runOnJS-Aufruf informiert die JS-Seite über das Entfernen der Karte. Bleibt die Verschiebung darunter, federt die Karte per withSpring auf ihre Ausgangsposition zurück.
.onEnd((event) => {
const shouldDismiss = Math.abs(translateX.value) > CARD_WIDTH / 3;
if (shouldDismiss) {
translateX.value = withSpring(
Math.sign(translateX.value) * CARD_WIDTH * 1.5,
{ velocity: event.velocityX },
() => runOnJS(onCardDismissed)(),
);
} else {
translateX.value = withSpring(0, { damping: 15, stiffness: 150 });
}
});
6. Pinch-Gesten für Zoom-Interaktionen kombinieren
Eine Pinch-Geste liefert über event.scale einen relativen Skalierungsfaktor zum Startzeitpunkt der Geste. Wie bei der Pan-Geste wird auch hier der Startwert in einem eigenen Shared Value gesichert, damit mehrere aufeinanderfolgende Pinch-Gesten korrekt aufeinander aufbauen, statt bei jeder neuen Geste wieder bei eins zu beginnen.
Kombiniert man Pinch mit Pan über Gesture.Simultaneous(), lassen sich Bildbetrachter oder Kartenansichten bauen, bei denen gleichzeitig gezoomt und verschoben werden kann, ein Muster, das ohne die enge Kopplung von Gesture Handler und Reanimated kaum ruckelfrei umsetzbar wäre.
const scale = useSharedValue(1);
const startScale = useSharedValue(1);
const pinch = Gesture.Pinch()
.onStart(() => {
startScale.value = scale.value;
})
.onUpdate((event) => {
scale.value = startScale.value * event.scale;
})
.onEnd(() => {
scale.value = withSpring(Math.max(1, Math.min(scale.value, 4)));
});
const combined = Gesture.Simultaneous(pan, pinch);
7. Häufige Fallstricke bei der Kombination
Ein verbreiteter Fehler ist, Zustand für den Gestenstart in einer normalen JavaScript-Variablen statt in einem Shared Value zu halten. Da Gesture-Callbacks als Worklets auf dem UI-Thread laufen, verhält sich eine solche Variable bei jedem Callback wie eine neue, unabhängige Kopie, wodurch der Startwert bei aufeinanderfolgenden Gesten verloren geht.
Ein zweiter Fallstrick ist die Nutzung von runOnJS innerhalb von onUpdate, also bei jedem einzelnen Frame einer laufenden Geste, statt nur in onStart oder onEnd. Das flutet den JS-Thread mit Nachrichten und führt paradoxerweise genau zu dem Ruckeln, das die Kombination von Gesture Handler und Reanimated eigentlich vermeiden soll.
8. Ein wiederverwendbarer Hook: useSwipeableCard()
Sobald mehrere Kartentypen dieselbe Swipe-und-Rückfeder-Logik brauchen, lohnt es sich, Geste, Shared Values und Style in einen eigenen Hook auszulagern, der Gesture-Objekt und animierten Style als fertiges Paar zurückgibt. Die aufrufende Komponente muss dann nur noch den Hook einbinden und den zurückgegebenen Style an eine Animated.View hängen, ohne die Gesten-Details selbst zu kennen.
Diese Kapselung erleichtert auch das Testen: Reine Berechnungslogik, etwa die Umrechnung von Verschiebung in Rotationswinkel oder die Schwellenwert-Prüfung fürs Verwerfen, lässt sich als eigene, von Worklets unabhängige Funktion extrahieren und mit gewöhnlichen Jest-Tests abdecken, während der Hook selbst nur noch die Verdrahtung mit Gesture Handler und Reanimated übernimmt.
function useSwipeableCard(cardWidth: number, onDismissed: () => void) {
const translateX = useSharedValue(0);
const startX = useSharedValue(0);
const gesture = Gesture.Pan()
.onStart(() => {
startX.value = translateX.value;
})
.onUpdate((event) => {
translateX.value = startX.value + event.translationX;
})
.onEnd((event) => {
const dismiss = Math.abs(translateX.value) > cardWidth / 3;
translateX.value = dismiss
? withSpring(Math.sign(translateX.value) * cardWidth * 1.5, {
velocity: event.velocityX,
}, () => runOnJS(onDismissed)())
: withSpring(0, { damping: 15, stiffness: 150 });
});
const style = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ rotateZ: `${interpolate(translateX.value, [-cardWidth, cardWidth], [-15, 15])}deg` },
],
}));
return { gesture, style };
}
9. Der Unterschied zur klassischen Animated-API mit useNativeDriver
Vor Reanimated und der neuen Gesture-API war die übliche Lösung eine Kombination aus PanResponder und Animated.Value mit useNativeDriver: true. Das funktionierte für einfache Transform- und Opacity-Animationen brauchbar, war aber auf genau diese Property-Liste beschränkt und ließ sich nicht mit beliebiger JavaScript-Logik innerhalb der Animation kombinieren.
Gesture Handler und Reanimated heben diese Einschränkung vollständig auf, weil Worklets beliebige Berechnungen direkt auf dem UI-Thread ausführen können, nicht nur eine feste Liste unterstützter Properties. Das macht komplexe, bedingte Interaktionen wie die Swipeable Card mit Rückfeder-Effekt überhaupt erst praktikabel umsetzbar.
| Interaktion | Geste | Animations-API | Wichtiger Parameter |
|---|---|---|---|
| Karte horizontal ziehen | Gesture.Pan() |
useAnimatedStyle |
translationX |
| Rückfeder beim Loslassen | onEnd |
withSpring |
damping, stiffness |
| Karte verwerfen | onEnd mit Schwellenwert |
withSpring + runOnJS |
velocity |
| Bild zoomen | Gesture.Pinch() |
useAnimatedStyle |
event.scale |
| Zoom und Verschieben gleichzeitig | Gesture.Simultaneous() |
useAnimatedStyle |
getrennte Shared Values pro Geste |
| Rotation proportional zur Verschiebung | Gesture.Pan() |
interpolate() |
Eingabe- und Ausgabebereich |
Mironsoft
React-Native-App-Entwicklung und Magento-Anbindung
Eine mobile App zum Magento-Shop, die wirklich rund läuft?
Wir entwickeln React-Native-Apps, die sauber an die Magento REST- oder GraphQL-API angebunden sind, von der ersten Codezeile bis zur Veröffentlichung im App Store und bei Google Play.
App-Konzeption
Architektur und Feature-Umfang einer Magento-angebundenen App gemeinsam planen.
Magento-API-Integration
Produktkatalog, Warenkorb und Checkout sauber an die Shop-API anbinden.
Store-Veröffentlichung
App Store- und Google-Play-Freigabeprozess ohne Stolperfallen begleiten.
10. Zusammenfassung
Gesture Handler und Reanimated
Kernidee
Gesten schreiben ihre Rohdaten direkt in Shared Values, wodurch Animationen ohne Bridge-Roundtrip im selben Frame reagieren.
Rückfeder-Effekt
withSpring mit passenden damping- und stiffness-Werten erzeugt ein natürliches Ausschwingen beim Loslassen.
Größte Falle
Gestenstart-Werte in normalen Variablen statt Shared Values gehen zwischen aufeinanderfolgenden Gesten verloren.
Kombinierbarkeit
Gesture.Simultaneous() erlaubt Pan und Pinch gleichzeitig, jede Geste mit eigenem Shared-Value-Paar.