Toggle lightbox controls on tap (#1687)

* Make the lightbox controls animation smoother

* Toggle controls on tap

* Disable pointer events when hidden
zio/stable
dan 2023-10-13 20:10:27 +01:00 committed by GitHub
parent f447eaa669
commit abfd9a8c0b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 81 additions and 63 deletions

View File

@ -34,11 +34,13 @@ const initialTransform = createTransform()
type Props = { type Props = {
imageSrc: ImageSource imageSrc: ImageSource
onRequestClose: () => void onRequestClose: () => void
onTap: () => void
onZoom: (isZoomed: boolean) => void onZoom: (isZoomed: boolean) => void
isScrollViewBeingDragged: boolean isScrollViewBeingDragged: boolean
} }
const ImageItem = ({ const ImageItem = ({
imageSrc, imageSrc,
onTap,
onZoom, onZoom,
onRequestClose, onRequestClose,
isScrollViewBeingDragged, isScrollViewBeingDragged,
@ -227,6 +229,10 @@ const ImageItem = ({
panTranslation.value = {x: 0, y: 0} panTranslation.value = {x: 0, y: 0}
}) })
const singleTap = Gesture.Tap().onEnd(() => {
runOnJS(onTap)()
})
const doubleTap = Gesture.Tap() const doubleTap = Gesture.Tap()
.numberOfTaps(2) .numberOfTaps(2)
.onEnd(e => { .onEnd(e => {
@ -297,6 +303,7 @@ const ImageItem = ({
dismissSwipePan, dismissSwipePan,
Gesture.Simultaneous(pinch, pan), Gesture.Simultaneous(pinch, pan),
doubleTap, doubleTap,
singleTap,
) )
const isLoading = !isLoaded || !imageDimensions const isLoading = !isLoaded || !imageDimensions

View File

@ -6,16 +6,9 @@
* *
*/ */
import React, {useCallback, useState} from 'react' import React, {useState} from 'react'
import { import {Dimensions, StyleSheet} from 'react-native'
Dimensions,
StyleSheet,
View,
NativeSyntheticEvent,
NativeTouchEvent,
TouchableWithoutFeedback,
} from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import Animated, { import Animated, {
interpolate, interpolate,
@ -25,13 +18,13 @@ import Animated, {
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import useImageDimensions from '../../hooks/useImageDimensions' import useImageDimensions from '../../hooks/useImageDimensions'
import {ImageSource, Dimensions as ImageDimensions} from '../../@types' import {ImageSource, Dimensions as ImageDimensions} from '../../@types'
import {ImageLoading} from './ImageLoading' import {ImageLoading} from './ImageLoading'
const DOUBLE_TAP_DELAY = 300
const SWIPE_CLOSE_OFFSET = 75 const SWIPE_CLOSE_OFFSET = 75
const SWIPE_CLOSE_VELOCITY = 1 const SWIPE_CLOSE_VELOCITY = 1
const SCREEN = Dimensions.get('screen') const SCREEN = Dimensions.get('screen')
@ -41,15 +34,14 @@ const MIN_DOUBLE_TAP_SCALE = 2
type Props = { type Props = {
imageSrc: ImageSource imageSrc: ImageSource
onRequestClose: () => void onRequestClose: () => void
onTap: () => void
onZoom: (scaled: boolean) => void onZoom: (scaled: boolean) => void
isScrollViewBeingDragged: boolean isScrollViewBeingDragged: boolean
} }
const AnimatedImage = Animated.createAnimatedComponent(Image) const AnimatedImage = Animated.createAnimatedComponent(Image)
let lastTapTS: number | null = null const ImageItem = ({imageSrc, onTap, onZoom, onRequestClose}: Props) => {
const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
const scrollViewRef = useAnimatedRef<Animated.ScrollView>() const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
const translationY = useSharedValue(0) const translationY = useSharedValue(0)
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
@ -71,12 +63,18 @@ const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
const scrollHandler = useAnimatedScrollHandler({ const scrollHandler = useAnimatedScrollHandler({
onScroll(e) { onScroll(e) {
translationY.value = e.zoomScale > 1 ? 0 : e.contentOffset.y const nextIsScaled = e.zoomScale > 1
translationY.value = nextIsScaled ? 0 : e.contentOffset.y
if (scaled !== nextIsScaled) {
runOnJS(handleZoom)(nextIsScaled)
}
}, },
onEndDrag(e) { onEndDrag(e) {
const velocityY = e.velocity?.y ?? 0 const velocityY = e.velocity?.y ?? 0
const nextIsScaled = e.zoomScale > 1 const nextIsScaled = e.zoomScale > 1
runOnJS(handleZoom)(nextIsScaled) if (scaled !== nextIsScaled) {
runOnJS(handleZoom)(nextIsScaled)
}
if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) { if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) {
runOnJS(onRequestClose)() runOnJS(onRequestClose)()
} }
@ -88,43 +86,46 @@ const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
setScaled(nextIsScaled) setScaled(nextIsScaled)
} }
const handleDoubleTap = useCallback( function handleDoubleTap(absoluteX: number, absoluteY: number) {
(event: NativeSyntheticEvent<NativeTouchEvent>) => { const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
const nowTS = new Date().getTime() let nextZoomRect = {
const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() x: 0,
y: 0,
width: SCREEN.width,
height: SCREEN.height,
}
if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) { const willZoom = !scaled
let nextZoomRect = { if (willZoom) {
x: 0, nextZoomRect = getZoomRectAfterDoubleTap(
y: 0, imageDimensions,
width: SCREEN.width, absoluteX,
height: SCREEN.height, absoluteY,
} )
}
const willZoom = !scaled // @ts-ignore
if (willZoom) { scrollResponderRef?.scrollResponderZoomTo({
const {pageX, pageY} = event.nativeEvent ...nextZoomRect, // This rect is in screen coordinates
nextZoomRect = getZoomRectAfterDoubleTap( animated: true,
imageDimensions, })
pageX, }
pageY,
)
}
// @ts-ignore const singleTap = Gesture.Tap().onEnd(() => {
scrollResponderRef?.scrollResponderZoomTo({ runOnJS(onTap)()
...nextZoomRect, // This rect is in screen coordinates })
animated: true,
}) const doubleTap = Gesture.Tap()
} else { .numberOfTaps(2)
lastTapTS = nowTS .onEnd(e => {
} const {absoluteX, absoluteY} = e
}, runOnJS(handleDoubleTap)(absoluteX, absoluteY)
[imageDimensions, scaled, scrollViewRef], })
)
const composedGesture = Gesture.Exclusive(doubleTap, singleTap)
return ( return (
<View> <GestureDetector gesture={composedGesture}>
<Animated.ScrollView <Animated.ScrollView
// @ts-ignore Something's up with the types here // @ts-ignore Something's up with the types here
ref={scrollViewRef} ref={scrollViewRef}
@ -136,21 +137,17 @@ const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
contentContainerStyle={styles.imageScrollContainer} contentContainerStyle={styles.imageScrollContainer}
onScroll={scrollHandler}> onScroll={scrollHandler}>
{(!loaded || !imageDimensions) && <ImageLoading />} {(!loaded || !imageDimensions) && <ImageLoading />}
<TouchableWithoutFeedback <AnimatedImage
onPress={handleDoubleTap} contentFit="contain"
accessibilityRole="image" // NOTE: Don't pass imageSrc={imageSrc} or MobX will break.
source={{uri: imageSrc.uri}}
style={[styles.image, animatedStyle]}
accessibilityLabel={imageSrc.alt} accessibilityLabel={imageSrc.alt}
accessibilityHint=""> accessibilityHint=""
<AnimatedImage onLoad={() => setLoaded(true)}
contentFit="contain" />
// NOTE: Don't pass imageSrc={imageSrc} or MobX will break.
source={{uri: imageSrc.uri}}
style={[styles.image, animatedStyle]}
onLoad={() => setLoaded(true)}
/>
</TouchableWithoutFeedback>
</Animated.ScrollView> </Animated.ScrollView>
</View> </GestureDetector>
) )
} }

View File

@ -7,6 +7,7 @@ import {ImageSource} from '../../@types'
type Props = { type Props = {
imageSrc: ImageSource imageSrc: ImageSource
onRequestClose: () => void onRequestClose: () => void
onTap: () => void
onZoom: (scaled: boolean) => void onZoom: (scaled: boolean) => void
isScrollViewBeingDragged: boolean isScrollViewBeingDragged: boolean
} }

View File

@ -43,24 +43,36 @@ function ImageViewing({
const [isScaled, setIsScaled] = useState(false) const [isScaled, setIsScaled] = useState(false)
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 animatedHeaderStyle = useAnimatedStyle(() => ({ const animatedHeaderStyle = useAnimatedStyle(() => ({
pointerEvents: showControls ? 'auto' : 'none',
opacity: withClampedSpring(showControls ? 1 : 0),
transform: [ transform: [
{ {
translateY: withClampedSpring(isScaled ? -300 : 0), translateY: withClampedSpring(showControls ? 0 : -30),
}, },
], ],
})) }))
const animatedFooterStyle = useAnimatedStyle(() => ({ const animatedFooterStyle = useAnimatedStyle(() => ({
pointerEvents: showControls ? 'auto' : 'none',
opacity: withClampedSpring(showControls ? 1 : 0),
transform: [ transform: [
{ {
translateY: withClampedSpring(isScaled ? 300 : 0), translateY: withClampedSpring(showControls ? 0 : 30),
}, },
], ],
})) }))
const onTap = useCallback(() => {
setShowControls(show => !show)
}, [])
const onZoom = useCallback((nextIsScaled: boolean) => { const onZoom = useCallback((nextIsScaled: boolean) => {
setIsScaled(nextIsScaled) setIsScaled(nextIsScaled)
if (nextIsScaled) {
setShowControls(false)
}
}, []) }, [])
const edges = useMemo(() => { const edges = useMemo(() => {
@ -105,6 +117,7 @@ function ImageViewing({
{images.map(imageSrc => ( {images.map(imageSrc => (
<View key={imageSrc.uri}> <View key={imageSrc.uri}>
<ImageItem <ImageItem
onTap={onTap}
onZoom={onZoom} onZoom={onZoom}
imageSrc={imageSrc} imageSrc={imageSrc}
onRequestClose={onRequestClose} onRequestClose={onRequestClose}
@ -161,7 +174,7 @@ const EnhancedImageViewing = (props: Props) => (
function withClampedSpring(value: any) { function withClampedSpring(value: any) {
'worklet' 'worklet'
return withSpring(value, {overshootClamping: true}) return withSpring(value, {overshootClamping: true, stiffness: 300})
} }
export default EnhancedImageViewing export default EnhancedImageViewing