An ATproto social media client -- with an independent Appview.
6
fork

Configure Feed

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

Rewrite Android lightbox (#1624)

authored by

dan and committed by
GitHub
64153067 8366fe2c

+537 -595
+358 -119
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
··· 1 - /** 2 - * Copyright (c) JOB TODAY S.A. and its affiliates. 3 - * 4 - * This source code is licensed under the MIT license found in the 5 - * LICENSE file in the root directory of this source tree. 6 - * 7 - */ 1 + import React, {MutableRefObject, useState} from 'react' 8 2 9 - import React, {useCallback, useRef, useState} from 'react' 10 - 11 - import { 12 - Animated, 13 - ScrollView, 14 - Dimensions, 15 - StyleSheet, 16 - NativeScrollEvent, 17 - NativeSyntheticEvent, 18 - NativeMethodsMixin, 19 - } from 'react-native' 3 + import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native' 20 4 import {Image} from 'expo-image' 21 - 5 + import Animated, { 6 + measure, 7 + runOnJS, 8 + useAnimatedRef, 9 + useAnimatedStyle, 10 + useAnimatedReaction, 11 + useSharedValue, 12 + withDecay, 13 + withSpring, 14 + } from 'react-native-reanimated' 15 + import { 16 + GestureDetector, 17 + Gesture, 18 + GestureType, 19 + } from 'react-native-gesture-handler' 22 20 import useImageDimensions from '../../hooks/useImageDimensions' 23 - import usePanResponder from '../../hooks/usePanResponder' 24 - 25 - import {getImageTransform} from '../../utils' 26 - import {ImageSource} from '../../@types' 27 - import {ImageLoading} from './ImageLoading' 21 + import { 22 + createTransform, 23 + readTransform, 24 + applyRounding, 25 + prependPan, 26 + prependPinch, 27 + prependTransform, 28 + TransformMatrix, 29 + } from '../../transforms' 30 + import type {ImageSource, Dimensions as ImageDimensions} from '../../@types' 28 31 29 - const SWIPE_CLOSE_OFFSET = 75 30 - const SWIPE_CLOSE_VELOCITY = 1.75 31 32 const SCREEN = Dimensions.get('window') 32 - const SCREEN_WIDTH = SCREEN.width 33 - const SCREEN_HEIGHT = SCREEN.height 33 + const MIN_DOUBLE_TAP_SCALE = 2 34 + const MAX_ORIGINAL_IMAGE_ZOOM = 2 35 + 36 + const AnimatedImage = Animated.createAnimatedComponent(Image) 37 + const initialTransform = createTransform() 34 38 35 39 type Props = { 36 40 imageSrc: ImageSource 37 41 onRequestClose: () => void 38 42 onZoom: (isZoomed: boolean) => void 43 + pinchGestureRef: MutableRefObject<GestureType | undefined> 44 + isScrollViewBeingDragged: boolean 39 45 } 40 - 41 - const AnimatedImage = Animated.createAnimatedComponent(Image) 42 - 43 - const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => { 44 - const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null) 46 + const ImageItem = ({ 47 + imageSrc, 48 + onZoom, 49 + onRequestClose, 50 + isScrollViewBeingDragged, 51 + pinchGestureRef, 52 + }: Props) => { 53 + const [isScaled, setIsScaled] = useState(false) 54 + const [isLoaded, setIsLoaded] = useState(false) 45 55 const imageDimensions = useImageDimensions(imageSrc) 46 - const [translate, scale] = getImageTransform(imageDimensions, SCREEN) 47 - const scrollValueY = new Animated.Value(0) 48 - const [isLoaded, setLoadEnd] = useState(false) 56 + const committedTransform = useSharedValue(initialTransform) 57 + const panTranslation = useSharedValue({x: 0, y: 0}) 58 + const pinchOrigin = useSharedValue({x: 0, y: 0}) 59 + const pinchScale = useSharedValue(1) 60 + const pinchTranslation = useSharedValue({x: 0, y: 0}) 61 + const dismissSwipeTranslateY = useSharedValue(0) 62 + const containerRef = useAnimatedRef() 49 63 50 - const onLoaded = useCallback(() => setLoadEnd(true), []) 51 - const onZoomPerformed = useCallback( 52 - (isZoomed: boolean) => { 53 - onZoom(isZoomed) 54 - if (imageContainer?.current) { 55 - imageContainer.current.setNativeProps({ 56 - scrollEnabled: !isZoomed, 57 - }) 64 + function getCommittedScale(): number { 65 + 'worklet' 66 + const [, , committedScale] = readTransform(committedTransform.value) 67 + return committedScale 68 + } 69 + 70 + // Keep track of when we're entering or leaving scaled rendering. 71 + useAnimatedReaction( 72 + () => { 73 + return pinchScale.value !== 1 || getCommittedScale() !== 1 74 + }, 75 + (nextIsScaled, prevIsScaled) => { 76 + if (nextIsScaled !== prevIsScaled) { 77 + runOnJS(handleZoom)(nextIsScaled) 58 78 } 59 79 }, 60 - [onZoom], 61 80 ) 62 81 63 - const [panHandlers, scaleValue, translateValue] = usePanResponder({ 64 - initialScale: scale || 1, 65 - initialTranslate: translate || {x: 0, y: 0}, 66 - onZoom: onZoomPerformed, 82 + function handleZoom(nextIsScaled: boolean) { 83 + setIsScaled(nextIsScaled) 84 + onZoom(nextIsScaled) 85 + } 86 + 87 + const animatedStyle = useAnimatedStyle(() => { 88 + // Apply the active adjustments on top of the committed transform before the gestures. 89 + // This is matrix multiplication, so operations are applied in the reverse order. 90 + let t = createTransform() 91 + prependPan(t, panTranslation.value) 92 + prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value) 93 + prependTransform(t, committedTransform.value) 94 + const [translateX, translateY, scale] = readTransform(t) 95 + 96 + const dismissDistance = dismissSwipeTranslateY.value 97 + const dismissProgress = Math.min( 98 + Math.abs(dismissDistance) / (SCREEN.height / 2), 99 + 1, 100 + ) 101 + return { 102 + opacity: 1 - dismissProgress, 103 + transform: [ 104 + {translateX}, 105 + {translateY: translateY + dismissDistance}, 106 + {scale}, 107 + ], 108 + } 67 109 }) 68 110 69 - const imagesStyles = getImageStyles( 70 - imageDimensions, 71 - translateValue, 72 - scaleValue, 73 - ) 74 - const imageOpacity = scrollValueY.interpolate({ 75 - inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], 76 - outputRange: [0.7, 1, 0.7], 111 + // On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges. 112 + // If the user tried to pan too hard, this function will provide the negative panning to stay in bounds. 113 + function getExtraTranslationToStayInBounds( 114 + candidateTransform: TransformMatrix, 115 + ) { 116 + 'worklet' 117 + if (!imageDimensions) { 118 + return [0, 0] 119 + } 120 + const [nextTranslateX, nextTranslateY, nextScale] = 121 + readTransform(candidateTransform) 122 + const scaledDimensions = getScaledDimensions(imageDimensions, nextScale) 123 + const clampedTranslateX = clampTranslation( 124 + nextTranslateX, 125 + scaledDimensions.width, 126 + SCREEN.width, 127 + ) 128 + const clampedTranslateY = clampTranslation( 129 + nextTranslateY, 130 + scaledDimensions.height, 131 + SCREEN.height, 132 + ) 133 + const dx = clampedTranslateX - nextTranslateX 134 + const dy = clampedTranslateY - nextTranslateY 135 + return [dx, dy] 136 + } 137 + 138 + // This is a hack. 139 + // We need to disallow any gestures (and let the native parent scroll view scroll) while you're scrolling it. 140 + // However, there is no great reliable way to coordinate this yet in RGNH. 141 + // This "fake" manual gesture handler whenever you're trying to touch something while the parent scrollview is not at rest. 142 + const consumeHScroll = Gesture.Manual().onTouchesDown((e, manager) => { 143 + if (isScrollViewBeingDragged) { 144 + // Steal the gesture (and do nothing, so native ScrollView does its thing). 145 + manager.activate() 146 + return 147 + } 148 + const measurement = measure(containerRef) 149 + if (!measurement || measurement.pageX !== 0) { 150 + // Steal the gesture (and do nothing, so native ScrollView does its thing). 151 + manager.activate() 152 + return 153 + } 154 + // Fail this "fake" gesture so that the gestures after it can proceed. 155 + manager.fail() 77 156 }) 78 - const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity} 157 + 158 + const pinch = Gesture.Pinch() 159 + .withRef(pinchGestureRef) 160 + .onStart(e => { 161 + pinchOrigin.value = { 162 + x: e.focalX - SCREEN.width / 2, 163 + y: e.focalY - SCREEN.height / 2, 164 + } 165 + }) 166 + .onChange(e => { 167 + if (!imageDimensions) { 168 + return 169 + } 170 + // Don't let the picture zoom in so close that it gets blurry. 171 + // Also, like in stock Android apps, don't let the user zoom out further than 1:1. 172 + const committedScale = getCommittedScale() 173 + const maxCommittedScale = 174 + (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM 175 + const minPinchScale = 1 / committedScale 176 + const maxPinchScale = maxCommittedScale / committedScale 177 + const nextPinchScale = Math.min( 178 + Math.max(minPinchScale, e.scale), 179 + maxPinchScale, 180 + ) 181 + pinchScale.value = nextPinchScale 182 + 183 + // Zooming out close to the corner could push us out of bounds, which we don't want on Android. 184 + // Calculate where we'll end up so we know how much to translate back to stay in bounds. 185 + const t = createTransform() 186 + prependPan(t, panTranslation.value) 187 + prependPinch(t, nextPinchScale, pinchOrigin.value, pinchTranslation.value) 188 + prependTransform(t, committedTransform.value) 189 + const [dx, dy] = getExtraTranslationToStayInBounds(t) 190 + if (dx !== 0 || dy !== 0) { 191 + pinchTranslation.value = { 192 + x: pinchTranslation.value.x + dx, 193 + y: pinchTranslation.value.y + dy, 194 + } 195 + } 196 + }) 197 + .onEnd(() => { 198 + // Commit just the pinch. 199 + let t = createTransform() 200 + prependPinch( 201 + t, 202 + pinchScale.value, 203 + pinchOrigin.value, 204 + pinchTranslation.value, 205 + ) 206 + prependTransform(t, committedTransform.value) 207 + applyRounding(t) 208 + committedTransform.value = t 209 + 210 + // Reset just the pinch. 211 + pinchScale.value = 1 212 + pinchOrigin.value = {x: 0, y: 0} 213 + pinchTranslation.value = {x: 0, y: 0} 214 + }) 79 215 80 - const onScrollEndDrag = ({ 81 - nativeEvent, 82 - }: NativeSyntheticEvent<NativeScrollEvent>) => { 83 - const velocityY = nativeEvent?.velocity?.y ?? 0 84 - const offsetY = nativeEvent?.contentOffset?.y ?? 0 216 + const pan = Gesture.Pan() 217 + .averageTouches(true) 218 + // Unlike .enabled(isScaled), this ensures that an initial pinch can turn into a pan midway: 219 + .minPointers(isScaled ? 1 : 2) 220 + .onChange(e => { 221 + if (!imageDimensions) { 222 + return 223 + } 224 + const nextPanTranslation = {x: e.translationX, y: e.translationY} 225 + let t = createTransform() 226 + prependPan(t, nextPanTranslation) 227 + prependPinch( 228 + t, 229 + pinchScale.value, 230 + pinchOrigin.value, 231 + pinchTranslation.value, 232 + ) 233 + prependTransform(t, committedTransform.value) 234 + 235 + // Prevent panning from going out of bounds. 236 + const [dx, dy] = getExtraTranslationToStayInBounds(t) 237 + nextPanTranslation.x += dx 238 + nextPanTranslation.y += dy 239 + panTranslation.value = nextPanTranslation 240 + }) 241 + .onEnd(() => { 242 + // Commit just the pan. 243 + let t = createTransform() 244 + prependPan(t, panTranslation.value) 245 + prependTransform(t, committedTransform.value) 246 + applyRounding(t) 247 + committedTransform.value = t 248 + 249 + // Reset just the pan. 250 + panTranslation.value = {x: 0, y: 0} 251 + }) 252 + 253 + const doubleTap = Gesture.Tap() 254 + .numberOfTaps(2) 255 + .onEnd(e => { 256 + if (!imageDimensions) { 257 + return 258 + } 259 + const committedScale = getCommittedScale() 260 + if (committedScale !== 1) { 261 + // Go back to 1:1 using the identity vector. 262 + let t = createTransform() 263 + committedTransform.value = withClampedSpring(t) 264 + return 265 + } 85 266 86 - if ( 87 - (Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY && 88 - offsetY > SWIPE_CLOSE_OFFSET) || 89 - offsetY > SCREEN_HEIGHT / 2 90 - ) { 91 - onRequestClose() 92 - } 93 - } 267 + // Try to zoom in so that we get rid of the black bars (whatever the orientation was). 268 + const imageAspect = imageDimensions.width / imageDimensions.height 269 + const screenAspect = SCREEN.width / SCREEN.height 270 + const candidateScale = Math.max( 271 + imageAspect / screenAspect, 272 + screenAspect / imageAspect, 273 + MIN_DOUBLE_TAP_SCALE, 274 + ) 275 + // But don't zoom in so close that the picture gets blurry. 276 + const maxScale = 277 + (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM 278 + const scale = Math.min(candidateScale, maxScale) 94 279 95 - const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => { 96 - const offsetY = nativeEvent?.contentOffset?.y ?? 0 280 + // Calculate where we would be if the user pinched into the double tapped point. 281 + // We won't use this transform directly because it may go out of bounds. 282 + const candidateTransform = createTransform() 283 + const origin = { 284 + x: e.absoluteX - SCREEN.width / 2, 285 + y: e.absoluteY - SCREEN.height / 2, 286 + } 287 + prependPinch(candidateTransform, scale, origin, {x: 0, y: 0}) 97 288 98 - scrollValueY.setValue(offsetY) 99 - } 289 + // Now we know how much we went out of bounds, so we can shoot correctly. 290 + const [dx, dy] = getExtraTranslationToStayInBounds(candidateTransform) 291 + const finalTransform = createTransform() 292 + prependPinch(finalTransform, scale, origin, {x: dx, y: dy}) 293 + committedTransform.value = withClampedSpring(finalTransform) 294 + }) 100 295 296 + const dismissSwipePan = Gesture.Pan() 297 + .enabled(!isScaled) 298 + .activeOffsetY([-10, 10]) 299 + .failOffsetX([-10, 10]) 300 + .maxPointers(1) 301 + .onUpdate(e => { 302 + dismissSwipeTranslateY.value = e.translationY 303 + }) 304 + .onEnd(e => { 305 + if (Math.abs(e.velocityY) > 1000) { 306 + dismissSwipeTranslateY.value = withDecay({velocity: e.velocityY}) 307 + runOnJS(onRequestClose)() 308 + } else { 309 + dismissSwipeTranslateY.value = withSpring(0, { 310 + stiffness: 700, 311 + damping: 50, 312 + }) 313 + } 314 + }) 315 + 316 + const isLoading = !isLoaded || !imageDimensions 101 317 return ( 102 - <ScrollView 103 - ref={imageContainer} 104 - style={styles.listItem} 105 - pagingEnabled 106 - nestedScrollEnabled 107 - showsHorizontalScrollIndicator={false} 108 - showsVerticalScrollIndicator={false} 109 - contentContainerStyle={styles.imageScrollContainer} 110 - scrollEnabled={true} 111 - onScroll={onScroll} 112 - onScrollEndDrag={onScrollEndDrag}> 113 - <AnimatedImage 114 - {...panHandlers} 115 - source={imageSrc} 116 - style={imageStylesWithOpacity} 117 - onLoad={onLoaded} 118 - accessibilityLabel={imageSrc.alt} 119 - accessibilityHint="" 120 - /> 121 - {(!isLoaded || !imageDimensions) && <ImageLoading />} 122 - </ScrollView> 318 + <Animated.View ref={containerRef} style={styles.container}> 319 + {isLoading && ( 320 + <ActivityIndicator size="small" color="#FFF" style={styles.loading} /> 321 + )} 322 + <GestureDetector 323 + gesture={Gesture.Exclusive( 324 + consumeHScroll, 325 + dismissSwipePan, 326 + Gesture.Simultaneous(pinch, pan), 327 + doubleTap, 328 + )}> 329 + <AnimatedImage 330 + source={imageSrc} 331 + contentFit="contain" 332 + style={[styles.image, animatedStyle]} 333 + accessibilityLabel={imageSrc.alt} 334 + accessibilityHint="" 335 + onLoad={() => setIsLoaded(true)} 336 + /> 337 + </GestureDetector> 338 + </Animated.View> 123 339 ) 124 340 } 125 341 126 342 const styles = StyleSheet.create({ 127 - listItem: { 128 - width: SCREEN_WIDTH, 129 - height: SCREEN_HEIGHT, 343 + container: { 344 + width: SCREEN.width, 345 + height: SCREEN.height, 346 + overflow: 'hidden', 347 + }, 348 + image: { 349 + flex: 1, 130 350 }, 131 - imageScrollContainer: { 132 - height: SCREEN_HEIGHT * 2, 351 + loading: { 352 + position: 'absolute', 353 + left: 0, 354 + right: 0, 355 + top: 0, 356 + bottom: 0, 133 357 }, 134 358 }) 135 359 136 - const getImageStyles = ( 137 - image: {width: number; height: number} | null, 138 - translate: Animated.ValueXY, 139 - scale?: Animated.Value, 140 - ) => { 141 - if (!image?.width || !image?.height) { 142 - return {width: 0, height: 0} 360 + function getScaledDimensions( 361 + imageDimensions: ImageDimensions, 362 + scale: number, 363 + ): ImageDimensions { 364 + 'worklet' 365 + const imageAspect = imageDimensions.width / imageDimensions.height 366 + const screenAspect = SCREEN.width / SCREEN.height 367 + const isLandscape = imageAspect > screenAspect 368 + if (isLandscape) { 369 + return { 370 + width: scale * SCREEN.width, 371 + height: (scale * SCREEN.width) / imageAspect, 372 + } 373 + } else { 374 + return { 375 + width: scale * SCREEN.height * imageAspect, 376 + height: scale * SCREEN.height, 377 + } 143 378 } 379 + } 144 380 145 - const transform = translate.getTranslateTransform() 381 + function clampTranslation( 382 + value: number, 383 + scaledSize: number, 384 + screenSize: number, 385 + ): number { 386 + 'worklet' 387 + // Figure out how much the user should be allowed to pan, and constrain the translation. 388 + const panDistance = Math.max(0, (scaledSize - screenSize) / 2) 389 + const clampedValue = Math.min(Math.max(-panDistance, value), panDistance) 390 + return clampedValue 391 + } 146 392 147 - if (scale) { 148 - // @ts-ignore TODO - is scale incorrect? might need to remove -prf 149 - transform.push({scale}, {perspective: new Animated.Value(1000)}) 150 - } 151 - 152 - return { 153 - width: image.width, 154 - height: image.height, 155 - transform, 156 - } 393 + function withClampedSpring(value: any) { 394 + 'worklet' 395 + return withSpring(value, {overshootClamping: true}) 157 396 } 158 397 159 398 export default React.memo(ImageItem)
+40 -5
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
··· 6 6 * 7 7 */ 8 8 9 - import React, {useCallback, useRef, useState} from 'react' 9 + import React, {MutableRefObject, useCallback, useRef, useState} from 'react' 10 10 11 11 import { 12 12 Animated, ··· 20 20 TouchableWithoutFeedback, 21 21 } from 'react-native' 22 22 import {Image} from 'expo-image' 23 + import {GestureType} from 'react-native-gesture-handler' 23 24 24 25 import useImageDimensions from '../../hooks/useImageDimensions' 25 26 26 - import {getImageTransform} from '../../utils' 27 - import {ImageSource} from '../../@types' 27 + import {ImageSource, Dimensions as ImageDimensions} from '../../@types' 28 28 import {ImageLoading} from './ImageLoading' 29 29 30 30 const DOUBLE_TAP_DELAY = 300 ··· 40 40 imageSrc: ImageSource 41 41 onRequestClose: () => void 42 42 onZoom: (scaled: boolean) => void 43 + pinchGestureRef: MutableRefObject<GestureType> 44 + isScrollViewBeingDragged: boolean 43 45 } 44 46 45 47 const AnimatedImage = Animated.createAnimatedComponent(Image) ··· 164 166 }) 165 167 166 168 const getZoomRectAfterDoubleTap = ( 167 - imageDimensions: {width: number; height: number} | null, 169 + imageDimensions: ImageDimensions | null, 168 170 touchX: number, 169 171 touchY: number, 170 172 ): { ··· 252 254 } 253 255 254 256 const getImageStyles = ( 255 - image: {width: number; height: number} | null, 257 + image: ImageDimensions | null, 256 258 translate: {readonly x: number; readonly y: number} | undefined, 257 259 scale?: number, 258 260 ) => { ··· 272 274 width: image.width, 273 275 height: image.height, 274 276 transform, 277 + } 278 + } 279 + 280 + const getImageTransform = ( 281 + image: ImageDimensions | null, 282 + screen: ImageDimensions, 283 + ) => { 284 + if (!image?.width || !image?.height) { 285 + return [] as const 286 + } 287 + 288 + const wScale = screen.width / image.width 289 + const hScale = screen.height / image.height 290 + const scale = Math.min(wScale, hScale) 291 + const {x, y} = getImageTranslate(image, screen) 292 + 293 + return [{x, y}, scale] as const 294 + } 295 + 296 + const getImageTranslate = ( 297 + image: ImageDimensions, 298 + screen: ImageDimensions, 299 + ): {x: number; y: number} => { 300 + const getTranslateForAxis = (axis: 'x' | 'y'): number => { 301 + const imageSize = axis === 'x' ? image.width : image.height 302 + const screenSize = axis === 'x' ? screen.width : screen.height 303 + 304 + return (screenSize - imageSize) / 2 305 + } 306 + 307 + return { 308 + x: getTranslateForAxis('x'), 309 + y: getTranslateForAxis('y'), 275 310 } 276 311 } 277 312
+4 -1
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
··· 1 1 // default implementation fallback for web 2 2 3 - import React from 'react' 3 + import React, {MutableRefObject} from 'react' 4 4 import {View} from 'react-native' 5 + import {GestureType} from 'react-native-gesture-handler' 5 6 import {ImageSource} from '../../@types' 6 7 7 8 type Props = { 8 9 imageSrc: ImageSource 9 10 onRequestClose: () => void 10 11 onZoom: (scaled: boolean) => void 12 + pinchGestureRef: MutableRefObject<GestureType | undefined> 13 + isScrollViewBeingDragged: boolean 11 14 } 12 15 13 16 const ImageItem = (_props: Props) => {
-423
src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts
··· 1 - /** 2 - * Copyright (c) JOB TODAY S.A. and its affiliates. 3 - * 4 - * This source code is licensed under the MIT license found in the 5 - * LICENSE file in the root directory of this source tree. 6 - * 7 - */ 8 - 9 - import {useEffect} from 'react' 10 - import { 11 - Animated, 12 - Dimensions, 13 - GestureResponderEvent, 14 - GestureResponderHandlers, 15 - NativeTouchEvent, 16 - PanResponder, 17 - PanResponderGestureState, 18 - } from 'react-native' 19 - 20 - import {Position} from '../@types' 21 - import {getImageTranslate} from '../utils' 22 - 23 - const SCREEN = Dimensions.get('window') 24 - const SCREEN_WIDTH = SCREEN.width 25 - const SCREEN_HEIGHT = SCREEN.height 26 - const ANDROID_BAR_HEIGHT = 24 27 - 28 - const MIN_ZOOM = 2 29 - const MAX_SCALE = 2 30 - const DOUBLE_TAP_DELAY = 300 31 - const OUT_BOUND_MULTIPLIER = 0.75 32 - 33 - type Props = { 34 - initialScale: number 35 - initialTranslate: Position 36 - onZoom: (isZoomed: boolean) => void 37 - } 38 - 39 - const usePanResponder = ({ 40 - initialScale, 41 - initialTranslate, 42 - onZoom, 43 - }: Props): Readonly< 44 - [GestureResponderHandlers, Animated.Value, Animated.ValueXY] 45 - > => { 46 - let numberInitialTouches = 1 47 - let initialTouches: NativeTouchEvent[] = [] 48 - let currentScale = initialScale 49 - let currentTranslate = initialTranslate 50 - let tmpScale = 0 51 - let tmpTranslate: Position | null = null 52 - let isDoubleTapPerformed = false 53 - let lastTapTS: number | null = null 54 - 55 - // TODO: It's not valid to reinitialize Animated values during render. 56 - // This is a bug. 57 - const scaleValue = new Animated.Value(initialScale) 58 - const translateValue = new Animated.ValueXY(initialTranslate) 59 - 60 - const imageDimensions = getImageDimensionsByTranslate( 61 - initialTranslate, 62 - SCREEN, 63 - ) 64 - 65 - const getBounds = (scale: number) => { 66 - const scaledImageDimensions = { 67 - width: imageDimensions.width * scale, 68 - height: imageDimensions.height * scale, 69 - } 70 - const translateDelta = getImageTranslate(scaledImageDimensions, SCREEN) 71 - 72 - const left = initialTranslate.x - translateDelta.x 73 - const right = left - (scaledImageDimensions.width - SCREEN.width) 74 - const top = initialTranslate.y - translateDelta.y 75 - const bottom = top - (scaledImageDimensions.height - SCREEN.height) 76 - 77 - return [top, left, bottom, right] 78 - } 79 - 80 - const getTransformAfterDoubleTap = ( 81 - touchX: number, 82 - touchY: number, 83 - ): [number, Position] => { 84 - let nextScale = initialScale 85 - let nextTranslateX = initialTranslate.x 86 - let nextTranslateY = initialTranslate.y 87 - 88 - // First, let's figure out how much we want to zoom in. 89 - // We want to try to zoom in at least close enough to get rid of black bars. 90 - const imageAspect = imageDimensions.width / imageDimensions.height 91 - const screenAspect = SCREEN.width / SCREEN.height 92 - let zoom = Math.max( 93 - imageAspect / screenAspect, 94 - screenAspect / imageAspect, 95 - MIN_ZOOM, 96 - ) 97 - // Don't zoom so hard that the original image's pixels become blurry. 98 - zoom = Math.min(zoom, MAX_SCALE / initialScale) 99 - nextScale = initialScale * zoom 100 - 101 - // Next, let's see if we need to adjust the scaled image translation. 102 - // Ideally, we want the tapped point to stay under the finger after the scaling. 103 - const dx = SCREEN.width / 2 - touchX 104 - const dy = SCREEN.height / 2 - (touchY - ANDROID_BAR_HEIGHT) 105 - // Before we try to adjust the translation, check how much wiggle room we have. 106 - // We don't want to introduce new black bars or make existing black bars unbalanced. 107 - const [topBound, leftBound, bottomBound, rightBound] = getBounds(nextScale) 108 - if (leftBound > rightBound) { 109 - // Content fills the screen horizontally so we have horizontal wiggle room. 110 - // Try to keep the tapped point under the finger after zoom. 111 - nextTranslateX += dx * zoom - dx 112 - nextTranslateX = Math.min(nextTranslateX, leftBound) 113 - nextTranslateX = Math.max(nextTranslateX, rightBound) 114 - } 115 - if (topBound > bottomBound) { 116 - // Content fills the screen vertically so we have vertical wiggle room. 117 - // Try to keep the tapped point under the finger after zoom. 118 - nextTranslateY += dy * zoom - dy 119 - nextTranslateY = Math.min(nextTranslateY, topBound) 120 - nextTranslateY = Math.max(nextTranslateY, bottomBound) 121 - } 122 - 123 - return [ 124 - nextScale, 125 - { 126 - x: nextTranslateX, 127 - y: nextTranslateY, 128 - }, 129 - ] 130 - } 131 - 132 - const fitsScreenByWidth = () => 133 - imageDimensions.width * currentScale < SCREEN_WIDTH 134 - const fitsScreenByHeight = () => 135 - imageDimensions.height * currentScale < SCREEN_HEIGHT 136 - 137 - useEffect(() => { 138 - scaleValue.addListener(({value}) => { 139 - if (typeof onZoom === 'function') { 140 - onZoom(value !== initialScale) 141 - } 142 - }) 143 - 144 - return () => scaleValue.removeAllListeners() 145 - }) 146 - 147 - const panResponder = PanResponder.create({ 148 - onStartShouldSetPanResponder: () => true, 149 - onStartShouldSetPanResponderCapture: () => true, 150 - onMoveShouldSetPanResponder: () => true, 151 - onMoveShouldSetPanResponderCapture: () => true, 152 - onPanResponderGrant: ( 153 - _: GestureResponderEvent, 154 - gestureState: PanResponderGestureState, 155 - ) => { 156 - numberInitialTouches = gestureState.numberActiveTouches 157 - 158 - if (gestureState.numberActiveTouches > 1) { 159 - return 160 - } 161 - }, 162 - onPanResponderStart: ( 163 - event: GestureResponderEvent, 164 - gestureState: PanResponderGestureState, 165 - ) => { 166 - initialTouches = event.nativeEvent.touches 167 - numberInitialTouches = gestureState.numberActiveTouches 168 - 169 - if (gestureState.numberActiveTouches > 1) { 170 - return 171 - } 172 - 173 - const tapTS = Date.now() 174 - // Handle double tap event by calculating diff between first and second taps timestamps 175 - 176 - isDoubleTapPerformed = Boolean( 177 - lastTapTS && tapTS - lastTapTS < DOUBLE_TAP_DELAY, 178 - ) 179 - 180 - if (isDoubleTapPerformed) { 181 - let nextScale = initialScale 182 - let nextTranslate = initialTranslate 183 - 184 - const willZoom = currentScale === initialScale 185 - if (willZoom) { 186 - const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0] 187 - ;[nextScale, nextTranslate] = getTransformAfterDoubleTap( 188 - touchX, 189 - touchY, 190 - ) 191 - } 192 - onZoom(willZoom) 193 - 194 - Animated.parallel( 195 - [ 196 - Animated.timing(translateValue.x, { 197 - toValue: nextTranslate.x, 198 - duration: 300, 199 - useNativeDriver: true, 200 - }), 201 - Animated.timing(translateValue.y, { 202 - toValue: nextTranslate.y, 203 - duration: 300, 204 - useNativeDriver: true, 205 - }), 206 - Animated.timing(scaleValue, { 207 - toValue: nextScale, 208 - duration: 300, 209 - useNativeDriver: true, 210 - }), 211 - ], 212 - {stopTogether: false}, 213 - ).start(() => { 214 - currentScale = nextScale 215 - currentTranslate = nextTranslate 216 - }) 217 - 218 - lastTapTS = null 219 - } else { 220 - lastTapTS = Date.now() 221 - } 222 - }, 223 - onPanResponderMove: ( 224 - event: GestureResponderEvent, 225 - gestureState: PanResponderGestureState, 226 - ) => { 227 - // Don't need to handle move because double tap in progress (was handled in onStart) 228 - if (isDoubleTapPerformed) { 229 - return 230 - } 231 - 232 - if ( 233 - numberInitialTouches === 1 && 234 - gestureState.numberActiveTouches === 2 235 - ) { 236 - numberInitialTouches = 2 237 - initialTouches = event.nativeEvent.touches 238 - } 239 - 240 - const isTapGesture = 241 - numberInitialTouches === 1 && gestureState.numberActiveTouches === 1 242 - const isPinchGesture = 243 - numberInitialTouches === 2 && gestureState.numberActiveTouches === 2 244 - 245 - if (isPinchGesture) { 246 - const initialDistance = getDistanceBetweenTouches(initialTouches) 247 - const currentDistance = getDistanceBetweenTouches( 248 - event.nativeEvent.touches, 249 - ) 250 - 251 - let nextScale = (currentDistance / initialDistance) * currentScale 252 - 253 - /** 254 - * In case image is scaling smaller than initial size -> 255 - * slow down this transition by applying OUT_BOUND_MULTIPLIER 256 - */ 257 - if (nextScale < initialScale) { 258 - nextScale = 259 - nextScale + (initialScale - nextScale) * OUT_BOUND_MULTIPLIER 260 - } 261 - 262 - /** 263 - * In case image is scaling down -> move it in direction of initial position 264 - */ 265 - if (currentScale > initialScale && currentScale > nextScale) { 266 - const k = (currentScale - initialScale) / (currentScale - nextScale) 267 - 268 - const nextTranslateX = 269 - nextScale < initialScale 270 - ? initialTranslate.x 271 - : currentTranslate.x - 272 - (currentTranslate.x - initialTranslate.x) / k 273 - 274 - const nextTranslateY = 275 - nextScale < initialScale 276 - ? initialTranslate.y 277 - : currentTranslate.y - 278 - (currentTranslate.y - initialTranslate.y) / k 279 - 280 - translateValue.x.setValue(nextTranslateX) 281 - translateValue.y.setValue(nextTranslateY) 282 - 283 - tmpTranslate = {x: nextTranslateX, y: nextTranslateY} 284 - } 285 - 286 - scaleValue.setValue(nextScale) 287 - tmpScale = nextScale 288 - } 289 - 290 - if (isTapGesture && currentScale > initialScale) { 291 - const {x, y} = currentTranslate 292 - 293 - const {dx, dy} = gestureState 294 - const [topBound, leftBound, bottomBound, rightBound] = 295 - getBounds(currentScale) 296 - 297 - let nextTranslateX = x + dx 298 - let nextTranslateY = y + dy 299 - 300 - if (nextTranslateX > leftBound) { 301 - nextTranslateX = 302 - nextTranslateX - (nextTranslateX - leftBound) * OUT_BOUND_MULTIPLIER 303 - } 304 - 305 - if (nextTranslateX < rightBound) { 306 - nextTranslateX = 307 - nextTranslateX - 308 - (nextTranslateX - rightBound) * OUT_BOUND_MULTIPLIER 309 - } 310 - 311 - if (nextTranslateY > topBound) { 312 - nextTranslateY = 313 - nextTranslateY - (nextTranslateY - topBound) * OUT_BOUND_MULTIPLIER 314 - } 315 - 316 - if (nextTranslateY < bottomBound) { 317 - nextTranslateY = 318 - nextTranslateY - 319 - (nextTranslateY - bottomBound) * OUT_BOUND_MULTIPLIER 320 - } 321 - 322 - if (fitsScreenByWidth()) { 323 - nextTranslateX = x 324 - } 325 - 326 - if (fitsScreenByHeight()) { 327 - nextTranslateY = y 328 - } 329 - 330 - translateValue.x.setValue(nextTranslateX) 331 - translateValue.y.setValue(nextTranslateY) 332 - 333 - tmpTranslate = {x: nextTranslateX, y: nextTranslateY} 334 - } 335 - }, 336 - onPanResponderRelease: () => { 337 - if (isDoubleTapPerformed) { 338 - isDoubleTapPerformed = false 339 - } 340 - 341 - if (tmpScale > 0) { 342 - if (tmpScale < initialScale || tmpScale > MAX_SCALE) { 343 - tmpScale = tmpScale < initialScale ? initialScale : MAX_SCALE 344 - Animated.timing(scaleValue, { 345 - toValue: tmpScale, 346 - duration: 100, 347 - useNativeDriver: true, 348 - }).start() 349 - } 350 - 351 - currentScale = tmpScale 352 - tmpScale = 0 353 - } 354 - 355 - if (tmpTranslate) { 356 - const {x, y} = tmpTranslate 357 - const [topBound, leftBound, bottomBound, rightBound] = 358 - getBounds(currentScale) 359 - 360 - let nextTranslateX = x 361 - let nextTranslateY = y 362 - 363 - if (!fitsScreenByWidth()) { 364 - if (nextTranslateX > leftBound) { 365 - nextTranslateX = leftBound 366 - } else if (nextTranslateX < rightBound) { 367 - nextTranslateX = rightBound 368 - } 369 - } 370 - 371 - if (!fitsScreenByHeight()) { 372 - if (nextTranslateY > topBound) { 373 - nextTranslateY = topBound 374 - } else if (nextTranslateY < bottomBound) { 375 - nextTranslateY = bottomBound 376 - } 377 - } 378 - 379 - Animated.parallel([ 380 - Animated.timing(translateValue.x, { 381 - toValue: nextTranslateX, 382 - duration: 100, 383 - useNativeDriver: true, 384 - }), 385 - Animated.timing(translateValue.y, { 386 - toValue: nextTranslateY, 387 - duration: 100, 388 - useNativeDriver: true, 389 - }), 390 - ]).start() 391 - 392 - currentTranslate = {x: nextTranslateX, y: nextTranslateY} 393 - tmpTranslate = null 394 - } 395 - }, 396 - onPanResponderTerminationRequest: () => false, 397 - onShouldBlockNativeResponder: () => false, 398 - }) 399 - 400 - return [panResponder.panHandlers, scaleValue, translateValue] 401 - } 402 - 403 - const getImageDimensionsByTranslate = ( 404 - translate: Position, 405 - screen: {width: number; height: number}, 406 - ): {width: number; height: number} => ({ 407 - width: screen.width - translate.x * 2, 408 - height: screen.height - translate.y * 2, 409 - }) 410 - 411 - const getDistanceBetweenTouches = (touches: NativeTouchEvent[]): number => { 412 - const [a, b] = touches 413 - 414 - if (a == null || b == null) { 415 - return 0 416 - } 417 - 418 - return Math.sqrt( 419 - Math.pow(a.pageX - b.pageX, 2) + Math.pow(a.pageY - b.pageY, 2), 420 - ) 421 - } 422 - 423 - export default usePanResponder
+37 -5
src/view/com/lightbox/ImageViewing/index.tsx
··· 10 10 11 11 import React, { 12 12 ComponentType, 13 + createRef, 13 14 useCallback, 14 15 useRef, 15 16 useMemo, ··· 32 33 import ImageDefaultHeader from './components/ImageDefaultHeader' 33 34 34 35 import {ImageSource} from './@types' 36 + import {ScrollView, GestureType} from 'react-native-gesture-handler' 35 37 import {Edge, SafeAreaView} from 'react-native-safe-area-context' 36 38 37 39 type Props = { ··· 67 69 FooterComponent, 68 70 }: Props) { 69 71 const imageList = useRef<VirtualizedList<ImageSource>>(null) 72 + const [isScaled, setIsScaled] = useState(false) 73 + const [isDragging, setIsDragging] = useState(false) 70 74 const [opacity, setOpacity] = useState(1) 71 75 const [currentImageIndex, setImageIndex] = useState(imageIndex) 72 76 const [headerTranslate] = useState( ··· 115 119 } 116 120 } 117 121 118 - const onZoom = (isScaled: boolean) => { 119 - // @ts-ignore 120 - imageList?.current?.setNativeProps({scrollEnabled: !isScaled}) 121 - toggleBarsVisible(!isScaled) 122 + const onZoom = (nextIsScaled: boolean) => { 123 + toggleBarsVisible(!nextIsScaled) 124 + setIsScaled(false) 122 125 } 123 126 124 127 const edges = useMemo(() => { ··· 134 137 } 135 138 }, [imageList, imageIndex]) 136 139 140 + // This is a hack. 141 + // RNGH doesn't have an easy way to express that pinch of individual items 142 + // should "steal" all pinches from the scroll view. So we're keeping a ref 143 + // to all pinch gestures so that we may give them to <ScrollView waitFor={...}>. 144 + const [pinchGestureRefs] = useState(new Map()) 145 + for (let imageSrc of images) { 146 + if (!pinchGestureRefs.get(imageSrc)) { 147 + pinchGestureRefs.set(imageSrc, createRef<GestureType | undefined>()) 148 + } 149 + } 150 + 137 151 if (!visible) { 138 152 return null 139 153 } ··· 163 177 data={images} 164 178 horizontal 165 179 pagingEnabled 180 + scrollEnabled={!isScaled || isDragging} 166 181 showsHorizontalScrollIndicator={false} 167 182 showsVerticalScrollIndicator={false} 168 183 getItem={(_, index) => images[index]} ··· 177 192 onZoom={onZoom} 178 193 imageSrc={imageSrc} 179 194 onRequestClose={onRequestCloseEnhanced} 195 + pinchGestureRef={pinchGestureRefs.get(imageSrc)} 196 + isScrollViewBeingDragged={isDragging} 180 197 /> 181 198 )} 182 - onMomentumScrollEnd={onScroll} 199 + renderScrollComponent={props => ( 200 + <ScrollView 201 + {...props} 202 + waitFor={Array.from(pinchGestureRefs.values())} 203 + /> 204 + )} 205 + onScrollBeginDrag={() => { 206 + setIsDragging(true) 207 + }} 208 + onScrollEndDrag={() => { 209 + setIsDragging(false) 210 + }} 211 + onMomentumScrollEnd={e => { 212 + setIsScaled(false) 213 + onScroll(e) 214 + }} 183 215 //@ts-ignore 184 216 keyExtractor={(imageSrc, index) => 185 217 keyExtractor
+98
src/view/com/lightbox/ImageViewing/transforms.ts
··· 1 + import type {Position} from './@types' 2 + 3 + export type TransformMatrix = [ 4 + number, 5 + number, 6 + number, 7 + number, 8 + number, 9 + number, 10 + number, 11 + number, 12 + number, 13 + ] 14 + 15 + // These are affine transforms. See explanation of every cell here: 16 + // https://en.wikipedia.org/wiki/Transformation_matrix#/media/File:2D_affine_transformation_matrix.svg 17 + 18 + export function createTransform(): TransformMatrix { 19 + 'worklet' 20 + return [1, 0, 0, 0, 1, 0, 0, 0, 1] 21 + } 22 + 23 + export function applyRounding(t: TransformMatrix) { 24 + 'worklet' 25 + t[2] = Math.round(t[2]) 26 + t[5] = Math.round(t[5]) 27 + // For example: 0.985, 0.99, 0.995, then 1: 28 + t[0] = Math.round(t[0] * 200) / 200 29 + t[4] = Math.round(t[0] * 200) / 200 30 + } 31 + 32 + // We're using a limited subset (always scaling and translating while keeping aspect ratio) so 33 + // we can assume the transform doesn't encode have skew, rotation, or non-uniform stretching. 34 + 35 + // All write operations are applied in-place to avoid unnecessary allocations. 36 + 37 + export function readTransform(t: TransformMatrix): [number, number, number] { 38 + 'worklet' 39 + const scale = t[0] 40 + const translateX = t[2] 41 + const translateY = t[5] 42 + return [translateX, translateY, scale] 43 + } 44 + 45 + export function prependTranslate(t: TransformMatrix, x: number, y: number) { 46 + 'worklet' 47 + t[2] += t[0] * x + t[1] * y 48 + t[5] += t[3] * x + t[4] * y 49 + } 50 + 51 + export function prependScale(t: TransformMatrix, value: number) { 52 + 'worklet' 53 + t[0] *= value 54 + t[1] *= value 55 + t[3] *= value 56 + t[4] *= value 57 + } 58 + 59 + export function prependTransform(ta: TransformMatrix, tb: TransformMatrix) { 60 + 'worklet' 61 + // In-place matrix multiplication. 62 + const a00 = ta[0], 63 + a01 = ta[1], 64 + a02 = ta[2] 65 + const a10 = ta[3], 66 + a11 = ta[4], 67 + a12 = ta[5] 68 + const a20 = ta[6], 69 + a21 = ta[7], 70 + a22 = ta[8] 71 + ta[0] = a00 * tb[0] + a01 * tb[3] + a02 * tb[6] 72 + ta[1] = a00 * tb[1] + a01 * tb[4] + a02 * tb[7] 73 + ta[2] = a00 * tb[2] + a01 * tb[5] + a02 * tb[8] 74 + ta[3] = a10 * tb[0] + a11 * tb[3] + a12 * tb[6] 75 + ta[4] = a10 * tb[1] + a11 * tb[4] + a12 * tb[7] 76 + ta[5] = a10 * tb[2] + a11 * tb[5] + a12 * tb[8] 77 + ta[6] = a20 * tb[0] + a21 * tb[3] + a22 * tb[6] 78 + ta[7] = a20 * tb[1] + a21 * tb[4] + a22 * tb[7] 79 + ta[8] = a20 * tb[2] + a21 * tb[5] + a22 * tb[8] 80 + } 81 + 82 + export function prependPan(t: TransformMatrix, translation: Position) { 83 + 'worklet' 84 + prependTranslate(t, translation.x, translation.y) 85 + } 86 + 87 + export function prependPinch( 88 + t: TransformMatrix, 89 + scale: number, 90 + origin: Position, 91 + translation: Position, 92 + ) { 93 + 'worklet' 94 + prependTranslate(t, translation.x, translation.y) 95 + prependTranslate(t, origin.x, origin.y) 96 + prependScale(t, scale) 97 + prependTranslate(t, -origin.x, -origin.y) 98 + }
-42
src/view/com/lightbox/ImageViewing/utils.ts
··· 1 - /** 2 - * Copyright (c) JOB TODAY S.A. and its affiliates. 3 - * 4 - * This source code is licensed under the MIT license found in the 5 - * LICENSE file in the root directory of this source tree. 6 - * 7 - */ 8 - 9 - import {Dimensions, Position} from './@types' 10 - 11 - export const getImageTransform = ( 12 - image: Dimensions | null, 13 - screen: Dimensions, 14 - ) => { 15 - if (!image?.width || !image?.height) { 16 - return [] as const 17 - } 18 - 19 - const wScale = screen.width / image.width 20 - const hScale = screen.height / image.height 21 - const scale = Math.min(wScale, hScale) 22 - const {x, y} = getImageTranslate(image, screen) 23 - 24 - return [{x, y}, scale] as const 25 - } 26 - 27 - export const getImageTranslate = ( 28 - image: Dimensions, 29 - screen: Dimensions, 30 - ): Position => { 31 - const getTranslateForAxis = (axis: 'x' | 'y'): number => { 32 - const imageSize = axis === 'x' ? image.width : image.height 33 - const screenSize = axis === 'x' ? screen.width : screen.height 34 - 35 - return (screenSize - imageSize) / 2 36 - } 37 - 38 - return { 39 - x: getTranslateForAxis('x'), 40 - y: getTranslateForAxis('y'), 41 - } 42 - }