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 f882dcf9eb..8f9848fbf7 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -302,11 +302,6 @@ const ImageItem = ({ committedTransform.value = withClampedSpring(finalTransform) }) - const innerStyle = useAnimatedStyle(() => ({ - width: '100%', - aspectRatio: imageAspect, - })) - const composedGesture = isScrollViewBeingDragged ? // If the parent is not at rest, provide a no-op gesture. Gesture.Manual() @@ -317,12 +312,9 @@ const ImageItem = ({ singleTap, ) - const type = imageSrc.type - const borderRadius = - type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 return ( - + { - const screenSize = measure(safeAreaRef) ?? screenSizeDelayedForJSThreadOnly - return { - width: screenSize.width, - maxHeight: screenSize.height, - alignSelf: 'center', - aspectRatio: imageAspect, - } - }) - const scrollHandler = useAnimatedScrollHandler({ onScroll(e) { const nextIsScaled = e.zoomScale > 1 @@ -143,9 +132,6 @@ const ImageItem = ({ singleTap, ) - const type = imageSrc.type - const borderRadius = - type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 return ( void }) { const ref = useAnimatedRef() + const [activeLightbox, setActiveLightbox] = useState(nextLightbox) + const openProgress = useSharedValue(0) + + if (!activeLightbox && nextLightbox) { + setActiveLightbox(nextLightbox) + } + + React.useEffect(() => { + if (!nextLightbox) { + return + } + const canAnimate = + !PlatformInfo.getIsReducedMotionEnabled() && + nextLightbox.images.every(img => img.dimensions && img.thumbRect) + if (canAnimate) { + openProgress.value = withClampedSpring(1) + return () => { + openProgress.value = withClampedSpring(0) + } + } else { + openProgress.value = 1 + return () => { + openProgress.value = 0 + } + } + }, [nextLightbox, openProgress]) + + useAnimatedReaction( + () => openProgress.value === 0, + (isGone, wasGone) => { + if (isGone && !wasGone) { + runOnJS(setActiveLightbox)(null) + } + }, + ) + + const onFlyAway = React.useCallback(() => { + 'worklet' + openProgress.value = 0 + runOnJS(onRequestClose)() + }, [onRequestClose, openProgress]) + return ( // Keep it always mounted to avoid flicker on the first frame. + aria-hidden={!activeLightbox}> - {lightbox && ( + {activeLightbox && ( )} @@ -86,13 +140,17 @@ function ImageView({ onRequestClose, onPressSave, onPressShare, + onFlyAway, safeAreaRef, + openProgress, }: { lightbox: Lightbox onRequestClose: () => void onPressSave: (uri: string) => void onPressShare: (uri: string) => void + onFlyAway: () => void safeAreaRef: AnimatedRef + openProgress: SharedValue }) { const {images, index: initialImageIndex} = lightbox const [isScaled, setIsScaled] = useState(false) @@ -104,7 +162,7 @@ function ImageView({ const isFlyingAway = useSharedValue(false) const containerStyle = useAnimatedStyle(() => { - if (isFlyingAway.value) { + if (openProgress.value < 1 || isFlyingAway.value) { return {pointerEvents: 'none'} } return {pointerEvents: 'auto'} @@ -112,7 +170,9 @@ function ImageView({ const backdropStyle = useAnimatedStyle(() => { const screenSize = measure(safeAreaRef) let opacity = 1 - if (screenSize) { + if (openProgress.value < 1) { + opacity = Math.sqrt(openProgress.value) + } else if (screenSize) { const dragProgress = Math.min( Math.abs(dismissSwipeTranslateY.value) / (screenSize.height / 2), 1, @@ -127,7 +187,7 @@ function ImageView({ const show = showControls && dismissSwipeTranslateY.value === 0 return { pointerEvents: show ? 'box-none' : 'none', - opacity: withClampedSpring(show ? 1 : 0), + opacity: withClampedSpring(show && openProgress.value === 1 ? 1 : 0), transform: [ { translateY: withClampedSpring(show ? 0 : -30), @@ -140,7 +200,7 @@ function ImageView({ return { flexGrow: 1, pointerEvents: show ? 'box-none' : 'none', - opacity: withClampedSpring(show ? 1 : 0), + opacity: withClampedSpring(show && openProgress.value === 1 ? 1 : 0), transform: [ { translateY: withClampedSpring(show ? 0 : 30), @@ -202,6 +262,7 @@ function ImageView({ onZoom={onZoom} imageSrc={imageSrc} onRequestClose={onRequestClose} + onFlyAway={onFlyAway} isScrollViewBeingDragged={isDragging} showControls={showControls} safeAreaRef={safeAreaRef} @@ -209,6 +270,7 @@ function ImageView({ isFlyingAway={isFlyingAway} isActive={i === imageIndex} dismissSwipeTranslateY={dismissSwipeTranslateY} + openProgress={openProgress} /> ))} @@ -241,24 +303,28 @@ function LightboxImage({ onTap, onZoom, onRequestClose, + onFlyAway, isScrollViewBeingDragged, isScaled, isFlyingAway, isActive, showControls, safeAreaRef, + openProgress, dismissSwipeTranslateY, }: { imageSrc: ImageSource onRequestClose: () => void onTap: () => void onZoom: (scaled: boolean) => void + onFlyAway: () => void isScrollViewBeingDragged: boolean isScaled: boolean isActive: boolean isFlyingAway: SharedValue showControls: boolean safeAreaRef: AnimatedRef + openProgress: SharedValue dismissSwipeTranslateY: SharedValue }) { const [imageAspect, imageDimensions] = useImageDimensions({ @@ -266,6 +332,51 @@ function LightboxImage({ knownDimensions: imageSrc.dimensions, }) + const {thumbRect, dimensions} = imageSrc + const interpolation = useDerivedValue(() => { + 'worklet' + const screenSize = measure(safeAreaRef) + if (!screenSize) { + return {transform: [], width: 0, height: 0} + } + const finalWidth = screenSize.width + const finalHeight = imageAspect ? screenSize.width / imageAspect : undefined + if (isActive && thumbRect && dimensions && openProgress.value < 1) { + return interpolateTransform( + openProgress.value, + thumbRect, + screenSize, + dimensions, + ) + } + const translateY = isActive ? dismissSwipeTranslateY.value : 0 + return { + transform: [{translateY}], + width: finalWidth, + height: finalHeight, + } + }) + + const containerStyle = useAnimatedStyle(() => { + const {transform} = interpolation.value + return { + flex: 1, + transform, + } + }) + + const type = imageSrc.type + const borderRadius = + type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 + const imageStyle = useAnimatedStyle(() => { + const {width, height} = interpolation.value + return { + borderRadius, + width, + height, + } + }) + const dismissSwipePan = Gesture.Pan() .enabled(isActive && !isScaled) .activeOffsetY([-10, 10]) @@ -303,25 +414,22 @@ function LightboxImage({ } }) - const imageStyle = useAnimatedStyle(() => { - return { - transform: [{translateY: dismissSwipeTranslateY.value}], - } - }) return ( - + + + ) } @@ -476,7 +584,80 @@ const styles = StyleSheet.create({ }, }) +function interpolatePx( + px: number, + inputRange: readonly number[], + outputRange: readonly number[], +) { + 'worklet' + const value = interpolate(px, inputRange, outputRange) + return Math.round(value * PIXEL_RATIO) / PIXEL_RATIO +} + +function interpolateTransform( + progress: number, + thumbnailDims: { + pageX: number + width: number + pageY: number + height: number + }, + screenSize: {width: number; height: number}, + imageDims: {width: number; height: number}, +) { + 'worklet' + const imageAspect = imageDims.width / imageDims.height + const thumbAspect = thumbnailDims.width / thumbnailDims.height + let uncroppedInitialWidth + let uncroppedInitialHeight + if (imageAspect > thumbAspect) { + uncroppedInitialWidth = thumbnailDims.height * imageAspect + uncroppedInitialHeight = thumbnailDims.height + } else { + uncroppedInitialWidth = thumbnailDims.width + uncroppedInitialHeight = thumbnailDims.width / imageAspect + } + const finalWidth = screenSize.width + const finalHeight = screenSize.width / imageAspect + const initialScale = Math.min( + uncroppedInitialWidth / finalWidth, + uncroppedInitialHeight / finalHeight, + ) + const croppedFinalWidth = thumbnailDims.width / initialScale + const croppedFinalHeight = thumbnailDims.height / initialScale + const screenCenterX = screenSize.width / 2 + const screenCenterY = screenSize.height / 2 + const thumbnailCenterX = thumbnailDims.pageX + thumbnailDims.width / 2 + const thumbnailCenterY = thumbnailDims.pageY + thumbnailDims.height / 2 + const initialTranslateX = thumbnailCenterX - screenCenterX + const initialTranslateY = thumbnailCenterY - screenCenterY + const scale = interpolate(progress, [0, 1], [initialScale, 1]) + const translateX = interpolatePx(progress, [0, 1], [initialTranslateX, 0]) + const translateY = interpolatePx(progress, [0, 1], [initialTranslateY, 0]) + const width = interpolatePx(progress, [0, 1], [croppedFinalWidth, finalWidth]) + const height = interpolatePx( + progress, + [0, 1], + [croppedFinalHeight, finalHeight], + ) + const cropTranslateX = interpolatePx( + progress, + [0, 1], + [(finalWidth - croppedFinalWidth) / 2, 0], + ) + return { + transform: [ + {translateX}, + {translateY}, + {scale}, + {translateX: cropTranslateX}, + ], + width, + height, + } +} + function withClampedSpring(value: any) { 'worklet' - return withSpring(value, {overshootClamping: true, stiffness: 300}) + return withSpring(value, {overshootClamping: true, stiffness: 120}) }