Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

* Implement double tap for Android

* Match the new behavior on iOS

authored by

dan and committed by
GitHub
d2c253a2 859588c3

+173 -54
+9 -3
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
··· 32 32 const SCREEN = Dimensions.get('screen') 33 33 const SCREEN_WIDTH = SCREEN.width 34 34 const SCREEN_HEIGHT = SCREEN.height 35 + const MAX_SCALE = 2 35 36 36 37 type Props = { 37 38 imageSrc: ImageSource ··· 58 59 const [loaded, setLoaded] = useState(false) 59 60 const [scaled, setScaled] = useState(false) 60 61 const imageDimensions = useImageDimensions(imageSrc) 61 - const handleDoubleTap = useDoubleTapToZoom(scrollViewRef, scaled, SCREEN) 62 + const handleDoubleTap = useDoubleTapToZoom( 63 + scrollViewRef, 64 + scaled, 65 + SCREEN, 66 + imageDimensions, 67 + ) 62 68 63 69 const [translate, scale] = getImageTransform(imageDimensions, SCREEN) 64 70 const scrollValueY = new Animated.Value(0) 65 71 const scaleValue = new Animated.Value(scale || 1) 66 72 const translateValue = new Animated.ValueXY(translate) 67 - const maxScale = scale && scale > 0 ? Math.max(1 / scale, 1) : 1 73 + const maxScrollViewZoom = MAX_SCALE / (scale || 1) 68 74 69 75 const imageOpacity = scrollValueY.interpolate({ 70 76 inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], ··· 118 124 pinchGestureEnabled 119 125 showsHorizontalScrollIndicator={false} 120 126 showsVerticalScrollIndicator={false} 121 - maximumZoomScale={maxScale} 127 + maximumZoomScale={maxScrollViewZoom} 122 128 contentContainerStyle={styles.imageScrollContainer} 123 129 scrollEnabled={swipeToCloseEnabled} 124 130 onScrollEndDrag={onScrollEndDrag}
+102 -17
src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts
··· 12 12 import {Dimensions} from '../@types' 13 13 14 14 const DOUBLE_TAP_DELAY = 300 15 + const MIN_ZOOM = 2 16 + 15 17 let lastTapTS: number | null = null 16 18 17 19 /** ··· 22 24 scrollViewRef: React.RefObject<ScrollView>, 23 25 scaled: boolean, 24 26 screen: Dimensions, 27 + imageDimensions: Dimensions | null, 25 28 ) { 26 29 const handleDoubleTap = useCallback( 27 30 (event: NativeSyntheticEvent<NativeTouchEvent>) => { 28 31 const nowTS = new Date().getTime() 29 32 const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() 30 33 34 + const getZoomRectAfterDoubleTap = ( 35 + touchX: number, 36 + touchY: number, 37 + ): { 38 + x: number 39 + y: number 40 + width: number 41 + height: number 42 + } => { 43 + if (!imageDimensions) { 44 + return { 45 + x: 0, 46 + y: 0, 47 + width: screen.width, 48 + height: screen.height, 49 + } 50 + } 51 + 52 + // First, let's figure out how much we want to zoom in. 53 + // We want to try to zoom in at least close enough to get rid of black bars. 54 + const imageAspect = imageDimensions.width / imageDimensions.height 55 + const screenAspect = screen.width / screen.height 56 + const zoom = Math.max( 57 + imageAspect / screenAspect, 58 + screenAspect / imageAspect, 59 + MIN_ZOOM, 60 + ) 61 + // Unlike in the Android version, we don't constrain the *max* zoom level here. 62 + // Instead, this is done in the ScrollView props so that it constraints pinch too. 63 + 64 + // Next, we'll be calculating the rectangle to "zoom into" in screen coordinates. 65 + // We already know the zoom level, so this gives us the rectangle size. 66 + let rectWidth = screen.width / zoom 67 + let rectHeight = screen.height / zoom 68 + 69 + // Before we settle on the zoomed rect, figure out the safe area it has to be inside. 70 + // We don't want to introduce new black bars or make existing black bars unbalanced. 71 + let minX = 0 72 + let minY = 0 73 + let maxX = screen.width - rectWidth 74 + let maxY = screen.height - rectHeight 75 + if (imageAspect >= screenAspect) { 76 + // The image has horizontal black bars. Exclude them from the safe area. 77 + const renderedHeight = screen.width / imageAspect 78 + const horizontalBarHeight = (screen.height - renderedHeight) / 2 79 + minY += horizontalBarHeight 80 + maxY -= horizontalBarHeight 81 + } else { 82 + // The image has vertical black bars. Exclude them from the safe area. 83 + const renderedWidth = screen.height * imageAspect 84 + const verticalBarWidth = (screen.width - renderedWidth) / 2 85 + minX += verticalBarWidth 86 + maxX -= verticalBarWidth 87 + } 88 + 89 + // Finally, we can position the rect according to its size and the safe area. 90 + let rectX 91 + if (maxX >= minX) { 92 + // Content fills the screen horizontally so we have horizontal wiggle room. 93 + // Try to keep the tapped point under the finger after zoom. 94 + rectX = touchX - touchX / zoom 95 + rectX = Math.min(rectX, maxX) 96 + rectX = Math.max(rectX, minX) 97 + } else { 98 + // Keep the rect centered on the screen so that black bars are balanced. 99 + rectX = screen.width / 2 - rectWidth / 2 100 + } 101 + let rectY 102 + if (maxY >= minY) { 103 + // Content fills the screen vertically so we have vertical wiggle room. 104 + // Try to keep the tapped point under the finger after zoom. 105 + rectY = touchY - touchY / zoom 106 + rectY = Math.min(rectY, maxY) 107 + rectY = Math.max(rectY, minY) 108 + } else { 109 + // Keep the rect centered on the screen so that black bars are balanced. 110 + rectY = screen.height / 2 - rectHeight / 2 111 + } 112 + 113 + return { 114 + x: rectX, 115 + y: rectY, 116 + height: rectHeight, 117 + width: rectWidth, 118 + } 119 + } 120 + 31 121 if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) { 32 - const {pageX, pageY} = event.nativeEvent 33 - let targetX = 0 34 - let targetY = 0 35 - let targetWidth = screen.width 36 - let targetHeight = screen.height 122 + let nextZoomRect = { 123 + x: 0, 124 + y: 0, 125 + width: screen.width, 126 + height: screen.height, 127 + } 37 128 38 - // Zooming in 39 - // TODO: Add more precise calculation of targetX, targetY based on touch 40 - if (!scaled) { 41 - targetX = pageX / 2 42 - targetY = pageY / 2 43 - targetWidth = screen.width / 2 44 - targetHeight = screen.height / 2 129 + const willZoom = !scaled 130 + if (willZoom) { 131 + const {pageX, pageY} = event.nativeEvent 132 + nextZoomRect = getZoomRectAfterDoubleTap(pageX, pageY) 45 133 } 46 134 47 135 // @ts-ignore 48 136 scrollResponderRef?.scrollResponderZoomTo({ 49 - x: targetX, 50 - y: targetY, 51 - width: targetWidth, 52 - height: targetHeight, 137 + ...nextZoomRect, // This rect is in screen coordinates 53 138 animated: true, 54 139 }) 55 140 } else { 56 141 lastTapTS = nowTS 57 142 } 58 143 }, 59 - [scaled, screen.height, screen.width, scrollViewRef], 144 + [imageDimensions, scaled, screen.height, screen.width, scrollViewRef], 60 145 ) 61 146 62 147 return handleDoubleTap
+62 -34
src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts
··· 29 29 const SCREEN_WIDTH = SCREEN.width 30 30 const SCREEN_HEIGHT = SCREEN.height 31 31 const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT) 32 + const ANDROID_BAR_HEIGHT = 24 32 33 33 - const SCALE_MAX = 2 34 + const MIN_ZOOM = 2 35 + const MAX_SCALE = 2 34 36 const DOUBLE_TAP_DELAY = 300 35 37 const OUT_BOUND_MULTIPLIER = 0.75 36 38 ··· 87 89 return [top, left, bottom, right] 88 90 } 89 91 90 - const getTranslateInBounds = (translate: Position, scale: number) => { 91 - const inBoundTranslate = {x: translate.x, y: translate.y} 92 - const [topBound, leftBound, bottomBound, rightBound] = getBounds(scale) 92 + const getTransformAfterDoubleTap = ( 93 + touchX: number, 94 + touchY: number, 95 + ): [number, Position] => { 96 + let nextScale = initialScale 97 + let nextTranslateX = initialTranslate.x 98 + let nextTranslateY = initialTranslate.y 93 99 94 - if (translate.x > leftBound) { 95 - inBoundTranslate.x = leftBound 96 - } else if (translate.x < rightBound) { 97 - inBoundTranslate.x = rightBound 98 - } 100 + // First, let's figure out how much we want to zoom in. 101 + // We want to try to zoom in at least close enough to get rid of black bars. 102 + const imageAspect = imageDimensions.width / imageDimensions.height 103 + const screenAspect = SCREEN.width / SCREEN.height 104 + let zoom = Math.max( 105 + imageAspect / screenAspect, 106 + screenAspect / imageAspect, 107 + MIN_ZOOM, 108 + ) 109 + // Don't zoom so hard that the original image's pixels become blurry. 110 + zoom = Math.min(zoom, MAX_SCALE / initialScale) 111 + nextScale = initialScale * zoom 99 112 100 - if (translate.y > topBound) { 101 - inBoundTranslate.y = topBound 102 - } else if (translate.y < bottomBound) { 103 - inBoundTranslate.y = bottomBound 113 + // Next, let's see if we need to adjust the scaled image translation. 114 + // Ideally, we want the tapped point to stay under the finger after the scaling. 115 + const dx = SCREEN.width / 2 - touchX 116 + const dy = SCREEN.height / 2 - (touchY - ANDROID_BAR_HEIGHT) 117 + // Before we try to adjust the translation, check how much wiggle room we have. 118 + // We don't want to introduce new black bars or make existing black bars unbalanced. 119 + const [topBound, leftBound, bottomBound, rightBound] = getBounds(nextScale) 120 + if (leftBound > rightBound) { 121 + // Content fills the screen horizontally so we have horizontal wiggle room. 122 + // Try to keep the tapped point under the finger after zoom. 123 + nextTranslateX += dx * zoom - dx 124 + nextTranslateX = Math.min(nextTranslateX, leftBound) 125 + nextTranslateX = Math.max(nextTranslateX, rightBound) 126 + } 127 + if (topBound > bottomBound) { 128 + // Content fills the screen vertically so we have vertical wiggle room. 129 + // Try to keep the tapped point under the finger after zoom. 130 + nextTranslateY += dy * zoom - dy 131 + nextTranslateY = Math.min(nextTranslateY, topBound) 132 + nextTranslateY = Math.max(nextTranslateY, bottomBound) 104 133 } 105 134 106 - return inBoundTranslate 135 + return [ 136 + nextScale, 137 + { 138 + x: nextTranslateX, 139 + y: nextTranslateY, 140 + }, 141 + ] 107 142 } 108 143 109 144 const fitsScreenByWidth = () => ··· 157 192 ) 158 193 159 194 if (doubleTapToZoomEnabled && isDoubleTapPerformed) { 160 - const isScaled = currentTranslate.x !== initialTranslate.x // currentScale !== initialScale; 161 - const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0] 162 - const targetScale = SCALE_MAX 163 - const nextScale = isScaled ? initialScale : targetScale 164 - const nextTranslate = isScaled 165 - ? initialTranslate 166 - : getTranslateInBounds( 167 - { 168 - x: 169 - initialTranslate.x + 170 - (SCREEN_WIDTH / 2 - touchX) * (targetScale / currentScale), 171 - y: 172 - initialTranslate.y + 173 - (SCREEN_HEIGHT / 2 - touchY) * (targetScale / currentScale), 174 - }, 175 - targetScale, 176 - ) 195 + let nextScale = initialScale 196 + let nextTranslate = initialTranslate 177 197 178 - onZoom(!isScaled) 198 + const willZoom = currentScale === initialScale 199 + if (willZoom) { 200 + const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0] 201 + ;[nextScale, nextTranslate] = getTransformAfterDoubleTap( 202 + touchX, 203 + touchY, 204 + ) 205 + } 206 + onZoom(willZoom) 179 207 180 208 Animated.parallel( 181 209 [ ··· 336 364 } 337 365 338 366 if (tmpScale > 0) { 339 - if (tmpScale < initialScale || tmpScale > SCALE_MAX) { 340 - tmpScale = tmpScale < initialScale ? initialScale : SCALE_MAX 367 + if (tmpScale < initialScale || tmpScale > MAX_SCALE) { 368 + tmpScale = tmpScale < initialScale ? initialScale : MAX_SCALE 341 369 Animated.timing(scaleValue, { 342 370 toValue: tmpScale, 343 371 duration: 100,