diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index fe325c1e5f..093b9190a6 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -1,5 +1,12 @@ import React, {memo} from 'react' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' +import Animated, { + measure, + MeasuredDimensions, + runOnJS, + runOnUI, + useAnimatedRef, +} from 'react-native-reanimated' import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg} from '@lingui/macro' @@ -42,6 +49,7 @@ let ProfileHeaderShell = ({ const {openLightbox} = useLightboxControls() const navigation = useNavigation() const {isDesktop} = useWebMediaQueries() + const aviRef = useAnimatedRef() const onPressBack = React.useCallback(() => { if (navigation.canGoBack()) { @@ -51,14 +59,14 @@ let ProfileHeaderShell = ({ } }, [navigation]) - const onPressAvi = React.useCallback(() => { - const modui = moderation.ui('avatar') - if (profile.avatar && !(modui.blur && modui.noOverride)) { + const _openLightbox = React.useCallback( + (uri: string, thumbRect: MeasuredDimensions | null) => { openLightbox({ images: [ { - uri: profile.avatar, - thumbUri: profile.avatar, + uri, + thumbUri: uri, + thumbRect, dimensions: { // It's fine if it's actually smaller but we know it's 1:1. height: 1000, @@ -68,10 +76,22 @@ let ProfileHeaderShell = ({ }, ], index: 0, - thumbDims: null, }) + }, + [openLightbox], + ) + + const onPressAvi = React.useCallback(() => { + const modui = moderation.ui('avatar') + const avatar = profile.avatar + if (avatar && !(modui.blur && modui.noOverride)) { + runOnUI(() => { + 'worklet' + const rect = measure(aviRef) + runOnJS(_openLightbox)(avatar, rect) + })() } - }, [openLightbox, profile, moderation]) + }, [profile, moderation, _openLightbox, aviRef]) const isMe = React.useMemo( () => currentAccount?.did === profile.did, @@ -149,12 +169,14 @@ let ProfileHeaderShell = ({ styles.avi, profile.associated?.labeler && styles.aviLabeler, ]}> - + + + diff --git a/src/state/lightbox.tsx b/src/state/lightbox.tsx index 06541106e7..67a450991d 100644 --- a/src/state/lightbox.tsx +++ b/src/state/lightbox.tsx @@ -1,5 +1,4 @@ import React from 'react' -import type {MeasuredDimensions} from 'react-native-reanimated' import {nanoid} from 'nanoid/non-secure' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' @@ -8,7 +7,6 @@ import {ImageSource} from '#/view/com/lightbox/ImageViewing/@types' export type Lightbox = { id: string images: ImageSource[] - thumbDims: MeasuredDimensions | null index: number } diff --git a/src/view/com/lightbox/ImageViewing/@types/index.ts b/src/view/com/lightbox/ImageViewing/@types/index.ts index dc636a4495..1a3543c267 100644 --- a/src/view/com/lightbox/ImageViewing/@types/index.ts +++ b/src/view/com/lightbox/ImageViewing/@types/index.ts @@ -6,6 +6,9 @@ * */ +import {TransformsStyle} from 'react-native' +import {MeasuredDimensions} from 'react-native-reanimated' + export type Dimensions = { width: number height: number @@ -19,7 +22,13 @@ export type Position = { export type ImageSource = { uri: string thumbUri: string + thumbRect: MeasuredDimensions | null alt?: string dimensions: Dimensions | null type: 'image' | 'circle-avi' | 'rect-avi' } + +export type Transform = Exclude< + TransformsStyle['transform'], + string | undefined +> 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..069f9eb40b 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -1,23 +1,26 @@ import React, {useState} from 'react' -import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native' +import {ActivityIndicator, StyleSheet} from 'react-native' import { Gesture, GestureDetector, PanGesture, } from 'react-native-gesture-handler' import Animated, { - AnimatedRef, - measure, runOnJS, + SharedValue, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, useSharedValue, withSpring, } from 'react-native-reanimated' -import {Image, ImageStyle} from 'expo-image' +import {Image} from 'expo-image' -import type {Dimensions as ImageDimensions, ImageSource} from '../../@types' +import type { + Dimensions as ImageDimensions, + ImageSource, + Transform, +} from '../../@types' import { applyRounding, createTransform, @@ -28,8 +31,6 @@ import { TransformMatrix, } from '../../transforms' -const AnimatedImage = Animated.createAnimatedComponent(Image) - const MIN_SCREEN_ZOOM = 2 const MAX_ORIGINAL_IMAGE_ZOOM = 2 @@ -42,22 +43,35 @@ type Props = { onZoom: (isZoomed: boolean) => void isScrollViewBeingDragged: boolean showControls: boolean - safeAreaRef: AnimatedRef + measureSafeArea: () => { + x: number + y: number + width: number + height: number + } imageAspect: number | undefined imageDimensions: ImageDimensions | undefined - imageStyle: StyleProp dismissSwipePan: PanGesture + transforms: Readonly< + SharedValue<{ + scaleAndMoveTransform: Transform + cropFrameTransform: Transform + cropContentTransform: Transform + isResting: boolean + isHidden: boolean + }> + > } const ImageItem = ({ imageSrc, onTap, onZoom, isScrollViewBeingDragged, - safeAreaRef, + measureSafeArea, imageAspect, imageDimensions, - imageStyle, dismissSwipePan, + transforms, }: Props) => { const [isScaled, setIsScaled] = useState(false) const committedTransform = useSharedValue(initialTransform) @@ -95,19 +109,6 @@ const ImageItem = ({ onZoom(nextIsScaled) } - 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) - return { - transform: [{translateX}, {translateY: translateY}, {scale}], - } - }) - // 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( @@ -143,10 +144,7 @@ const ImageItem = ({ const pinch = Gesture.Pinch() .onStart(e => { 'worklet' - const screenSize = measure(safeAreaRef) - if (!screenSize) { - return - } + const screenSize = measureSafeArea() pinchOrigin.value = { x: e.focalX - screenSize.width / 2, y: e.focalY - screenSize.height / 2, @@ -154,8 +152,8 @@ const ImageItem = ({ }) .onChange(e => { 'worklet' - const screenSize = measure(safeAreaRef) - if (!imageDimensions || !screenSize) { + const screenSize = measureSafeArea() + if (!imageDimensions) { return } // Don't let the picture zoom in so close that it gets blurry. @@ -213,8 +211,8 @@ const ImageItem = ({ .minPointers(isScaled ? 1 : 2) .onChange(e => { 'worklet' - const screenSize = measure(safeAreaRef) - if (!imageDimensions || !screenSize) { + const screenSize = measureSafeArea() + if (!imageDimensions) { return } @@ -257,8 +255,8 @@ const ImageItem = ({ .numberOfTaps(2) .onEnd(e => { 'worklet' - const screenSize = measure(safeAreaRef) - if (!imageDimensions || !imageAspect || !screenSize) { + const screenSize = measureSafeArea() + if (!imageDimensions || !imageAspect) { return } const [, , committedScale] = readTransform(committedTransform.value) @@ -302,11 +300,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,29 +310,97 @@ const ImageItem = ({ singleTap, ) + const containerStyle = useAnimatedStyle(() => { + const {scaleAndMoveTransform, isHidden} = transforms.value + // 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 manipulationTransform = [ + {translateX}, + {translateY: translateY}, + {scale}, + ] + const screenSize = measureSafeArea() + return { + opacity: isHidden ? 0 : 1, + transform: scaleAndMoveTransform.concat(manipulationTransform), + width: screenSize.width, + maxHeight: screenSize.height, + aspectRatio: imageAspect, + alignSelf: 'center', + } + }) + + const imageCropStyle = useAnimatedStyle(() => { + const {cropFrameTransform} = transforms.value + return { + flex: 1, + overflow: 'hidden', + transform: cropFrameTransform, + } + }) + + const imageStyle = useAnimatedStyle(() => { + const {cropContentTransform} = transforms.value + return { + flex: 1, + transform: cropContentTransform, + } + }) + + const [showLoader, setShowLoader] = useState(false) + const [hasLoaded, setHasLoaded] = useState(false) + useAnimatedReaction( + () => { + return transforms.value.isResting && !hasLoaded + }, + (show, prevShow) => { + if (show && !prevShow) { + runOnJS(setShowLoader)(false) + } else if (!prevShow && show) { + runOnJS(setShowLoader)(true) + } + }, + ) + const type = imageSrc.type const borderRadius = type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 + return ( - - - - + + + {showLoader && ( + + )} + + + setHasLoaded(false)} + style={{flex: 1, borderRadius}} + accessibilityHint="" + accessibilityIgnoresInvertColors + cachePolicy="memory" + /> + + @@ -358,6 +419,7 @@ const styles = StyleSheet.create({ right: 0, top: 0, bottom: 0, + justifyContent: 'center', }, }) 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 e876479a39..7a9a18b91b 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -7,26 +7,28 @@ */ import React, {useState} from 'react' -import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native' +import {ActivityIndicator, StyleSheet} from 'react-native' import { Gesture, GestureDetector, PanGesture, } from 'react-native-gesture-handler' import Animated, { - AnimatedRef, - measure, runOnJS, + SharedValue, + useAnimatedReaction, useAnimatedRef, useAnimatedStyle, } from 'react-native-reanimated' import {useSafeAreaFrame} from 'react-native-safe-area-context' -import {Image, ImageStyle} from 'expo-image' +import {Image} from 'expo-image' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' -import {Dimensions as ImageDimensions, ImageSource} from '../../@types' - -const AnimatedImage = Animated.createAnimatedComponent(Image) +import { + Dimensions as ImageDimensions, + ImageSource, + Transform, +} from '../../@types' const MAX_ORIGINAL_IMAGE_ZOOM = 2 const MIN_SCREEN_ZOOM = 2 @@ -38,11 +40,24 @@ type Props = { onZoom: (scaled: boolean) => void isScrollViewBeingDragged: boolean showControls: boolean - safeAreaRef: AnimatedRef + measureSafeArea: () => { + x: number + y: number + width: number + height: number + } imageAspect: number | undefined imageDimensions: ImageDimensions | undefined - imageStyle: StyleProp dismissSwipePan: PanGesture + transforms: Readonly< + SharedValue<{ + scaleAndMoveTransform: Transform + cropFrameTransform: Transform + cropContentTransform: Transform + isResting: boolean + isHidden: boolean + }> + > } const ImageItem = ({ @@ -50,11 +65,11 @@ const ImageItem = ({ onTap, onZoom, showControls, - safeAreaRef, + measureSafeArea, imageAspect, imageDimensions, - imageStyle, dismissSwipePan, + transforms, }: Props) => { const scrollViewRef = useAnimatedRef() const [scaled, setScaled] = useState(false) @@ -67,16 +82,6 @@ const ImageItem = ({ : 1, ) - const animatedStyle = useAnimatedStyle(() => { - 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 @@ -114,10 +119,7 @@ const ImageItem = ({ .numberOfTaps(2) .onEnd(e => { 'worklet' - const screenSize = measure(safeAreaRef) - if (!screenSize) { - return - } + const screenSize = measureSafeArea() const {absoluteX, absoluteY} = e let nextZoomRect = { x: 0, @@ -143,9 +145,56 @@ const ImageItem = ({ singleTap, ) + const containerStyle = useAnimatedStyle(() => { + const {scaleAndMoveTransform, isHidden} = transforms.value + return { + flex: 1, + transform: scaleAndMoveTransform, + opacity: isHidden ? 0 : 1, + } + }) + + const imageCropStyle = useAnimatedStyle(() => { + const screenSize = measureSafeArea() + const {cropFrameTransform} = transforms.value + return { + overflow: 'hidden', + transform: cropFrameTransform, + width: screenSize.width, + maxHeight: screenSize.height, + aspectRatio: imageAspect, + alignSelf: 'center', + } + }) + + const imageStyle = useAnimatedStyle(() => { + const {cropContentTransform} = transforms.value + return { + transform: cropContentTransform, + width: '100%', + aspectRatio: imageAspect, + } + }) + + const [showLoader, setShowLoader] = useState(false) + const [hasLoaded, setHasLoaded] = useState(false) + useAnimatedReaction( + () => { + return transforms.value.isResting && !hasLoaded + }, + (show, prevShow) => { + if (show && !prevShow) { + runOnJS(setShowLoader)(false) + } else if (!prevShow && show) { + runOnJS(setShowLoader)(true) + } + }, + ) + const type = imageSrc.type const borderRadius = type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 + return ( - - + {showLoader && ( + + )} + + + setHasLoaded(true)} + /> + + ) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx index 1cd6b00204..543fad7726 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx @@ -1,11 +1,15 @@ // default implementation fallback for web import React from 'react' -import {ImageStyle, StyleProp, View} from 'react-native' +import {View} from 'react-native' import {PanGesture} from 'react-native-gesture-handler' -import {AnimatedRef} from 'react-native-reanimated' +import {SharedValue} from 'react-native-reanimated' -import {Dimensions as ImageDimensions, ImageSource} from '../../@types' +import { + Dimensions as ImageDimensions, + ImageSource, + Transform, +} from '../../@types' type Props = { imageSrc: ImageSource @@ -14,11 +18,24 @@ type Props = { onZoom: (scaled: boolean) => void isScrollViewBeingDragged: boolean showControls: boolean - safeAreaRef: AnimatedRef + measureSafeArea: () => { + x: number + y: number + width: number + height: number + } imageAspect: number | undefined imageDimensions: ImageDimensions | undefined - imageStyle: StyleProp dismissSwipePan: PanGesture + transforms: Readonly< + SharedValue<{ + scaleAndMoveTransform: Transform + cropFrameTransform: Transform + cropContentTransform: Transform + isResting: boolean + isHidden: boolean + }> + > } const ImageItem = (_props: Props) => { diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index 0a01c7fb3a..030c8dcf30 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -9,23 +9,36 @@ // https://github.com/jobtoday/react-native-image-viewing import React, {useCallback, useState} from 'react' -import {LayoutAnimation, Platform, StyleSheet, View} from 'react-native' +import { + LayoutAnimation, + PixelRatio, + Platform, + StyleSheet, + View, +} from 'react-native' import {Gesture} from 'react-native-gesture-handler' import PagerView from 'react-native-pager-view' import Animated, { AnimatedRef, cancelAnimation, + interpolate, measure, runOnJS, SharedValue, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, + useDerivedValue, useSharedValue, withDecay, withSpring, } from 'react-native-reanimated' -import {Edge, SafeAreaView} from 'react-native-safe-area-context' +import { + Edge, + SafeAreaView, + useSafeAreaFrame, + useSafeAreaInsets, +} from 'react-native-safe-area-context' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Trans} from '@lingui/macro' @@ -36,17 +49,24 @@ import {Lightbox} from '#/state/lightbox' import {Button} from '#/view/com/util/forms/Button' import {Text} from '#/view/com/util/text/Text' import {ScrollView} from '#/view/com/util/Views' -import {ImageSource} from './@types' +import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army' +import {ImageSource, Transform} from './@types' import ImageDefaultHeader from './components/ImageDefaultHeader' import ImageItem from './components/ImageItem/ImageItem' +type Rect = {x: number; y: number; width: number; height: number} + +const PIXEL_RATIO = PixelRatio.get() const EDGES = Platform.OS === 'android' ? (['top', 'bottom', 'left', 'right'] satisfies Edge[]) : (['left', 'right'] satisfies Edge[]) // iOS, so no top/bottom safe area +const SLOW_SPRING = {stiffness: 120} +const FAST_SPRING = {stiffness: 700} + export default function ImageViewRoot({ - lightbox, + lightbox: nextLightbox, onRequestClose, onPressSave, onPressShare, @@ -56,24 +76,70 @@ export default function ImageViewRoot({ onPressSave: (uri: string) => void onPressShare: (uri: string) => void }) { + 'use no memo' 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) + + // https://github.com/software-mansion/react-native-reanimated/issues/6677 + requestAnimationFrame(() => { + openProgress.value = canAnimate ? withClampedSpring(1, SLOW_SPRING) : 1 + }) + return () => { + // https://github.com/software-mansion/react-native-reanimated/issues/6677 + requestAnimationFrame(() => { + openProgress.value = canAnimate ? withClampedSpring(0, SLOW_SPRING) : 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 +152,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,33 +174,41 @@ function ImageView({ const isFlyingAway = useSharedValue(false) const containerStyle = useAnimatedStyle(() => { - if (isFlyingAway.value) { + if (openProgress.value < 1 || isFlyingAway.value) { return {pointerEvents: 'none'} } return {pointerEvents: 'auto'} }) + 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, ) opacity -= dragProgress } + const factor = isIOS ? 100 : 50 return { - opacity, + opacity: Math.round(opacity * factor) / factor, } }) + const animatedHeaderStyle = useAnimatedStyle(() => { 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, + FAST_SPRING, + ), transform: [ { - translateY: withClampedSpring(show ? 0 : -30), + translateY: withClampedSpring(show ? 0 : -30, FAST_SPRING), }, ], } @@ -140,10 +218,13 @@ function ImageView({ return { flexGrow: 1, pointerEvents: show ? 'box-none' : 'none', - opacity: withClampedSpring(show ? 1 : 0), + opacity: withClampedSpring( + show && openProgress.value === 1 ? 1 : 0, + FAST_SPRING, + ), transform: [ { - translateY: withClampedSpring(show ? 0 : 30), + translateY: withClampedSpring(show ? 0 : 30, FAST_SPRING), }, ], } @@ -172,7 +253,7 @@ function ImageView({ if (isOut && !wasOut) { // Stop the animation from blocking the screen forever. cancelAnimation(dismissSwipeTranslateY) - runOnJS(onRequestClose)() + onFlyAway() } }, ) @@ -209,6 +290,7 @@ function ImageView({ isFlyingAway={isFlyingAway} isActive={i === imageIndex} dismissSwipeTranslateY={dismissSwipeTranslateY} + openProgress={openProgress} /> ))} @@ -247,6 +329,7 @@ function LightboxImage({ isActive, showControls, safeAreaRef, + openProgress, dismissSwipeTranslateY, }: { imageSrc: ImageSource @@ -259,6 +342,7 @@ function LightboxImage({ isFlyingAway: SharedValue showControls: boolean safeAreaRef: AnimatedRef + openProgress: SharedValue dismissSwipeTranslateY: SharedValue }) { const [imageAspect, imageDimensions] = useImageDimensions({ @@ -266,6 +350,65 @@ function LightboxImage({ knownDimensions: imageSrc.dimensions, }) + const safeFrameDelayedForJSThreadOnly = useSafeAreaFrame() + const safeInsetsDelayedForJSThreadOnly = useSafeAreaInsets() + const measureSafeArea = React.useCallback(() => { + 'worklet' + let safeArea: Rect | null = measure(safeAreaRef) + if (!safeArea) { + if (_WORKLET) { + console.error('Expected to always be able to measure safe area.') + } + const frame = safeFrameDelayedForJSThreadOnly + const insets = safeInsetsDelayedForJSThreadOnly + safeArea = { + x: frame.x + insets.left, + y: frame.y + insets.top, + width: frame.width - insets.left - insets.right, + height: frame.height - insets.top - insets.bottom, + } + } + return safeArea + }, [ + safeFrameDelayedForJSThreadOnly, + safeInsetsDelayedForJSThreadOnly, + safeAreaRef, + ]) + + const {thumbRect} = imageSrc + const transforms = useDerivedValue(() => { + 'worklet' + const safeArea = measureSafeArea() + const dismissTranslateY = + isActive && openProgress.value === 1 ? dismissSwipeTranslateY.value : 0 + + if (openProgress.value === 0 && isFlyingAway.value) { + return { + isHidden: true, + isResting: false, + scaleAndMoveTransform: [], + cropFrameTransform: [], + cropContentTransform: [], + } + } + + if (isActive && thumbRect && imageAspect && openProgress.value < 1) { + return interpolateTransform( + openProgress.value, + thumbRect, + safeArea, + imageAspect, + ) + } + return { + isHidden: false, + isResting: dismissTranslateY === 0, + scaleAndMoveTransform: [{translateY: dismissTranslateY}], + cropFrameTransform: [], + cropContentTransform: [], + } + }) + const dismissSwipePan = Gesture.Pan() .enabled(isActive && !isScaled) .activeOffsetY([-10, 10]) @@ -273,14 +416,14 @@ function LightboxImage({ .maxPointers(1) .onUpdate(e => { 'worklet' - if (isFlyingAway.value) { + if (openProgress.value !== 1 || isFlyingAway.value) { return } dismissSwipeTranslateY.value = e.translationY }) .onEnd(e => { 'worklet' - if (isFlyingAway.value) { + if (openProgress.value !== 1 || isFlyingAway.value) { return } if (Math.abs(e.velocityY) > 1000) { @@ -303,11 +446,6 @@ function LightboxImage({ } }) - const imageStyle = useAnimatedStyle(() => { - return { - transform: [{translateY: dismissSwipeTranslateY.value}], - } - }) return ( ) } @@ -476,7 +614,91 @@ const styles = StyleSheet.create({ }, }) -function withClampedSpring(value: any) { +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 + }, + safeArea: {width: number; height: number; x: number; y: number}, + imageAspect: number, +): { + scaleAndMoveTransform: Transform + cropFrameTransform: Transform + cropContentTransform: Transform + isResting: boolean + isHidden: boolean +} { + 'worklet' + 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 safeAreaAspect = safeArea.width / safeArea.height + let finalWidth + let finalHeight + if (safeAreaAspect > imageAspect) { + finalWidth = safeArea.height * imageAspect + finalHeight = safeArea.height + } else { + finalWidth = safeArea.width + finalHeight = safeArea.width / imageAspect + } + const initialScale = Math.min( + uncroppedInitialWidth / finalWidth, + uncroppedInitialHeight / finalHeight, + ) + const croppedFinalWidth = thumbnailDims.width / initialScale + const croppedFinalHeight = thumbnailDims.height / initialScale + const screenCenterX = safeArea.width / 2 + const screenCenterY = safeArea.height / 2 + const thumbnailSafeAreaX = thumbnailDims.pageX - safeArea.x + const thumbnailSafeAreaY = thumbnailDims.pageY - safeArea.y + const thumbnailCenterX = thumbnailSafeAreaX + thumbnailDims.width / 2 + const thumbnailCenterY = thumbnailSafeAreaY + 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 cropScaleX = interpolate( + progress, + [0, 1], + [croppedFinalWidth / finalWidth, 1], + ) + const cropScaleY = interpolate( + progress, + [0, 1], + [croppedFinalHeight / finalHeight, 1], + ) + return { + isHidden: false, + isResting: progress === 1, + scaleAndMoveTransform: [{translateX}, {translateY}, {scale}], + cropFrameTransform: [{scaleX: cropScaleX}, {scaleY: cropScaleY}], + cropContentTransform: [{scaleX: 1 / cropScaleX}, {scaleY: 1 / cropScaleY}], + } +} + +function withClampedSpring(value: any, {stiffness}: {stiffness: number}) { 'worklet' - return withSpring(value, {overshootClamping: true, stiffness: 300}) + return withSpring(value, {overshootClamping: true, stiffness}) } diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx index 5208224c50..13d14ec502 100644 --- a/src/view/com/profile/ProfileSubpageHeader.tsx +++ b/src/view/com/profile/ProfileSubpageHeader.tsx @@ -1,5 +1,12 @@ import React from 'react' import {Pressable, StyleSheet, View} from 'react-native' +import Animated, { + measure, + MeasuredDimensions, + runOnJS, + runOnUI, + useAnimatedRef, +} from 'react-native-reanimated' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -53,6 +60,7 @@ export function ProfileSubpageHeader({ const {openLightbox} = useLightboxControls() const pal = usePalette('default') const canGoBack = navigation.canGoBack() + const aviRef = useAnimatedRef() const onPressBack = React.useCallback(() => { if (navigation.canGoBack()) { @@ -66,15 +74,14 @@ export function ProfileSubpageHeader({ setDrawerOpen(true) }, [setDrawerOpen]) - const onPressAvi = React.useCallback(() => { - if ( - avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) - ) { + const _openLightbox = React.useCallback( + (uri: string, thumbRect: MeasuredDimensions | null) => { openLightbox({ images: [ { - uri: avatar, - thumbUri: avatar, + uri, + thumbUri: uri, + thumbRect, dimensions: { // It's fine if it's actually smaller but we know it's 1:1. height: 1000, @@ -84,10 +91,22 @@ export function ProfileSubpageHeader({ }, ], index: 0, - thumbDims: null, }) + }, + [openLightbox], + ) + + const onPressAvi = React.useCallback(() => { + if ( + avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) + ) { + runOnUI(() => { + 'worklet' + const rect = measure(aviRef) + runOnJS(_openLightbox)(avatar, rect) + })() } - }, [openLightbox, avatar]) + }, [_openLightbox, avatar, aviRef]) return ( @@ -135,19 +154,21 @@ export function ProfileSubpageHeader({ paddingBottom: 6, paddingHorizontal: isMobile ? 12 : 14, }}> - - {avatarType === 'starter-pack' ? ( - - ) : ( - - )} - + + + {avatarType === 'starter-pack' ? ( + + ) : ( + + )} + + {isLoading ? ( void + onPress?: (containerRef: AnimatedRef>) => void onLongPress?: () => void onPressIn?: () => void }) { @@ -107,12 +108,14 @@ export function AutoSizedImage({ src: image.thumb, knownDimensions: image.aspectRatio ?? null, }) + const containerRef = useAnimatedRef() + const cropDisabled = crop === 'none' const isCropped = rawIsCropped && !cropDisabled const hasAlt = !!image.alt const contents = ( - <> + ) : null} - + ) if (cropDisabled) { return ( onPress?.(containerRef)} onLongPress={onLongPress} onPressIn={onPressIn} // alt here is what screen readers actually use @@ -213,7 +216,7 @@ export function AutoSizedImage({ fullBleed={crop === 'square'} aspectRatio={constrained ?? 1}> onPress?.(containerRef)} onLongPress={onLongPress} onPressIn={onPressIn} // alt here is what screen readers actually use diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx index d4d7d223d5..0c691ec9af 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -1,6 +1,6 @@ import React from 'react' import {Pressable, StyleProp, View, ViewStyle} from 'react-native' -import Animated, {AnimatedRef, useAnimatedRef} from 'react-native-reanimated' +import Animated, {AnimatedRef} from 'react-native-reanimated' import {Image, ImageStyle} from 'expo-image' import {AppBskyEmbedImages} from '@atproto/api' import {msg} from '@lingui/macro' @@ -19,13 +19,14 @@ interface Props { index: number onPress?: ( index: number, - containerRef: AnimatedRef>, + containerRefs: AnimatedRef>[], ) => void onLongPress?: EventFunction onPressIn?: EventFunction imageStyle?: StyleProp viewContext?: PostEmbedViewContext insetBorderStyle?: StyleProp + containerRefs: AnimatedRef>[] } export function GalleryItem({ @@ -37,6 +38,7 @@ export function GalleryItem({ onLongPress, viewContext, insetBorderStyle, + containerRefs, }: Props) { const t = useTheme() const {_} = useLingui() @@ -45,11 +47,13 @@ export function GalleryItem({ const hasAlt = !!image.alt const hideBadges = viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia - const containerRef = useAnimatedRef() return ( - + onPress(index, containerRef) : undefined} + onPress={onPress ? () => onPress(index, containerRefs) : undefined} onPressIn={onPressIn ? () => onPressIn(index) : undefined} onLongPress={onLongPress ? () => onLongPress(index) : undefined} style={[ diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx index 9d6a498362..b9b966302a 100644 --- a/src/view/com/util/images/ImageLayoutGrid.tsx +++ b/src/view/com/util/images/ImageLayoutGrid.tsx @@ -1,6 +1,6 @@ import React from 'react' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {AnimatedRef} from 'react-native-reanimated' +import {AnimatedRef, useAnimatedRef} from 'react-native-reanimated' import {AppBskyEmbedImages} from '@atproto/api' import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' @@ -11,7 +11,7 @@ interface ImageLayoutGridProps { images: AppBskyEmbedImages.ViewImage[] onPress?: ( index: number, - containerRef: AnimatedRef>, + containerRefs: AnimatedRef>[], ) => void onLongPress?: (index: number) => void onPressIn?: (index: number) => void @@ -41,7 +41,7 @@ interface ImageLayoutGridInnerProps { images: AppBskyEmbedImages.ViewImage[] onPress?: ( index: number, - containerRef: AnimatedRef>, + containerRefs: AnimatedRef>[], ) => void onLongPress?: (index: number) => void onPressIn?: (index: number) => void @@ -53,8 +53,14 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { const gap = props.gap const count = props.images.length + const containerRef1 = useAnimatedRef() + const containerRef2 = useAnimatedRef() + const containerRef3 = useAnimatedRef() + const containerRef4 = useAnimatedRef() + switch (count) { - case 2: + case 2: { + const containerRefs = [containerRef1, containerRef2] return ( @@ -62,6 +68,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { {...props} index={0} insetBorderStyle={noCorners(['topRight', 'bottomRight'])} + containerRefs={containerRefs} /> @@ -69,12 +76,15 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { {...props} index={1} insetBorderStyle={noCorners(['topLeft', 'bottomLeft'])} + containerRefs={containerRefs} /> ) + } - case 3: + case 3: { + const containerRefs = [containerRef1, containerRef2, containerRef3] return ( @@ -82,6 +92,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { {...props} index={0} insetBorderStyle={noCorners(['topRight', 'bottomRight'])} + containerRefs={containerRefs} /> @@ -94,6 +105,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'bottomLeft', 'bottomRight', ])} + containerRefs={containerRefs} /> @@ -105,13 +117,21 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'bottomLeft', 'topRight', ])} + containerRefs={containerRefs} /> ) + } - case 4: + case 4: { + const containerRefs = [ + containerRef1, + containerRef2, + containerRef3, + containerRef4, + ] return ( <> @@ -124,6 +144,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'topRight', 'bottomRight', ])} + containerRefs={containerRefs} /> @@ -135,6 +156,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'bottomLeft', 'bottomRight', ])} + containerRefs={containerRefs} /> @@ -148,6 +170,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'topRight', 'bottomRight', ])} + containerRefs={containerRefs} /> @@ -159,11 +182,13 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'bottomLeft', 'topRight', ])} + containerRefs={containerRefs} /> ) + } default: return null diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index ea0badab00..ab2471b33d 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -6,13 +6,12 @@ import { View, ViewStyle, } from 'react-native' -import Animated, { +import { AnimatedRef, measure, MeasuredDimensions, runOnJS, runOnUI, - useAnimatedRef, } from 'react-native-reanimated' import {Image} from 'expo-image' import { @@ -69,7 +68,6 @@ export function PostEmbeds({ viewContext?: PostEmbedViewContext }) { const {openLightbox} = useLightboxControls() - const containerRef = useAnimatedRef() // quote post with media // = @@ -149,25 +147,25 @@ export function PostEmbeds({ })) const _openLightbox = ( index: number, - thumbDims: MeasuredDimensions | null, + thumbRects: (MeasuredDimensions | null)[], ) => { openLightbox({ - images: items.map(item => ({ + images: items.map((item, i) => ({ ...item, + thumbRect: thumbRects[i] ?? null, type: 'image', })), index, - thumbDims, }) } const onPress = ( index: number, - ref: AnimatedRef>, + refs: AnimatedRef>[], ) => { runOnUI(() => { 'worklet' - const dims = measure(ref) - runOnJS(_openLightbox)(index, dims) + const rects = refs.map(ref => (ref ? measure(ref) : null)) + runOnJS(_openLightbox)(index, rects) })() } const onPressIn = (_: number) => { @@ -180,7 +178,7 @@ export function PostEmbeds({ const image = images[0] return ( - + onPress(0, containerRef)} + onPress={containerRef => onPress(0, [containerRef])} onPressIn={() => onPressIn(0)} hideBadge={ viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia } /> - + ) }