5. Gestures & animaties
5. Gestures & animaties
In dit hoofdstuk bespreken we hoe we gebaren en animaties kunnen toevoegen aan een React Native applicatie. We doen dit opnieuw op basis van de Gallery en ToDo applicaties.
Startbestanden
Gallery: Foto's vergroten
We beginnen met de Gallery applicatie. Momenteel zijn de foto's klein en kunnen we niet inzoomen of de foto's vergroten.
Als de gebruiker dubbel-tapped op een foto, moet deze groter worden en de volledige breedte van het scherm innemen. Om gebaar (dubbel-tap) te detecteren gebruiken we react-native-gesture-handler. De library biedt ondersteuning voor verschillende gebaren die bijzonder veel customization opties hebben waarmee we de gebaren kunnen aanpassen aan zowat elke omstandigheid. De tweede library is react-native-reanimated. Deze library bevat enkele nuttige functies en hooks die handig zijn om de gebaren correct te implementeren. Verder kan deze bibliotheek natuurlijk ook gebruikt worden om animaties te bouwen, dit doen we niet in dit voorbeeld, maar wel in het volgende voorbeeld. Tenslotte moeten we ook react-native-worklets installeren, deze library is een dependency van react-native-reanimated die in een apart package geplaatst is voor maintainability, maar wel altijd samen met reanimated geïnstalleerd moet worden.
React Native Reanimated heeft een babel plug-in nodig om correct te functioneren, de plugin converteert speciale JavaScript functies (worklets) zodat deze op het UI thread kunnen uitgevoerd worden in de plaats van op het JavaScript thread waar we tot nu toe alle functies uitvoerden. Deze plug-in moet als laatste toegevoegd worden aan de lijst van plugins.
We passen best ook de Metro configuratie aan, de wrapWithReanimatedMetroConfig wrapper zorgt voor betere, duidelijkere foutmeldingen voor de Reanimated API.
module.exports = function (api) {
api.cache(true)
return {
presets: [['babel-preset-expo'], 'nativewind/babel'],
plugins: [
[
'module-resolver',
{
root: ['./'],
alias: {
'@': './',
'tailwind.config': './tailwind.config.js',
},
},
],
'react-native-worklets/plugin',
],
}
}const {getDefaultConfig} = require('expo/metro-config')
const {withNativeWind} = require('nativewind/metro')
const {wrapWithReanimatedMetroConfig} = require('react-native-reanimated/metro-config')
const config = getDefaultConfig(__dirname)
const configWithNativeWind = withNativeWind(config, {input: './global.css'})
module.exports = wrapWithReanimatedMetroConfig(configWithNativeWind)Als laatste configuratie stap moeten we een de GestureHandlerRootView component rond de volledige applicatie plaatsen.
import {GestureHandlerRootView} from 'react-native-gesture-handler'
const RootLayout: FunctionComponent = () => {
return (
<GestureHandlerRootView>
<ThemeProvider>
...
</ThemeProvider>
</GestureHandlerRootView>
)
}Tap gesture
De eerste stap is het detecteren van een dubbel-tap, hiervoor moeten we een GestureDetector rond alke afbeelding plaatsen. Deze component heeft één property, gesture die een gebaar uit React Native Gesture Handler verwacht. We gebruiken het tap gebaar, de andere beschikbare gebaren zijn te vinden in de documentatie.
Om de code overzichtelijker te maken is de afbeelding in de startbestanden afgezonderd in een nieuwe component ImageWithGestures.
type ImageWithGestureProps = ComponentProps<typeof Image>
const ImageWithGestures: FunctionComponent<ImageWithGestureProps> = ({...imageProps}) => {
const [isZoomed, setIsZoomed] = useState<boolean>(false)
const doubleTap = Gesture.Tap()
.numberOfTaps(2)
.onEnd(() => setIsZoomed(x => !x))
return (
<GestureDetector gesture={doubleTap}>
{isZoomed ? <ZoomedImageView {...imageProps} /> : <Image {...imageProps} />}
</GestureDetector>
)
}const Index: FunctionComponent = () => {
// Code weggelaten in dit fragment.
return (
<>
{/* ... */}
<ScrollView>
<View className="m-4 gap-2 flex justify-between flex-row flex-wrap">
{photos?.map((photo, index) => (
<ImageWithGestures
style={ {width, height: calculateImageHeight(width, photo)}}
source={ {...photo, uri: photo.path}}
key={index}
resizeMode="contain"
/>
))}
<FlexSpaceBetweenLastRowFix itemsPerRow={3} totalItems={photos?.length ?? 0} style={{width}} />
</View>
</ScrollView>
</>
)
}Alhoewel onderstaande code op het eerste zicht correct lijkt, werkt deze niet zoals verwacht. Zodra we twee keer op een afbeelding klikken, wordt de onEnd methode uitgevoerd en crasht de applicatie. Onderstaande video demonstreert dit.
scheduleOnRN
Het probleem met bovenstaande aanpak is dat de onEnd methode wordt uitgevoerd op het UI-thread. Dit is, zoals in de inleiding besproken, een thread dat op het native-niveau draait, dit betekent dat dit thread niet zomaar kan communiceren met het JavaScript-thread dat in de Hermes JavaScript-engine draait.
State wordt niet gekopieerd naar het UI-thread, dus moeten we de callback van de onEnd functie uitvoeren op het React Native thread. Hiervoor gebruiken we de scheduleOnRN functie als wrapper.
const ImageWithGestures: FunctionComponent<ImageWithGestureProps> = ({...imageProps}) => {
const [isZoomed, setIsZoomed] = useState<boolean>(false)
const doubleTap = Gesture.Tap()
.numberOfTaps(2)
.onEnd(() => scheduleOnRN(() => setIsZoomed(x => !x)))
return (
<GestureDetector gesture={doubleTap}>
{isZoomed ? <ZoomedImageView {...imageProps} /> : <Image {...imageProps} />}
</GestureDetector>
)
}Ook bovenstaande code werkt niet en zorgt ervoor dat de applicatie crasht.
De reden voor deze foutmelding is de interne implementatie van React Native Reanimated. Alhoewel de scheduleOnRN functie een callback-functie als argument neemt, mag dit geen inline functie zijn. Een arrow functie is wel toegelaten, zolang deze in een aparte variabele gedefinieerd is.
const ImageWithGestures: FunctionComponent<ImageComponentProps> = ({path}) => {
const [isZoomed, setIsZoomed] = useState<boolean>(false)
const toggleIsZoomed = () => setIsZoomed(x => !x)
const doubleTap = Gesture.Tap()
.numberOfTaps(2)
.onEnd(() => scheduleOnRN(toggleIsZoomed))
return (
<GestureDetector gesture={doubleTap}>
{isZoomed ? <ZoomedImageView {...imageProps} /> : <Image {...imageProps} />}
</GestureDetector>
)
}Na deze aanpassing wordt de afbeelding wel correct vergroot, maar is het onmogelijk om de afbeelding terug te verkleinen.
Modals & gestures
De reden dat de afbeelding niet terug verkleind kan worden, is de manier waarom de ZoomedImageView geïmplementeerd is. De vergrote afbeelding wordt getoond in een Modal. Omdat deze component boven al de andere componenten gerenderd wordt, bevat deze de GestureHandlerRootView en GestureDetector componenten niet.
Om het probleem op te lossen, moeten we de GestureHandlerRootView en GestureDetector componenten terug toevoegen in de modal.
interface ZoomedImageViewProps extends ComponentProps<typeof Image> {
gesture: TapGesture
}
const ZoomedImageView: FunctionComponent<ZoomedImageViewProps> = ({gesture, ...imageProps}) => {
const dimensions = useWindowDimensions()
const theme = useTheme()
return (
<Modal>
<GestureHandlerRootView style={[{backgroundColor: theme.colors.card}]}>
<GestureDetector gesture={gesture}>
<Image {...imageProps} style={[imageProps.style, {width: dimensions.width, height: dimensions.height}]} />
</GestureDetector>
</GestureHandlerRootView>
</Modal>
)
}
const ImageWithGestures: FunctionComponent<ImageComponentProps> = ({path}) => {
const [isZoomed, setIsZoomed] = useState<boolean>(false)
const toggleIsZoomed = () => setIsZoomed(x => !x)
const doubleTap = Gesture.Tap()
.numberOfTaps(2)
.onEnd(() => scheduleOnRN(toggleIsZoomed))
return (
<GestureDetector gesture={doubleTap}>
{isZoomed ? <ZoomedImageView {...imageProps} gesture={doubleTap}/> : <Image {...imageProps} />}
</GestureDetector>
)
}Na deze laatste aanpassing werkt de applicatie zoals verwacht.
ToDo: List item met swipe acties
Onderstaand voorbeeld maakt opnieuw gebruik van Reanimated en React Native Gesture Handler, de installatie-instructies worden hier niet herhaald.
Als laatste functionaliteit voegen we een swipe-gebaar toe aan de ToDoItem component. Als we naar links swipen, moeten enkele knoppen zichtbaar worden waarmee de taak verwijderd kan worden en waarmee de status van de taak aangepast kan worden.
Dit keer gebruiken we het pan gebaar. In tegenstelling tot het tap gebaar is het pan gebaar continu, dit betekent dat de translatie (verschuiving) op de x-as en y-as continu geüpdatet worden. Via dit gebaar kunnen we een item verslepen over het scherm via een vloeiende beweging.
Net zoals bij het tap gebaar gebruiken we hier de scheduleOnRN functie om de variabelen in het JavaScript thread aan te passen. Om de parameter (de translatie) door te geven aan scheduleOnRN, gebruiken we een extra argument. Als er een tweede paramter zou zijn, dan wordt deze toegevoegd als derde argument voor scheduleOnRN, enzovoort. Omdat we de enkel naar links willen swipen voegen we een controle toe die controleert of de translatie naar links gebeurd.
Aangezien we de swipe actie kunnen gebruiken op verschillende schermen, maken we een nieuwe component SwipeableListItem aan die als wrapper gebruikt kan worden rond items in een lijst.
const SwipeableListItem: FunctionComponent<SwipeableListItemProps> = ({children, actionButtons}) => {
const [xOffset, setXOffset] = useState<number>(0)
const setXTranslation = (translation: number) => {
setXOffset(translation)
}
const gesture = Gesture.Pan().onUpdate(e => {
if (e.translationX < 0) {
scheduleOnRN(setXTranslation, e.translationX)
}
})
return (
<GestureDetector gesture={gesture}>
<View style={[{flexDirection: 'row', transform: [{translateX: xOffset}]}]}>
{children}
<HStack>
{actionButtons.map((action, index) => (
<Button onPress={() => action.onPress()} key={index} className="ms-4 h-full border-0" variant="outline">
<ButtonIcon as={action.icon} size={25} />
</Button>
))}
</HStack>
</View>
</GestureDetector>
)
}const ToDoItem: FunctionComponent<ITask> = ({name, id, completed, labels}) => {
const {mutate: toggleTaskStatus} = useToggleTaskStatus()
const {mutate: deleteTask} = useDeleteTask()
const router = useRouter()
const actions = [
{
icon: completed ? CircleCheckBig : Circle,
onPress: () => toggleTaskStatus({id}),
},
{
icon: Trash,
onPress: () => deleteTask({id}),
},
{
icon: ChevronRight,
onPress: () => router.push(`/tasks/${id}`),
},
]
return (
<SwipeableListItem actionButtons={actions}>
<View className="w-full my-2">
{/* ... Code die al in de startbestanden stond */}
</View>
</SwipeableListItem>
)
}Bovenstaande code produceert het volgende resultaat.
Het is duidelijk dat dit nog niet ideaal is, het is nog niet mogelijk om de taak terug te resetten naar de beginpositie. Er is echter ook een minder voor de hand liggend probleem. Momenteel wordt de scheduleOnRN functie gebruikt en alhoewel dit werkt, is dit niet de beste keuze.
useSharedValues & useAnimatedStyle
Reanimated voorziet de useSharedValue hook die een variable aanmaakt die automatisch gesynchroniseerd wordt tussen het UI-thread en het JavaScript-thread. Deze hook is performanter dan de scheduleOnRN functie omdat deze geen re-renders triggert in de component. Daarbovenop maakt de hook het mogelijk om de ingebouwde animaties van Reanimated te gebruiken.
Omdat de hook geen re-renders triggert, moeten we de stijlregels van het object verhuizen naar de useAnimatedStyle hook, anders worden wijzigingen niet doorgevoerd. Deze hook neemt een functie als argument die een object met stijlregels teruggeeft.
Zodra we de useAnimatedStyle hook gebruiken, moeten we ook een component gebruiken die animaties ondersteund. De Reanimated library voorziet een geanimeerde versie van FlatList, Image, View, ScrollView en Text componenten.
import Animated, {useAnimatedStyle, useSharedValue} from 'react-native-reanimated'
const SwipeableListItem: FunctionComponent<SwipeableListItemProps> = ({children}) => {
const xOffset = useSharedValue<number>(0)
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [{translateX: xOffset.value}],
}
})
const gesture = Gesture.Pan().onUpdate(e => {
if (e.translationX < 0) {
xOffset.value = e.translationX
}
})
return (
<GestureDetector gesture={gesture}>
<Animated.View style={[{flexDirection: 'row'}, animatedStyle]}>
{children}
<HStack>
{actionButtons.map((action, index) => (
<Button onPress={() => action.onPress()} key={index} className="ms-4 h-full border-0" variant="outline">
<ButtonIcon as={action.icon} size={25} />
</Button>
))}
</HStack>
</Animated.View>
</GestureDetector>
)
}Na deze aanpassing werkt de swipe functionaliteit opnieuw zoals in bovenstaande video. De knoppen kunnen dus nog steeds niet teruggezet worden op de startpositie.
Animaties
Via de animatie functies van Reanimated kunnen we de positie van de actieknoppen aanpassen met een aangename animatie. Er zijn verschillende animaties beschikbaar (te bekijken in de documentatie). In onderstaande code gebruiken we de withTiming animatie om de xOffset waarde geleidelijk aan terug naar 0 te brengen als de gebruiker minder dan één derde van de breedte van het scherm naar links swipet of om de xOffset waarde aan te passen zodat de actieknoppen juist in zicht komen als de gebruiker meer dan een derde van de breedte van het scherm geswiped heeft.
Om de breedte van de actieknoppen te berekenen gebruiken we het onLayout event, via dit event kunnen we de breedte en hoogte van een component uitlezen nadat deze gerenderd is. Aangezien deze breedte gebruikt moet worden op het UI en JavaScript thread, bewaren we de breedte opnieuw in een shared value.
const SwipeableListItem: FunctionComponent<SwipeableListItemProps> = ({children, actionButtons}) => {
const xOffset = useSharedValue<number>(0)
const dimensions = useWindowDimensions()
const actionButtonsWidth = useSharedValue<number>(0)
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [{translateX: xOffset.value}],
}
})
const gesture = Gesture.Pan()
.onUpdate(e => {
if (e.translationX < 0) {
xOffset.value = e.translationX
}
})
.onEnd(e => {
if (-1 * e.translationX > dimensions.width / 3) {
xOffset.value = withTiming(-actionButtonsWidth.value)
} else {
// Werkt ook om terug dicht te swipen.
xOffset.value = withTiming(0)
}
})
return (
<GestureDetector gesture={gesture}>
<Animated.View style={[animatedStyle, {flexDirection: 'row'}]}>
{children}
<HStack onLayout={evt => actionButtonsWidth.set(evt.nativeEvent.layout.width)}>
{actionButtons.map((action, index) => (
<Button onPress={() => action.onPress()} key={index} className="ms-4 h-full border-0" variant="outline">
<ButtonIcon as={action.icon} size={25} />
</Button>
))}
</HStack>
</Animated.View>
</GestureDetector>
)
}Bovenstaande code produceert het volgende resultaat.
Animatie hervatten
Alhoewel de ToDo items nu in correct open blijven staan, is de animatie niet vlot als we opnieuw beginnen swipen. Het list item verspringt even naar rechts.
Nadat de animatie gedaan is en het item vast staat op de open positie, wordt elke volgende transitie terug gestart met een waarde relatief ten opzichte vn de positie waarin het item zich bevindt. Via een nieuwe sharedValue kunnen we de eindpositie van de vorige translatie bijhouden, vervolgens gebruiken we deze waarde tijdens het verwerken van een verplaatsing.
const SwipeableListItem: FunctionComponent<SwipeableListItemProps> = ({children, actionButtons}) => {
const xOffset = useSharedValue<number>(0)
const dimensions = useWindowDimensions()
const actionButtonsWidth = useSharedValue<number>(0)
const startX = useSharedValue<number>(0)
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [{translateX: xOffset.value}],
}
})
const gesture = Gesture.Pan()
.onBegin(() => {
startX.value = xOffset.value
})
.onUpdate(e => {
if (startX.value + e.translationX < 0) {
xOffset.value = startX.value + e.translationX
}
})
.onEnd(e => {
if (-1 * e.translationX > dimensions.width / 3) {
xOffset.value = withTiming(-actionButtonsWidth.value)
} else {
// Werkt ook om terug dicht te swipen.
xOffset.value = withTiming(0)
}
})
return (
<GestureDetector gesture={gesture}>
<Animated.View style={[animatedStyle, {flexDirection: 'row'}]}>
{children}
<HStack onLayout={evt => actionButtonsWidth.set(evt.nativeEvent.layout.width)}>
{actionButtons.map((action, index) => (
<Button onPress={() => action.onPress()} key={index} className="ms-4 h-full border-0" variant="outline">
<ButtonIcon as={action.icon} size={25} />
</Button>
))}
</HStack>
</Animated.View>
</GestureDetector>
)
}Verticaal scrollen
Er zijn nog twee problemen met bovenstaande code, het menu wordt niet gesloten als we op een actieknop klikken en het is niet mogelijk om verticaal te scrollen door de lijst. Het eerste probleem is eenvoudig op te lossen door de xOffset variabele aan te passen in de onPress methode van de actieknoppen.
Om het tweede probleem op te lossen kunnen we de activeOffsetX methode van het gebaar gebruiken. Deze methode neemt een array van twee getallen als argument, de twee getallen stellen de minimale horizontale beweging voor die nodig is om het gebaar te activeren.
const SwipeableListItem: FunctionComponent<SwipeableListItemProps> = ({children, actionButtons}) => {
const xOffset = useSharedValue<number>(0)
const dimensions = useWindowDimensions()
const actionButtonsWidth = useSharedValue<number>(0)
const startX = useSharedValue<number>(0)
const animatedStyle = useAnimatedStyle(() => {
// ...
})
const gesture = Gesture.Pan()
.onBegin(() => {
// ...
})
.onUpdate(e => {
// ...
})
.onEnd(e => {
// ...
})
.activeOffsetX([-10, 10])
return (
<GestureDetector gesture={gesture}>
<Animated.View style={[animatedStyle, {flexDirection: 'row'}]}>
{children}
<HStack onLayout={evt => actionButtonsWidth.set(evt.nativeEvent.layout.width)}>
{actionButtons.map((action, index) => (
<Button
onPress={() => {
action.onPress()
xOffset.value = withTiming(0)
}}
key={index}
className="ms-4 h-full border-0"
variant="outline">
<ButtonIcon as={action.icon} size={25} />
</Button>
))}
</HStack>
</Animated.View>
</GestureDetector>
)
}