diff options
author | dan <dan.abramov@gmail.com> | 2024-11-09 22:34:46 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-11-09 22:34:46 +0000 |
commit | 2d73c5a24cf8ad06dbebcf44c8f4f053eedda5a4 (patch) | |
tree | 06e69c967f97d4d3fe6b4ba3f77efd66d85af2ae /src/view/com/lightbox/ImageViewing/components | |
parent | e73d5c6c207a5da842cdb02a703ef3f130112fa2 (diff) | |
download | voidsky-2d73c5a24cf8ad06dbebcf44c8f4f053eedda5a4.tar.zst |
[Lightbox] Open animation (#6159)
* Measure all rects for embeds * Measure avi rects too * Animate lightbox in and out * Account for safe area in the animation * Tune spring times * Remove null checks for measurements * Remove superfluous view * Block swipe while opening * Interpolate width/height on native side for Android * Make it fast by animating only affine transforms * Fix tall image final state The initial animation frame is still off on both platforms. * Try to squeeze perf * Avoid blank images during animation on iOS * Fix bad rebase * Fix a huge memory issue due to expo/expo#24894 * Fix last frame flash * Fix thum dim calculation for tall images
Diffstat (limited to 'src/view/com/lightbox/ImageViewing/components')
3 files changed, 235 insertions, 100 deletions
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 f882dcf9e..069f9eb40 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -1,23 +1,26 @@ import React, {useState} from 'react' -import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native' +import {ActivityIndicator, StyleSheet} from 'react-native' import { Gesture, GestureDetector, PanGesture, } from 'react-native-gesture-handler' import Animated, { - AnimatedRef, - measure, 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, @@ -28,8 +31,6 @@ import { TransformMatrix, } from '../../transforms' -const AnimatedImage = Animated.createAnimatedComponent(Image) - const MIN_SCREEN_ZOOM = 2 const MAX_ORIGINAL_IMAGE_ZOOM = 2 @@ -42,22 +43,35 @@ type Props = { onZoom: (isZoomed: boolean) => void isScrollViewBeingDragged: boolean showControls: boolean - safeAreaRef: AnimatedRef<View> + measureSafeArea: () => { + x: number + y: number + width: number + height: number + } imageAspect: number | undefined imageDimensions: ImageDimensions | undefined - imageStyle: StyleProp<ImageStyle> dismissSwipePan: PanGesture + transforms: Readonly< + SharedValue<{ + scaleAndMoveTransform: Transform + cropFrameTransform: Transform + cropContentTransform: Transform + isResting: boolean + isHidden: boolean + }> + > } const ImageItem = ({ imageSrc, onTap, onZoom, isScrollViewBeingDragged, - safeAreaRef, + measureSafeArea, imageAspect, imageDimensions, - imageStyle, dismissSwipePan, + transforms, }: Props) => { const [isScaled, setIsScaled] = useState(false) const committedTransform = useSharedValue(initialTransform) @@ -95,19 +109,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( @@ -143,10 +144,7 @@ const ImageItem = ({ const pinch = Gesture.Pinch() .onStart(e => { 'worklet' - const screenSize = measure(safeAreaRef) - if (!screenSize) { - return - } + const screenSize = measureSafeArea() pinchOrigin.value = { x: e.focalX - screenSize.width / 2, y: e.focalY - screenSize.height / 2, @@ -154,8 +152,8 @@ const ImageItem = ({ }) .onChange(e => { 'worklet' - const screenSize = measure(safeAreaRef) - if (!imageDimensions || !screenSize) { + const screenSize = measureSafeArea() + if (!imageDimensions) { return } // Don't let the picture zoom in so close that it gets blurry. @@ -213,8 +211,8 @@ const ImageItem = ({ .minPointers(isScaled ? 1 : 2) .onChange(e => { 'worklet' - const screenSize = measure(safeAreaRef) - if (!imageDimensions || !screenSize) { + const screenSize = measureSafeArea() + if (!imageDimensions) { return } @@ -257,8 +255,8 @@ const ImageItem = ({ .numberOfTaps(2) .onEnd(e => { 'worklet' - const screenSize = measure(safeAreaRef) - if (!imageDimensions || !imageAspect || !screenSize) { + const screenSize = measureSafeArea() + if (!imageDimensions || !imageAspect) { return } const [, , committedScale] = readTransform(committedTransform.value) @@ -302,11 +300,6 @@ const ImageItem = ({ committedTransform.value = withClampedSpring(finalTransform) }) - const innerStyle = useAnimatedStyle(() => ({ - width: '100%', - aspectRatio: imageAspect, - })) - const composedGesture = isScrollViewBeingDragged ? // If the parent is not at rest, provide a no-op gesture. Gesture.Manual() @@ -317,29 +310,97 @@ const ImageItem = ({ singleTap, ) + const containerStyle = useAnimatedStyle(() => { + const {scaleAndMoveTransform, isHidden} = 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}, + ] + const screenSize = measureSafeArea() + return { + opacity: isHidden ? 0 : 1, + transform: scaleAndMoveTransform.concat(manipulationTransform), + width: screenSize.width, + maxHeight: screenSize.height, + aspectRatio: imageAspect, + alignSelf: 'center', + } + }) + + const imageCropStyle = useAnimatedStyle(() => { + const {cropFrameTransform} = transforms.value + return { + flex: 1, + overflow: 'hidden', + transform: cropFrameTransform, + } + }) + + const imageStyle = useAnimatedStyle(() => { + const {cropContentTransform} = transforms.value + return { + flex: 1, + 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) + } + }, + ) + const type = imageSrc.type const borderRadius = type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 + return ( <GestureDetector gesture={composedGesture}> - <Animated.View style={imageStyle} renderToHardwareTextureAndroid> - <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="contain" - source={{uri: imageSrc.uri}} - placeholderContentFit="contain" - placeholder={{uri: imageSrc.thumbUri}} - style={[innerStyle, {borderRadius}]} - accessibilityLabel={imageSrc.alt} - accessibilityHint="" - accessibilityIgnoresInvertColors - cachePolicy="memory" - /> + <Animated.View + ref={containerRef} + style={[styles.container]} + renderToHardwareTextureAndroid> + <Animated.View style={containerStyle}> + {showLoader && ( + <ActivityIndicator + size="small" + color="#FFF" + style={styles.loading} + /> + )} + <Animated.View style={imageCropStyle}> + <Animated.View style={imageStyle}> + <Image + contentFit="cover" + source={{uri: imageSrc.uri}} + placeholderContentFit="cover" + placeholder={{uri: imageSrc.thumbUri}} + accessibilityLabel={imageSrc.alt} + onLoad={() => setHasLoaded(false)} + style={{flex: 1, borderRadius}} + accessibilityHint="" + accessibilityIgnoresInvertColors + cachePolicy="memory" + /> + </Animated.View> + </Animated.View> </Animated.View> </Animated.View> </GestureDetector> @@ -358,6 +419,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 e876479a3..7a9a18b91 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -7,26 +7,28 @@ */ import React, {useState} from 'react' -import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native' +import {ActivityIndicator, StyleSheet} from 'react-native' import { Gesture, GestureDetector, PanGesture, } from 'react-native-gesture-handler' import Animated, { - AnimatedRef, - measure, 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' - -const AnimatedImage = Animated.createAnimatedComponent(Image) +import { + Dimensions as ImageDimensions, + ImageSource, + Transform, +} from '../../@types' const MAX_ORIGINAL_IMAGE_ZOOM = 2 const MIN_SCREEN_ZOOM = 2 @@ -38,11 +40,24 @@ type Props = { onZoom: (scaled: boolean) => void isScrollViewBeingDragged: boolean showControls: boolean - safeAreaRef: AnimatedRef<View> + measureSafeArea: () => { + x: number + y: number + width: number + height: number + } imageAspect: number | undefined imageDimensions: ImageDimensions | undefined - imageStyle: StyleProp<ImageStyle> dismissSwipePan: PanGesture + transforms: Readonly< + SharedValue<{ + scaleAndMoveTransform: Transform + cropFrameTransform: Transform + cropContentTransform: Transform + isResting: boolean + isHidden: boolean + }> + > } const ImageItem = ({ @@ -50,11 +65,11 @@ const ImageItem = ({ onTap, onZoom, showControls, - safeAreaRef, + measureSafeArea, imageAspect, imageDimensions, - imageStyle, dismissSwipePan, + transforms, }: Props) => { const scrollViewRef = useAnimatedRef<Animated.ScrollView>() const [scaled, setScaled] = useState(false) @@ -67,16 +82,6 @@ const ImageItem = ({ : 1, ) - const animatedStyle = useAnimatedStyle(() => { - const screenSize = measure(safeAreaRef) ?? screenSizeDelayedForJSThreadOnly - return { - width: screenSize.width, - maxHeight: screenSize.height, - alignSelf: 'center', - aspectRatio: imageAspect, - } - }) - const scrollHandler = useAnimatedScrollHandler({ onScroll(e) { const nextIsScaled = e.zoomScale > 1 @@ -114,10 +119,7 @@ const ImageItem = ({ .numberOfTaps(2) .onEnd(e => { 'worklet' - const screenSize = measure(safeAreaRef) - if (!screenSize) { - return - } + const screenSize = measureSafeArea() const {absoluteX, absoluteY} = e let nextZoomRect = { x: 0, @@ -143,9 +145,56 @@ const ImageItem = ({ singleTap, ) + const containerStyle = useAnimatedStyle(() => { + const {scaleAndMoveTransform, isHidden} = transforms.value + return { + flex: 1, + transform: scaleAndMoveTransform, + opacity: isHidden ? 0 : 1, + } + }) + + 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 imageStyle = useAnimatedStyle(() => { + const {cropContentTransform} = transforms.value + return { + 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) + } + }, + ) + const type = imageSrc.type const borderRadius = type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 + return ( <GestureDetector gesture={composedGesture}> <Animated.ScrollView @@ -156,22 +205,29 @@ const ImageItem = ({ showsVerticalScrollIndicator={false} maximumZoomScale={maxZoomScale} onScroll={scrollHandler} + style={containerStyle} bounces={scaled} bouncesZoom={true} - style={imageStyle} centerContent> - <ActivityIndicator size="small" color="#FFF" style={styles.loading} /> - <AnimatedImage - contentFit="contain" - source={{uri: imageSrc.uri}} - placeholderContentFit="contain" - placeholder={{uri: imageSrc.thumbUri}} - style={[animatedStyle, {borderRadius}]} - accessibilityLabel={imageSrc.alt} - accessibilityHint="" - enableLiveTextInteraction={showControls && !scaled} - accessibilityIgnoresInvertColors - /> + {showLoader && ( + <ActivityIndicator size="small" color="#FFF" style={styles.loading} /> + )} + <Animated.View style={imageCropStyle}> + <Animated.View style={imageStyle}> + <Image + contentFit="contain" + source={{uri: imageSrc.uri}} + placeholderContentFit="contain" + placeholder={{uri: imageSrc.thumbUri}} + style={{flex: 1, borderRadius}} + accessibilityLabel={imageSrc.alt} + accessibilityHint="" + enableLiveTextInteraction={showControls && !scaled} + accessibilityIgnoresInvertColors + onLoad={() => setHasLoaded(true)} + /> + </Animated.View> + </Animated.View> </Animated.ScrollView> </GestureDetector> ) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx index 1cd6b0020..543fad772 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 {AnimatedRef} 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 @@ -14,11 +18,24 @@ type Props = { onZoom: (scaled: boolean) => void isScrollViewBeingDragged: boolean showControls: boolean - safeAreaRef: AnimatedRef<View> + measureSafeArea: () => { + x: number + y: number + width: number + height: number + } imageAspect: number | undefined imageDimensions: ImageDimensions | undefined - imageStyle: StyleProp<ImageStyle> dismissSwipePan: PanGesture + transforms: Readonly< + SharedValue<{ + scaleAndMoveTransform: Transform + cropFrameTransform: Transform + cropContentTransform: Transform + isResting: boolean + isHidden: boolean + }> + > } const ImageItem = (_props: Props) => { |