[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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user