Make "double tap to zoom" precise across platforms (#1482)

* Implement double tap for Android

* Match the new behavior on iOS
zio/stable
dan 2023-09-20 02:32:44 +01:00 committed by GitHub
parent 859588c3f6
commit d2c253a284
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 175 additions and 56 deletions

View File

@ -32,6 +32,7 @@ const SWIPE_CLOSE_VELOCITY = 1
const SCREEN = Dimensions.get('screen') const SCREEN = Dimensions.get('screen')
const SCREEN_WIDTH = SCREEN.width const SCREEN_WIDTH = SCREEN.width
const SCREEN_HEIGHT = SCREEN.height const SCREEN_HEIGHT = SCREEN.height
const MAX_SCALE = 2
type Props = { type Props = {
imageSrc: ImageSource imageSrc: ImageSource
@ -58,13 +59,18 @@ const ImageItem = ({
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [scaled, setScaled] = useState(false) const [scaled, setScaled] = useState(false)
const imageDimensions = useImageDimensions(imageSrc) const imageDimensions = useImageDimensions(imageSrc)
const handleDoubleTap = useDoubleTapToZoom(scrollViewRef, scaled, SCREEN) const handleDoubleTap = useDoubleTapToZoom(
scrollViewRef,
scaled,
SCREEN,
imageDimensions,
)
const [translate, scale] = getImageTransform(imageDimensions, SCREEN) const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
const scrollValueY = new Animated.Value(0) const scrollValueY = new Animated.Value(0)
const scaleValue = new Animated.Value(scale || 1) const scaleValue = new Animated.Value(scale || 1)
const translateValue = new Animated.ValueXY(translate) const translateValue = new Animated.ValueXY(translate)
const maxScale = scale && scale > 0 ? Math.max(1 / scale, 1) : 1 const maxScrollViewZoom = MAX_SCALE / (scale || 1)
const imageOpacity = scrollValueY.interpolate({ const imageOpacity = scrollValueY.interpolate({
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
@ -118,7 +124,7 @@ const ImageItem = ({
pinchGestureEnabled pinchGestureEnabled
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
maximumZoomScale={maxScale} maximumZoomScale={maxScrollViewZoom}
contentContainerStyle={styles.imageScrollContainer} contentContainerStyle={styles.imageScrollContainer}
scrollEnabled={swipeToCloseEnabled} scrollEnabled={swipeToCloseEnabled}
onScrollEndDrag={onScrollEndDrag} onScrollEndDrag={onScrollEndDrag}

View File

@ -12,6 +12,8 @@ import {ScrollView, NativeTouchEvent, NativeSyntheticEvent} from 'react-native'
import {Dimensions} from '../@types' import {Dimensions} from '../@types'
const DOUBLE_TAP_DELAY = 300 const DOUBLE_TAP_DELAY = 300
const MIN_ZOOM = 2
let lastTapTS: number | null = null let lastTapTS: number | null = null
/** /**
@ -22,41 +24,124 @@ function useDoubleTapToZoom(
scrollViewRef: React.RefObject<ScrollView>, scrollViewRef: React.RefObject<ScrollView>,
scaled: boolean, scaled: boolean,
screen: Dimensions, screen: Dimensions,
imageDimensions: Dimensions | null,
) { ) {
const handleDoubleTap = useCallback( const handleDoubleTap = useCallback(
(event: NativeSyntheticEvent<NativeTouchEvent>) => { (event: NativeSyntheticEvent<NativeTouchEvent>) => {
const nowTS = new Date().getTime() const nowTS = new Date().getTime()
const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) { const getZoomRectAfterDoubleTap = (
const {pageX, pageY} = event.nativeEvent touchX: number,
let targetX = 0 touchY: number,
let targetY = 0 ): {
let targetWidth = screen.width x: number
let targetHeight = screen.height y: number
width: number
height: number
} => {
if (!imageDimensions) {
return {
x: 0,
y: 0,
width: screen.width,
height: screen.height,
}
}
// Zooming in // First, let's figure out how much we want to zoom in.
// TODO: Add more precise calculation of targetX, targetY based on touch // We want to try to zoom in at least close enough to get rid of black bars.
if (!scaled) { const imageAspect = imageDimensions.width / imageDimensions.height
targetX = pageX / 2 const screenAspect = screen.width / screen.height
targetY = pageY / 2 const zoom = Math.max(
targetWidth = screen.width / 2 imageAspect / screenAspect,
targetHeight = screen.height / 2 screenAspect / imageAspect,
MIN_ZOOM,
)
// Unlike in the Android version, we don't constrain the *max* zoom level here.
// Instead, this is done in the ScrollView props so that it constraints pinch too.
// Next, we'll be calculating the rectangle to "zoom into" in screen coordinates.
// We already know the zoom level, so this gives us the rectangle size.
let rectWidth = screen.width / zoom
let rectHeight = screen.height / zoom
// Before we settle on the zoomed rect, figure out the safe area it has to be inside.
// We don't want to introduce new black bars or make existing black bars unbalanced.
let minX = 0
let minY = 0
let maxX = screen.width - rectWidth
let maxY = screen.height - rectHeight
if (imageAspect >= screenAspect) {
// The image has horizontal black bars. Exclude them from the safe area.
const renderedHeight = screen.width / imageAspect
const horizontalBarHeight = (screen.height - renderedHeight) / 2
minY += horizontalBarHeight
maxY -= horizontalBarHeight
} else {
// The image has vertical black bars. Exclude them from the safe area.
const renderedWidth = screen.height * imageAspect
const verticalBarWidth = (screen.width - renderedWidth) / 2
minX += verticalBarWidth
maxX -= verticalBarWidth
}
// Finally, we can position the rect according to its size and the safe area.
let rectX
if (maxX >= minX) {
// Content fills the screen horizontally so we have horizontal wiggle room.
// Try to keep the tapped point under the finger after zoom.
rectX = touchX - touchX / zoom
rectX = Math.min(rectX, maxX)
rectX = Math.max(rectX, minX)
} else {
// Keep the rect centered on the screen so that black bars are balanced.
rectX = screen.width / 2 - rectWidth / 2
}
let rectY
if (maxY >= minY) {
// Content fills the screen vertically so we have vertical wiggle room.
// Try to keep the tapped point under the finger after zoom.
rectY = touchY - touchY / zoom
rectY = Math.min(rectY, maxY)
rectY = Math.max(rectY, minY)
} else {
// Keep the rect centered on the screen so that black bars are balanced.
rectY = screen.height / 2 - rectHeight / 2
}
return {
x: rectX,
y: rectY,
height: rectHeight,
width: rectWidth,
}
}
if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) {
let nextZoomRect = {
x: 0,
y: 0,
width: screen.width,
height: screen.height,
}
const willZoom = !scaled
if (willZoom) {
const {pageX, pageY} = event.nativeEvent
nextZoomRect = getZoomRectAfterDoubleTap(pageX, pageY)
} }
// @ts-ignore // @ts-ignore
scrollResponderRef?.scrollResponderZoomTo({ scrollResponderRef?.scrollResponderZoomTo({
x: targetX, ...nextZoomRect, // This rect is in screen coordinates
y: targetY,
width: targetWidth,
height: targetHeight,
animated: true, animated: true,
}) })
} else { } else {
lastTapTS = nowTS lastTapTS = nowTS
} }
}, },
[scaled, screen.height, screen.width, scrollViewRef], [imageDimensions, scaled, screen.height, screen.width, scrollViewRef],
) )
return handleDoubleTap return handleDoubleTap

View File

@ -29,8 +29,10 @@ const SCREEN = Dimensions.get('window')
const SCREEN_WIDTH = SCREEN.width const SCREEN_WIDTH = SCREEN.width
const SCREEN_HEIGHT = SCREEN.height const SCREEN_HEIGHT = SCREEN.height
const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT) const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT)
const ANDROID_BAR_HEIGHT = 24
const SCALE_MAX = 2 const MIN_ZOOM = 2
const MAX_SCALE = 2
const DOUBLE_TAP_DELAY = 300 const DOUBLE_TAP_DELAY = 300
const OUT_BOUND_MULTIPLIER = 0.75 const OUT_BOUND_MULTIPLIER = 0.75
@ -87,23 +89,56 @@ const usePanResponder = ({
return [top, left, bottom, right] return [top, left, bottom, right]
} }
const getTranslateInBounds = (translate: Position, scale: number) => { const getTransformAfterDoubleTap = (
const inBoundTranslate = {x: translate.x, y: translate.y} touchX: number,
const [topBound, leftBound, bottomBound, rightBound] = getBounds(scale) touchY: number,
): [number, Position] => {
let nextScale = initialScale
let nextTranslateX = initialTranslate.x
let nextTranslateY = initialTranslate.y
if (translate.x > leftBound) { // First, let's figure out how much we want to zoom in.
inBoundTranslate.x = leftBound // We want to try to zoom in at least close enough to get rid of black bars.
} else if (translate.x < rightBound) { const imageAspect = imageDimensions.width / imageDimensions.height
inBoundTranslate.x = rightBound const screenAspect = SCREEN.width / SCREEN.height
let zoom = Math.max(
imageAspect / screenAspect,
screenAspect / imageAspect,
MIN_ZOOM,
)
// Don't zoom so hard that the original image's pixels become blurry.
zoom = Math.min(zoom, MAX_SCALE / initialScale)
nextScale = initialScale * zoom
// Next, let's see if we need to adjust the scaled image translation.
// Ideally, we want the tapped point to stay under the finger after the scaling.
const dx = SCREEN.width / 2 - touchX
const dy = SCREEN.height / 2 - (touchY - ANDROID_BAR_HEIGHT)
// Before we try to adjust the translation, check how much wiggle room we have.
// We don't want to introduce new black bars or make existing black bars unbalanced.
const [topBound, leftBound, bottomBound, rightBound] = getBounds(nextScale)
if (leftBound > rightBound) {
// Content fills the screen horizontally so we have horizontal wiggle room.
// Try to keep the tapped point under the finger after zoom.
nextTranslateX += dx * zoom - dx
nextTranslateX = Math.min(nextTranslateX, leftBound)
nextTranslateX = Math.max(nextTranslateX, rightBound)
}
if (topBound > bottomBound) {
// Content fills the screen vertically so we have vertical wiggle room.
// Try to keep the tapped point under the finger after zoom.
nextTranslateY += dy * zoom - dy
nextTranslateY = Math.min(nextTranslateY, topBound)
nextTranslateY = Math.max(nextTranslateY, bottomBound)
} }
if (translate.y > topBound) { return [
inBoundTranslate.y = topBound nextScale,
} else if (translate.y < bottomBound) { {
inBoundTranslate.y = bottomBound x: nextTranslateX,
} y: nextTranslateY,
},
return inBoundTranslate ]
} }
const fitsScreenByWidth = () => const fitsScreenByWidth = () =>
@ -157,25 +192,18 @@ const usePanResponder = ({
) )
if (doubleTapToZoomEnabled && isDoubleTapPerformed) { if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
const isScaled = currentTranslate.x !== initialTranslate.x // currentScale !== initialScale; let nextScale = initialScale
const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0] let nextTranslate = initialTranslate
const targetScale = SCALE_MAX
const nextScale = isScaled ? initialScale : targetScale
const nextTranslate = isScaled
? initialTranslate
: getTranslateInBounds(
{
x:
initialTranslate.x +
(SCREEN_WIDTH / 2 - touchX) * (targetScale / currentScale),
y:
initialTranslate.y +
(SCREEN_HEIGHT / 2 - touchY) * (targetScale / currentScale),
},
targetScale,
)
onZoom(!isScaled) const willZoom = currentScale === initialScale
if (willZoom) {
const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0]
;[nextScale, nextTranslate] = getTransformAfterDoubleTap(
touchX,
touchY,
)
}
onZoom(willZoom)
Animated.parallel( Animated.parallel(
[ [
@ -336,8 +364,8 @@ const usePanResponder = ({
} }
if (tmpScale > 0) { if (tmpScale > 0) {
if (tmpScale < initialScale || tmpScale > SCALE_MAX) { if (tmpScale < initialScale || tmpScale > MAX_SCALE) {
tmpScale = tmpScale < initialScale ? initialScale : SCALE_MAX tmpScale = tmpScale < initialScale ? initialScale : MAX_SCALE
Animated.timing(scaleValue, { Animated.timing(scaleValue, {
toValue: tmpScale, toValue: tmpScale,
duration: 100, duration: 100,