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 = {
|
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
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue