Skip to content

Commit

Permalink
[Lightbox] New dismiss gesture (#6135)
Browse files Browse the repository at this point in the history
* Make iOS scrollview bounded to the image

I've had to remove the dismiss handling because the scroll view no longer scrolls at rest.

* Fix double-tap not working right after a vertical swipe

It seems like for some reason the vertical swipe is still being handled by the scroll view, so double tap gets eaten while it's "coming back". But you don't really see it moving. Weird.

* Add an intermediate LightboxImage component

* Hoist useImageDimensions up

* Implement xplat dismiss gesture

This is now shared between platforms, letting us animate the backdrop and add a consistent "fly away" behavior.

* Optimize Android compositing perf

* Fix supertall images

For example, https://bsky.app/profile/schlagteslinks.bsky.social/post/3l7y4l6yur72e

* Fix oopsie
  • Loading branch information
gaearon authored Nov 8, 2024
1 parent 6570f56 commit 5d0610d
Show file tree
Hide file tree
Showing 4 changed files with 274 additions and 145 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import React, {useState} from 'react'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native'
import {
Gesture,
GestureDetector,
PanGesture,
} from 'react-native-gesture-handler'
import Animated, {
AnimatedRef,
measure,
Expand All @@ -9,12 +13,10 @@ import Animated, {
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
withDecay,
withSpring,
} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {Image, ImageStyle} from 'expo-image'

import {useImageDimensions} from '#/lib/media/image-sizes'
import type {Dimensions as ImageDimensions, ImageSource} from '../../@types'
import {
applyRounding,
Expand All @@ -26,6 +28,8 @@ import {
TransformMatrix,
} from '../../transforms'

const AnimatedImage = Animated.createAnimatedComponent(Image)

const MIN_SCREEN_ZOOM = 2
const MAX_ORIGINAL_IMAGE_ZOOM = 2

Expand All @@ -39,26 +43,28 @@ type Props = {
isScrollViewBeingDragged: boolean
showControls: boolean
safeAreaRef: AnimatedRef<View>
imageAspect: number | undefined
imageDimensions: ImageDimensions | undefined
imageStyle: StyleProp<ImageStyle>
dismissSwipePan: PanGesture
}
const ImageItem = ({
imageSrc,
onTap,
onZoom,
onRequestClose,
isScrollViewBeingDragged,
safeAreaRef,
imageAspect,
imageDimensions,
imageStyle,
dismissSwipePan,
}: Props) => {
const [isScaled, setIsScaled] = useState(false)
const [imageAspect, imageDimensions] = useImageDimensions({
src: imageSrc.uri,
knownDimensions: imageSrc.dimensions,
})
const committedTransform = useSharedValue(initialTransform)
const panTranslation = useSharedValue({x: 0, y: 0})
const pinchOrigin = useSharedValue({x: 0, y: 0})
const pinchScale = useSharedValue(1)
const pinchTranslation = useSharedValue({x: 0, y: 0})
const dismissSwipeTranslateY = useSharedValue(0)
const containerRef = useAnimatedRef()

// Keep track of when we're entering or leaving scaled rendering.
Expand Down Expand Up @@ -97,19 +103,8 @@ const ImageItem = ({
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
prependTransform(t, committedTransform.value)
const [translateX, translateY, scale] = readTransform(t)

const dismissDistance = dismissSwipeTranslateY.value
const screenSize = measure(safeAreaRef)
const dismissProgress = screenSize
? Math.min(Math.abs(dismissDistance) / (screenSize.height / 2), 1)
: 0
return {
opacity: 1 - dismissProgress,
transform: [
{translateX},
{translateY: translateY + dismissDistance},
{scale},
],
transform: [{translateX}, {translateY: translateY}, {scale}],
}
})

Expand Down Expand Up @@ -307,28 +302,6 @@ const ImageItem = ({
committedTransform.value = withClampedSpring(finalTransform)
})

const dismissSwipePan = Gesture.Pan()
.enabled(!isScaled)
.activeOffsetY([-10, 10])
.failOffsetX([-10, 10])
.maxPointers(1)
.onUpdate(e => {
'worklet'
dismissSwipeTranslateY.value = e.translationY
})
.onEnd(e => {
'worklet'
if (Math.abs(e.velocityY) > 1000) {
dismissSwipeTranslateY.value = withDecay({velocity: e.velocityY})
runOnJS(onRequestClose)()
} else {
dismissSwipeTranslateY.value = withSpring(0, {
stiffness: 700,
damping: 50,
})
}
})

const composedGesture = isScrollViewBeingDragged
? // If the parent is not at rest, provide a no-op gesture.
Gesture.Manual()
Expand All @@ -340,26 +313,28 @@ const ImageItem = ({
)

return (
<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} />
<GestureDetector gesture={composedGesture}>
<Image
contentFit="contain"
source={{uri: imageSrc.uri}}
placeholderContentFit="contain"
placeholder={{uri: imageSrc.thumbUri}}
style={styles.image}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
cachePolicy="memory"
/>
</GestureDetector>
</Animated.View>
<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={[styles.image]}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
cachePolicy="memory"
/>
</Animated.View>
</Animated.View>
</GestureDetector>
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,27 @@
*/

import React, {useState} from 'react'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native'
import {
Gesture,
GestureDetector,
PanGesture,
} from 'react-native-gesture-handler'
import Animated, {
AnimatedRef,
interpolate,
measure,
runOnJS,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import {useSafeAreaFrame} from 'react-native-safe-area-context'
import {Image} from 'expo-image'
import {Image, ImageStyle} from 'expo-image'

import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {useImageDimensions} from '#/lib/media/image-sizes'
import {ImageSource} from '../../@types'
import {Dimensions as ImageDimensions, ImageSource} from '../../@types'

const AnimatedImage = Animated.createAnimatedComponent(Image)

const SWIPE_CLOSE_OFFSET = 75
const SWIPE_CLOSE_VELOCITY = 1
const MAX_ORIGINAL_IMAGE_ZOOM = 2
const MIN_SCREEN_ZOOM = 2

Expand All @@ -38,24 +39,26 @@ type Props = {
isScrollViewBeingDragged: boolean
showControls: boolean
safeAreaRef: AnimatedRef<View>
imageAspect: number | undefined
imageDimensions: ImageDimensions | undefined
imageStyle: StyleProp<ImageStyle>
dismissSwipePan: PanGesture
}

const ImageItem = ({
imageSrc,
onTap,
onZoom,
onRequestClose,
showControls,
safeAreaRef,
imageAspect,
imageDimensions,
imageStyle,
dismissSwipePan,
}: Props) => {
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
const translationY = useSharedValue(0)
const [scaled, setScaled] = useState(false)
const screenSizeDelayedForJSThreadOnly = useSafeAreaFrame()
const [imageAspect, imageDimensions] = useImageDimensions({
src: imageSrc.uri,
knownDimensions: imageSrc.dimensions,
})
const maxZoomScale = Math.max(
MIN_SCREEN_ZOOM,
imageDimensions
Expand All @@ -65,33 +68,21 @@ const ImageItem = ({
)

const animatedStyle = useAnimatedStyle(() => {
const screenSize = measure(safeAreaRef) ?? screenSizeDelayedForJSThreadOnly
return {
flex: 1,
opacity: interpolate(
translationY.value,
[-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
[0.5, 1, 0.5],
),
width: screenSize.width,
maxHeight: screenSize.height,
alignSelf: 'center',
aspectRatio: imageAspect,
}
})

const scrollHandler = useAnimatedScrollHandler({
onScroll(e) {
const nextIsScaled = e.zoomScale > 1
translationY.value = nextIsScaled ? 0 : e.contentOffset.y
if (scaled !== nextIsScaled) {
runOnJS(handleZoom)(nextIsScaled)
}
},
onEndDrag(e) {
const velocityY = e.velocity?.y ?? 0
const nextIsScaled = e.zoomScale > 1
if (scaled !== nextIsScaled) {
runOnJS(handleZoom)(nextIsScaled)
}
if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) {
runOnJS(onRequestClose)()
}
},
})

Expand Down Expand Up @@ -146,7 +137,11 @@ const ImageItem = ({
runOnJS(zoomTo)(nextZoomRect)
})

const composedGesture = Gesture.Exclusive(doubleTap, singleTap)
const composedGesture = Gesture.Exclusive(
dismissSwipePan,
doubleTap,
singleTap,
)

return (
<GestureDetector gesture={composedGesture}>
Expand All @@ -158,21 +153,22 @@ const ImageItem = ({
showsVerticalScrollIndicator={false}
maximumZoomScale={maxZoomScale}
onScroll={scrollHandler}
contentContainerStyle={styles.scrollContainer}>
<Animated.View style={animatedStyle}>
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
<Image
contentFit="contain"
source={{uri: imageSrc.uri}}
placeholderContentFit="contain"
placeholder={{uri: imageSrc.thumbUri}}
style={styles.image}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
enableLiveTextInteraction={showControls && !scaled}
accessibilityIgnoresInvertColors
/>
</Animated.View>
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}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
enableLiveTextInteraction={showControls && !scaled}
accessibilityIgnoresInvertColors
/>
</Animated.ScrollView>
</GestureDetector>
)
Expand All @@ -186,9 +182,6 @@ const styles = StyleSheet.create({
right: 0,
bottom: 0,
},
scrollContainer: {
flex: 1,
},
image: {
flex: 1,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// default implementation fallback for web

import React from 'react'
import {View} from 'react-native'
import {ImageStyle, StyleProp, View} from 'react-native'
import {PanGesture} from 'react-native-gesture-handler'
import {AnimatedRef} from 'react-native-reanimated'

import {ImageSource} from '../../@types'
import {Dimensions as ImageDimensions, ImageSource} from '../../@types'

type Props = {
imageSrc: ImageSource
Expand All @@ -14,6 +15,10 @@ type Props = {
isScrollViewBeingDragged: boolean
showControls: boolean
safeAreaRef: AnimatedRef<View>
imageAspect: number | undefined
imageDimensions: ImageDimensions | undefined
imageStyle: StyleProp<ImageStyle>
dismissSwipePan: PanGesture
}

const ImageItem = (_props: Props) => {
Expand Down
Loading

0 comments on commit 5d0610d

Please sign in to comment.