[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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user