Toggle lightbox controls on tap (#1687)
* Make the lightbox controls animation smoother * Toggle controls on tap * Disable pointer events when hiddenzio/stable
parent
f447eaa669
commit
abfd9a8c0b
|
@ -34,11 +34,13 @@ const initialTransform = createTransform()
|
|||
type Props = {
|
||||
imageSrc: ImageSource
|
||||
onRequestClose: () => void
|
||||
onTap: () => void
|
||||
onZoom: (isZoomed: boolean) => void
|
||||
isScrollViewBeingDragged: boolean
|
||||
}
|
||||
const ImageItem = ({
|
||||
imageSrc,
|
||||
onTap,
|
||||
onZoom,
|
||||
onRequestClose,
|
||||
isScrollViewBeingDragged,
|
||||
|
@ -227,6 +229,10 @@ const ImageItem = ({
|
|||
panTranslation.value = {x: 0, y: 0}
|
||||
})
|
||||
|
||||
const singleTap = Gesture.Tap().onEnd(() => {
|
||||
runOnJS(onTap)()
|
||||
})
|
||||
|
||||
const doubleTap = Gesture.Tap()
|
||||
.numberOfTaps(2)
|
||||
.onEnd(e => {
|
||||
|
@ -297,6 +303,7 @@ const ImageItem = ({
|
|||
dismissSwipePan,
|
||||
Gesture.Simultaneous(pinch, pan),
|
||||
doubleTap,
|
||||
singleTap,
|
||||
)
|
||||
|
||||
const isLoading = !isLoaded || !imageDimensions
|
||||
|
|
|
@ -6,16 +6,9 @@
|
|||
*
|
||||
*/
|
||||
|
||||
import React, {useCallback, useState} from 'react'
|
||||
import React, {useState} from 'react'
|
||||
|
||||
import {
|
||||
Dimensions,
|
||||
StyleSheet,
|
||||
View,
|
||||
NativeSyntheticEvent,
|
||||
NativeTouchEvent,
|
||||
TouchableWithoutFeedback,
|
||||
} from 'react-native'
|
||||
import {Dimensions, StyleSheet} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import Animated, {
|
||||
interpolate,
|
||||
|
@ -25,13 +18,13 @@ import Animated, {
|
|||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated'
|
||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
||||
|
||||
import useImageDimensions from '../../hooks/useImageDimensions'
|
||||
|
||||
import {ImageSource, Dimensions as ImageDimensions} from '../../@types'
|
||||
import {ImageLoading} from './ImageLoading'
|
||||
|
||||
const DOUBLE_TAP_DELAY = 300
|
||||
const SWIPE_CLOSE_OFFSET = 75
|
||||
const SWIPE_CLOSE_VELOCITY = 1
|
||||
const SCREEN = Dimensions.get('screen')
|
||||
|
@ -41,15 +34,14 @@ const MIN_DOUBLE_TAP_SCALE = 2
|
|||
type Props = {
|
||||
imageSrc: ImageSource
|
||||
onRequestClose: () => void
|
||||
onTap: () => void
|
||||
onZoom: (scaled: boolean) => void
|
||||
isScrollViewBeingDragged: boolean
|
||||
}
|
||||
|
||||
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
||||
|
||||
let lastTapTS: number | null = null
|
||||
|
||||
const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
|
||||
const ImageItem = ({imageSrc, onTap, onZoom, onRequestClose}: Props) => {
|
||||
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
|
||||
const translationY = useSharedValue(0)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
@ -71,12 +63,18 @@ const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
|
|||
|
||||
const scrollHandler = useAnimatedScrollHandler({
|
||||
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) {
|
||||
const velocityY = e.velocity?.y ?? 0
|
||||
const nextIsScaled = e.zoomScale > 1
|
||||
runOnJS(handleZoom)(nextIsScaled)
|
||||
if (scaled !== nextIsScaled) {
|
||||
runOnJS(handleZoom)(nextIsScaled)
|
||||
}
|
||||
if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) {
|
||||
runOnJS(onRequestClose)()
|
||||
}
|
||||
|
@ -88,43 +86,46 @@ const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
|
|||
setScaled(nextIsScaled)
|
||||
}
|
||||
|
||||
const handleDoubleTap = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeTouchEvent>) => {
|
||||
const nowTS = new Date().getTime()
|
||||
const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
|
||||
function handleDoubleTap(absoluteX: number, absoluteY: number) {
|
||||
const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
|
||||
let nextZoomRect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: SCREEN.width,
|
||||
height: SCREEN.height,
|
||||
}
|
||||
|
||||
if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) {
|
||||
let nextZoomRect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: SCREEN.width,
|
||||
height: SCREEN.height,
|
||||
}
|
||||
const willZoom = !scaled
|
||||
if (willZoom) {
|
||||
nextZoomRect = getZoomRectAfterDoubleTap(
|
||||
imageDimensions,
|
||||
absoluteX,
|
||||
absoluteY,
|
||||
)
|
||||
}
|
||||
|
||||
const willZoom = !scaled
|
||||
if (willZoom) {
|
||||
const {pageX, pageY} = event.nativeEvent
|
||||
nextZoomRect = getZoomRectAfterDoubleTap(
|
||||
imageDimensions,
|
||||
pageX,
|
||||
pageY,
|
||||
)
|
||||
}
|
||||
// @ts-ignore
|
||||
scrollResponderRef?.scrollResponderZoomTo({
|
||||
...nextZoomRect, // This rect is in screen coordinates
|
||||
animated: true,
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
scrollResponderRef?.scrollResponderZoomTo({
|
||||
...nextZoomRect, // This rect is in screen coordinates
|
||||
animated: true,
|
||||
})
|
||||
} else {
|
||||
lastTapTS = nowTS
|
||||
}
|
||||
},
|
||||
[imageDimensions, scaled, scrollViewRef],
|
||||
)
|
||||
const singleTap = Gesture.Tap().onEnd(() => {
|
||||
runOnJS(onTap)()
|
||||
})
|
||||
|
||||
const doubleTap = Gesture.Tap()
|
||||
.numberOfTaps(2)
|
||||
.onEnd(e => {
|
||||
const {absoluteX, absoluteY} = e
|
||||
runOnJS(handleDoubleTap)(absoluteX, absoluteY)
|
||||
})
|
||||
|
||||
const composedGesture = Gesture.Exclusive(doubleTap, singleTap)
|
||||
|
||||
return (
|
||||
<View>
|
||||
<GestureDetector gesture={composedGesture}>
|
||||
<Animated.ScrollView
|
||||
// @ts-ignore Something's up with the types here
|
||||
ref={scrollViewRef}
|
||||
|
@ -136,21 +137,17 @@ const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
|
|||
contentContainerStyle={styles.imageScrollContainer}
|
||||
onScroll={scrollHandler}>
|
||||
{(!loaded || !imageDimensions) && <ImageLoading />}
|
||||
<TouchableWithoutFeedback
|
||||
onPress={handleDoubleTap}
|
||||
accessibilityRole="image"
|
||||
<AnimatedImage
|
||||
contentFit="contain"
|
||||
// NOTE: Don't pass imageSrc={imageSrc} or MobX will break.
|
||||
source={{uri: imageSrc.uri}}
|
||||
style={[styles.image, animatedStyle]}
|
||||
accessibilityLabel={imageSrc.alt}
|
||||
accessibilityHint="">
|
||||
<AnimatedImage
|
||||
contentFit="contain"
|
||||
// NOTE: Don't pass imageSrc={imageSrc} or MobX will break.
|
||||
source={{uri: imageSrc.uri}}
|
||||
style={[styles.image, animatedStyle]}
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
</TouchableWithoutFeedback>
|
||||
accessibilityHint=""
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
</Animated.ScrollView>
|
||||
</View>
|
||||
</GestureDetector>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import {ImageSource} from '../../@types'
|
|||
type Props = {
|
||||
imageSrc: ImageSource
|
||||
onRequestClose: () => void
|
||||
onTap: () => void
|
||||
onZoom: (scaled: boolean) => void
|
||||
isScrollViewBeingDragged: boolean
|
||||
}
|
||||
|
|
|
@ -43,24 +43,36 @@ function ImageViewing({
|
|||
const [isScaled, setIsScaled] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [imageIndex, setImageIndex] = useState(initialImageIndex)
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
|
||||
const animatedHeaderStyle = useAnimatedStyle(() => ({
|
||||
pointerEvents: showControls ? 'auto' : 'none',
|
||||
opacity: withClampedSpring(showControls ? 1 : 0),
|
||||
transform: [
|
||||
{
|
||||
translateY: withClampedSpring(isScaled ? -300 : 0),
|
||||
translateY: withClampedSpring(showControls ? 0 : -30),
|
||||
},
|
||||
],
|
||||
}))
|
||||
const animatedFooterStyle = useAnimatedStyle(() => ({
|
||||
pointerEvents: showControls ? 'auto' : 'none',
|
||||
opacity: withClampedSpring(showControls ? 1 : 0),
|
||||
transform: [
|
||||
{
|
||||
translateY: withClampedSpring(isScaled ? 300 : 0),
|
||||
translateY: withClampedSpring(showControls ? 0 : 30),
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const onTap = useCallback(() => {
|
||||
setShowControls(show => !show)
|
||||
}, [])
|
||||
|
||||
const onZoom = useCallback((nextIsScaled: boolean) => {
|
||||
setIsScaled(nextIsScaled)
|
||||
if (nextIsScaled) {
|
||||
setShowControls(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const edges = useMemo(() => {
|
||||
|
@ -105,6 +117,7 @@ function ImageViewing({
|
|||
{images.map(imageSrc => (
|
||||
<View key={imageSrc.uri}>
|
||||
<ImageItem
|
||||
onTap={onTap}
|
||||
onZoom={onZoom}
|
||||
imageSrc={imageSrc}
|
||||
onRequestClose={onRequestClose}
|
||||
|
@ -161,7 +174,7 @@ const EnhancedImageViewing = (props: Props) => (
|
|||
|
||||
function withClampedSpring(value: any) {
|
||||
'worklet'
|
||||
return withSpring(value, {overshootClamping: true})
|
||||
return withSpring(value, {overshootClamping: true, stiffness: 300})
|
||||
}
|
||||
|
||||
export default EnhancedImageViewing
|
||||
|
|
Loading…
Reference in New Issue