From d78d65e290d8633370e334b910c0989ea41077b9 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Fri, 8 Nov 2024 02:48:59 +0000 Subject: [PATCH] Make it fast by animating only affine transforms --- .../com/lightbox/ImageViewing/@types/index.ts | 6 + .../ImageItem/ImageItem.android.tsx | 137 +++++++++++---- .../components/ImageItem/ImageItem.ios.tsx | 108 ++++++++++-- .../components/ImageItem/ImageItem.tsx | 20 ++- src/view/com/lightbox/ImageViewing/index.tsx | 159 ++++++------------ 5 files changed, 264 insertions(+), 166 deletions(-) diff --git a/src/view/com/lightbox/ImageViewing/@types/index.ts b/src/view/com/lightbox/ImageViewing/@types/index.ts index 9b1cc785fb..1a3543c267 100644 --- a/src/view/com/lightbox/ImageViewing/@types/index.ts +++ b/src/view/com/lightbox/ImageViewing/@types/index.ts @@ -6,6 +6,7 @@ * */ +import {TransformsStyle} from 'react-native' import {MeasuredDimensions} from 'react-native-reanimated' export type Dimensions = { @@ -26,3 +27,8 @@ export type ImageSource = { dimensions: Dimensions | null type: 'image' | 'circle-avi' | 'rect-avi' } + +export type Transform = Exclude< + TransformsStyle['transform'], + string | undefined +> diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx index f72c4b5a45..1a158fd1c3 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -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 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 ( - - + style={[styles.container]} + renderToHardwareTextureAndroid> + + {showLoader && ( + + )} + + setHasLoaded(false)} + style={imageStyle} + accessibilityHint="" + accessibilityIgnoresInvertColors + cachePolicy="memory" + /> + + ) @@ -352,6 +414,7 @@ const styles = StyleSheet.create({ right: 0, top: 0, bottom: 0, + justifyContent: 'center', }, }) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx index b1fdfc7159..fe6458f813 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -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 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() 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 ( - - + {showLoader && ( + + )} + + setHasLoaded(true)} + /> + ) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx index a4c5c6c1b3..fbece6eed5 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx @@ -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 dismissSwipePan: PanGesture - layoutAnimationAndroid: BaseAnimationBuilder + transforms: Readonly< + SharedValue<{ + scaleAndMoveTransform: Transform + cropFrameTransform: Transform + cropContentTransform: Transform + isResting: boolean + }> + > } const ImageItem = (_props: Props) => { diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index a6a9e80dbe..33302534cb 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -22,7 +22,6 @@ import Animated, { AnimatedRef, cancelAnimation, interpolate, - LinearTransition, measure, runOnJS, SharedValue, @@ -45,13 +44,13 @@ import {Trans} from '@lingui/macro' import {useImageDimensions} from '#/lib/media/image-sizes' import {colors, s} from '#/lib/styles' -import {isAndroid, isIOS} from '#/platform/detection' +import {isIOS} from '#/platform/detection' import {Lightbox} from '#/state/lightbox' import {Button} from '#/view/com/util/forms/Button' import {Text} from '#/view/com/util/text/Text' import {ScrollView} from '#/view/com/util/Views' import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army' -import {ImageSource} from './@types' +import {ImageSource, Transform} from './@types' import ImageDefaultHeader from './components/ImageDefaultHeader' import ImageItem from './components/ImageItem/ImageItem' @@ -65,9 +64,6 @@ const EDGES = const SLOW_SPRING = {stiffness: 120} const FAST_SPRING = {stiffness: 700} -const SLOW_SPRING_AS_TRANSITION = LinearTransition.springify() - .overshootClamping(true as any /* Typings are wrong */) - .stiffness(SLOW_SPRING.stiffness) export default function ImageViewRoot({ lightbox: nextLightbox, @@ -84,24 +80,11 @@ export default function ImageViewRoot({ const ref = useAnimatedRef() const [activeLightbox, setActiveLightbox] = useState(nextLightbox) const openProgress = useSharedValue(0) - const openProgressTo = useSharedValue(0) if (!activeLightbox && nextLightbox) { setActiveLightbox(nextLightbox) } - const updateOpenProgress = React.useCallback( - (toValue: number, animate: boolean) => { - 'worklet' - // These *must* always be updated together. - openProgressTo.value = toValue - openProgress.value = animate - ? withClampedSpring(toValue, SLOW_SPRING) - : toValue - }, - [openProgress, openProgressTo], - ) - React.useEffect(() => { if (!nextLightbox) { return @@ -111,11 +94,17 @@ export default function ImageViewRoot({ !PlatformInfo.getIsReducedMotionEnabled() && nextLightbox.images.every(img => img.dimensions && img.thumbRect) - updateOpenProgress(1, canAnimate) + // https://github.com/software-mansion/react-native-reanimated/issues/6677 + requestAnimationFrame(() => { + openProgress.value = canAnimate ? withClampedSpring(1, SLOW_SPRING) : 1 + }) return () => { - updateOpenProgress(0, canAnimate) + // https://github.com/software-mansion/react-native-reanimated/issues/6677 + requestAnimationFrame(() => { + openProgress.value = canAnimate ? withClampedSpring(0, SLOW_SPRING) : 0 + }) } - }, [nextLightbox, updateOpenProgress]) + }, [nextLightbox, openProgress]) useAnimatedReaction( () => openProgress.value === 0, @@ -128,9 +117,9 @@ export default function ImageViewRoot({ const onFlyAway = React.useCallback(() => { 'worklet' - updateOpenProgress(0, false) + openProgress.value = 0 runOnJS(onRequestClose)() - }, [onRequestClose, updateOpenProgress]) + }, [onRequestClose, openProgress]) return ( // Keep it always mounted to avoid flicker on the first frame. @@ -151,7 +140,6 @@ export default function ImageViewRoot({ onFlyAway={onFlyAway} safeAreaRef={ref} openProgress={openProgress} - openProgressTo={openProgressTo} /> )} @@ -167,7 +155,6 @@ function ImageView({ onFlyAway, safeAreaRef, openProgress, - openProgressTo, }: { lightbox: Lightbox onRequestClose: () => void @@ -176,7 +163,6 @@ function ImageView({ onFlyAway: () => void safeAreaRef: AnimatedRef openProgress: SharedValue - openProgressTo: SharedValue }) { const {images, index: initialImageIndex} = lightbox const [isScaled, setIsScaled] = useState(false) @@ -303,7 +289,6 @@ function ImageView({ isActive={i === imageIndex} dismissSwipeTranslateY={dismissSwipeTranslateY} openProgress={openProgress} - openProgressTo={openProgressTo} /> ))} @@ -344,7 +329,6 @@ function LightboxImage({ showControls, safeAreaRef, openProgress, - openProgressTo, dismissSwipeTranslateY, }: { imageSrc: ImageSource @@ -359,7 +343,6 @@ function LightboxImage({ showControls: boolean safeAreaRef: AnimatedRef openProgress: SharedValue - openProgressTo: SharedValue dismissSwipeTranslateY: SharedValue }) { const [imageAspect, imageDimensions] = useImageDimensions({ @@ -392,48 +375,26 @@ function LightboxImage({ safeAreaRef, ]) - const {thumbRect, dimensions} = imageSrc - const interpolation = useDerivedValue(() => { + const {thumbRect} = imageSrc + const transforms = useDerivedValue(() => { 'worklet' const safeArea = measureSafeArea() - const finalWidth = safeArea.width - const finalHeight = imageAspect ? safeArea.width / imageAspect : undefined const dismissTranslateY = isActive && openProgress.value === 1 ? dismissSwipeTranslateY.value : 0 - if (isActive && thumbRect && dimensions) { + + if (isActive && thumbRect && imageAspect && openProgress.value < 1) { return interpolateTransform( openProgress.value, - openProgressTo.value, thumbRect, safeArea, - dimensions, - dismissTranslateY, + imageAspect, ) } return { - transform: [{translateY: dismissTranslateY}], - width: finalWidth, - height: finalHeight, - } - }) - - const containerStyle = useAnimatedStyle(() => { - const {transform} = interpolation.value - return { - flex: 1, - transform, - } - }) - - const type = imageSrc.type - const borderRadius = - type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 - const imageStyle = useAnimatedStyle(() => { - const {width, height} = interpolation.value - return { - borderRadius, - width, - height, + isResting: dismissTranslateY === 0, + scaleAndMoveTransform: [{translateY: dismissTranslateY}], + cropFrameTransform: [], + cropContentTransform: [], } }) @@ -475,22 +436,19 @@ function LightboxImage({ }) return ( - - - + ) } @@ -657,7 +615,6 @@ function interpolatePx( function interpolateTransform( progress: number, - progressTo: number, thumbnailDims: { pageX: number width: number @@ -665,11 +622,14 @@ function interpolateTransform( height: number }, safeArea: {width: number; height: number; x: number; y: number}, - imageDims: {width: number; height: number}, - dismissTranslateY: number, -) { + imageAspect: number, +): { + scaleAndMoveTransform: Transform + cropFrameTransform: Transform + cropContentTransform: Transform + isResting: boolean +} { 'worklet' - const imageAspect = imageDims.width / imageDims.height const thumbAspect = thumbnailDims.width / thumbnailDims.height let uncroppedInitialWidth let uncroppedInitialHeight @@ -699,34 +659,21 @@ function interpolateTransform( const scale = interpolate(progress, [0, 1], [initialScale, 1]) const translateX = interpolatePx(progress, [0, 1], [initialTranslateX, 0]) const translateY = interpolatePx(progress, [0, 1], [initialTranslateY, 0]) - const cropTranslateX = interpolatePx( + const cropScaleX = interpolate( progress, [0, 1], - [(finalWidth - croppedFinalWidth) / 2, 0], + [croppedFinalWidth / finalWidth, 1], + ) + const cropScaleY = interpolate( + progress, + [0, 1], + [croppedFinalHeight / finalHeight, 1], ) - let width - let height - if (isAndroid) { - // On Android, interpolating `progress` here is too slow and choppy. - // Instead, we'll use discrete `progressTo` and rely on `layout` animation. - width = progressTo === 0 ? croppedFinalWidth : finalWidth - height = progressTo === 0 ? croppedFinalHeight : finalHeight - } else { - // On iOS, interpolating these directly works fine. - // In fact, using the above approach would lead to incorrect positions. - width = interpolatePx(progress, [0, 1], [croppedFinalWidth, finalWidth]) - height = interpolatePx(progress, [0, 1], [croppedFinalHeight, finalHeight]) - } return { - transform: [ - {translateY: dismissTranslateY}, - {translateX}, - {translateY}, - {scale}, - {translateX: cropTranslateX}, - ], - width, - height, + isResting: progress === 1, + scaleAndMoveTransform: [{translateX}, {translateY}, {scale}], + cropFrameTransform: [{scaleX: cropScaleX}, {scaleY: cropScaleY}], + cropContentTransform: [{scaleX: 1 / cropScaleX}, {scaleY: 1 / cropScaleY}], } }