Make it fast by animating only affine transforms
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {TransformsStyle} from 'react-native'
|
||||||
import {MeasuredDimensions} from 'react-native-reanimated'
|
import {MeasuredDimensions} from 'react-native-reanimated'
|
||||||
|
|
||||||
export type Dimensions = {
|
export type Dimensions = {
|
||||||
@@ -26,3 +27,8 @@ export type ImageSource = {
|
|||||||
dimensions: Dimensions | null
|
dimensions: Dimensions | null
|
||||||
type: 'image' | 'circle-avi' | 'rect-avi'
|
type: 'image' | 'circle-avi' | 'rect-avi'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Transform = Exclude<
|
||||||
|
TransformsStyle['transform'],
|
||||||
|
string | undefined
|
||||||
|
>
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
import React, {useState} from 'react'
|
import React, {useState} from 'react'
|
||||||
import {ActivityIndicator, StyleProp, StyleSheet} from 'react-native'
|
import {ActivityIndicator, StyleSheet} from 'react-native'
|
||||||
import {
|
import {
|
||||||
Gesture,
|
Gesture,
|
||||||
GestureDetector,
|
GestureDetector,
|
||||||
PanGesture,
|
PanGesture,
|
||||||
} from 'react-native-gesture-handler'
|
} from 'react-native-gesture-handler'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
BaseAnimationBuilder,
|
|
||||||
runOnJS,
|
runOnJS,
|
||||||
|
SharedValue,
|
||||||
useAnimatedReaction,
|
useAnimatedReaction,
|
||||||
useAnimatedRef,
|
useAnimatedRef,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
withSpring,
|
withSpring,
|
||||||
} from 'react-native-reanimated'
|
} 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 {
|
import {
|
||||||
applyRounding,
|
applyRounding,
|
||||||
createTransform,
|
createTransform,
|
||||||
@@ -49,9 +53,15 @@ type Props = {
|
|||||||
}
|
}
|
||||||
imageAspect: number | undefined
|
imageAspect: number | undefined
|
||||||
imageDimensions: ImageDimensions | undefined
|
imageDimensions: ImageDimensions | undefined
|
||||||
imageStyle: StyleProp<ImageStyle>
|
|
||||||
dismissSwipePan: PanGesture
|
dismissSwipePan: PanGesture
|
||||||
layoutAnimationAndroid: BaseAnimationBuilder
|
transforms: Readonly<
|
||||||
|
SharedValue<{
|
||||||
|
scaleAndMoveTransform: Transform
|
||||||
|
cropFrameTransform: Transform
|
||||||
|
cropContentTransform: Transform
|
||||||
|
isResting: boolean
|
||||||
|
}>
|
||||||
|
>
|
||||||
}
|
}
|
||||||
const ImageItem = ({
|
const ImageItem = ({
|
||||||
imageSrc,
|
imageSrc,
|
||||||
@@ -61,9 +71,8 @@ const ImageItem = ({
|
|||||||
measureSafeArea,
|
measureSafeArea,
|
||||||
imageAspect,
|
imageAspect,
|
||||||
imageDimensions,
|
imageDimensions,
|
||||||
imageStyle,
|
|
||||||
dismissSwipePan,
|
dismissSwipePan,
|
||||||
layoutAnimationAndroid,
|
transforms,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [isScaled, setIsScaled] = useState(false)
|
const [isScaled, setIsScaled] = useState(false)
|
||||||
const committedTransform = useSharedValue(initialTransform)
|
const committedTransform = useSharedValue(initialTransform)
|
||||||
@@ -101,19 +110,6 @@ const ImageItem = ({
|
|||||||
onZoom(nextIsScaled)
|
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.
|
// 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.
|
// If the user tried to pan too hard, this function will provide the negative panning to stay in bounds.
|
||||||
function getExtraTranslationToStayInBounds(
|
function getExtraTranslationToStayInBounds(
|
||||||
@@ -315,26 +311,92 @@ const ImageItem = ({
|
|||||||
singleTap,
|
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 (
|
return (
|
||||||
<GestureDetector gesture={composedGesture}>
|
<GestureDetector gesture={composedGesture}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
// Necessary to make opacity work for both children together.
|
style={[styles.container]}
|
||||||
renderToHardwareTextureAndroid
|
renderToHardwareTextureAndroid>
|
||||||
style={[styles.container, animatedStyle]}>
|
<Animated.View style={containerStyle}>
|
||||||
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
{showLoader && (
|
||||||
<AnimatedImage
|
<ActivityIndicator
|
||||||
contentFit="cover"
|
size="small"
|
||||||
source={{uri: imageSrc.uri}}
|
color="#FFF"
|
||||||
placeholderContentFit="cover"
|
style={styles.loading}
|
||||||
placeholder={{uri: imageSrc.thumbUri}}
|
/>
|
||||||
style={imageStyle}
|
)}
|
||||||
layout={layoutAnimationAndroid}
|
<Animated.View style={imageCropStyle}>
|
||||||
accessibilityLabel={imageSrc.alt}
|
<AnimatedImage
|
||||||
accessibilityHint=""
|
contentFit="cover"
|
||||||
accessibilityIgnoresInvertColors
|
source={{uri: imageSrc.uri}}
|
||||||
cachePolicy="memory"
|
placeholderContentFit="cover"
|
||||||
/>
|
placeholder={{uri: imageSrc.thumbUri}}
|
||||||
|
accessibilityLabel={imageSrc.alt}
|
||||||
|
onLoad={() => setHasLoaded(false)}
|
||||||
|
style={imageStyle}
|
||||||
|
accessibilityHint=""
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
cachePolicy="memory"
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
)
|
)
|
||||||
@@ -352,6 +414,7 @@ const styles = StyleSheet.create({
|
|||||||
right: 0,
|
right: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,18 +7,28 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {useState} from 'react'
|
import React, {useState} from 'react'
|
||||||
import {ActivityIndicator, StyleProp, StyleSheet} from 'react-native'
|
import {ActivityIndicator, StyleSheet} from 'react-native'
|
||||||
import {
|
import {
|
||||||
Gesture,
|
Gesture,
|
||||||
GestureDetector,
|
GestureDetector,
|
||||||
PanGesture,
|
PanGesture,
|
||||||
} from 'react-native-gesture-handler'
|
} 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 {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 {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)
|
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
||||||
|
|
||||||
@@ -40,8 +50,15 @@ type Props = {
|
|||||||
}
|
}
|
||||||
imageAspect: number | undefined
|
imageAspect: number | undefined
|
||||||
imageDimensions: ImageDimensions | undefined
|
imageDimensions: ImageDimensions | undefined
|
||||||
imageStyle: StyleProp<ImageStyle>
|
|
||||||
dismissSwipePan: PanGesture
|
dismissSwipePan: PanGesture
|
||||||
|
transforms: Readonly<
|
||||||
|
SharedValue<{
|
||||||
|
scaleAndMoveTransform: Transform
|
||||||
|
cropFrameTransform: Transform
|
||||||
|
cropContentTransform: Transform
|
||||||
|
isResting: boolean
|
||||||
|
}>
|
||||||
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImageItem = ({
|
const ImageItem = ({
|
||||||
@@ -52,8 +69,8 @@ const ImageItem = ({
|
|||||||
measureSafeArea,
|
measureSafeArea,
|
||||||
imageAspect,
|
imageAspect,
|
||||||
imageDimensions,
|
imageDimensions,
|
||||||
imageStyle,
|
|
||||||
dismissSwipePan,
|
dismissSwipePan,
|
||||||
|
transforms,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
|
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
|
||||||
const [scaled, setScaled] = useState(false)
|
const [scaled, setScaled] = useState(false)
|
||||||
@@ -129,6 +146,55 @@ const ImageItem = ({
|
|||||||
singleTap,
|
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 (
|
return (
|
||||||
<GestureDetector gesture={composedGesture}>
|
<GestureDetector gesture={composedGesture}>
|
||||||
<Animated.ScrollView
|
<Animated.ScrollView
|
||||||
@@ -139,21 +205,27 @@ const ImageItem = ({
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
maximumZoomScale={maxZoomScale}
|
maximumZoomScale={maxZoomScale}
|
||||||
onScroll={scrollHandler}
|
onScroll={scrollHandler}
|
||||||
|
style={containerStyle}
|
||||||
bounces={scaled}
|
bounces={scaled}
|
||||||
bouncesZoom={true}
|
bouncesZoom={true}
|
||||||
centerContent>
|
centerContent>
|
||||||
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
{showLoader && (
|
||||||
<AnimatedImage
|
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
||||||
contentFit="cover"
|
)}
|
||||||
source={{uri: imageSrc.uri}}
|
<Animated.View style={imageCropStyle}>
|
||||||
placeholderContentFit="cover"
|
<AnimatedImage
|
||||||
placeholder={{uri: imageSrc.thumbUri}}
|
contentFit="contain"
|
||||||
style={imageStyle}
|
source={{uri: imageSrc.uri}}
|
||||||
accessibilityLabel={imageSrc.alt}
|
placeholderContentFit="contain"
|
||||||
accessibilityHint=""
|
placeholder={{uri: imageSrc.thumbUri}}
|
||||||
enableLiveTextInteraction={showControls && !scaled}
|
style={imageStyle}
|
||||||
accessibilityIgnoresInvertColors
|
accessibilityLabel={imageSrc.alt}
|
||||||
/>
|
accessibilityHint=""
|
||||||
|
enableLiveTextInteraction={showControls && !scaled}
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
onLoad={() => setHasLoaded(true)}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
</Animated.ScrollView>
|
</Animated.ScrollView>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
// default implementation fallback for web
|
// default implementation fallback for web
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {ImageStyle, StyleProp, View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {PanGesture} from 'react-native-gesture-handler'
|
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 = {
|
type Props = {
|
||||||
imageSrc: ImageSource
|
imageSrc: ImageSource
|
||||||
@@ -22,9 +26,15 @@ type Props = {
|
|||||||
}
|
}
|
||||||
imageAspect: number | undefined
|
imageAspect: number | undefined
|
||||||
imageDimensions: ImageDimensions | undefined
|
imageDimensions: ImageDimensions | undefined
|
||||||
imageStyle: StyleProp<ImageStyle>
|
|
||||||
dismissSwipePan: PanGesture
|
dismissSwipePan: PanGesture
|
||||||
layoutAnimationAndroid: BaseAnimationBuilder
|
transforms: Readonly<
|
||||||
|
SharedValue<{
|
||||||
|
scaleAndMoveTransform: Transform
|
||||||
|
cropFrameTransform: Transform
|
||||||
|
cropContentTransform: Transform
|
||||||
|
isResting: boolean
|
||||||
|
}>
|
||||||
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImageItem = (_props: Props) => {
|
const ImageItem = (_props: Props) => {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import Animated, {
|
|||||||
AnimatedRef,
|
AnimatedRef,
|
||||||
cancelAnimation,
|
cancelAnimation,
|
||||||
interpolate,
|
interpolate,
|
||||||
LinearTransition,
|
|
||||||
measure,
|
measure,
|
||||||
runOnJS,
|
runOnJS,
|
||||||
SharedValue,
|
SharedValue,
|
||||||
@@ -45,13 +44,13 @@ import {Trans} from '@lingui/macro'
|
|||||||
|
|
||||||
import {useImageDimensions} from '#/lib/media/image-sizes'
|
import {useImageDimensions} from '#/lib/media/image-sizes'
|
||||||
import {colors, s} from '#/lib/styles'
|
import {colors, s} from '#/lib/styles'
|
||||||
import {isAndroid, isIOS} from '#/platform/detection'
|
import {isIOS} from '#/platform/detection'
|
||||||
import {Lightbox} from '#/state/lightbox'
|
import {Lightbox} from '#/state/lightbox'
|
||||||
import {Button} from '#/view/com/util/forms/Button'
|
import {Button} from '#/view/com/util/forms/Button'
|
||||||
import {Text} from '#/view/com/util/text/Text'
|
import {Text} from '#/view/com/util/text/Text'
|
||||||
import {ScrollView} from '#/view/com/util/Views'
|
import {ScrollView} from '#/view/com/util/Views'
|
||||||
import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army'
|
import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army'
|
||||||
import {ImageSource} from './@types'
|
import {ImageSource, Transform} from './@types'
|
||||||
import ImageDefaultHeader from './components/ImageDefaultHeader'
|
import ImageDefaultHeader from './components/ImageDefaultHeader'
|
||||||
import ImageItem from './components/ImageItem/ImageItem'
|
import ImageItem from './components/ImageItem/ImageItem'
|
||||||
|
|
||||||
@@ -65,9 +64,6 @@ const EDGES =
|
|||||||
|
|
||||||
const SLOW_SPRING = {stiffness: 120}
|
const SLOW_SPRING = {stiffness: 120}
|
||||||
const FAST_SPRING = {stiffness: 700}
|
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({
|
export default function ImageViewRoot({
|
||||||
lightbox: nextLightbox,
|
lightbox: nextLightbox,
|
||||||
@@ -84,24 +80,11 @@ export default function ImageViewRoot({
|
|||||||
const ref = useAnimatedRef<View>()
|
const ref = useAnimatedRef<View>()
|
||||||
const [activeLightbox, setActiveLightbox] = useState(nextLightbox)
|
const [activeLightbox, setActiveLightbox] = useState(nextLightbox)
|
||||||
const openProgress = useSharedValue(0)
|
const openProgress = useSharedValue(0)
|
||||||
const openProgressTo = useSharedValue(0)
|
|
||||||
|
|
||||||
if (!activeLightbox && nextLightbox) {
|
if (!activeLightbox && nextLightbox) {
|
||||||
setActiveLightbox(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(() => {
|
React.useEffect(() => {
|
||||||
if (!nextLightbox) {
|
if (!nextLightbox) {
|
||||||
return
|
return
|
||||||
@@ -111,11 +94,17 @@ export default function ImageViewRoot({
|
|||||||
!PlatformInfo.getIsReducedMotionEnabled() &&
|
!PlatformInfo.getIsReducedMotionEnabled() &&
|
||||||
nextLightbox.images.every(img => img.dimensions && img.thumbRect)
|
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 () => {
|
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(
|
useAnimatedReaction(
|
||||||
() => openProgress.value === 0,
|
() => openProgress.value === 0,
|
||||||
@@ -128,9 +117,9 @@ export default function ImageViewRoot({
|
|||||||
|
|
||||||
const onFlyAway = React.useCallback(() => {
|
const onFlyAway = React.useCallback(() => {
|
||||||
'worklet'
|
'worklet'
|
||||||
updateOpenProgress(0, false)
|
openProgress.value = 0
|
||||||
runOnJS(onRequestClose)()
|
runOnJS(onRequestClose)()
|
||||||
}, [onRequestClose, updateOpenProgress])
|
}, [onRequestClose, openProgress])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Keep it always mounted to avoid flicker on the first frame.
|
// Keep it always mounted to avoid flicker on the first frame.
|
||||||
@@ -151,7 +140,6 @@ export default function ImageViewRoot({
|
|||||||
onFlyAway={onFlyAway}
|
onFlyAway={onFlyAway}
|
||||||
safeAreaRef={ref}
|
safeAreaRef={ref}
|
||||||
openProgress={openProgress}
|
openProgress={openProgress}
|
||||||
openProgressTo={openProgressTo}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -167,7 +155,6 @@ function ImageView({
|
|||||||
onFlyAway,
|
onFlyAway,
|
||||||
safeAreaRef,
|
safeAreaRef,
|
||||||
openProgress,
|
openProgress,
|
||||||
openProgressTo,
|
|
||||||
}: {
|
}: {
|
||||||
lightbox: Lightbox
|
lightbox: Lightbox
|
||||||
onRequestClose: () => void
|
onRequestClose: () => void
|
||||||
@@ -176,7 +163,6 @@ function ImageView({
|
|||||||
onFlyAway: () => void
|
onFlyAway: () => void
|
||||||
safeAreaRef: AnimatedRef<View>
|
safeAreaRef: AnimatedRef<View>
|
||||||
openProgress: SharedValue<number>
|
openProgress: SharedValue<number>
|
||||||
openProgressTo: SharedValue<number>
|
|
||||||
}) {
|
}) {
|
||||||
const {images, index: initialImageIndex} = lightbox
|
const {images, index: initialImageIndex} = lightbox
|
||||||
const [isScaled, setIsScaled] = useState(false)
|
const [isScaled, setIsScaled] = useState(false)
|
||||||
@@ -303,7 +289,6 @@ function ImageView({
|
|||||||
isActive={i === imageIndex}
|
isActive={i === imageIndex}
|
||||||
dismissSwipeTranslateY={dismissSwipeTranslateY}
|
dismissSwipeTranslateY={dismissSwipeTranslateY}
|
||||||
openProgress={openProgress}
|
openProgress={openProgress}
|
||||||
openProgressTo={openProgressTo}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -344,7 +329,6 @@ function LightboxImage({
|
|||||||
showControls,
|
showControls,
|
||||||
safeAreaRef,
|
safeAreaRef,
|
||||||
openProgress,
|
openProgress,
|
||||||
openProgressTo,
|
|
||||||
dismissSwipeTranslateY,
|
dismissSwipeTranslateY,
|
||||||
}: {
|
}: {
|
||||||
imageSrc: ImageSource
|
imageSrc: ImageSource
|
||||||
@@ -359,7 +343,6 @@ function LightboxImage({
|
|||||||
showControls: boolean
|
showControls: boolean
|
||||||
safeAreaRef: AnimatedRef<View>
|
safeAreaRef: AnimatedRef<View>
|
||||||
openProgress: SharedValue<number>
|
openProgress: SharedValue<number>
|
||||||
openProgressTo: SharedValue<number>
|
|
||||||
dismissSwipeTranslateY: SharedValue<number>
|
dismissSwipeTranslateY: SharedValue<number>
|
||||||
}) {
|
}) {
|
||||||
const [imageAspect, imageDimensions] = useImageDimensions({
|
const [imageAspect, imageDimensions] = useImageDimensions({
|
||||||
@@ -392,48 +375,26 @@ function LightboxImage({
|
|||||||
safeAreaRef,
|
safeAreaRef,
|
||||||
])
|
])
|
||||||
|
|
||||||
const {thumbRect, dimensions} = imageSrc
|
const {thumbRect} = imageSrc
|
||||||
const interpolation = useDerivedValue(() => {
|
const transforms = useDerivedValue(() => {
|
||||||
'worklet'
|
'worklet'
|
||||||
const safeArea = measureSafeArea()
|
const safeArea = measureSafeArea()
|
||||||
const finalWidth = safeArea.width
|
|
||||||
const finalHeight = imageAspect ? safeArea.width / imageAspect : undefined
|
|
||||||
const dismissTranslateY =
|
const dismissTranslateY =
|
||||||
isActive && openProgress.value === 1 ? dismissSwipeTranslateY.value : 0
|
isActive && openProgress.value === 1 ? dismissSwipeTranslateY.value : 0
|
||||||
if (isActive && thumbRect && dimensions) {
|
|
||||||
|
if (isActive && thumbRect && imageAspect && openProgress.value < 1) {
|
||||||
return interpolateTransform(
|
return interpolateTransform(
|
||||||
openProgress.value,
|
openProgress.value,
|
||||||
openProgressTo.value,
|
|
||||||
thumbRect,
|
thumbRect,
|
||||||
safeArea,
|
safeArea,
|
||||||
dimensions,
|
imageAspect,
|
||||||
dismissTranslateY,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
transform: [{translateY: dismissTranslateY}],
|
isResting: dismissTranslateY === 0,
|
||||||
width: finalWidth,
|
scaleAndMoveTransform: [{translateY: dismissTranslateY}],
|
||||||
height: finalHeight,
|
cropFrameTransform: [],
|
||||||
}
|
cropContentTransform: [],
|
||||||
})
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -475,22 +436,19 @@ function LightboxImage({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={containerStyle}>
|
<ImageItem
|
||||||
<ImageItem
|
imageSrc={imageSrc}
|
||||||
imageSrc={imageSrc}
|
onTap={onTap}
|
||||||
onTap={onTap}
|
onZoom={onZoom}
|
||||||
onZoom={onZoom}
|
onRequestClose={onRequestClose}
|
||||||
onRequestClose={onRequestClose}
|
isScrollViewBeingDragged={isScrollViewBeingDragged}
|
||||||
isScrollViewBeingDragged={isScrollViewBeingDragged}
|
showControls={showControls}
|
||||||
showControls={showControls}
|
measureSafeArea={measureSafeArea}
|
||||||
measureSafeArea={measureSafeArea}
|
imageAspect={imageAspect}
|
||||||
imageAspect={imageAspect}
|
imageDimensions={imageDimensions}
|
||||||
imageDimensions={imageDimensions}
|
dismissSwipePan={dismissSwipePan}
|
||||||
imageStyle={imageStyle}
|
transforms={transforms}
|
||||||
dismissSwipePan={dismissSwipePan}
|
/>
|
||||||
layoutAnimationAndroid={SLOW_SPRING_AS_TRANSITION}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,7 +615,6 @@ function interpolatePx(
|
|||||||
|
|
||||||
function interpolateTransform(
|
function interpolateTransform(
|
||||||
progress: number,
|
progress: number,
|
||||||
progressTo: number,
|
|
||||||
thumbnailDims: {
|
thumbnailDims: {
|
||||||
pageX: number
|
pageX: number
|
||||||
width: number
|
width: number
|
||||||
@@ -665,11 +622,14 @@ function interpolateTransform(
|
|||||||
height: number
|
height: number
|
||||||
},
|
},
|
||||||
safeArea: {width: number; height: number; x: number; y: number},
|
safeArea: {width: number; height: number; x: number; y: number},
|
||||||
imageDims: {width: number; height: number},
|
imageAspect: number,
|
||||||
dismissTranslateY: number,
|
): {
|
||||||
) {
|
scaleAndMoveTransform: Transform
|
||||||
|
cropFrameTransform: Transform
|
||||||
|
cropContentTransform: Transform
|
||||||
|
isResting: boolean
|
||||||
|
} {
|
||||||
'worklet'
|
'worklet'
|
||||||
const imageAspect = imageDims.width / imageDims.height
|
|
||||||
const thumbAspect = thumbnailDims.width / thumbnailDims.height
|
const thumbAspect = thumbnailDims.width / thumbnailDims.height
|
||||||
let uncroppedInitialWidth
|
let uncroppedInitialWidth
|
||||||
let uncroppedInitialHeight
|
let uncroppedInitialHeight
|
||||||
@@ -699,34 +659,21 @@ function interpolateTransform(
|
|||||||
const scale = interpolate(progress, [0, 1], [initialScale, 1])
|
const scale = interpolate(progress, [0, 1], [initialScale, 1])
|
||||||
const translateX = interpolatePx(progress, [0, 1], [initialTranslateX, 0])
|
const translateX = interpolatePx(progress, [0, 1], [initialTranslateX, 0])
|
||||||
const translateY = interpolatePx(progress, [0, 1], [initialTranslateY, 0])
|
const translateY = interpolatePx(progress, [0, 1], [initialTranslateY, 0])
|
||||||
const cropTranslateX = interpolatePx(
|
const cropScaleX = interpolate(
|
||||||
progress,
|
progress,
|
||||||
[0, 1],
|
[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 {
|
return {
|
||||||
transform: [
|
isResting: progress === 1,
|
||||||
{translateY: dismissTranslateY},
|
scaleAndMoveTransform: [{translateX}, {translateY}, {scale}],
|
||||||
{translateX},
|
cropFrameTransform: [{scaleX: cropScaleX}, {scaleY: cropScaleY}],
|
||||||
{translateY},
|
cropContentTransform: [{scaleX: 1 / cropScaleX}, {scaleY: 1 / cropScaleY}],
|
||||||
{scale},
|
|
||||||
{translateX: cropTranslateX},
|
|
||||||
],
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user