[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
This commit is contained in:
dan
2024-11-09 22:34:46 +00:00
committed by GitHub
parent e73d5c6c20
commit 2d73c5a24c
12 changed files with 629 additions and 192 deletions
@@ -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',
},
})
@@ -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>
)
@@ -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) => {