[Lightbox] New dismiss gesture (#6135)

* 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
This commit is contained in:
dan
2024-11-08 02:49:32 +00:00
committed by GitHub
parent 6570f56d8e
commit 5d0610d419
4 changed files with 274 additions and 145 deletions
@@ -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,
@@ -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,
@@ -26,6 +28,8 @@ import {
TransformMatrix,
} from '../../transforms'
const AnimatedImage = Animated.createAnimatedComponent(Image)
const MIN_SCREEN_ZOOM = 2
const MAX_ORIGINAL_IMAGE_ZOOM = 2
@@ -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.
@@ -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}],
}
})
@@ -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()
@@ -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>
)
}
@@ -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
@@ -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
@@ -65,34 +68,22 @@ 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)()
}
},
})
function handleZoom(nextIsScaled: boolean) {
@@ -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}>
@@ -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>
)
@@ -186,9 +182,6 @@ const styles = StyleSheet.create({
right: 0,
bottom: 0,
},
scrollContainer: {
flex: 1,
},
image: {
flex: 1,
},
@@ -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
@@ -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) => {
+183 -27
View File
@@ -10,17 +10,26 @@
import React, {useCallback, useState} from 'react'
import {LayoutAnimation, Platform, StyleSheet, View} from 'react-native'
import {Gesture} from 'react-native-gesture-handler'
import PagerView from 'react-native-pager-view'
import Animated, {
AnimatedRef,
cancelAnimation,
measure,
runOnJS,
SharedValue,
useAnimatedReaction,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
withDecay,
withSpring,
} from 'react-native-reanimated'
import {Edge, SafeAreaView} from 'react-native-safe-area-context'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Trans} from '@lingui/macro'
import {useImageDimensions} from '#/lib/media/image-sizes'
import {colors, s} from '#/lib/styles'
import {isIOS} from '#/platform/detection'
import {Lightbox} from '#/state/lightbox'
@@ -90,26 +99,55 @@ function ImageView({
const [isDragging, setIsDragging] = useState(false)
const [imageIndex, setImageIndex] = useState(initialImageIndex)
const [showControls, setShowControls] = useState(true)
const [isAltExpanded, setAltExpanded] = React.useState(false)
const dismissSwipeTranslateY = useSharedValue(0)
const isFlyingAway = useSharedValue(false)
const animatedHeaderStyle = useAnimatedStyle(() => ({
pointerEvents: showControls ? 'box-none' : 'none',
opacity: withClampedSpring(showControls ? 1 : 0),
transform: [
{
translateY: withClampedSpring(showControls ? 0 : -30),
},
],
}))
const animatedFooterStyle = useAnimatedStyle(() => ({
flexGrow: 1,
pointerEvents: showControls ? 'box-none' : 'none',
opacity: withClampedSpring(showControls ? 1 : 0),
transform: [
{
translateY: withClampedSpring(showControls ? 0 : 30),
},
],
}))
const containerStyle = useAnimatedStyle(() => {
if (isFlyingAway.value) {
return {pointerEvents: 'none'}
}
return {pointerEvents: 'auto'}
})
const backdropStyle = useAnimatedStyle(() => {
const screenSize = measure(safeAreaRef)
let opacity = 1
if (screenSize) {
const dragProgress = Math.min(
Math.abs(dismissSwipeTranslateY.value) / (screenSize.height / 2),
1,
)
opacity -= dragProgress
}
return {
opacity,
}
})
const animatedHeaderStyle = useAnimatedStyle(() => {
const show = showControls && dismissSwipeTranslateY.value === 0
return {
pointerEvents: show ? 'box-none' : 'none',
opacity: withClampedSpring(show ? 1 : 0),
transform: [
{
translateY: withClampedSpring(show ? 0 : -30),
},
],
}
})
const animatedFooterStyle = useAnimatedStyle(() => {
const show = showControls && dismissSwipeTranslateY.value === 0
return {
flexGrow: 1,
pointerEvents: show ? 'box-none' : 'none',
opacity: withClampedSpring(show ? 1 : 0),
transform: [
{
translateY: withClampedSpring(show ? 0 : 30),
},
],
}
})
const onTap = useCallback(() => {
setShowControls(show => !show)
@@ -123,7 +161,11 @@ function ImageView({
}, [])
return (
<View style={[styles.container]}>
<Animated.View style={[styles.container, containerStyle]}>
<Animated.View
style={[styles.backdrop, backdropStyle]}
renderToHardwareTextureAndroid
/>
<PagerView
scrollEnabled={!isScaled}
initialPage={initialImageIndex}
@@ -136,9 +178,9 @@ function ImageView({
}}
overdrag={true}
style={styles.pager}>
{images.map(imageSrc => (
{images.map((imageSrc, i) => (
<View key={imageSrc.uri}>
<ImageItem
<LightboxImage
onTap={onTap}
onZoom={onZoom}
imageSrc={imageSrc}
@@ -146,40 +188,147 @@ function ImageView({
isScrollViewBeingDragged={isDragging}
showControls={showControls}
safeAreaRef={safeAreaRef}
isScaled={isScaled}
isFlyingAway={isFlyingAway}
isActive={i === imageIndex}
dismissSwipeTranslateY={dismissSwipeTranslateY}
/>
</View>
))}
</PagerView>
<View style={styles.controls}>
<Animated.View style={animatedHeaderStyle}>
<Animated.View
style={animatedHeaderStyle}
renderToHardwareTextureAndroid>
<ImageDefaultHeader onRequestClose={onRequestClose} />
</Animated.View>
<Animated.View style={animatedFooterStyle}>
<Animated.View
style={animatedFooterStyle}
renderToHardwareTextureAndroid={!isAltExpanded}>
<LightboxFooter
images={images}
index={imageIndex}
isAltExpanded={isAltExpanded}
toggleAltExpanded={() => setAltExpanded(e => !e)}
onPressSave={onPressSave}
onPressShare={onPressShare}
/>
</Animated.View>
</View>
</View>
</Animated.View>
)
}
function LightboxImage({
imageSrc,
onTap,
onZoom,
onRequestClose,
isScrollViewBeingDragged,
isScaled,
isFlyingAway,
isActive,
showControls,
safeAreaRef,
dismissSwipeTranslateY,
}: {
imageSrc: ImageSource
onRequestClose: () => void
onTap: () => void
onZoom: (scaled: boolean) => void
isScrollViewBeingDragged: boolean
isScaled: boolean
isActive: boolean
isFlyingAway: SharedValue<boolean>
showControls: boolean
safeAreaRef: AnimatedRef<View>
dismissSwipeTranslateY: SharedValue<number>
}) {
const [imageAspect, imageDimensions] = useImageDimensions({
src: imageSrc.uri,
knownDimensions: imageSrc.dimensions,
})
const dismissSwipePan = Gesture.Pan()
.enabled(isActive && !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) {
isFlyingAway.value = true
dismissSwipeTranslateY.value = withDecay({
velocity: e.velocityY,
velocityFactor: Math.max(3000 / Math.abs(e.velocityY), 1), // Speed up if it's too slow.
deceleration: 1, // Danger! This relies on the reaction below stopping it.
})
} else {
dismissSwipeTranslateY.value = withSpring(0, {
stiffness: 700,
damping: 50,
})
}
})
useAnimatedReaction(
() => {
const screenSize = measure(safeAreaRef)
return (
!screenSize ||
Math.abs(dismissSwipeTranslateY.value) > screenSize.height
)
},
(isOut, wasOut) => {
if (isOut && !wasOut) {
// Stop the animation from blocking the screen forever.
cancelAnimation(dismissSwipeTranslateY)
runOnJS(onRequestClose)()
}
},
)
const imageStyle = useAnimatedStyle(() => {
return {
transform: [{translateY: dismissSwipeTranslateY.value}],
}
})
return (
<ImageItem
imageSrc={imageSrc}
onTap={onTap}
onZoom={onZoom}
onRequestClose={onRequestClose}
isScrollViewBeingDragged={isScrollViewBeingDragged}
showControls={showControls}
safeAreaRef={safeAreaRef}
imageAspect={imageAspect}
imageDimensions={imageDimensions}
imageStyle={imageStyle}
dismissSwipePan={dismissSwipePan}
/>
)
}
function LightboxFooter({
images,
index,
isAltExpanded,
toggleAltExpanded,
onPressSave,
onPressShare,
}: {
images: ImageSource[]
index: number
isAltExpanded: boolean
toggleAltExpanded: () => void
onPressSave: (uri: string) => void
onPressShare: (uri: string) => void
}) {
const {alt: altText, uri} = images[index]
const [isAltExpanded, setAltExpanded] = React.useState(false)
const isMomentumScrolling = React.useRef(false)
return (
<ScrollView
@@ -210,7 +359,7 @@ function LightboxFooter({
duration: 450,
update: {type: 'spring', springDamping: 1},
})
setAltExpanded(prev => !prev)
toggleAltExpanded()
}}
onLongPress={() => {}}>
{altText}
@@ -256,7 +405,14 @@ const styles = StyleSheet.create({
},
container: {
flex: 1,
},
backdrop: {
backgroundColor: '#000',
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
},
controls: {
position: 'absolute',