my fork of the bluesky client

[Lightbox] Open animation (#6159)

* Measure all rects for embeds

* Measure avi rects too

* Animate lightbox in and out

* Account for safe area in the animation

* Tune spring times

* Remove null checks for measurements

* Remove superfluous view

* Block swipe while opening

* Interpolate width/height on native side for Android

* Make it fast by animating only affine transforms

* Fix tall image final state

The initial animation frame is still off on both platforms.

* Try to squeeze perf

* Avoid blank images during animation on iOS

* Fix bad rebase

* Fix a huge memory issue due to expo/expo#24894

* Fix last frame flash

* Fix thum dim calculation for tall images

authored by danabra.mov and committed by

GitHub 2d73c5a2 e73d5c6c

+629 -192
+35 -13
src/screens/Profile/Header/Shell.tsx
··· 1 1 import React, {memo} from 'react' 2 2 import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' 3 + import Animated, { 4 + measure, 5 + MeasuredDimensions, 6 + runOnJS, 7 + runOnUI, 8 + useAnimatedRef, 9 + } from 'react-native-reanimated' 3 10 import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' 4 11 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 12 import {msg} from '@lingui/macro' ··· 42 49 const {openLightbox} = useLightboxControls() 43 50 const navigation = useNavigation<NavigationProp>() 44 51 const {isDesktop} = useWebMediaQueries() 52 + const aviRef = useAnimatedRef() 45 53 46 54 const onPressBack = React.useCallback(() => { 47 55 if (navigation.canGoBack()) { ··· 51 59 } 52 60 }, [navigation]) 53 61 54 - const onPressAvi = React.useCallback(() => { 55 - const modui = moderation.ui('avatar') 56 - if (profile.avatar && !(modui.blur && modui.noOverride)) { 62 + const _openLightbox = React.useCallback( 63 + (uri: string, thumbRect: MeasuredDimensions | null) => { 57 64 openLightbox({ 58 65 images: [ 59 66 { 60 - uri: profile.avatar, 61 - thumbUri: profile.avatar, 67 + uri, 68 + thumbUri: uri, 69 + thumbRect, 62 70 dimensions: { 63 71 // It's fine if it's actually smaller but we know it's 1:1. 64 72 height: 1000, ··· 68 76 }, 69 77 ], 70 78 index: 0, 71 - thumbDims: null, 72 79 }) 80 + }, 81 + [openLightbox], 82 + ) 83 + 84 + const onPressAvi = React.useCallback(() => { 85 + const modui = moderation.ui('avatar') 86 + const avatar = profile.avatar 87 + if (avatar && !(modui.blur && modui.noOverride)) { 88 + runOnUI(() => { 89 + 'worklet' 90 + const rect = measure(aviRef) 91 + runOnJS(_openLightbox)(avatar, rect) 92 + })() 73 93 } 74 - }, [openLightbox, profile, moderation]) 94 + }, [profile, moderation, _openLightbox, aviRef]) 75 95 76 96 const isMe = React.useMemo( 77 97 () => currentAccount?.did === profile.did, ··· 149 169 styles.avi, 150 170 profile.associated?.labeler && styles.aviLabeler, 151 171 ]}> 152 - <UserAvatar 153 - type={profile.associated?.labeler ? 'labeler' : 'user'} 154 - size={90} 155 - avatar={profile.avatar} 156 - moderation={moderation.ui('avatar')} 157 - /> 172 + <Animated.View ref={aviRef} collapsable={false}> 173 + <UserAvatar 174 + type={profile.associated?.labeler ? 'labeler' : 'user'} 175 + size={90} 176 + avatar={profile.avatar} 177 + moderation={moderation.ui('avatar')} 178 + /> 179 + </Animated.View> 158 180 </View> 159 181 </TouchableWithoutFeedback> 160 182 </GrowableAvatar>
-2
src/state/lightbox.tsx
··· 1 1 import React from 'react' 2 - import type {MeasuredDimensions} from 'react-native-reanimated' 3 2 import {nanoid} from 'nanoid/non-secure' 4 3 5 4 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' ··· 8 7 export type Lightbox = { 9 8 id: string 10 9 images: ImageSource[] 11 - thumbDims: MeasuredDimensions | null 12 10 index: number 13 11 } 14 12
+9
src/view/com/lightbox/ImageViewing/@types/index.ts
··· 6 6 * 7 7 */ 8 8 9 + import {TransformsStyle} from 'react-native' 10 + import {MeasuredDimensions} from 'react-native-reanimated' 11 + 9 12 export type Dimensions = { 10 13 width: number 11 14 height: number ··· 19 22 export type ImageSource = { 20 23 uri: string 21 24 thumbUri: string 25 + thumbRect: MeasuredDimensions | null 22 26 alt?: string 23 27 dimensions: Dimensions | null 24 28 type: 'image' | 'circle-avi' | 'rect-avi' 25 29 } 30 + 31 + export type Transform = Exclude< 32 + TransformsStyle['transform'], 33 + string | undefined 34 + >
+119 -57
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
··· 1 1 import React, {useState} from 'react' 2 - import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native' 2 + import {ActivityIndicator, StyleSheet} from 'react-native' 3 3 import { 4 4 Gesture, 5 5 GestureDetector, 6 6 PanGesture, 7 7 } from 'react-native-gesture-handler' 8 8 import Animated, { 9 - AnimatedRef, 10 - measure, 11 9 runOnJS, 10 + SharedValue, 12 11 useAnimatedReaction, 13 12 useAnimatedRef, 14 13 useAnimatedStyle, 15 14 useSharedValue, 16 15 withSpring, 17 16 } from 'react-native-reanimated' 18 - import {Image, ImageStyle} from 'expo-image' 17 + import {Image} from 'expo-image' 19 18 20 - import type {Dimensions as ImageDimensions, ImageSource} from '../../@types' 19 + import type { 20 + Dimensions as ImageDimensions, 21 + ImageSource, 22 + Transform, 23 + } from '../../@types' 21 24 import { 22 25 applyRounding, 23 26 createTransform, ··· 27 30 readTransform, 28 31 TransformMatrix, 29 32 } from '../../transforms' 30 - 31 - const AnimatedImage = Animated.createAnimatedComponent(Image) 32 33 33 34 const MIN_SCREEN_ZOOM = 2 34 35 const MAX_ORIGINAL_IMAGE_ZOOM = 2 ··· 42 43 onZoom: (isZoomed: boolean) => void 43 44 isScrollViewBeingDragged: boolean 44 45 showControls: boolean 45 - safeAreaRef: AnimatedRef<View> 46 + measureSafeArea: () => { 47 + x: number 48 + y: number 49 + width: number 50 + height: number 51 + } 46 52 imageAspect: number | undefined 47 53 imageDimensions: ImageDimensions | undefined 48 - imageStyle: StyleProp<ImageStyle> 49 54 dismissSwipePan: PanGesture 55 + transforms: Readonly< 56 + SharedValue<{ 57 + scaleAndMoveTransform: Transform 58 + cropFrameTransform: Transform 59 + cropContentTransform: Transform 60 + isResting: boolean 61 + isHidden: boolean 62 + }> 63 + > 50 64 } 51 65 const ImageItem = ({ 52 66 imageSrc, 53 67 onTap, 54 68 onZoom, 55 69 isScrollViewBeingDragged, 56 - safeAreaRef, 70 + measureSafeArea, 57 71 imageAspect, 58 72 imageDimensions, 59 - imageStyle, 60 73 dismissSwipePan, 74 + transforms, 61 75 }: Props) => { 62 76 const [isScaled, setIsScaled] = useState(false) 63 77 const committedTransform = useSharedValue(initialTransform) ··· 95 109 onZoom(nextIsScaled) 96 110 } 97 111 98 - const animatedStyle = useAnimatedStyle(() => { 99 - // Apply the active adjustments on top of the committed transform before the gestures. 100 - // This is matrix multiplication, so operations are applied in the reverse order. 101 - let t = createTransform() 102 - prependPan(t, panTranslation.value) 103 - prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value) 104 - prependTransform(t, committedTransform.value) 105 - const [translateX, translateY, scale] = readTransform(t) 106 - return { 107 - transform: [{translateX}, {translateY: translateY}, {scale}], 108 - } 109 - }) 110 - 111 112 // On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges. 112 113 // If the user tried to pan too hard, this function will provide the negative panning to stay in bounds. 113 114 function getExtraTranslationToStayInBounds( ··· 143 144 const pinch = Gesture.Pinch() 144 145 .onStart(e => { 145 146 'worklet' 146 - const screenSize = measure(safeAreaRef) 147 - if (!screenSize) { 148 - return 149 - } 147 + const screenSize = measureSafeArea() 150 148 pinchOrigin.value = { 151 149 x: e.focalX - screenSize.width / 2, 152 150 y: e.focalY - screenSize.height / 2, ··· 154 152 }) 155 153 .onChange(e => { 156 154 'worklet' 157 - const screenSize = measure(safeAreaRef) 158 - if (!imageDimensions || !screenSize) { 155 + const screenSize = measureSafeArea() 156 + if (!imageDimensions) { 159 157 return 160 158 } 161 159 // Don't let the picture zoom in so close that it gets blurry. ··· 213 211 .minPointers(isScaled ? 1 : 2) 214 212 .onChange(e => { 215 213 'worklet' 216 - const screenSize = measure(safeAreaRef) 217 - if (!imageDimensions || !screenSize) { 214 + const screenSize = measureSafeArea() 215 + if (!imageDimensions) { 218 216 return 219 217 } 220 218 ··· 257 255 .numberOfTaps(2) 258 256 .onEnd(e => { 259 257 'worklet' 260 - const screenSize = measure(safeAreaRef) 261 - if (!imageDimensions || !imageAspect || !screenSize) { 258 + const screenSize = measureSafeArea() 259 + if (!imageDimensions || !imageAspect) { 262 260 return 263 261 } 264 262 const [, , committedScale] = readTransform(committedTransform.value) ··· 302 300 committedTransform.value = withClampedSpring(finalTransform) 303 301 }) 304 302 305 - const innerStyle = useAnimatedStyle(() => ({ 306 - width: '100%', 307 - aspectRatio: imageAspect, 308 - })) 309 - 310 303 const composedGesture = isScrollViewBeingDragged 311 304 ? // If the parent is not at rest, provide a no-op gesture. 312 305 Gesture.Manual() ··· 317 310 singleTap, 318 311 ) 319 312 313 + const containerStyle = useAnimatedStyle(() => { 314 + const {scaleAndMoveTransform, isHidden} = transforms.value 315 + // Apply the active adjustments on top of the committed transform before the gestures. 316 + // This is matrix multiplication, so operations are applied in the reverse order. 317 + let t = createTransform() 318 + prependPan(t, panTranslation.value) 319 + prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value) 320 + prependTransform(t, committedTransform.value) 321 + const [translateX, translateY, scale] = readTransform(t) 322 + const manipulationTransform = [ 323 + {translateX}, 324 + {translateY: translateY}, 325 + {scale}, 326 + ] 327 + const screenSize = measureSafeArea() 328 + return { 329 + opacity: isHidden ? 0 : 1, 330 + transform: scaleAndMoveTransform.concat(manipulationTransform), 331 + width: screenSize.width, 332 + maxHeight: screenSize.height, 333 + aspectRatio: imageAspect, 334 + alignSelf: 'center', 335 + } 336 + }) 337 + 338 + const imageCropStyle = useAnimatedStyle(() => { 339 + const {cropFrameTransform} = transforms.value 340 + return { 341 + flex: 1, 342 + overflow: 'hidden', 343 + transform: cropFrameTransform, 344 + } 345 + }) 346 + 347 + const imageStyle = useAnimatedStyle(() => { 348 + const {cropContentTransform} = transforms.value 349 + return { 350 + flex: 1, 351 + transform: cropContentTransform, 352 + } 353 + }) 354 + 355 + const [showLoader, setShowLoader] = useState(false) 356 + const [hasLoaded, setHasLoaded] = useState(false) 357 + useAnimatedReaction( 358 + () => { 359 + return transforms.value.isResting && !hasLoaded 360 + }, 361 + (show, prevShow) => { 362 + if (show && !prevShow) { 363 + runOnJS(setShowLoader)(false) 364 + } else if (!prevShow && show) { 365 + runOnJS(setShowLoader)(true) 366 + } 367 + }, 368 + ) 369 + 320 370 const type = imageSrc.type 321 371 const borderRadius = 322 372 type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 373 + 323 374 return ( 324 375 <GestureDetector gesture={composedGesture}> 325 - <Animated.View style={imageStyle} renderToHardwareTextureAndroid> 326 - <Animated.View 327 - ref={containerRef} 328 - // Necessary to make opacity work for both children together. 329 - renderToHardwareTextureAndroid 330 - style={[styles.container, animatedStyle]}> 331 - <ActivityIndicator size="small" color="#FFF" style={styles.loading} /> 332 - <AnimatedImage 333 - contentFit="contain" 334 - source={{uri: imageSrc.uri}} 335 - placeholderContentFit="contain" 336 - placeholder={{uri: imageSrc.thumbUri}} 337 - style={[innerStyle, {borderRadius}]} 338 - accessibilityLabel={imageSrc.alt} 339 - accessibilityHint="" 340 - accessibilityIgnoresInvertColors 341 - cachePolicy="memory" 342 - /> 376 + <Animated.View 377 + ref={containerRef} 378 + style={[styles.container]} 379 + renderToHardwareTextureAndroid> 380 + <Animated.View style={containerStyle}> 381 + {showLoader && ( 382 + <ActivityIndicator 383 + size="small" 384 + color="#FFF" 385 + style={styles.loading} 386 + /> 387 + )} 388 + <Animated.View style={imageCropStyle}> 389 + <Animated.View style={imageStyle}> 390 + <Image 391 + contentFit="cover" 392 + source={{uri: imageSrc.uri}} 393 + placeholderContentFit="cover" 394 + placeholder={{uri: imageSrc.thumbUri}} 395 + accessibilityLabel={imageSrc.alt} 396 + onLoad={() => setHasLoaded(false)} 397 + style={{flex: 1, borderRadius}} 398 + accessibilityHint="" 399 + accessibilityIgnoresInvertColors 400 + cachePolicy="memory" 401 + /> 402 + </Animated.View> 403 + </Animated.View> 343 404 </Animated.View> 344 405 </Animated.View> 345 406 </GestureDetector> ··· 358 419 right: 0, 359 420 top: 0, 360 421 bottom: 0, 422 + justifyContent: 'center', 361 423 }, 362 424 }) 363 425
+94 -38
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
··· 7 7 */ 8 8 9 9 import React, {useState} from 'react' 10 - import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native' 10 + import {ActivityIndicator, StyleSheet} from 'react-native' 11 11 import { 12 12 Gesture, 13 13 GestureDetector, 14 14 PanGesture, 15 15 } from 'react-native-gesture-handler' 16 16 import Animated, { 17 - AnimatedRef, 18 - measure, 19 17 runOnJS, 18 + SharedValue, 19 + useAnimatedReaction, 20 20 useAnimatedRef, 21 21 useAnimatedStyle, 22 22 } from 'react-native-reanimated' 23 23 import {useSafeAreaFrame} from 'react-native-safe-area-context' 24 - import {Image, ImageStyle} from 'expo-image' 24 + import {Image} from 'expo-image' 25 25 26 26 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' 27 - import {Dimensions as ImageDimensions, ImageSource} from '../../@types' 28 - 29 - const AnimatedImage = Animated.createAnimatedComponent(Image) 27 + import { 28 + Dimensions as ImageDimensions, 29 + ImageSource, 30 + Transform, 31 + } from '../../@types' 30 32 31 33 const MAX_ORIGINAL_IMAGE_ZOOM = 2 32 34 const MIN_SCREEN_ZOOM = 2 ··· 38 40 onZoom: (scaled: boolean) => void 39 41 isScrollViewBeingDragged: boolean 40 42 showControls: boolean 41 - safeAreaRef: AnimatedRef<View> 43 + measureSafeArea: () => { 44 + x: number 45 + y: number 46 + width: number 47 + height: number 48 + } 42 49 imageAspect: number | undefined 43 50 imageDimensions: ImageDimensions | undefined 44 - imageStyle: StyleProp<ImageStyle> 45 51 dismissSwipePan: PanGesture 52 + transforms: Readonly< 53 + SharedValue<{ 54 + scaleAndMoveTransform: Transform 55 + cropFrameTransform: Transform 56 + cropContentTransform: Transform 57 + isResting: boolean 58 + isHidden: boolean 59 + }> 60 + > 46 61 } 47 62 48 63 const ImageItem = ({ ··· 50 65 onTap, 51 66 onZoom, 52 67 showControls, 53 - safeAreaRef, 68 + measureSafeArea, 54 69 imageAspect, 55 70 imageDimensions, 56 - imageStyle, 57 71 dismissSwipePan, 72 + transforms, 58 73 }: Props) => { 59 74 const scrollViewRef = useAnimatedRef<Animated.ScrollView>() 60 75 const [scaled, setScaled] = useState(false) ··· 66 81 MAX_ORIGINAL_IMAGE_ZOOM 67 82 : 1, 68 83 ) 69 - 70 - const animatedStyle = useAnimatedStyle(() => { 71 - const screenSize = measure(safeAreaRef) ?? screenSizeDelayedForJSThreadOnly 72 - return { 73 - width: screenSize.width, 74 - maxHeight: screenSize.height, 75 - alignSelf: 'center', 76 - aspectRatio: imageAspect, 77 - } 78 - }) 79 84 80 85 const scrollHandler = useAnimatedScrollHandler({ 81 86 onScroll(e) { ··· 114 119 .numberOfTaps(2) 115 120 .onEnd(e => { 116 121 'worklet' 117 - const screenSize = measure(safeAreaRef) 118 - if (!screenSize) { 119 - return 120 - } 122 + const screenSize = measureSafeArea() 121 123 const {absoluteX, absoluteY} = e 122 124 let nextZoomRect = { 123 125 x: 0, ··· 143 145 singleTap, 144 146 ) 145 147 148 + const containerStyle = useAnimatedStyle(() => { 149 + const {scaleAndMoveTransform, isHidden} = transforms.value 150 + return { 151 + flex: 1, 152 + transform: scaleAndMoveTransform, 153 + opacity: isHidden ? 0 : 1, 154 + } 155 + }) 156 + 157 + const imageCropStyle = useAnimatedStyle(() => { 158 + const screenSize = measureSafeArea() 159 + const {cropFrameTransform} = transforms.value 160 + return { 161 + overflow: 'hidden', 162 + transform: cropFrameTransform, 163 + width: screenSize.width, 164 + maxHeight: screenSize.height, 165 + aspectRatio: imageAspect, 166 + alignSelf: 'center', 167 + } 168 + }) 169 + 170 + const imageStyle = useAnimatedStyle(() => { 171 + const {cropContentTransform} = transforms.value 172 + return { 173 + transform: cropContentTransform, 174 + width: '100%', 175 + aspectRatio: imageAspect, 176 + } 177 + }) 178 + 179 + const [showLoader, setShowLoader] = useState(false) 180 + const [hasLoaded, setHasLoaded] = useState(false) 181 + useAnimatedReaction( 182 + () => { 183 + return transforms.value.isResting && !hasLoaded 184 + }, 185 + (show, prevShow) => { 186 + if (show && !prevShow) { 187 + runOnJS(setShowLoader)(false) 188 + } else if (!prevShow && show) { 189 + runOnJS(setShowLoader)(true) 190 + } 191 + }, 192 + ) 193 + 146 194 const type = imageSrc.type 147 195 const borderRadius = 148 196 type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 197 + 149 198 return ( 150 199 <GestureDetector gesture={composedGesture}> 151 200 <Animated.ScrollView ··· 156 205 showsVerticalScrollIndicator={false} 157 206 maximumZoomScale={maxZoomScale} 158 207 onScroll={scrollHandler} 208 + style={containerStyle} 159 209 bounces={scaled} 160 210 bouncesZoom={true} 161 - style={imageStyle} 162 211 centerContent> 163 - <ActivityIndicator size="small" color="#FFF" style={styles.loading} /> 164 - <AnimatedImage 165 - contentFit="contain" 166 - source={{uri: imageSrc.uri}} 167 - placeholderContentFit="contain" 168 - placeholder={{uri: imageSrc.thumbUri}} 169 - style={[animatedStyle, {borderRadius}]} 170 - accessibilityLabel={imageSrc.alt} 171 - accessibilityHint="" 172 - enableLiveTextInteraction={showControls && !scaled} 173 - accessibilityIgnoresInvertColors 174 - /> 212 + {showLoader && ( 213 + <ActivityIndicator size="small" color="#FFF" style={styles.loading} /> 214 + )} 215 + <Animated.View style={imageCropStyle}> 216 + <Animated.View style={imageStyle}> 217 + <Image 218 + contentFit="contain" 219 + source={{uri: imageSrc.uri}} 220 + placeholderContentFit="contain" 221 + placeholder={{uri: imageSrc.thumbUri}} 222 + style={{flex: 1, borderRadius}} 223 + accessibilityLabel={imageSrc.alt} 224 + accessibilityHint="" 225 + enableLiveTextInteraction={showControls && !scaled} 226 + accessibilityIgnoresInvertColors 227 + onLoad={() => setHasLoaded(true)} 228 + /> 229 + </Animated.View> 230 + </Animated.View> 175 231 </Animated.ScrollView> 176 232 </GestureDetector> 177 233 )
+22 -5
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
··· 1 1 // default implementation fallback for web 2 2 3 3 import React from 'react' 4 - import {ImageStyle, StyleProp, View} from 'react-native' 4 + import {View} from 'react-native' 5 5 import {PanGesture} from 'react-native-gesture-handler' 6 - import {AnimatedRef} from 'react-native-reanimated' 6 + import {SharedValue} from 'react-native-reanimated' 7 7 8 - import {Dimensions as ImageDimensions, ImageSource} from '../../@types' 8 + import { 9 + Dimensions as ImageDimensions, 10 + ImageSource, 11 + Transform, 12 + } from '../../@types' 9 13 10 14 type Props = { 11 15 imageSrc: ImageSource ··· 14 18 onZoom: (scaled: boolean) => void 15 19 isScrollViewBeingDragged: boolean 16 20 showControls: boolean 17 - safeAreaRef: AnimatedRef<View> 21 + measureSafeArea: () => { 22 + x: number 23 + y: number 24 + width: number 25 + height: number 26 + } 18 27 imageAspect: number | undefined 19 28 imageDimensions: ImageDimensions | undefined 20 - imageStyle: StyleProp<ImageStyle> 21 29 dismissSwipePan: PanGesture 30 + transforms: Readonly< 31 + SharedValue<{ 32 + scaleAndMoveTransform: Transform 33 + cropFrameTransform: Transform 34 + cropContentTransform: Transform 35 + isResting: boolean 36 + isHidden: boolean 37 + }> 38 + > 22 39 } 23 40 24 41 const ImageItem = (_props: Props) => {
+250 -28
src/view/com/lightbox/ImageViewing/index.tsx
··· 9 9 // https://github.com/jobtoday/react-native-image-viewing 10 10 11 11 import React, {useCallback, useState} from 'react' 12 - import {LayoutAnimation, Platform, StyleSheet, View} from 'react-native' 12 + import { 13 + LayoutAnimation, 14 + PixelRatio, 15 + Platform, 16 + StyleSheet, 17 + View, 18 + } from 'react-native' 13 19 import {Gesture} from 'react-native-gesture-handler' 14 20 import PagerView from 'react-native-pager-view' 15 21 import Animated, { 16 22 AnimatedRef, 17 23 cancelAnimation, 24 + interpolate, 18 25 measure, 19 26 runOnJS, 20 27 SharedValue, 21 28 useAnimatedReaction, 22 29 useAnimatedRef, 23 30 useAnimatedStyle, 31 + useDerivedValue, 24 32 useSharedValue, 25 33 withDecay, 26 34 withSpring, 27 35 } from 'react-native-reanimated' 28 - import {Edge, SafeAreaView} from 'react-native-safe-area-context' 36 + import { 37 + Edge, 38 + SafeAreaView, 39 + useSafeAreaFrame, 40 + useSafeAreaInsets, 41 + } from 'react-native-safe-area-context' 29 42 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 30 43 import {Trans} from '@lingui/macro' 31 44 ··· 36 49 import {Button} from '#/view/com/util/forms/Button' 37 50 import {Text} from '#/view/com/util/text/Text' 38 51 import {ScrollView} from '#/view/com/util/Views' 39 - import {ImageSource} from './@types' 52 + import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army' 53 + import {ImageSource, Transform} from './@types' 40 54 import ImageDefaultHeader from './components/ImageDefaultHeader' 41 55 import ImageItem from './components/ImageItem/ImageItem' 42 56 57 + type Rect = {x: number; y: number; width: number; height: number} 58 + 59 + const PIXEL_RATIO = PixelRatio.get() 43 60 const EDGES = 44 61 Platform.OS === 'android' 45 62 ? (['top', 'bottom', 'left', 'right'] satisfies Edge[]) 46 63 : (['left', 'right'] satisfies Edge[]) // iOS, so no top/bottom safe area 47 64 65 + const SLOW_SPRING = {stiffness: 120} 66 + const FAST_SPRING = {stiffness: 700} 67 + 48 68 export default function ImageViewRoot({ 49 - lightbox, 69 + lightbox: nextLightbox, 50 70 onRequestClose, 51 71 onPressSave, 52 72 onPressShare, ··· 56 76 onPressSave: (uri: string) => void 57 77 onPressShare: (uri: string) => void 58 78 }) { 79 + 'use no memo' 59 80 const ref = useAnimatedRef<View>() 81 + const [activeLightbox, setActiveLightbox] = useState(nextLightbox) 82 + const openProgress = useSharedValue(0) 83 + 84 + if (!activeLightbox && nextLightbox) { 85 + setActiveLightbox(nextLightbox) 86 + } 87 + 88 + React.useEffect(() => { 89 + if (!nextLightbox) { 90 + return 91 + } 92 + 93 + const canAnimate = 94 + !PlatformInfo.getIsReducedMotionEnabled() && 95 + nextLightbox.images.every(img => img.dimensions && img.thumbRect) 96 + 97 + // https://github.com/software-mansion/react-native-reanimated/issues/6677 98 + requestAnimationFrame(() => { 99 + openProgress.value = canAnimate ? withClampedSpring(1, SLOW_SPRING) : 1 100 + }) 101 + return () => { 102 + // https://github.com/software-mansion/react-native-reanimated/issues/6677 103 + requestAnimationFrame(() => { 104 + openProgress.value = canAnimate ? withClampedSpring(0, SLOW_SPRING) : 0 105 + }) 106 + } 107 + }, [nextLightbox, openProgress]) 108 + 109 + useAnimatedReaction( 110 + () => openProgress.value === 0, 111 + (isGone, wasGone) => { 112 + if (isGone && !wasGone) { 113 + runOnJS(setActiveLightbox)(null) 114 + } 115 + }, 116 + ) 117 + 118 + const onFlyAway = React.useCallback(() => { 119 + 'worklet' 120 + openProgress.value = 0 121 + runOnJS(onRequestClose)() 122 + }, [onRequestClose, openProgress]) 123 + 60 124 return ( 61 125 // Keep it always mounted to avoid flicker on the first frame. 62 126 <SafeAreaView 63 - style={[styles.screen, !lightbox && styles.screenHidden]} 127 + style={[styles.screen, !activeLightbox && styles.screenHidden]} 64 128 edges={EDGES} 65 129 aria-modal 66 130 accessibilityViewIsModal 67 - aria-hidden={!lightbox}> 131 + aria-hidden={!activeLightbox}> 68 132 <Animated.View ref={ref} style={{flex: 1}} collapsable={false}> 69 - {lightbox && ( 133 + {activeLightbox && ( 70 134 <ImageView 71 - key={lightbox.id} 72 - lightbox={lightbox} 135 + key={activeLightbox.id} 136 + lightbox={activeLightbox} 73 137 onRequestClose={onRequestClose} 74 138 onPressSave={onPressSave} 75 139 onPressShare={onPressShare} 140 + onFlyAway={onFlyAway} 76 141 safeAreaRef={ref} 142 + openProgress={openProgress} 77 143 /> 78 144 )} 79 145 </Animated.View> ··· 86 152 onRequestClose, 87 153 onPressSave, 88 154 onPressShare, 155 + onFlyAway, 89 156 safeAreaRef, 157 + openProgress, 90 158 }: { 91 159 lightbox: Lightbox 92 160 onRequestClose: () => void 93 161 onPressSave: (uri: string) => void 94 162 onPressShare: (uri: string) => void 163 + onFlyAway: () => void 95 164 safeAreaRef: AnimatedRef<View> 165 + openProgress: SharedValue<number> 96 166 }) { 97 167 const {images, index: initialImageIndex} = lightbox 98 168 const [isScaled, setIsScaled] = useState(false) ··· 104 174 const isFlyingAway = useSharedValue(false) 105 175 106 176 const containerStyle = useAnimatedStyle(() => { 107 - if (isFlyingAway.value) { 177 + if (openProgress.value < 1 || isFlyingAway.value) { 108 178 return {pointerEvents: 'none'} 109 179 } 110 180 return {pointerEvents: 'auto'} 111 181 }) 182 + 112 183 const backdropStyle = useAnimatedStyle(() => { 113 184 const screenSize = measure(safeAreaRef) 114 185 let opacity = 1 115 - if (screenSize) { 186 + if (openProgress.value < 1) { 187 + opacity = Math.sqrt(openProgress.value) 188 + } else if (screenSize) { 116 189 const dragProgress = Math.min( 117 190 Math.abs(dismissSwipeTranslateY.value) / (screenSize.height / 2), 118 191 1, 119 192 ) 120 193 opacity -= dragProgress 121 194 } 195 + const factor = isIOS ? 100 : 50 122 196 return { 123 - opacity, 197 + opacity: Math.round(opacity * factor) / factor, 124 198 } 125 199 }) 200 + 126 201 const animatedHeaderStyle = useAnimatedStyle(() => { 127 202 const show = showControls && dismissSwipeTranslateY.value === 0 128 203 return { 129 204 pointerEvents: show ? 'box-none' : 'none', 130 - opacity: withClampedSpring(show ? 1 : 0), 205 + opacity: withClampedSpring( 206 + show && openProgress.value === 1 ? 1 : 0, 207 + FAST_SPRING, 208 + ), 131 209 transform: [ 132 210 { 133 - translateY: withClampedSpring(show ? 0 : -30), 211 + translateY: withClampedSpring(show ? 0 : -30, FAST_SPRING), 134 212 }, 135 213 ], 136 214 } ··· 140 218 return { 141 219 flexGrow: 1, 142 220 pointerEvents: show ? 'box-none' : 'none', 143 - opacity: withClampedSpring(show ? 1 : 0), 221 + opacity: withClampedSpring( 222 + show && openProgress.value === 1 ? 1 : 0, 223 + FAST_SPRING, 224 + ), 144 225 transform: [ 145 226 { 146 - translateY: withClampedSpring(show ? 0 : 30), 227 + translateY: withClampedSpring(show ? 0 : 30, FAST_SPRING), 147 228 }, 148 229 ], 149 230 } ··· 172 253 if (isOut && !wasOut) { 173 254 // Stop the animation from blocking the screen forever. 174 255 cancelAnimation(dismissSwipeTranslateY) 175 - runOnJS(onRequestClose)() 256 + onFlyAway() 176 257 } 177 258 }, 178 259 ) ··· 209 290 isFlyingAway={isFlyingAway} 210 291 isActive={i === imageIndex} 211 292 dismissSwipeTranslateY={dismissSwipeTranslateY} 293 + openProgress={openProgress} 212 294 /> 213 295 </View> 214 296 ))} ··· 247 329 isActive, 248 330 showControls, 249 331 safeAreaRef, 332 + openProgress, 250 333 dismissSwipeTranslateY, 251 334 }: { 252 335 imageSrc: ImageSource ··· 259 342 isFlyingAway: SharedValue<boolean> 260 343 showControls: boolean 261 344 safeAreaRef: AnimatedRef<View> 345 + openProgress: SharedValue<number> 262 346 dismissSwipeTranslateY: SharedValue<number> 263 347 }) { 264 348 const [imageAspect, imageDimensions] = useImageDimensions({ ··· 266 350 knownDimensions: imageSrc.dimensions, 267 351 }) 268 352 353 + const safeFrameDelayedForJSThreadOnly = useSafeAreaFrame() 354 + const safeInsetsDelayedForJSThreadOnly = useSafeAreaInsets() 355 + const measureSafeArea = React.useCallback(() => { 356 + 'worklet' 357 + let safeArea: Rect | null = measure(safeAreaRef) 358 + if (!safeArea) { 359 + if (_WORKLET) { 360 + console.error('Expected to always be able to measure safe area.') 361 + } 362 + const frame = safeFrameDelayedForJSThreadOnly 363 + const insets = safeInsetsDelayedForJSThreadOnly 364 + safeArea = { 365 + x: frame.x + insets.left, 366 + y: frame.y + insets.top, 367 + width: frame.width - insets.left - insets.right, 368 + height: frame.height - insets.top - insets.bottom, 369 + } 370 + } 371 + return safeArea 372 + }, [ 373 + safeFrameDelayedForJSThreadOnly, 374 + safeInsetsDelayedForJSThreadOnly, 375 + safeAreaRef, 376 + ]) 377 + 378 + const {thumbRect} = imageSrc 379 + const transforms = useDerivedValue(() => { 380 + 'worklet' 381 + const safeArea = measureSafeArea() 382 + const dismissTranslateY = 383 + isActive && openProgress.value === 1 ? dismissSwipeTranslateY.value : 0 384 + 385 + if (openProgress.value === 0 && isFlyingAway.value) { 386 + return { 387 + isHidden: true, 388 + isResting: false, 389 + scaleAndMoveTransform: [], 390 + cropFrameTransform: [], 391 + cropContentTransform: [], 392 + } 393 + } 394 + 395 + if (isActive && thumbRect && imageAspect && openProgress.value < 1) { 396 + return interpolateTransform( 397 + openProgress.value, 398 + thumbRect, 399 + safeArea, 400 + imageAspect, 401 + ) 402 + } 403 + return { 404 + isHidden: false, 405 + isResting: dismissTranslateY === 0, 406 + scaleAndMoveTransform: [{translateY: dismissTranslateY}], 407 + cropFrameTransform: [], 408 + cropContentTransform: [], 409 + } 410 + }) 411 + 269 412 const dismissSwipePan = Gesture.Pan() 270 413 .enabled(isActive && !isScaled) 271 414 .activeOffsetY([-10, 10]) ··· 273 416 .maxPointers(1) 274 417 .onUpdate(e => { 275 418 'worklet' 276 - if (isFlyingAway.value) { 419 + if (openProgress.value !== 1 || isFlyingAway.value) { 277 420 return 278 421 } 279 422 dismissSwipeTranslateY.value = e.translationY 280 423 }) 281 424 .onEnd(e => { 282 425 'worklet' 283 - if (isFlyingAway.value) { 426 + if (openProgress.value !== 1 || isFlyingAway.value) { 284 427 return 285 428 } 286 429 if (Math.abs(e.velocityY) > 1000) { ··· 303 446 } 304 447 }) 305 448 306 - const imageStyle = useAnimatedStyle(() => { 307 - return { 308 - transform: [{translateY: dismissSwipeTranslateY.value}], 309 - } 310 - }) 311 449 return ( 312 450 <ImageItem 313 451 imageSrc={imageSrc} ··· 316 454 onRequestClose={onRequestClose} 317 455 isScrollViewBeingDragged={isScrollViewBeingDragged} 318 456 showControls={showControls} 319 - safeAreaRef={safeAreaRef} 457 + measureSafeArea={measureSafeArea} 320 458 imageAspect={imageAspect} 321 459 imageDimensions={imageDimensions} 322 - imageStyle={imageStyle} 323 460 dismissSwipePan={dismissSwipePan} 461 + transforms={transforms} 324 462 /> 325 463 ) 326 464 } ··· 476 614 }, 477 615 }) 478 616 479 - function withClampedSpring(value: any) { 617 + function interpolatePx( 618 + px: number, 619 + inputRange: readonly number[], 620 + outputRange: readonly number[], 621 + ) { 622 + 'worklet' 623 + const value = interpolate(px, inputRange, outputRange) 624 + return Math.round(value * PIXEL_RATIO) / PIXEL_RATIO 625 + } 626 + 627 + function interpolateTransform( 628 + progress: number, 629 + thumbnailDims: { 630 + pageX: number 631 + width: number 632 + pageY: number 633 + height: number 634 + }, 635 + safeArea: {width: number; height: number; x: number; y: number}, 636 + imageAspect: number, 637 + ): { 638 + scaleAndMoveTransform: Transform 639 + cropFrameTransform: Transform 640 + cropContentTransform: Transform 641 + isResting: boolean 642 + isHidden: boolean 643 + } { 644 + 'worklet' 645 + const thumbAspect = thumbnailDims.width / thumbnailDims.height 646 + let uncroppedInitialWidth 647 + let uncroppedInitialHeight 648 + if (imageAspect > thumbAspect) { 649 + uncroppedInitialWidth = thumbnailDims.height * imageAspect 650 + uncroppedInitialHeight = thumbnailDims.height 651 + } else { 652 + uncroppedInitialWidth = thumbnailDims.width 653 + uncroppedInitialHeight = thumbnailDims.width / imageAspect 654 + } 655 + const safeAreaAspect = safeArea.width / safeArea.height 656 + let finalWidth 657 + let finalHeight 658 + if (safeAreaAspect > imageAspect) { 659 + finalWidth = safeArea.height * imageAspect 660 + finalHeight = safeArea.height 661 + } else { 662 + finalWidth = safeArea.width 663 + finalHeight = safeArea.width / imageAspect 664 + } 665 + const initialScale = Math.min( 666 + uncroppedInitialWidth / finalWidth, 667 + uncroppedInitialHeight / finalHeight, 668 + ) 669 + const croppedFinalWidth = thumbnailDims.width / initialScale 670 + const croppedFinalHeight = thumbnailDims.height / initialScale 671 + const screenCenterX = safeArea.width / 2 672 + const screenCenterY = safeArea.height / 2 673 + const thumbnailSafeAreaX = thumbnailDims.pageX - safeArea.x 674 + const thumbnailSafeAreaY = thumbnailDims.pageY - safeArea.y 675 + const thumbnailCenterX = thumbnailSafeAreaX + thumbnailDims.width / 2 676 + const thumbnailCenterY = thumbnailSafeAreaY + thumbnailDims.height / 2 677 + const initialTranslateX = thumbnailCenterX - screenCenterX 678 + const initialTranslateY = thumbnailCenterY - screenCenterY 679 + const scale = interpolate(progress, [0, 1], [initialScale, 1]) 680 + const translateX = interpolatePx(progress, [0, 1], [initialTranslateX, 0]) 681 + const translateY = interpolatePx(progress, [0, 1], [initialTranslateY, 0]) 682 + const cropScaleX = interpolate( 683 + progress, 684 + [0, 1], 685 + [croppedFinalWidth / finalWidth, 1], 686 + ) 687 + const cropScaleY = interpolate( 688 + progress, 689 + [0, 1], 690 + [croppedFinalHeight / finalHeight, 1], 691 + ) 692 + return { 693 + isHidden: false, 694 + isResting: progress === 1, 695 + scaleAndMoveTransform: [{translateX}, {translateY}, {scale}], 696 + cropFrameTransform: [{scaleX: cropScaleX}, {scaleY: cropScaleY}], 697 + cropContentTransform: [{scaleX: 1 / cropScaleX}, {scaleY: 1 / cropScaleY}], 698 + } 699 + } 700 + 701 + function withClampedSpring(value: any, {stiffness}: {stiffness: number}) { 480 702 'worklet' 481 - return withSpring(value, {overshootClamping: true, stiffness: 300}) 703 + return withSpring(value, {overshootClamping: true, stiffness}) 482 704 }
+42 -21
src/view/com/profile/ProfileSubpageHeader.tsx
··· 1 1 import React from 'react' 2 2 import {Pressable, StyleSheet, View} from 'react-native' 3 + import Animated, { 4 + measure, 5 + MeasuredDimensions, 6 + runOnJS, 7 + runOnUI, 8 + useAnimatedRef, 9 + } from 'react-native-reanimated' 3 10 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 11 import {msg, Trans} from '@lingui/macro' 5 12 import {useLingui} from '@lingui/react' ··· 53 60 const {openLightbox} = useLightboxControls() 54 61 const pal = usePalette('default') 55 62 const canGoBack = navigation.canGoBack() 63 + const aviRef = useAnimatedRef() 56 64 57 65 const onPressBack = React.useCallback(() => { 58 66 if (navigation.canGoBack()) { ··· 66 74 setDrawerOpen(true) 67 75 }, [setDrawerOpen]) 68 76 69 - const onPressAvi = React.useCallback(() => { 70 - if ( 71 - avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) 72 - ) { 77 + const _openLightbox = React.useCallback( 78 + (uri: string, thumbRect: MeasuredDimensions | null) => { 73 79 openLightbox({ 74 80 images: [ 75 81 { 76 - uri: avatar, 77 - thumbUri: avatar, 82 + uri, 83 + thumbUri: uri, 84 + thumbRect, 78 85 dimensions: { 79 86 // It's fine if it's actually smaller but we know it's 1:1. 80 87 height: 1000, ··· 84 91 }, 85 92 ], 86 93 index: 0, 87 - thumbDims: null, 88 94 }) 95 + }, 96 + [openLightbox], 97 + ) 98 + 99 + const onPressAvi = React.useCallback(() => { 100 + if ( 101 + avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) 102 + ) { 103 + runOnUI(() => { 104 + 'worklet' 105 + const rect = measure(aviRef) 106 + runOnJS(_openLightbox)(avatar, rect) 107 + })() 89 108 } 90 - }, [openLightbox, avatar]) 109 + }, [_openLightbox, avatar, aviRef]) 91 110 92 111 return ( 93 112 <CenteredView style={pal.view}> ··· 135 154 paddingBottom: 6, 136 155 paddingHorizontal: isMobile ? 12 : 14, 137 156 }}> 138 - <Pressable 139 - testID="headerAviButton" 140 - onPress={onPressAvi} 141 - accessibilityRole="image" 142 - accessibilityLabel={_(msg`View the avatar`)} 143 - accessibilityHint="" 144 - style={{width: 58}}> 145 - {avatarType === 'starter-pack' ? ( 146 - <StarterPack width={58} gradient="sky" /> 147 - ) : ( 148 - <UserAvatar type={avatarType} size={58} avatar={avatar} /> 149 - )} 150 - </Pressable> 157 + <Animated.View ref={aviRef} collapsable={false}> 158 + <Pressable 159 + testID="headerAviButton" 160 + onPress={onPressAvi} 161 + accessibilityRole="image" 162 + accessibilityLabel={_(msg`View the avatar`)} 163 + accessibilityHint="" 164 + style={{width: 58}}> 165 + {avatarType === 'starter-pack' ? ( 166 + <StarterPack width={58} gradient="sky" /> 167 + ) : ( 168 + <UserAvatar type={avatarType} size={58} avatar={avatar} /> 169 + )} 170 + </Pressable> 171 + </Animated.View> 151 172 <View style={{flex: 1}}> 152 173 {isLoading ? ( 153 174 <LoadingPlaceholder
+8 -5
src/view/com/util/images/AutoSizedImage.tsx
··· 1 1 import React from 'react' 2 2 import {DimensionValue, Pressable, View} from 'react-native' 3 + import Animated, {AnimatedRef, useAnimatedRef} from 'react-native-reanimated' 3 4 import {Image} from 'expo-image' 4 5 import {AppBskyEmbedImages} from '@atproto/api' 5 6 import {msg} from '@lingui/macro' ··· 92 93 image: AppBskyEmbedImages.ViewImage 93 94 crop?: 'none' | 'square' | 'constrained' 94 95 hideBadge?: boolean 95 - onPress?: () => void 96 + onPress?: (containerRef: AnimatedRef<React.Component<{}, {}, any>>) => void 96 97 onLongPress?: () => void 97 98 onPressIn?: () => void 98 99 }) { ··· 107 108 src: image.thumb, 108 109 knownDimensions: image.aspectRatio ?? null, 109 110 }) 111 + const containerRef = useAnimatedRef() 112 + 110 113 const cropDisabled = crop === 'none' 111 114 const isCropped = rawIsCropped && !cropDisabled 112 115 const hasAlt = !!image.alt 113 116 114 117 const contents = ( 115 - <> 118 + <Animated.View ref={containerRef} collapsable={false}> 116 119 <Image 117 120 style={[a.w_full, a.h_full]} 118 121 source={image.thumb} ··· 185 188 )} 186 189 </View> 187 190 ) : null} 188 - </> 191 + </Animated.View> 189 192 ) 190 193 191 194 if (cropDisabled) { 192 195 return ( 193 196 <Pressable 194 - onPress={onPress} 197 + onPress={() => onPress?.(containerRef)} 195 198 onLongPress={onLongPress} 196 199 onPressIn={onPressIn} 197 200 // alt here is what screen readers actually use ··· 213 216 fullBleed={crop === 'square'} 214 217 aspectRatio={constrained ?? 1}> 215 218 <Pressable 216 - onPress={onPress} 219 + onPress={() => onPress?.(containerRef)} 217 220 onLongPress={onLongPress} 218 221 onPressIn={onPressIn} 219 222 // alt here is what screen readers actually use
+9 -5
src/view/com/util/images/Gallery.tsx
··· 1 1 import React from 'react' 2 2 import {Pressable, StyleProp, View, ViewStyle} from 'react-native' 3 - import Animated, {AnimatedRef, useAnimatedRef} from 'react-native-reanimated' 3 + import Animated, {AnimatedRef} from 'react-native-reanimated' 4 4 import {Image, ImageStyle} from 'expo-image' 5 5 import {AppBskyEmbedImages} from '@atproto/api' 6 6 import {msg} from '@lingui/macro' ··· 19 19 index: number 20 20 onPress?: ( 21 21 index: number, 22 - containerRef: AnimatedRef<React.Component<{}, {}, any>>, 22 + containerRefs: AnimatedRef<React.Component<{}, {}, any>>[], 23 23 ) => void 24 24 onLongPress?: EventFunction 25 25 onPressIn?: EventFunction 26 26 imageStyle?: StyleProp<ImageStyle> 27 27 viewContext?: PostEmbedViewContext 28 28 insetBorderStyle?: StyleProp<ViewStyle> 29 + containerRefs: AnimatedRef<React.Component<{}, {}, any>>[] 29 30 } 30 31 31 32 export function GalleryItem({ ··· 37 38 onLongPress, 38 39 viewContext, 39 40 insetBorderStyle, 41 + containerRefs, 40 42 }: Props) { 41 43 const t = useTheme() 42 44 const {_} = useLingui() ··· 45 47 const hasAlt = !!image.alt 46 48 const hideBadges = 47 49 viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia 48 - const containerRef = useAnimatedRef() 49 50 return ( 50 - <Animated.View style={a.flex_1} ref={containerRef}> 51 + <Animated.View 52 + style={a.flex_1} 53 + ref={containerRefs[index]} 54 + collapsable={false}> 51 55 <Pressable 52 - onPress={onPress ? () => onPress(index, containerRef) : undefined} 56 + onPress={onPress ? () => onPress(index, containerRefs) : undefined} 53 57 onPressIn={onPressIn ? () => onPressIn(index) : undefined} 54 58 onLongPress={onLongPress ? () => onLongPress(index) : undefined} 55 59 style={[
+31 -6
src/view/com/util/images/ImageLayoutGrid.tsx
··· 1 1 import React from 'react' 2 2 import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' 3 - import {AnimatedRef} from 'react-native-reanimated' 3 + import {AnimatedRef, useAnimatedRef} from 'react-native-reanimated' 4 4 import {AppBskyEmbedImages} from '@atproto/api' 5 5 6 6 import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' ··· 11 11 images: AppBskyEmbedImages.ViewImage[] 12 12 onPress?: ( 13 13 index: number, 14 - containerRef: AnimatedRef<React.Component<{}, {}, any>>, 14 + containerRefs: AnimatedRef<React.Component<{}, {}, any>>[], 15 15 ) => void 16 16 onLongPress?: (index: number) => void 17 17 onPressIn?: (index: number) => void ··· 41 41 images: AppBskyEmbedImages.ViewImage[] 42 42 onPress?: ( 43 43 index: number, 44 - containerRef: AnimatedRef<React.Component<{}, {}, any>>, 44 + containerRefs: AnimatedRef<React.Component<{}, {}, any>>[], 45 45 ) => void 46 46 onLongPress?: (index: number) => void 47 47 onPressIn?: (index: number) => void ··· 53 53 const gap = props.gap 54 54 const count = props.images.length 55 55 56 + const containerRef1 = useAnimatedRef() 57 + const containerRef2 = useAnimatedRef() 58 + const containerRef3 = useAnimatedRef() 59 + const containerRef4 = useAnimatedRef() 60 + 56 61 switch (count) { 57 - case 2: 62 + case 2: { 63 + const containerRefs = [containerRef1, containerRef2] 58 64 return ( 59 65 <View style={[a.flex_1, a.flex_row, gap]}> 60 66 <View style={[a.flex_1, {aspectRatio: 1}]}> ··· 62 68 {...props} 63 69 index={0} 64 70 insetBorderStyle={noCorners(['topRight', 'bottomRight'])} 71 + containerRefs={containerRefs} 65 72 /> 66 73 </View> 67 74 <View style={[a.flex_1, {aspectRatio: 1}]}> ··· 69 76 {...props} 70 77 index={1} 71 78 insetBorderStyle={noCorners(['topLeft', 'bottomLeft'])} 79 + containerRefs={containerRefs} 72 80 /> 73 81 </View> 74 82 </View> 75 83 ) 84 + } 76 85 77 - case 3: 86 + case 3: { 87 + const containerRefs = [containerRef1, containerRef2, containerRef3] 78 88 return ( 79 89 <View style={[a.flex_1, a.flex_row, gap]}> 80 90 <View style={[a.flex_1, {aspectRatio: 1}]}> ··· 82 92 {...props} 83 93 index={0} 84 94 insetBorderStyle={noCorners(['topRight', 'bottomRight'])} 95 + containerRefs={containerRefs} 85 96 /> 86 97 </View> 87 98 <View style={[a.flex_1, {aspectRatio: 1}, gap]}> ··· 94 105 'bottomLeft', 95 106 'bottomRight', 96 107 ])} 108 + containerRefs={containerRefs} 97 109 /> 98 110 </View> 99 111 <View style={[a.flex_1]}> ··· 105 117 'bottomLeft', 106 118 'topRight', 107 119 ])} 120 + containerRefs={containerRefs} 108 121 /> 109 122 </View> 110 123 </View> 111 124 </View> 112 125 ) 126 + } 113 127 114 - case 4: 128 + case 4: { 129 + const containerRefs = [ 130 + containerRef1, 131 + containerRef2, 132 + containerRef3, 133 + containerRef4, 134 + ] 115 135 return ( 116 136 <> 117 137 <View style={[a.flex_row, gap]}> ··· 124 144 'topRight', 125 145 'bottomRight', 126 146 ])} 147 + containerRefs={containerRefs} 127 148 /> 128 149 </View> 129 150 <View style={[a.flex_1, {aspectRatio: 1.5}]}> ··· 135 156 'bottomLeft', 136 157 'bottomRight', 137 158 ])} 159 + containerRefs={containerRefs} 138 160 /> 139 161 </View> 140 162 </View> ··· 148 170 'topRight', 149 171 'bottomRight', 150 172 ])} 173 + containerRefs={containerRefs} 151 174 /> 152 175 </View> 153 176 <View style={[a.flex_1, {aspectRatio: 1.5}]}> ··· 159 182 'bottomLeft', 160 183 'topRight', 161 184 ])} 185 + containerRefs={containerRefs} 162 186 /> 163 187 </View> 164 188 </View> 165 189 </> 166 190 ) 191 + } 167 192 168 193 default: 169 194 return null
+10 -12
src/view/com/util/post-embeds/index.tsx
··· 6 6 View, 7 7 ViewStyle, 8 8 } from 'react-native' 9 - import Animated, { 9 + import { 10 10 AnimatedRef, 11 11 measure, 12 12 MeasuredDimensions, 13 13 runOnJS, 14 14 runOnUI, 15 - useAnimatedRef, 16 15 } from 'react-native-reanimated' 17 16 import {Image} from 'expo-image' 18 17 import { ··· 69 68 viewContext?: PostEmbedViewContext 70 69 }) { 71 70 const {openLightbox} = useLightboxControls() 72 - const containerRef = useAnimatedRef() 73 71 74 72 // quote post with media 75 73 // = ··· 149 147 })) 150 148 const _openLightbox = ( 151 149 index: number, 152 - thumbDims: MeasuredDimensions | null, 150 + thumbRects: (MeasuredDimensions | null)[], 153 151 ) => { 154 152 openLightbox({ 155 - images: items.map(item => ({ 153 + images: items.map((item, i) => ({ 156 154 ...item, 155 + thumbRect: thumbRects[i] ?? null, 157 156 type: 'image', 158 157 })), 159 158 index, 160 - thumbDims, 161 159 }) 162 160 } 163 161 const onPress = ( 164 162 index: number, 165 - ref: AnimatedRef<React.Component<{}, {}, any>>, 163 + refs: AnimatedRef<React.Component<{}, {}, any>>[], 166 164 ) => { 167 165 runOnUI(() => { 168 166 'worklet' 169 - const dims = measure(ref) 170 - runOnJS(_openLightbox)(index, dims) 167 + const rects = refs.map(ref => (ref ? measure(ref) : null)) 168 + runOnJS(_openLightbox)(index, rects) 171 169 })() 172 170 } 173 171 const onPressIn = (_: number) => { ··· 180 178 const image = images[0] 181 179 return ( 182 180 <ContentHider modui={moderation?.ui('contentMedia')}> 183 - <Animated.View ref={containerRef} style={[a.mt_sm, style]}> 181 + <View style={[a.mt_sm, style]}> 184 182 <AutoSizedImage 185 183 crop={ 186 184 viewContext === PostEmbedViewContext.ThreadHighlighted ··· 191 189 : 'constrained' 192 190 } 193 191 image={image} 194 - onPress={() => onPress(0, containerRef)} 192 + onPress={containerRef => onPress(0, [containerRef])} 195 193 onPressIn={() => onPressIn(0)} 196 194 hideBadge={ 197 195 viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia 198 196 } 199 197 /> 200 - </Animated.View> 198 + </View> 201 199 </ContentHider> 202 200 ) 203 201 }