Make it fast by animating only affine transforms

This commit is contained in:
Dan Abramov
2024-11-08 02:48:59 +00:00
parent 8569910b2d
commit d78d65e290
5 changed files with 264 additions and 166 deletions
@@ -1,22 +1,26 @@
import React, {useState} from 'react'
import {ActivityIndicator, StyleProp, StyleSheet} from 'react-native'
import {ActivityIndicator, StyleSheet} from 'react-native'
import {
Gesture,
GestureDetector,
PanGesture,
} from 'react-native-gesture-handler'
import Animated, {
BaseAnimationBuilder,
runOnJS,
SharedValue,
useAnimatedReaction,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated'
import {Image, ImageStyle} from 'expo-image'
import {Image} from 'expo-image'
import type {Dimensions as ImageDimensions, ImageSource} from '../../@types'
import type {
Dimensions as ImageDimensions,
ImageSource,
Transform,
} from '../../@types'
import {
applyRounding,
createTransform,
@@ -49,9 +53,15 @@ type Props = {
}
imageAspect: number | undefined
imageDimensions: ImageDimensions | undefined
imageStyle: StyleProp<ImageStyle>
dismissSwipePan: PanGesture
layoutAnimationAndroid: BaseAnimationBuilder
transforms: Readonly<
SharedValue<{
scaleAndMoveTransform: Transform
cropFrameTransform: Transform
cropContentTransform: Transform
isResting: boolean
}>
>
}
const ImageItem = ({
imageSrc,
@@ -61,9 +71,8 @@ const ImageItem = ({
measureSafeArea,
imageAspect,
imageDimensions,
imageStyle,
dismissSwipePan,
layoutAnimationAndroid,
transforms,
}: Props) => {
const [isScaled, setIsScaled] = useState(false)
const committedTransform = useSharedValue(initialTransform)
@@ -101,19 +110,6 @@ const ImageItem = ({
onZoom(nextIsScaled)
}
const animatedStyle = useAnimatedStyle(() => {
// Apply the active adjustments on top of the committed transform before the gestures.
// This is matrix multiplication, so operations are applied in the reverse order.
let t = createTransform()
prependPan(t, panTranslation.value)
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
prependTransform(t, committedTransform.value)
const [translateX, translateY, scale] = readTransform(t)
return {
transform: [{translateX}, {translateY: translateY}, {scale}],
}
})
// On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges.
// If the user tried to pan too hard, this function will provide the negative panning to stay in bounds.
function getExtraTranslationToStayInBounds(
@@ -315,26 +311,92 @@ const ImageItem = ({
singleTap,
)
const containerStyle = useAnimatedStyle(() => {
const {scaleAndMoveTransform} = transforms.value
// Apply the active adjustments on top of the committed transform before the gestures.
// This is matrix multiplication, so operations are applied in the reverse order.
let t = createTransform()
prependPan(t, panTranslation.value)
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
prependTransform(t, committedTransform.value)
const [translateX, translateY, scale] = readTransform(t)
const manipulationTransform = [
{translateX},
{translateY: translateY},
{scale},
]
return {
width: '100%',
aspectRatio: imageAspect,
transform: scaleAndMoveTransform.concat(manipulationTransform),
}
})
const imageCropStyle = useAnimatedStyle(() => {
const {cropFrameTransform} = transforms.value
return {
flex: 1,
overflow: 'hidden',
transform: cropFrameTransform,
}
})
const type = imageSrc.type
const borderRadius =
type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0
const imageStyle = useAnimatedStyle(() => {
const {cropContentTransform} = transforms.value
return {
flex: 1,
borderRadius,
transform: cropContentTransform,
}
})
const [showLoader, setShowLoader] = useState(false)
const [hasLoaded, setHasLoaded] = useState(false)
useAnimatedReaction(
() => {
return transforms.value.isResting && !hasLoaded
},
(show, prevShow) => {
if (show && !prevShow) {
runOnJS(setShowLoader)(false)
} else if (!prevShow && show) {
runOnJS(setShowLoader)(true)
}
},
)
return (
<GestureDetector gesture={composedGesture}>
<Animated.View
ref={containerRef}
// Necessary to make opacity work for both children together.
renderToHardwareTextureAndroid
style={[styles.container, animatedStyle]}>
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
<AnimatedImage
contentFit="cover"
source={{uri: imageSrc.uri}}
placeholderContentFit="cover"
placeholder={{uri: imageSrc.thumbUri}}
style={imageStyle}
layout={layoutAnimationAndroid}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
cachePolicy="memory"
/>
style={[styles.container]}
renderToHardwareTextureAndroid>
<Animated.View style={containerStyle}>
{showLoader && (
<ActivityIndicator
size="small"
color="#FFF"
style={styles.loading}
/>
)}
<Animated.View style={imageCropStyle}>
<AnimatedImage
contentFit="cover"
source={{uri: imageSrc.uri}}
placeholderContentFit="cover"
placeholder={{uri: imageSrc.thumbUri}}
accessibilityLabel={imageSrc.alt}
onLoad={() => setHasLoaded(false)}
style={imageStyle}
accessibilityHint=""
accessibilityIgnoresInvertColors
cachePolicy="memory"
/>
</Animated.View>
</Animated.View>
</Animated.View>
</GestureDetector>
)
@@ -352,6 +414,7 @@ const styles = StyleSheet.create({
right: 0,
top: 0,
bottom: 0,
justifyContent: 'center',
},
})
@@ -7,18 +7,28 @@
*/
import React, {useState} from 'react'
import {ActivityIndicator, StyleProp, StyleSheet} from 'react-native'
import {ActivityIndicator, StyleSheet} from 'react-native'
import {
Gesture,
GestureDetector,
PanGesture,
} from 'react-native-gesture-handler'
import Animated, {runOnJS, useAnimatedRef} from 'react-native-reanimated'
import Animated, {
runOnJS,
SharedValue,
useAnimatedReaction,
useAnimatedRef,
useAnimatedStyle,
} from 'react-native-reanimated'
import {useSafeAreaFrame} from 'react-native-safe-area-context'
import {Image, ImageStyle} from 'expo-image'
import {Image} from 'expo-image'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {Dimensions as ImageDimensions, ImageSource} from '../../@types'
import {
Dimensions as ImageDimensions,
ImageSource,
Transform,
} from '../../@types'
const AnimatedImage = Animated.createAnimatedComponent(Image)
@@ -40,8 +50,15 @@ type Props = {
}
imageAspect: number | undefined
imageDimensions: ImageDimensions | undefined
imageStyle: StyleProp<ImageStyle>
dismissSwipePan: PanGesture
transforms: Readonly<
SharedValue<{
scaleAndMoveTransform: Transform
cropFrameTransform: Transform
cropContentTransform: Transform
isResting: boolean
}>
>
}
const ImageItem = ({
@@ -52,8 +69,8 @@ const ImageItem = ({
measureSafeArea,
imageAspect,
imageDimensions,
imageStyle,
dismissSwipePan,
transforms,
}: Props) => {
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
const [scaled, setScaled] = useState(false)
@@ -129,6 +146,55 @@ const ImageItem = ({
singleTap,
)
const containerStyle = useAnimatedStyle(() => {
const {scaleAndMoveTransform} = transforms.value
return {
flex: 1,
transform: scaleAndMoveTransform,
}
})
const imageCropStyle = useAnimatedStyle(() => {
const screenSize = measureSafeArea()
const {cropFrameTransform} = transforms.value
return {
overflow: 'hidden',
transform: cropFrameTransform,
width: screenSize.width,
maxHeight: screenSize.height,
aspectRatio: imageAspect,
alignSelf: 'center',
}
})
const type = imageSrc.type
const borderRadius =
type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0
const imageStyle = useAnimatedStyle(() => {
const {cropContentTransform} = transforms.value
return {
borderRadius,
transform: cropContentTransform,
width: '100%',
aspectRatio: imageAspect,
}
})
const [showLoader, setShowLoader] = useState(false)
const [hasLoaded, setHasLoaded] = useState(false)
useAnimatedReaction(
() => {
return transforms.value.isResting && !hasLoaded
},
(show, prevShow) => {
if (show && !prevShow) {
runOnJS(setShowLoader)(false)
} else if (!prevShow && show) {
runOnJS(setShowLoader)(true)
}
},
)
return (
<GestureDetector gesture={composedGesture}>
<Animated.ScrollView
@@ -139,21 +205,27 @@ const ImageItem = ({
showsVerticalScrollIndicator={false}
maximumZoomScale={maxZoomScale}
onScroll={scrollHandler}
style={containerStyle}
bounces={scaled}
bouncesZoom={true}
centerContent>
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
<AnimatedImage
contentFit="cover"
source={{uri: imageSrc.uri}}
placeholderContentFit="cover"
placeholder={{uri: imageSrc.thumbUri}}
style={imageStyle}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
enableLiveTextInteraction={showControls && !scaled}
accessibilityIgnoresInvertColors
/>
{showLoader && (
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
)}
<Animated.View style={imageCropStyle}>
<AnimatedImage
contentFit="contain"
source={{uri: imageSrc.uri}}
placeholderContentFit="contain"
placeholder={{uri: imageSrc.thumbUri}}
style={imageStyle}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
enableLiveTextInteraction={showControls && !scaled}
accessibilityIgnoresInvertColors
onLoad={() => setHasLoaded(true)}
/>
</Animated.View>
</Animated.ScrollView>
</GestureDetector>
)
@@ -1,11 +1,15 @@
// default implementation fallback for web
import React from 'react'
import {ImageStyle, StyleProp, View} from 'react-native'
import {View} from 'react-native'
import {PanGesture} from 'react-native-gesture-handler'
import {BaseAnimationBuilder} from 'react-native-reanimated'
import {SharedValue} from 'react-native-reanimated'
import {Dimensions as ImageDimensions, ImageSource} from '../../@types'
import {
Dimensions as ImageDimensions,
ImageSource,
Transform,
} from '../../@types'
type Props = {
imageSrc: ImageSource
@@ -22,9 +26,15 @@ type Props = {
}
imageAspect: number | undefined
imageDimensions: ImageDimensions | undefined
imageStyle: StyleProp<ImageStyle>
dismissSwipePan: PanGesture
layoutAnimationAndroid: BaseAnimationBuilder
transforms: Readonly<
SharedValue<{
scaleAndMoveTransform: Transform
cropFrameTransform: Transform
cropContentTransform: Transform
isResting: boolean
}>
>
}
const ImageItem = (_props: Props) => {