Fix gestures outside image bounds

This commit is contained in:
Dan Abramov
2024-11-02 18:56:01 +00:00
parent 01b350175e
commit b3f4338d21
4 changed files with 87 additions and 62 deletions
@@ -13,7 +13,9 @@ import Animated, {
useSharedValue, useSharedValue,
withSpring, withSpring,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {Image} from 'expo-image' import {Image, ImageStyle} from 'expo-image'
const AnimatedImage = Animated.createAnimatedComponent(Image)
import {useImageDimensions} from '#/lib/media/image-sizes' import {useImageDimensions} from '#/lib/media/image-sizes'
import type {Dimensions as ImageDimensions, ImageSource} from '../../@types' import type {Dimensions as ImageDimensions, ImageSource} from '../../@types'
@@ -47,6 +49,7 @@ type Props = {
isScrollViewBeingDragged: boolean isScrollViewBeingDragged: boolean
showControls: boolean showControls: boolean
dismissSwipePan: PanGesture dismissSwipePan: PanGesture
animatedStyle: ImageStyle | null
} }
const ImageItem = ({ const ImageItem = ({
imageSrc, imageSrc,
@@ -54,6 +57,7 @@ const ImageItem = ({
onZoom, onZoom,
isScrollViewBeingDragged, isScrollViewBeingDragged,
dismissSwipePan, dismissSwipePan,
animatedStyle,
}: Props) => { }: Props) => {
const [isScaled, setIsScaled] = useState(false) const [isScaled, setIsScaled] = useState(false)
const [imageAspect, imageDimensions] = useImageDimensions({ const [imageAspect, imageDimensions] = useImageDimensions({
@@ -95,7 +99,7 @@ const ImageItem = ({
onZoom(nextIsScaled) onZoom(nextIsScaled)
} }
const animatedStyle = useAnimatedStyle(() => { const animatedContainerStyle = useAnimatedStyle(() => {
// Apply the active adjustments on top of the committed transform before the gestures. // 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. // This is matrix multiplication, so operations are applied in the reverse order.
let t = createTransform() let t = createTransform()
@@ -293,19 +297,14 @@ const ImageItem = ({
) )
return ( return (
<GestureDetector gesture={composedGesture}>
<Animated.View <Animated.View
ref={containerRef} ref={containerRef}
// Necessary to make opacity work for both children together. style={[styles.container, animatedContainerStyle, {}]}>
renderToHardwareTextureAndroid <Animated.View
style={[styles.container, animatedStyle, {}]}> style={[
<ActivityIndicator size="small" color="#FFF" style={styles.loading} /> animatedStyle,
<GestureDetector gesture={composedGesture}> {
<Image
contentFit="contain"
source={{uri: imageSrc.uri}}
placeholderContentFit="contain"
placeholder={{uri: imageSrc.thumbUri}}
style={{
width: SCREEN.width, width: SCREEN.width,
height: imageAspect ? SCREEN.width / imageAspect : undefined, height: imageAspect ? SCREEN.width / imageAspect : undefined,
borderRadius: borderRadius:
@@ -314,14 +313,23 @@ const ImageItem = ({
: imageSrc.type === 'rect-avi' : imageSrc.type === 'rect-avi'
? 20 ? 20
: 0, : 0,
}} },
]}>
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
<AnimatedImage
contentFit="contain"
source={{uri: imageSrc.uri}}
placeholderContentFit="contain"
placeholder={{uri: imageSrc.thumbUri}}
style={{flex: 1}}
accessibilityLabel={imageSrc.alt} accessibilityLabel={imageSrc.alt}
accessibilityHint="" accessibilityHint=""
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
cachePolicy="memory" cachePolicy="memory"
/> />
</GestureDetector>
</Animated.View> </Animated.View>
</Animated.View>
</GestureDetector>
) )
} }
@@ -14,12 +14,14 @@ import {
PanGesture, PanGesture,
} from 'react-native-gesture-handler' } from 'react-native-gesture-handler'
import Animated, {runOnJS, useAnimatedRef} from 'react-native-reanimated' import Animated, {runOnJS, useAnimatedRef} from 'react-native-reanimated'
import {Image} from 'expo-image' import {Image, ImageStyle} from 'expo-image'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {useImageDimensions} from '#/lib/media/image-sizes' import {useImageDimensions} from '#/lib/media/image-sizes'
import {ImageSource} from '../../@types' import {ImageSource} from '../../@types'
const AnimatedImage = Animated.createAnimatedComponent(Image)
const SCREEN = Dimensions.get('screen') const SCREEN = Dimensions.get('screen')
const MAX_ORIGINAL_IMAGE_ZOOM = 2 const MAX_ORIGINAL_IMAGE_ZOOM = 2
const MIN_DOUBLE_TAP_SCALE = 2 const MIN_DOUBLE_TAP_SCALE = 2
@@ -32,6 +34,7 @@ type Props = {
isScrollViewBeingDragged: boolean isScrollViewBeingDragged: boolean
showControls: boolean showControls: boolean
dismissSwipePan: PanGesture | null dismissSwipePan: PanGesture | null
animatedStyle: ImageStyle | null
} }
const ImageItem = ({ const ImageItem = ({
@@ -40,6 +43,7 @@ const ImageItem = ({
onZoom, onZoom,
showControls, showControls,
dismissSwipePan, dismissSwipePan,
animatedStyle,
}: Props) => { }: Props) => {
const scrollViewRef = useAnimatedRef<Animated.ScrollView>() const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
@@ -128,13 +132,10 @@ const ImageItem = ({
onScroll={scrollHandler} onScroll={scrollHandler}
bounces={scaled} bounces={scaled}
centerContent> centerContent>
<ActivityIndicator size="small" color="#FFF" style={styles.loading} /> <Animated.View
<Image style={[
contentFit="contain" animatedStyle,
source={{uri: imageSrc.uri}} {
placeholderContentFit="contain"
placeholder={{uri: imageSrc.thumbUri}}
style={{
width: SCREEN.width, width: SCREEN.width,
height: imageAspect ? SCREEN.width / imageAspect : undefined, height: imageAspect ? SCREEN.width / imageAspect : undefined,
borderRadius: borderRadius:
@@ -143,12 +144,21 @@ const ImageItem = ({
: imageSrc.type === 'rect-avi' : imageSrc.type === 'rect-avi'
? 20 ? 20
: 0, : 0,
}} },
]}>
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
<AnimatedImage
contentFit="cover"
source={{uri: imageSrc.uri}}
placeholderContentFit="cover"
placeholder={{uri: imageSrc.thumbUri}}
accessibilityLabel={imageSrc.alt} accessibilityLabel={imageSrc.alt}
accessibilityHint="" accessibilityHint=""
enableLiveTextInteraction={showControls && !scaled} enableLiveTextInteraction={showControls && !scaled}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
style={{flex: 1}}
/> />
</Animated.View>
</Animated.ScrollView> </Animated.ScrollView>
</GestureDetector> </GestureDetector>
) )
@@ -3,6 +3,7 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {PanGesture} from 'react-native-gesture-handler' import {PanGesture} from 'react-native-gesture-handler'
import {ImageStyle} from 'expo-image'
import {ImageSource} from '../../@types' import {ImageSource} from '../../@types'
@@ -14,6 +15,7 @@ type Props = {
isScrollViewBeingDragged: boolean isScrollViewBeingDragged: boolean
showControls: boolean showControls: boolean
dismissSwipePan: PanGesture | null dismissSwipePan: PanGesture | null
animatedStyle: ImageStyle | null
} }
const ImageItem = (_props: Props) => { const ImageItem = (_props: Props) => {
+14 -9
View File
@@ -72,6 +72,7 @@ function ImageViewing({
const [imageIndex, setImageIndex] = useState(initialImageIndex) const [imageIndex, setImageIndex] = useState(initialImageIndex)
const [showControls, setShowControls] = useState(true) const [showControls, setShowControls] = useState(true)
const dismissSwipeTranslateY = useSharedValue(0) const dismissSwipeTranslateY = useSharedValue(0)
const isFlyingAway = useSharedValue(false)
const animatedHeaderStyle = useAnimatedStyle(() => { const animatedHeaderStyle = useAnimatedStyle(() => {
const show = showControls && dismissSwipeTranslateY.value === 0 const show = showControls && dismissSwipeTranslateY.value === 0
@@ -98,10 +99,16 @@ function ImageViewing({
} }
}) })
const containerStyle = useAnimatedStyle(() => {
if (openProgress.value < 1 || isFlyingAway.value) {
return {pointerEvents: 'none'}
}
return {pointerEvents: 'auto'}
})
const activeImageStyle = useAnimatedStyle(() => { const activeImageStyle = useAnimatedStyle(() => {
if (openProgress.value === 1 || dismissSwipeTranslateY.value !== 0) { if (openProgress.value === 1 || dismissSwipeTranslateY.value !== 0) {
return { return {
pointerEvents: 'auto',
transform: [{translateY: dismissSwipeTranslateY.value}], transform: [{translateY: dismissSwipeTranslateY.value}],
} }
} }
@@ -114,12 +121,10 @@ function ImageViewing({
image.dimensions, image.dimensions,
) )
return { return {
pointerEvents: 'none',
transform: interpolatedTransform, transform: interpolatedTransform,
} }
} }
return { return {
pointerEvents: 'auto',
transform: [], transform: [],
} }
}) })
@@ -136,6 +141,7 @@ function ImageViewing({
.onEnd(e => { .onEnd(e => {
'worklet' 'worklet'
if (Math.abs(e.velocityY) > 1000) { if (Math.abs(e.velocityY) > 1000) {
isFlyingAway.value = true
dismissSwipeTranslateY.value = withDecay({ dismissSwipeTranslateY.value = withDecay({
velocity: e.velocityY, velocity: e.velocityY,
velocityFactor: Math.max(3000 / Math.abs(e.velocityY), 1), // Speed up if it's too slow. velocityFactor: Math.max(3000 / Math.abs(e.velocityY), 1), // Speed up if it's too slow.
@@ -198,7 +204,7 @@ function ImageViewing({
edges={edges} edges={edges}
aria-modal aria-modal
accessibilityViewIsModal> accessibilityViewIsModal>
<View style={[styles.container]}> <Animated.View style={[styles.container, containerStyle]}>
<Animated.View style={[styles.backdrop, backdropStyle]} /> <Animated.View style={[styles.backdrop, backdropStyle]} />
<Animated.View style={[styles.header, animatedHeaderStyle]}> <Animated.View style={[styles.header, animatedHeaderStyle]}>
<ImageDefaultHeader onRequestClose={onRequestClose} /> <ImageDefaultHeader onRequestClose={onRequestClose} />
@@ -216,9 +222,7 @@ function ImageViewing({
overdrag={true} overdrag={true}
style={styles.pager}> style={styles.pager}>
{images.map((imageSrc, i) => ( {images.map((imageSrc, i) => (
<Animated.View <View key={imageSrc.uri}>
key={imageSrc.uri}
style={imageIndex === i ? activeImageStyle : null}>
<ImageItem <ImageItem
onTap={onTap} onTap={onTap}
onZoom={onZoom} onZoom={onZoom}
@@ -226,11 +230,12 @@ function ImageViewing({
onRequestClose={onRequestClose} onRequestClose={onRequestClose}
isScrollViewBeingDragged={isDragging} isScrollViewBeingDragged={isDragging}
showControls={showControls} showControls={showControls}
animatedStyle={imageIndex === i ? activeImageStyle : null}
dismissSwipePan={ dismissSwipePan={
imageIndex === i && !isDragging ? dismissSwipePan : null imageIndex === i && !isDragging ? dismissSwipePan : null
} }
/> />
</Animated.View> </View>
))} ))}
</PagerView> </PagerView>
<Animated.View style={[styles.footer, animatedFooterStyle]}> <Animated.View style={[styles.footer, animatedFooterStyle]}>
@@ -241,7 +246,7 @@ function ImageViewing({
onPressShare={onPressShare} onPressShare={onPressShare}
/> />
</Animated.View> </Animated.View>
</View> </Animated.View>
</SafeAreaView> </SafeAreaView>
) )
} }