[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:
@@ -1,6 +1,10 @@
|
|||||||
import React, {useState} from 'react'
|
import React, {useState} from 'react'
|
||||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native'
|
||||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
import {
|
||||||
|
Gesture,
|
||||||
|
GestureDetector,
|
||||||
|
PanGesture,
|
||||||
|
} from 'react-native-gesture-handler'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
AnimatedRef,
|
AnimatedRef,
|
||||||
measure,
|
measure,
|
||||||
@@ -9,12 +13,10 @@ import Animated, {
|
|||||||
useAnimatedRef,
|
useAnimatedRef,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
withDecay,
|
|
||||||
withSpring,
|
withSpring,
|
||||||
} from 'react-native-reanimated'
|
} 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 type {Dimensions as ImageDimensions, ImageSource} from '../../@types'
|
||||||
import {
|
import {
|
||||||
applyRounding,
|
applyRounding,
|
||||||
@@ -26,6 +28,8 @@ import {
|
|||||||
TransformMatrix,
|
TransformMatrix,
|
||||||
} from '../../transforms'
|
} from '../../transforms'
|
||||||
|
|
||||||
|
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
||||||
|
|
||||||
const MIN_SCREEN_ZOOM = 2
|
const MIN_SCREEN_ZOOM = 2
|
||||||
const MAX_ORIGINAL_IMAGE_ZOOM = 2
|
const MAX_ORIGINAL_IMAGE_ZOOM = 2
|
||||||
|
|
||||||
@@ -39,26 +43,28 @@ type Props = {
|
|||||||
isScrollViewBeingDragged: boolean
|
isScrollViewBeingDragged: boolean
|
||||||
showControls: boolean
|
showControls: boolean
|
||||||
safeAreaRef: AnimatedRef<View>
|
safeAreaRef: AnimatedRef<View>
|
||||||
|
imageAspect: number | undefined
|
||||||
|
imageDimensions: ImageDimensions | undefined
|
||||||
|
imageStyle: StyleProp<ImageStyle>
|
||||||
|
dismissSwipePan: PanGesture
|
||||||
}
|
}
|
||||||
const ImageItem = ({
|
const ImageItem = ({
|
||||||
imageSrc,
|
imageSrc,
|
||||||
onTap,
|
onTap,
|
||||||
onZoom,
|
onZoom,
|
||||||
onRequestClose,
|
|
||||||
isScrollViewBeingDragged,
|
isScrollViewBeingDragged,
|
||||||
safeAreaRef,
|
safeAreaRef,
|
||||||
|
imageAspect,
|
||||||
|
imageDimensions,
|
||||||
|
imageStyle,
|
||||||
|
dismissSwipePan,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [isScaled, setIsScaled] = useState(false)
|
const [isScaled, setIsScaled] = useState(false)
|
||||||
const [imageAspect, imageDimensions] = useImageDimensions({
|
|
||||||
src: imageSrc.uri,
|
|
||||||
knownDimensions: imageSrc.dimensions,
|
|
||||||
})
|
|
||||||
const committedTransform = useSharedValue(initialTransform)
|
const committedTransform = useSharedValue(initialTransform)
|
||||||
const panTranslation = useSharedValue({x: 0, y: 0})
|
const panTranslation = useSharedValue({x: 0, y: 0})
|
||||||
const pinchOrigin = useSharedValue({x: 0, y: 0})
|
const pinchOrigin = useSharedValue({x: 0, y: 0})
|
||||||
const pinchScale = useSharedValue(1)
|
const pinchScale = useSharedValue(1)
|
||||||
const pinchTranslation = useSharedValue({x: 0, y: 0})
|
const pinchTranslation = useSharedValue({x: 0, y: 0})
|
||||||
const dismissSwipeTranslateY = useSharedValue(0)
|
|
||||||
const containerRef = useAnimatedRef()
|
const containerRef = useAnimatedRef()
|
||||||
|
|
||||||
// Keep track of when we're entering or leaving scaled rendering.
|
// 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)
|
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
|
||||||
prependTransform(t, committedTransform.value)
|
prependTransform(t, committedTransform.value)
|
||||||
const [translateX, translateY, scale] = readTransform(t)
|
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 {
|
return {
|
||||||
opacity: 1 - dismissProgress,
|
transform: [{translateX}, {translateY: translateY}, {scale}],
|
||||||
transform: [
|
|
||||||
{translateX},
|
|
||||||
{translateY: translateY + dismissDistance},
|
|
||||||
{scale},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -307,28 +302,6 @@ const ImageItem = ({
|
|||||||
committedTransform.value = withClampedSpring(finalTransform)
|
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
|
const composedGesture = isScrollViewBeingDragged
|
||||||
? // If the parent is not at rest, provide a no-op gesture.
|
? // If the parent is not at rest, provide a no-op gesture.
|
||||||
Gesture.Manual()
|
Gesture.Manual()
|
||||||
@@ -340,26 +313,28 @@ const ImageItem = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<GestureDetector gesture={composedGesture}>
|
||||||
ref={containerRef}
|
<Animated.View style={imageStyle} renderToHardwareTextureAndroid>
|
||||||
// Necessary to make opacity work for both children together.
|
<Animated.View
|
||||||
renderToHardwareTextureAndroid
|
ref={containerRef}
|
||||||
style={[styles.container, animatedStyle]}>
|
// Necessary to make opacity work for both children together.
|
||||||
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
renderToHardwareTextureAndroid
|
||||||
<GestureDetector gesture={composedGesture}>
|
style={[styles.container, animatedStyle]}>
|
||||||
<Image
|
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
||||||
contentFit="contain"
|
<AnimatedImage
|
||||||
source={{uri: imageSrc.uri}}
|
contentFit="contain"
|
||||||
placeholderContentFit="contain"
|
source={{uri: imageSrc.uri}}
|
||||||
placeholder={{uri: imageSrc.thumbUri}}
|
placeholderContentFit="contain"
|
||||||
style={styles.image}
|
placeholder={{uri: imageSrc.thumbUri}}
|
||||||
accessibilityLabel={imageSrc.alt}
|
style={[styles.image]}
|
||||||
accessibilityHint=""
|
accessibilityLabel={imageSrc.alt}
|
||||||
accessibilityIgnoresInvertColors
|
accessibilityHint=""
|
||||||
cachePolicy="memory"
|
accessibilityIgnoresInvertColors
|
||||||
/>
|
cachePolicy="memory"
|
||||||
</GestureDetector>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,26 +7,27 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {useState} from 'react'
|
import React, {useState} from 'react'
|
||||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native'
|
||||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
import {
|
||||||
|
Gesture,
|
||||||
|
GestureDetector,
|
||||||
|
PanGesture,
|
||||||
|
} from 'react-native-gesture-handler'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
AnimatedRef,
|
AnimatedRef,
|
||||||
interpolate,
|
|
||||||
measure,
|
measure,
|
||||||
runOnJS,
|
runOnJS,
|
||||||
useAnimatedRef,
|
useAnimatedRef,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
|
||||||
} from 'react-native-reanimated'
|
} from 'react-native-reanimated'
|
||||||
import {useSafeAreaFrame} from 'react-native-safe-area-context'
|
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 {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||||
import {useImageDimensions} from '#/lib/media/image-sizes'
|
import {Dimensions as ImageDimensions, ImageSource} from '../../@types'
|
||||||
import {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 MAX_ORIGINAL_IMAGE_ZOOM = 2
|
||||||
const MIN_SCREEN_ZOOM = 2
|
const MIN_SCREEN_ZOOM = 2
|
||||||
|
|
||||||
@@ -38,24 +39,26 @@ type Props = {
|
|||||||
isScrollViewBeingDragged: boolean
|
isScrollViewBeingDragged: boolean
|
||||||
showControls: boolean
|
showControls: boolean
|
||||||
safeAreaRef: AnimatedRef<View>
|
safeAreaRef: AnimatedRef<View>
|
||||||
|
imageAspect: number | undefined
|
||||||
|
imageDimensions: ImageDimensions | undefined
|
||||||
|
imageStyle: StyleProp<ImageStyle>
|
||||||
|
dismissSwipePan: PanGesture
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImageItem = ({
|
const ImageItem = ({
|
||||||
imageSrc,
|
imageSrc,
|
||||||
onTap,
|
onTap,
|
||||||
onZoom,
|
onZoom,
|
||||||
onRequestClose,
|
|
||||||
showControls,
|
showControls,
|
||||||
safeAreaRef,
|
safeAreaRef,
|
||||||
|
imageAspect,
|
||||||
|
imageDimensions,
|
||||||
|
imageStyle,
|
||||||
|
dismissSwipePan,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
|
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
|
||||||
const translationY = useSharedValue(0)
|
|
||||||
const [scaled, setScaled] = useState(false)
|
const [scaled, setScaled] = useState(false)
|
||||||
const screenSizeDelayedForJSThreadOnly = useSafeAreaFrame()
|
const screenSizeDelayedForJSThreadOnly = useSafeAreaFrame()
|
||||||
const [imageAspect, imageDimensions] = useImageDimensions({
|
|
||||||
src: imageSrc.uri,
|
|
||||||
knownDimensions: imageSrc.dimensions,
|
|
||||||
})
|
|
||||||
const maxZoomScale = Math.max(
|
const maxZoomScale = Math.max(
|
||||||
MIN_SCREEN_ZOOM,
|
MIN_SCREEN_ZOOM,
|
||||||
imageDimensions
|
imageDimensions
|
||||||
@@ -65,34 +68,22 @@ const ImageItem = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
const screenSize = measure(safeAreaRef) ?? screenSizeDelayedForJSThreadOnly
|
||||||
return {
|
return {
|
||||||
flex: 1,
|
width: screenSize.width,
|
||||||
opacity: interpolate(
|
maxHeight: screenSize.height,
|
||||||
translationY.value,
|
alignSelf: 'center',
|
||||||
[-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
|
aspectRatio: imageAspect,
|
||||||
[0.5, 1, 0.5],
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const scrollHandler = useAnimatedScrollHandler({
|
const scrollHandler = useAnimatedScrollHandler({
|
||||||
onScroll(e) {
|
onScroll(e) {
|
||||||
const nextIsScaled = e.zoomScale > 1
|
const nextIsScaled = e.zoomScale > 1
|
||||||
translationY.value = nextIsScaled ? 0 : e.contentOffset.y
|
|
||||||
if (scaled !== nextIsScaled) {
|
if (scaled !== nextIsScaled) {
|
||||||
runOnJS(handleZoom)(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) {
|
function handleZoom(nextIsScaled: boolean) {
|
||||||
@@ -146,7 +137,11 @@ const ImageItem = ({
|
|||||||
runOnJS(zoomTo)(nextZoomRect)
|
runOnJS(zoomTo)(nextZoomRect)
|
||||||
})
|
})
|
||||||
|
|
||||||
const composedGesture = Gesture.Exclusive(doubleTap, singleTap)
|
const composedGesture = Gesture.Exclusive(
|
||||||
|
dismissSwipePan,
|
||||||
|
doubleTap,
|
||||||
|
singleTap,
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureDetector gesture={composedGesture}>
|
<GestureDetector gesture={composedGesture}>
|
||||||
@@ -158,21 +153,22 @@ const ImageItem = ({
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
maximumZoomScale={maxZoomScale}
|
maximumZoomScale={maxZoomScale}
|
||||||
onScroll={scrollHandler}
|
onScroll={scrollHandler}
|
||||||
contentContainerStyle={styles.scrollContainer}>
|
bounces={scaled}
|
||||||
<Animated.View style={animatedStyle}>
|
bouncesZoom={true}
|
||||||
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
style={imageStyle}
|
||||||
<Image
|
centerContent>
|
||||||
contentFit="contain"
|
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
||||||
source={{uri: imageSrc.uri}}
|
<AnimatedImage
|
||||||
placeholderContentFit="contain"
|
contentFit="contain"
|
||||||
placeholder={{uri: imageSrc.thumbUri}}
|
source={{uri: imageSrc.uri}}
|
||||||
style={styles.image}
|
placeholderContentFit="contain"
|
||||||
accessibilityLabel={imageSrc.alt}
|
placeholder={{uri: imageSrc.thumbUri}}
|
||||||
accessibilityHint=""
|
style={animatedStyle}
|
||||||
enableLiveTextInteraction={showControls && !scaled}
|
accessibilityLabel={imageSrc.alt}
|
||||||
accessibilityIgnoresInvertColors
|
accessibilityHint=""
|
||||||
/>
|
enableLiveTextInteraction={showControls && !scaled}
|
||||||
</Animated.View>
|
accessibilityIgnoresInvertColors
|
||||||
|
/>
|
||||||
</Animated.ScrollView>
|
</Animated.ScrollView>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
)
|
)
|
||||||
@@ -186,9 +182,6 @@ const styles = StyleSheet.create({
|
|||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
},
|
},
|
||||||
scrollContainer: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
image: {
|
image: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// default implementation fallback for web
|
// default implementation fallback for web
|
||||||
|
|
||||||
import React from 'react'
|
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 {AnimatedRef} from 'react-native-reanimated'
|
||||||
|
|
||||||
import {ImageSource} from '../../@types'
|
import {Dimensions as ImageDimensions, ImageSource} from '../../@types'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
imageSrc: ImageSource
|
imageSrc: ImageSource
|
||||||
@@ -14,6 +15,10 @@ type Props = {
|
|||||||
isScrollViewBeingDragged: boolean
|
isScrollViewBeingDragged: boolean
|
||||||
showControls: boolean
|
showControls: boolean
|
||||||
safeAreaRef: AnimatedRef<View>
|
safeAreaRef: AnimatedRef<View>
|
||||||
|
imageAspect: number | undefined
|
||||||
|
imageDimensions: ImageDimensions | undefined
|
||||||
|
imageStyle: StyleProp<ImageStyle>
|
||||||
|
dismissSwipePan: PanGesture
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImageItem = (_props: Props) => {
|
const ImageItem = (_props: Props) => {
|
||||||
|
|||||||
@@ -10,17 +10,26 @@
|
|||||||
|
|
||||||
import React, {useCallback, useState} from 'react'
|
import React, {useCallback, useState} from 'react'
|
||||||
import {LayoutAnimation, Platform, StyleSheet, View} from 'react-native'
|
import {LayoutAnimation, Platform, StyleSheet, View} from 'react-native'
|
||||||
|
import {Gesture} from 'react-native-gesture-handler'
|
||||||
import PagerView from 'react-native-pager-view'
|
import PagerView from 'react-native-pager-view'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
AnimatedRef,
|
AnimatedRef,
|
||||||
|
cancelAnimation,
|
||||||
|
measure,
|
||||||
|
runOnJS,
|
||||||
|
SharedValue,
|
||||||
|
useAnimatedReaction,
|
||||||
useAnimatedRef,
|
useAnimatedRef,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withDecay,
|
||||||
withSpring,
|
withSpring,
|
||||||
} from 'react-native-reanimated'
|
} from 'react-native-reanimated'
|
||||||
import {Edge, SafeAreaView} from 'react-native-safe-area-context'
|
import {Edge, SafeAreaView} from 'react-native-safe-area-context'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {Trans} from '@lingui/macro'
|
import {Trans} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {useImageDimensions} from '#/lib/media/image-sizes'
|
||||||
import {colors, s} from '#/lib/styles'
|
import {colors, s} from '#/lib/styles'
|
||||||
import {isIOS} from '#/platform/detection'
|
import {isIOS} from '#/platform/detection'
|
||||||
import {Lightbox} from '#/state/lightbox'
|
import {Lightbox} from '#/state/lightbox'
|
||||||
@@ -90,26 +99,55 @@ function ImageView({
|
|||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
const [imageIndex, setImageIndex] = useState(initialImageIndex)
|
const [imageIndex, setImageIndex] = useState(initialImageIndex)
|
||||||
const [showControls, setShowControls] = useState(true)
|
const [showControls, setShowControls] = useState(true)
|
||||||
|
const [isAltExpanded, setAltExpanded] = React.useState(false)
|
||||||
|
const dismissSwipeTranslateY = useSharedValue(0)
|
||||||
|
const isFlyingAway = useSharedValue(false)
|
||||||
|
|
||||||
const animatedHeaderStyle = useAnimatedStyle(() => ({
|
const containerStyle = useAnimatedStyle(() => {
|
||||||
pointerEvents: showControls ? 'box-none' : 'none',
|
if (isFlyingAway.value) {
|
||||||
opacity: withClampedSpring(showControls ? 1 : 0),
|
return {pointerEvents: 'none'}
|
||||||
transform: [
|
}
|
||||||
{
|
return {pointerEvents: 'auto'}
|
||||||
translateY: withClampedSpring(showControls ? 0 : -30),
|
})
|
||||||
},
|
const backdropStyle = useAnimatedStyle(() => {
|
||||||
],
|
const screenSize = measure(safeAreaRef)
|
||||||
}))
|
let opacity = 1
|
||||||
const animatedFooterStyle = useAnimatedStyle(() => ({
|
if (screenSize) {
|
||||||
flexGrow: 1,
|
const dragProgress = Math.min(
|
||||||
pointerEvents: showControls ? 'box-none' : 'none',
|
Math.abs(dismissSwipeTranslateY.value) / (screenSize.height / 2),
|
||||||
opacity: withClampedSpring(showControls ? 1 : 0),
|
1,
|
||||||
transform: [
|
)
|
||||||
{
|
opacity -= dragProgress
|
||||||
translateY: withClampedSpring(showControls ? 0 : 30),
|
}
|
||||||
},
|
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(() => {
|
const onTap = useCallback(() => {
|
||||||
setShowControls(show => !show)
|
setShowControls(show => !show)
|
||||||
@@ -123,7 +161,11 @@ function ImageView({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container]}>
|
<Animated.View style={[styles.container, containerStyle]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[styles.backdrop, backdropStyle]}
|
||||||
|
renderToHardwareTextureAndroid
|
||||||
|
/>
|
||||||
<PagerView
|
<PagerView
|
||||||
scrollEnabled={!isScaled}
|
scrollEnabled={!isScaled}
|
||||||
initialPage={initialImageIndex}
|
initialPage={initialImageIndex}
|
||||||
@@ -136,9 +178,9 @@ function ImageView({
|
|||||||
}}
|
}}
|
||||||
overdrag={true}
|
overdrag={true}
|
||||||
style={styles.pager}>
|
style={styles.pager}>
|
||||||
{images.map(imageSrc => (
|
{images.map((imageSrc, i) => (
|
||||||
<View key={imageSrc.uri}>
|
<View key={imageSrc.uri}>
|
||||||
<ImageItem
|
<LightboxImage
|
||||||
onTap={onTap}
|
onTap={onTap}
|
||||||
onZoom={onZoom}
|
onZoom={onZoom}
|
||||||
imageSrc={imageSrc}
|
imageSrc={imageSrc}
|
||||||
@@ -146,40 +188,147 @@ function ImageView({
|
|||||||
isScrollViewBeingDragged={isDragging}
|
isScrollViewBeingDragged={isDragging}
|
||||||
showControls={showControls}
|
showControls={showControls}
|
||||||
safeAreaRef={safeAreaRef}
|
safeAreaRef={safeAreaRef}
|
||||||
|
isScaled={isScaled}
|
||||||
|
isFlyingAway={isFlyingAway}
|
||||||
|
isActive={i === imageIndex}
|
||||||
|
dismissSwipeTranslateY={dismissSwipeTranslateY}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</PagerView>
|
</PagerView>
|
||||||
<View style={styles.controls}>
|
<View style={styles.controls}>
|
||||||
<Animated.View style={animatedHeaderStyle}>
|
<Animated.View
|
||||||
|
style={animatedHeaderStyle}
|
||||||
|
renderToHardwareTextureAndroid>
|
||||||
<ImageDefaultHeader onRequestClose={onRequestClose} />
|
<ImageDefaultHeader onRequestClose={onRequestClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<Animated.View style={animatedFooterStyle}>
|
<Animated.View
|
||||||
|
style={animatedFooterStyle}
|
||||||
|
renderToHardwareTextureAndroid={!isAltExpanded}>
|
||||||
<LightboxFooter
|
<LightboxFooter
|
||||||
images={images}
|
images={images}
|
||||||
index={imageIndex}
|
index={imageIndex}
|
||||||
|
isAltExpanded={isAltExpanded}
|
||||||
|
toggleAltExpanded={() => setAltExpanded(e => !e)}
|
||||||
onPressSave={onPressSave}
|
onPressSave={onPressSave}
|
||||||
onPressShare={onPressShare}
|
onPressShare={onPressShare}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</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({
|
function LightboxFooter({
|
||||||
images,
|
images,
|
||||||
index,
|
index,
|
||||||
|
isAltExpanded,
|
||||||
|
toggleAltExpanded,
|
||||||
onPressSave,
|
onPressSave,
|
||||||
onPressShare,
|
onPressShare,
|
||||||
}: {
|
}: {
|
||||||
images: ImageSource[]
|
images: ImageSource[]
|
||||||
index: number
|
index: number
|
||||||
|
isAltExpanded: boolean
|
||||||
|
toggleAltExpanded: () => void
|
||||||
onPressSave: (uri: string) => void
|
onPressSave: (uri: string) => void
|
||||||
onPressShare: (uri: string) => void
|
onPressShare: (uri: string) => void
|
||||||
}) {
|
}) {
|
||||||
const {alt: altText, uri} = images[index]
|
const {alt: altText, uri} = images[index]
|
||||||
const [isAltExpanded, setAltExpanded] = React.useState(false)
|
|
||||||
const isMomentumScrolling = React.useRef(false)
|
const isMomentumScrolling = React.useRef(false)
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -210,7 +359,7 @@ function LightboxFooter({
|
|||||||
duration: 450,
|
duration: 450,
|
||||||
update: {type: 'spring', springDamping: 1},
|
update: {type: 'spring', springDamping: 1},
|
||||||
})
|
})
|
||||||
setAltExpanded(prev => !prev)
|
toggleAltExpanded()
|
||||||
}}
|
}}
|
||||||
onLongPress={() => {}}>
|
onLongPress={() => {}}>
|
||||||
{altText}
|
{altText}
|
||||||
@@ -256,7 +405,14 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
},
|
||||||
|
backdrop: {
|
||||||
backgroundColor: '#000',
|
backgroundColor: '#000',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
},
|
},
|
||||||
controls: {
|
controls: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
Reference in New Issue
Block a user