about summary refs log tree commit diff
path: root/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts
diff options
context:
space:
mode:
authordan <dan.abramov@gmail.com>2023-09-20 02:32:44 +0100
committerGitHub <noreply@github.com>2023-09-19 18:32:44 -0700
commitd2c253a284b3341e92ae104e49f2584602795575 (patch)
tree59def99fd9cf8353acb8cdd8528490cb41c5ace8 /src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts
parent859588c3f63949182acf3ca800b0229dd5e1d88e (diff)
downloadvoidsky-d2c253a284b3341e92ae104e49f2584602795575.tar.zst
Make "double tap to zoom" precise across platforms (#1482)
* Implement double tap for Android

* Match the new behavior on iOS
Diffstat (limited to 'src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts')
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts121
1 files changed, 103 insertions, 18 deletions
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts b/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts
index 92746e951..ea81d9f1c 100644
--- a/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts
+++ b/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts
@@ -12,6 +12,8 @@ import {ScrollView, NativeTouchEvent, NativeSyntheticEvent} from 'react-native'
 import {Dimensions} from '../@types'
 
 const DOUBLE_TAP_DELAY = 300
+const MIN_ZOOM = 2
+
 let lastTapTS: number | null = null
 
 /**
@@ -22,41 +24,124 @@ function useDoubleTapToZoom(
   scrollViewRef: React.RefObject<ScrollView>,
   scaled: boolean,
   screen: Dimensions,
+  imageDimensions: Dimensions | null,
 ) {
   const handleDoubleTap = useCallback(
     (event: NativeSyntheticEvent<NativeTouchEvent>) => {
       const nowTS = new Date().getTime()
       const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
 
+      const getZoomRectAfterDoubleTap = (
+        touchX: number,
+        touchY: number,
+      ): {
+        x: number
+        y: number
+        width: number
+        height: number
+      } => {
+        if (!imageDimensions) {
+          return {
+            x: 0,
+            y: 0,
+            width: screen.width,
+            height: screen.height,
+          }
+        }
+
+        // First, let's figure out how much we want to zoom in.
+        // We want to try to zoom in at least close enough to get rid of black bars.
+        const imageAspect = imageDimensions.width / imageDimensions.height
+        const screenAspect = screen.width / screen.height
+        const zoom = Math.max(
+          imageAspect / screenAspect,
+          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) {
-        const {pageX, pageY} = event.nativeEvent
-        let targetX = 0
-        let targetY = 0
-        let targetWidth = screen.width
-        let targetHeight = screen.height
-
-        // Zooming in
-        // TODO: Add more precise calculation of targetX, targetY based on touch
-        if (!scaled) {
-          targetX = pageX / 2
-          targetY = pageY / 2
-          targetWidth = screen.width / 2
-          targetHeight = screen.height / 2
+        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
         scrollResponderRef?.scrollResponderZoomTo({
-          x: targetX,
-          y: targetY,
-          width: targetWidth,
-          height: targetHeight,
+          ...nextZoomRect, // This rect is in screen coordinates
           animated: true,
         })
       } else {
         lastTapTS = nowTS
       }
     },
-    [scaled, screen.height, screen.width, scrollViewRef],
+    [imageDimensions, scaled, screen.height, screen.width, scrollViewRef],
   )
 
   return handleDoubleTap