diff options
author | dan <dan.abramov@gmail.com> | 2023-10-06 03:54:36 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-10-05 19:54:36 -0700 |
commit | 64153067e3387b71ae5b5c67ae2837c317a08b0d (patch) | |
tree | 06f0f32a12d8ed523cee7a0ab325e5330a75adf5 /src/view/com/lightbox/ImageViewing/components | |
parent | 8366fe2c4aae18ef67025386425ea90a83174a72 (diff) | |
download | voidsky-64153067e3387b71ae5b5c67ae2837c317a08b0d.tar.zst |
Rewrite Android lightbox (#1624)
Diffstat (limited to 'src/view/com/lightbox/ImageViewing/components')
3 files changed, 403 insertions, 126 deletions
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx index 6276a1a14..6ff4dee2e 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -1,159 +1,398 @@ -/** - * Copyright (c) JOB TODAY S.A. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ +import React, {MutableRefObject, useState} from 'react' -import React, {useCallback, useRef, useState} from 'react' - -import { - Animated, - ScrollView, - Dimensions, - StyleSheet, - NativeScrollEvent, - NativeSyntheticEvent, - NativeMethodsMixin, -} from 'react-native' +import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native' import {Image} from 'expo-image' - +import Animated, { + measure, + runOnJS, + useAnimatedRef, + useAnimatedStyle, + useAnimatedReaction, + useSharedValue, + withDecay, + withSpring, +} from 'react-native-reanimated' +import { + GestureDetector, + Gesture, + GestureType, +} from 'react-native-gesture-handler' import useImageDimensions from '../../hooks/useImageDimensions' -import usePanResponder from '../../hooks/usePanResponder' - -import {getImageTransform} from '../../utils' -import {ImageSource} from '../../@types' -import {ImageLoading} from './ImageLoading' +import { + createTransform, + readTransform, + applyRounding, + prependPan, + prependPinch, + prependTransform, + TransformMatrix, +} from '../../transforms' +import type {ImageSource, Dimensions as ImageDimensions} from '../../@types' -const SWIPE_CLOSE_OFFSET = 75 -const SWIPE_CLOSE_VELOCITY = 1.75 const SCREEN = Dimensions.get('window') -const SCREEN_WIDTH = SCREEN.width -const SCREEN_HEIGHT = SCREEN.height +const MIN_DOUBLE_TAP_SCALE = 2 +const MAX_ORIGINAL_IMAGE_ZOOM = 2 + +const AnimatedImage = Animated.createAnimatedComponent(Image) +const initialTransform = createTransform() type Props = { imageSrc: ImageSource onRequestClose: () => void onZoom: (isZoomed: boolean) => void + pinchGestureRef: MutableRefObject<GestureType | undefined> + isScrollViewBeingDragged: boolean } +const ImageItem = ({ + imageSrc, + onZoom, + onRequestClose, + isScrollViewBeingDragged, + pinchGestureRef, +}: Props) => { + const [isScaled, setIsScaled] = useState(false) + const [isLoaded, setIsLoaded] = useState(false) + const imageDimensions = useImageDimensions(imageSrc) + const committedTransform = useSharedValue(initialTransform) + const panTranslation = useSharedValue({x: 0, y: 0}) + const pinchOrigin = useSharedValue({x: 0, y: 0}) + const pinchScale = useSharedValue(1) + const pinchTranslation = useSharedValue({x: 0, y: 0}) + const dismissSwipeTranslateY = useSharedValue(0) + const containerRef = useAnimatedRef() -const AnimatedImage = Animated.createAnimatedComponent(Image) + function getCommittedScale(): number { + 'worklet' + const [, , committedScale] = readTransform(committedTransform.value) + return committedScale + } -const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => { - const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null) - const imageDimensions = useImageDimensions(imageSrc) - const [translate, scale] = getImageTransform(imageDimensions, SCREEN) - const scrollValueY = new Animated.Value(0) - const [isLoaded, setLoadEnd] = useState(false) - - const onLoaded = useCallback(() => setLoadEnd(true), []) - const onZoomPerformed = useCallback( - (isZoomed: boolean) => { - onZoom(isZoomed) - if (imageContainer?.current) { - imageContainer.current.setNativeProps({ - scrollEnabled: !isZoomed, - }) + // Keep track of when we're entering or leaving scaled rendering. + useAnimatedReaction( + () => { + return pinchScale.value !== 1 || getCommittedScale() !== 1 + }, + (nextIsScaled, prevIsScaled) => { + if (nextIsScaled !== prevIsScaled) { + runOnJS(handleZoom)(nextIsScaled) } }, - [onZoom], ) - const [panHandlers, scaleValue, translateValue] = usePanResponder({ - initialScale: scale || 1, - initialTranslate: translate || {x: 0, y: 0}, - onZoom: onZoomPerformed, - }) + function handleZoom(nextIsScaled: boolean) { + setIsScaled(nextIsScaled) + onZoom(nextIsScaled) + } - const imagesStyles = getImageStyles( - imageDimensions, - translateValue, - scaleValue, - ) - const imageOpacity = scrollValueY.interpolate({ - inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], - outputRange: [0.7, 1, 0.7], + const animatedStyle = useAnimatedStyle(() => { + // Apply the active adjustments on top of the committed transform before the gestures. + // This is matrix multiplication, so operations are applied in the reverse order. + let t = createTransform() + prependPan(t, panTranslation.value) + prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value) + prependTransform(t, committedTransform.value) + const [translateX, translateY, scale] = readTransform(t) + + const dismissDistance = dismissSwipeTranslateY.value + const dismissProgress = Math.min( + Math.abs(dismissDistance) / (SCREEN.height / 2), + 1, + ) + return { + opacity: 1 - dismissProgress, + transform: [ + {translateX}, + {translateY: translateY + dismissDistance}, + {scale}, + ], + } }) - const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity} - - const onScrollEndDrag = ({ - nativeEvent, - }: NativeSyntheticEvent<NativeScrollEvent>) => { - const velocityY = nativeEvent?.velocity?.y ?? 0 - const offsetY = nativeEvent?.contentOffset?.y ?? 0 - - if ( - (Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY && - offsetY > SWIPE_CLOSE_OFFSET) || - offsetY > SCREEN_HEIGHT / 2 - ) { - onRequestClose() + + // On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges. + // If the user tried to pan too hard, this function will provide the negative panning to stay in bounds. + function getExtraTranslationToStayInBounds( + candidateTransform: TransformMatrix, + ) { + 'worklet' + if (!imageDimensions) { + return [0, 0] } + const [nextTranslateX, nextTranslateY, nextScale] = + readTransform(candidateTransform) + const scaledDimensions = getScaledDimensions(imageDimensions, nextScale) + const clampedTranslateX = clampTranslation( + nextTranslateX, + scaledDimensions.width, + SCREEN.width, + ) + const clampedTranslateY = clampTranslation( + nextTranslateY, + scaledDimensions.height, + SCREEN.height, + ) + const dx = clampedTranslateX - nextTranslateX + const dy = clampedTranslateY - nextTranslateY + return [dx, dy] } - const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => { - const offsetY = nativeEvent?.contentOffset?.y ?? 0 + // This is a hack. + // We need to disallow any gestures (and let the native parent scroll view scroll) while you're scrolling it. + // However, there is no great reliable way to coordinate this yet in RGNH. + // This "fake" manual gesture handler whenever you're trying to touch something while the parent scrollview is not at rest. + const consumeHScroll = Gesture.Manual().onTouchesDown((e, manager) => { + if (isScrollViewBeingDragged) { + // Steal the gesture (and do nothing, so native ScrollView does its thing). + manager.activate() + return + } + const measurement = measure(containerRef) + if (!measurement || measurement.pageX !== 0) { + // Steal the gesture (and do nothing, so native ScrollView does its thing). + manager.activate() + return + } + // Fail this "fake" gesture so that the gestures after it can proceed. + manager.fail() + }) - scrollValueY.setValue(offsetY) - } + const pinch = Gesture.Pinch() + .withRef(pinchGestureRef) + .onStart(e => { + pinchOrigin.value = { + x: e.focalX - SCREEN.width / 2, + y: e.focalY - SCREEN.height / 2, + } + }) + .onChange(e => { + if (!imageDimensions) { + return + } + // Don't let the picture zoom in so close that it gets blurry. + // Also, like in stock Android apps, don't let the user zoom out further than 1:1. + const committedScale = getCommittedScale() + const maxCommittedScale = + (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM + const minPinchScale = 1 / committedScale + const maxPinchScale = maxCommittedScale / committedScale + const nextPinchScale = Math.min( + Math.max(minPinchScale, e.scale), + maxPinchScale, + ) + pinchScale.value = nextPinchScale + // Zooming out close to the corner could push us out of bounds, which we don't want on Android. + // Calculate where we'll end up so we know how much to translate back to stay in bounds. + const t = createTransform() + prependPan(t, panTranslation.value) + prependPinch(t, nextPinchScale, pinchOrigin.value, pinchTranslation.value) + prependTransform(t, committedTransform.value) + const [dx, dy] = getExtraTranslationToStayInBounds(t) + if (dx !== 0 || dy !== 0) { + pinchTranslation.value = { + x: pinchTranslation.value.x + dx, + y: pinchTranslation.value.y + dy, + } + } + }) + .onEnd(() => { + // Commit just the pinch. + let t = createTransform() + prependPinch( + t, + pinchScale.value, + pinchOrigin.value, + pinchTranslation.value, + ) + prependTransform(t, committedTransform.value) + applyRounding(t) + committedTransform.value = t + + // Reset just the pinch. + pinchScale.value = 1 + pinchOrigin.value = {x: 0, y: 0} + pinchTranslation.value = {x: 0, y: 0} + }) + + const pan = Gesture.Pan() + .averageTouches(true) + // Unlike .enabled(isScaled), this ensures that an initial pinch can turn into a pan midway: + .minPointers(isScaled ? 1 : 2) + .onChange(e => { + if (!imageDimensions) { + return + } + const nextPanTranslation = {x: e.translationX, y: e.translationY} + let t = createTransform() + prependPan(t, nextPanTranslation) + prependPinch( + t, + pinchScale.value, + pinchOrigin.value, + pinchTranslation.value, + ) + prependTransform(t, committedTransform.value) + + // Prevent panning from going out of bounds. + const [dx, dy] = getExtraTranslationToStayInBounds(t) + nextPanTranslation.x += dx + nextPanTranslation.y += dy + panTranslation.value = nextPanTranslation + }) + .onEnd(() => { + // Commit just the pan. + let t = createTransform() + prependPan(t, panTranslation.value) + prependTransform(t, committedTransform.value) + applyRounding(t) + committedTransform.value = t + + // Reset just the pan. + panTranslation.value = {x: 0, y: 0} + }) + + const doubleTap = Gesture.Tap() + .numberOfTaps(2) + .onEnd(e => { + if (!imageDimensions) { + return + } + const committedScale = getCommittedScale() + if (committedScale !== 1) { + // Go back to 1:1 using the identity vector. + let t = createTransform() + committedTransform.value = withClampedSpring(t) + return + } + + // Try to zoom in so that we get rid of the black bars (whatever the orientation was). + const imageAspect = imageDimensions.width / imageDimensions.height + const screenAspect = SCREEN.width / SCREEN.height + const candidateScale = Math.max( + imageAspect / screenAspect, + screenAspect / imageAspect, + MIN_DOUBLE_TAP_SCALE, + ) + // But don't zoom in so close that the picture gets blurry. + const maxScale = + (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM + const scale = Math.min(candidateScale, maxScale) + + // Calculate where we would be if the user pinched into the double tapped point. + // We won't use this transform directly because it may go out of bounds. + const candidateTransform = createTransform() + const origin = { + x: e.absoluteX - SCREEN.width / 2, + y: e.absoluteY - SCREEN.height / 2, + } + prependPinch(candidateTransform, scale, origin, {x: 0, y: 0}) + + // Now we know how much we went out of bounds, so we can shoot correctly. + const [dx, dy] = getExtraTranslationToStayInBounds(candidateTransform) + const finalTransform = createTransform() + prependPinch(finalTransform, scale, origin, {x: dx, y: dy}) + committedTransform.value = withClampedSpring(finalTransform) + }) + + const dismissSwipePan = Gesture.Pan() + .enabled(!isScaled) + .activeOffsetY([-10, 10]) + .failOffsetX([-10, 10]) + .maxPointers(1) + .onUpdate(e => { + dismissSwipeTranslateY.value = e.translationY + }) + .onEnd(e => { + if (Math.abs(e.velocityY) > 1000) { + dismissSwipeTranslateY.value = withDecay({velocity: e.velocityY}) + runOnJS(onRequestClose)() + } else { + dismissSwipeTranslateY.value = withSpring(0, { + stiffness: 700, + damping: 50, + }) + } + }) + + const isLoading = !isLoaded || !imageDimensions return ( - <ScrollView - ref={imageContainer} - style={styles.listItem} - pagingEnabled - nestedScrollEnabled - showsHorizontalScrollIndicator={false} - showsVerticalScrollIndicator={false} - contentContainerStyle={styles.imageScrollContainer} - scrollEnabled={true} - onScroll={onScroll} - onScrollEndDrag={onScrollEndDrag}> - <AnimatedImage - {...panHandlers} - source={imageSrc} - style={imageStylesWithOpacity} - onLoad={onLoaded} - accessibilityLabel={imageSrc.alt} - accessibilityHint="" - /> - {(!isLoaded || !imageDimensions) && <ImageLoading />} - </ScrollView> + <Animated.View ref={containerRef} style={styles.container}> + {isLoading && ( + <ActivityIndicator size="small" color="#FFF" style={styles.loading} /> + )} + <GestureDetector + gesture={Gesture.Exclusive( + consumeHScroll, + dismissSwipePan, + Gesture.Simultaneous(pinch, pan), + doubleTap, + )}> + <AnimatedImage + source={imageSrc} + contentFit="contain" + style={[styles.image, animatedStyle]} + accessibilityLabel={imageSrc.alt} + accessibilityHint="" + onLoad={() => setIsLoaded(true)} + /> + </GestureDetector> + </Animated.View> ) } const styles = StyleSheet.create({ - listItem: { - width: SCREEN_WIDTH, - height: SCREEN_HEIGHT, + container: { + width: SCREEN.width, + height: SCREEN.height, + overflow: 'hidden', + }, + image: { + flex: 1, }, - imageScrollContainer: { - height: SCREEN_HEIGHT * 2, + loading: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, }, }) -const getImageStyles = ( - image: {width: number; height: number} | null, - translate: Animated.ValueXY, - scale?: Animated.Value, -) => { - if (!image?.width || !image?.height) { - return {width: 0, height: 0} +function getScaledDimensions( + imageDimensions: ImageDimensions, + scale: number, +): ImageDimensions { + 'worklet' + const imageAspect = imageDimensions.width / imageDimensions.height + const screenAspect = SCREEN.width / SCREEN.height + const isLandscape = imageAspect > screenAspect + if (isLandscape) { + return { + width: scale * SCREEN.width, + height: (scale * SCREEN.width) / imageAspect, + } + } else { + return { + width: scale * SCREEN.height * imageAspect, + height: scale * SCREEN.height, + } } +} - const transform = translate.getTranslateTransform() - - if (scale) { - // @ts-ignore TODO - is scale incorrect? might need to remove -prf - transform.push({scale}, {perspective: new Animated.Value(1000)}) - } +function clampTranslation( + value: number, + scaledSize: number, + screenSize: number, +): number { + 'worklet' + // Figure out how much the user should be allowed to pan, and constrain the translation. + const panDistance = Math.max(0, (scaledSize - screenSize) / 2) + const clampedValue = Math.min(Math.max(-panDistance, value), panDistance) + return clampedValue +} - return { - width: image.width, - height: image.height, - transform, - } +function withClampedSpring(value: any) { + 'worklet' + return withSpring(value, {overshootClamping: true}) } export default React.memo(ImageItem) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx index b9f3ae510..598b18ed2 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -6,7 +6,7 @@ * */ -import React, {useCallback, useRef, useState} from 'react' +import React, {MutableRefObject, useCallback, useRef, useState} from 'react' import { Animated, @@ -20,11 +20,11 @@ import { TouchableWithoutFeedback, } from 'react-native' import {Image} from 'expo-image' +import {GestureType} from 'react-native-gesture-handler' import useImageDimensions from '../../hooks/useImageDimensions' -import {getImageTransform} from '../../utils' -import {ImageSource} from '../../@types' +import {ImageSource, Dimensions as ImageDimensions} from '../../@types' import {ImageLoading} from './ImageLoading' const DOUBLE_TAP_DELAY = 300 @@ -40,6 +40,8 @@ type Props = { imageSrc: ImageSource onRequestClose: () => void onZoom: (scaled: boolean) => void + pinchGestureRef: MutableRefObject<GestureType> + isScrollViewBeingDragged: boolean } const AnimatedImage = Animated.createAnimatedComponent(Image) @@ -164,7 +166,7 @@ const styles = StyleSheet.create({ }) const getZoomRectAfterDoubleTap = ( - imageDimensions: {width: number; height: number} | null, + imageDimensions: ImageDimensions | null, touchX: number, touchY: number, ): { @@ -252,7 +254,7 @@ const getZoomRectAfterDoubleTap = ( } const getImageStyles = ( - image: {width: number; height: number} | null, + image: ImageDimensions | null, translate: {readonly x: number; readonly y: number} | undefined, scale?: number, ) => { @@ -275,4 +277,37 @@ const getImageStyles = ( } } +const getImageTransform = ( + image: ImageDimensions | null, + screen: ImageDimensions, +) => { + if (!image?.width || !image?.height) { + return [] as const + } + + const wScale = screen.width / image.width + const hScale = screen.height / image.height + const scale = Math.min(wScale, hScale) + const {x, y} = getImageTranslate(image, screen) + + return [{x, y}, scale] as const +} + +const getImageTranslate = ( + image: ImageDimensions, + screen: ImageDimensions, +): {x: number; y: number} => { + const getTranslateForAxis = (axis: 'x' | 'y'): number => { + const imageSize = axis === 'x' ? image.width : image.height + const screenSize = axis === 'x' ? screen.width : screen.height + + return (screenSize - imageSize) / 2 + } + + return { + x: getTranslateForAxis('x'), + y: getTranslateForAxis('y'), + } +} + export default React.memo(ImageItem) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx index 82ee86d7d..898b00c78 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx @@ -1,13 +1,16 @@ // default implementation fallback for web -import React from 'react' +import React, {MutableRefObject} from 'react' import {View} from 'react-native' +import {GestureType} from 'react-native-gesture-handler' import {ImageSource} from '../../@types' type Props = { imageSrc: ImageSource onRequestClose: () => void onZoom: (scaled: boolean) => void + pinchGestureRef: MutableRefObject<GestureType | undefined> + isScrollViewBeingDragged: boolean } const ImageItem = (_props: Props) => { |