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 import React, {memo} from 'react' 2 import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' 3 import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' 4 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 import {msg} from '@lingui/macro' ··· 42 const {openLightbox} = useLightboxControls() 43 const navigation = useNavigation<NavigationProp>() 44 const {isDesktop} = useWebMediaQueries() 45 46 const onPressBack = React.useCallback(() => { 47 if (navigation.canGoBack()) { ··· 51 } 52 }, [navigation]) 53 54 - const onPressAvi = React.useCallback(() => { 55 - const modui = moderation.ui('avatar') 56 - if (profile.avatar && !(modui.blur && modui.noOverride)) { 57 openLightbox({ 58 images: [ 59 { 60 - uri: profile.avatar, 61 - thumbUri: profile.avatar, 62 dimensions: { 63 // It's fine if it's actually smaller but we know it's 1:1. 64 height: 1000, ··· 68 }, 69 ], 70 index: 0, 71 - thumbDims: null, 72 }) 73 } 74 - }, [openLightbox, profile, moderation]) 75 76 const isMe = React.useMemo( 77 () => currentAccount?.did === profile.did, ··· 149 styles.avi, 150 profile.associated?.labeler && styles.aviLabeler, 151 ]}> 152 - <UserAvatar 153 - type={profile.associated?.labeler ? 'labeler' : 'user'} 154 - size={90} 155 - avatar={profile.avatar} 156 - moderation={moderation.ui('avatar')} 157 - /> 158 </View> 159 </TouchableWithoutFeedback> 160 </GrowableAvatar>
··· 1 import React, {memo} from 'react' 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' 10 import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' 11 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 12 import {msg} from '@lingui/macro' ··· 49 const {openLightbox} = useLightboxControls() 50 const navigation = useNavigation<NavigationProp>() 51 const {isDesktop} = useWebMediaQueries() 52 + const aviRef = useAnimatedRef() 53 54 const onPressBack = React.useCallback(() => { 55 if (navigation.canGoBack()) { ··· 59 } 60 }, [navigation]) 61 62 + const _openLightbox = React.useCallback( 63 + (uri: string, thumbRect: MeasuredDimensions | null) => { 64 openLightbox({ 65 images: [ 66 { 67 + uri, 68 + thumbUri: uri, 69 + thumbRect, 70 dimensions: { 71 // It's fine if it's actually smaller but we know it's 1:1. 72 height: 1000, ··· 76 }, 77 ], 78 index: 0, 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 + })() 93 } 94 + }, [profile, moderation, _openLightbox, aviRef]) 95 96 const isMe = React.useMemo( 97 () => currentAccount?.did === profile.did, ··· 169 styles.avi, 170 profile.associated?.labeler && styles.aviLabeler, 171 ]}> 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> 180 </View> 181 </TouchableWithoutFeedback> 182 </GrowableAvatar>
-2
src/state/lightbox.tsx
··· 1 import React from 'react' 2 - import type {MeasuredDimensions} from 'react-native-reanimated' 3 import {nanoid} from 'nanoid/non-secure' 4 5 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' ··· 8 export type Lightbox = { 9 id: string 10 images: ImageSource[] 11 - thumbDims: MeasuredDimensions | null 12 index: number 13 } 14
··· 1 import React from 'react' 2 import {nanoid} from 'nanoid/non-secure' 3 4 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' ··· 7 export type Lightbox = { 8 id: string 9 images: ImageSource[] 10 index: number 11 } 12
+9
src/view/com/lightbox/ImageViewing/@types/index.ts
··· 6 * 7 */ 8 9 export type Dimensions = { 10 width: number 11 height: number ··· 19 export type ImageSource = { 20 uri: string 21 thumbUri: string 22 alt?: string 23 dimensions: Dimensions | null 24 type: 'image' | 'circle-avi' | 'rect-avi' 25 }
··· 6 * 7 */ 8 9 + import {TransformsStyle} from 'react-native' 10 + import {MeasuredDimensions} from 'react-native-reanimated' 11 + 12 export type Dimensions = { 13 width: number 14 height: number ··· 22 export type ImageSource = { 23 uri: string 24 thumbUri: string 25 + thumbRect: MeasuredDimensions | null 26 alt?: string 27 dimensions: Dimensions | null 28 type: 'image' | 'circle-avi' | 'rect-avi' 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 import React, {useState} from 'react' 2 - import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native' 3 import { 4 Gesture, 5 GestureDetector, 6 PanGesture, 7 } from 'react-native-gesture-handler' 8 import Animated, { 9 - AnimatedRef, 10 - measure, 11 runOnJS, 12 useAnimatedReaction, 13 useAnimatedRef, 14 useAnimatedStyle, 15 useSharedValue, 16 withSpring, 17 } from 'react-native-reanimated' 18 - import {Image, ImageStyle} from 'expo-image' 19 20 - import type {Dimensions as ImageDimensions, ImageSource} from '../../@types' 21 import { 22 applyRounding, 23 createTransform, ··· 27 readTransform, 28 TransformMatrix, 29 } from '../../transforms' 30 - 31 - const AnimatedImage = Animated.createAnimatedComponent(Image) 32 33 const MIN_SCREEN_ZOOM = 2 34 const MAX_ORIGINAL_IMAGE_ZOOM = 2 ··· 42 onZoom: (isZoomed: boolean) => void 43 isScrollViewBeingDragged: boolean 44 showControls: boolean 45 - safeAreaRef: AnimatedRef<View> 46 imageAspect: number | undefined 47 imageDimensions: ImageDimensions | undefined 48 - imageStyle: StyleProp<ImageStyle> 49 dismissSwipePan: PanGesture 50 } 51 const ImageItem = ({ 52 imageSrc, 53 onTap, 54 onZoom, 55 isScrollViewBeingDragged, 56 - safeAreaRef, 57 imageAspect, 58 imageDimensions, 59 - imageStyle, 60 dismissSwipePan, 61 }: Props) => { 62 const [isScaled, setIsScaled] = useState(false) 63 const committedTransform = useSharedValue(initialTransform) ··· 95 onZoom(nextIsScaled) 96 } 97 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 // On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges. 112 // If the user tried to pan too hard, this function will provide the negative panning to stay in bounds. 113 function getExtraTranslationToStayInBounds( ··· 143 const pinch = Gesture.Pinch() 144 .onStart(e => { 145 'worklet' 146 - const screenSize = measure(safeAreaRef) 147 - if (!screenSize) { 148 - return 149 - } 150 pinchOrigin.value = { 151 x: e.focalX - screenSize.width / 2, 152 y: e.focalY - screenSize.height / 2, ··· 154 }) 155 .onChange(e => { 156 'worklet' 157 - const screenSize = measure(safeAreaRef) 158 - if (!imageDimensions || !screenSize) { 159 return 160 } 161 // Don't let the picture zoom in so close that it gets blurry. ··· 213 .minPointers(isScaled ? 1 : 2) 214 .onChange(e => { 215 'worklet' 216 - const screenSize = measure(safeAreaRef) 217 - if (!imageDimensions || !screenSize) { 218 return 219 } 220 ··· 257 .numberOfTaps(2) 258 .onEnd(e => { 259 'worklet' 260 - const screenSize = measure(safeAreaRef) 261 - if (!imageDimensions || !imageAspect || !screenSize) { 262 return 263 } 264 const [, , committedScale] = readTransform(committedTransform.value) ··· 302 committedTransform.value = withClampedSpring(finalTransform) 303 }) 304 305 - const innerStyle = useAnimatedStyle(() => ({ 306 - width: '100%', 307 - aspectRatio: imageAspect, 308 - })) 309 - 310 const composedGesture = isScrollViewBeingDragged 311 ? // If the parent is not at rest, provide a no-op gesture. 312 Gesture.Manual() ··· 317 singleTap, 318 ) 319 320 const type = imageSrc.type 321 const borderRadius = 322 type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 323 return ( 324 <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 - /> 343 </Animated.View> 344 </Animated.View> 345 </GestureDetector> ··· 358 right: 0, 359 top: 0, 360 bottom: 0, 361 }, 362 }) 363
··· 1 import React, {useState} from 'react' 2 + import {ActivityIndicator, StyleSheet} from 'react-native' 3 import { 4 Gesture, 5 GestureDetector, 6 PanGesture, 7 } from 'react-native-gesture-handler' 8 import Animated, { 9 runOnJS, 10 + SharedValue, 11 useAnimatedReaction, 12 useAnimatedRef, 13 useAnimatedStyle, 14 useSharedValue, 15 withSpring, 16 } from 'react-native-reanimated' 17 + import {Image} from 'expo-image' 18 19 + import type { 20 + Dimensions as ImageDimensions, 21 + ImageSource, 22 + Transform, 23 + } from '../../@types' 24 import { 25 applyRounding, 26 createTransform, ··· 30 readTransform, 31 TransformMatrix, 32 } from '../../transforms' 33 34 const MIN_SCREEN_ZOOM = 2 35 const MAX_ORIGINAL_IMAGE_ZOOM = 2 ··· 43 onZoom: (isZoomed: boolean) => void 44 isScrollViewBeingDragged: boolean 45 showControls: boolean 46 + measureSafeArea: () => { 47 + x: number 48 + y: number 49 + width: number 50 + height: number 51 + } 52 imageAspect: number | undefined 53 imageDimensions: ImageDimensions | undefined 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 + > 64 } 65 const ImageItem = ({ 66 imageSrc, 67 onTap, 68 onZoom, 69 isScrollViewBeingDragged, 70 + measureSafeArea, 71 imageAspect, 72 imageDimensions, 73 dismissSwipePan, 74 + transforms, 75 }: Props) => { 76 const [isScaled, setIsScaled] = useState(false) 77 const committedTransform = useSharedValue(initialTransform) ··· 109 onZoom(nextIsScaled) 110 } 111 112 // On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges. 113 // If the user tried to pan too hard, this function will provide the negative panning to stay in bounds. 114 function getExtraTranslationToStayInBounds( ··· 144 const pinch = Gesture.Pinch() 145 .onStart(e => { 146 'worklet' 147 + const screenSize = measureSafeArea() 148 pinchOrigin.value = { 149 x: e.focalX - screenSize.width / 2, 150 y: e.focalY - screenSize.height / 2, ··· 152 }) 153 .onChange(e => { 154 'worklet' 155 + const screenSize = measureSafeArea() 156 + if (!imageDimensions) { 157 return 158 } 159 // Don't let the picture zoom in so close that it gets blurry. ··· 211 .minPointers(isScaled ? 1 : 2) 212 .onChange(e => { 213 'worklet' 214 + const screenSize = measureSafeArea() 215 + if (!imageDimensions) { 216 return 217 } 218 ··· 255 .numberOfTaps(2) 256 .onEnd(e => { 257 'worklet' 258 + const screenSize = measureSafeArea() 259 + if (!imageDimensions || !imageAspect) { 260 return 261 } 262 const [, , committedScale] = readTransform(committedTransform.value) ··· 300 committedTransform.value = withClampedSpring(finalTransform) 301 }) 302 303 const composedGesture = isScrollViewBeingDragged 304 ? // If the parent is not at rest, provide a no-op gesture. 305 Gesture.Manual() ··· 310 singleTap, 311 ) 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 + 370 const type = imageSrc.type 371 const borderRadius = 372 type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 373 + 374 return ( 375 <GestureDetector gesture={composedGesture}> 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> 404 </Animated.View> 405 </Animated.View> 406 </GestureDetector> ··· 419 right: 0, 420 top: 0, 421 bottom: 0, 422 + justifyContent: 'center', 423 }, 424 }) 425
+94 -38
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
··· 7 */ 8 9 import React, {useState} from 'react' 10 - import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native' 11 import { 12 Gesture, 13 GestureDetector, 14 PanGesture, 15 } from 'react-native-gesture-handler' 16 import Animated, { 17 - AnimatedRef, 18 - measure, 19 runOnJS, 20 useAnimatedRef, 21 useAnimatedStyle, 22 } from 'react-native-reanimated' 23 import {useSafeAreaFrame} from 'react-native-safe-area-context' 24 - import {Image, ImageStyle} from 'expo-image' 25 26 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' 27 - import {Dimensions as ImageDimensions, ImageSource} from '../../@types' 28 - 29 - const AnimatedImage = Animated.createAnimatedComponent(Image) 30 31 const MAX_ORIGINAL_IMAGE_ZOOM = 2 32 const MIN_SCREEN_ZOOM = 2 ··· 38 onZoom: (scaled: boolean) => void 39 isScrollViewBeingDragged: boolean 40 showControls: boolean 41 - safeAreaRef: AnimatedRef<View> 42 imageAspect: number | undefined 43 imageDimensions: ImageDimensions | undefined 44 - imageStyle: StyleProp<ImageStyle> 45 dismissSwipePan: PanGesture 46 } 47 48 const ImageItem = ({ ··· 50 onTap, 51 onZoom, 52 showControls, 53 - safeAreaRef, 54 imageAspect, 55 imageDimensions, 56 - imageStyle, 57 dismissSwipePan, 58 }: Props) => { 59 const scrollViewRef = useAnimatedRef<Animated.ScrollView>() 60 const [scaled, setScaled] = useState(false) ··· 66 MAX_ORIGINAL_IMAGE_ZOOM 67 : 1, 68 ) 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 80 const scrollHandler = useAnimatedScrollHandler({ 81 onScroll(e) { ··· 114 .numberOfTaps(2) 115 .onEnd(e => { 116 'worklet' 117 - const screenSize = measure(safeAreaRef) 118 - if (!screenSize) { 119 - return 120 - } 121 const {absoluteX, absoluteY} = e 122 let nextZoomRect = { 123 x: 0, ··· 143 singleTap, 144 ) 145 146 const type = imageSrc.type 147 const borderRadius = 148 type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 149 return ( 150 <GestureDetector gesture={composedGesture}> 151 <Animated.ScrollView ··· 156 showsVerticalScrollIndicator={false} 157 maximumZoomScale={maxZoomScale} 158 onScroll={scrollHandler} 159 bounces={scaled} 160 bouncesZoom={true} 161 - style={imageStyle} 162 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 - /> 175 </Animated.ScrollView> 176 </GestureDetector> 177 )
··· 7 */ 8 9 import React, {useState} from 'react' 10 + import {ActivityIndicator, StyleSheet} from 'react-native' 11 import { 12 Gesture, 13 GestureDetector, 14 PanGesture, 15 } from 'react-native-gesture-handler' 16 import Animated, { 17 runOnJS, 18 + SharedValue, 19 + useAnimatedReaction, 20 useAnimatedRef, 21 useAnimatedStyle, 22 } from 'react-native-reanimated' 23 import {useSafeAreaFrame} from 'react-native-safe-area-context' 24 + import {Image} from 'expo-image' 25 26 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' 27 + import { 28 + Dimensions as ImageDimensions, 29 + ImageSource, 30 + Transform, 31 + } from '../../@types' 32 33 const MAX_ORIGINAL_IMAGE_ZOOM = 2 34 const MIN_SCREEN_ZOOM = 2 ··· 40 onZoom: (scaled: boolean) => void 41 isScrollViewBeingDragged: boolean 42 showControls: boolean 43 + measureSafeArea: () => { 44 + x: number 45 + y: number 46 + width: number 47 + height: number 48 + } 49 imageAspect: number | undefined 50 imageDimensions: ImageDimensions | undefined 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 + > 61 } 62 63 const ImageItem = ({ ··· 65 onTap, 66 onZoom, 67 showControls, 68 + measureSafeArea, 69 imageAspect, 70 imageDimensions, 71 dismissSwipePan, 72 + transforms, 73 }: Props) => { 74 const scrollViewRef = useAnimatedRef<Animated.ScrollView>() 75 const [scaled, setScaled] = useState(false) ··· 81 MAX_ORIGINAL_IMAGE_ZOOM 82 : 1, 83 ) 84 85 const scrollHandler = useAnimatedScrollHandler({ 86 onScroll(e) { ··· 119 .numberOfTaps(2) 120 .onEnd(e => { 121 'worklet' 122 + const screenSize = measureSafeArea() 123 const {absoluteX, absoluteY} = e 124 let nextZoomRect = { 125 x: 0, ··· 145 singleTap, 146 ) 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 + 194 const type = imageSrc.type 195 const borderRadius = 196 type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 197 + 198 return ( 199 <GestureDetector gesture={composedGesture}> 200 <Animated.ScrollView ··· 205 showsVerticalScrollIndicator={false} 206 maximumZoomScale={maxZoomScale} 207 onScroll={scrollHandler} 208 + style={containerStyle} 209 bounces={scaled} 210 bouncesZoom={true} 211 centerContent> 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> 231 </Animated.ScrollView> 232 </GestureDetector> 233 )
+22 -5
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
··· 1 // default implementation fallback for web 2 3 import React from 'react' 4 - import {ImageStyle, StyleProp, View} from 'react-native' 5 import {PanGesture} from 'react-native-gesture-handler' 6 - import {AnimatedRef} from 'react-native-reanimated' 7 8 - import {Dimensions as ImageDimensions, ImageSource} from '../../@types' 9 10 type Props = { 11 imageSrc: ImageSource ··· 14 onZoom: (scaled: boolean) => void 15 isScrollViewBeingDragged: boolean 16 showControls: boolean 17 - safeAreaRef: AnimatedRef<View> 18 imageAspect: number | undefined 19 imageDimensions: ImageDimensions | undefined 20 - imageStyle: StyleProp<ImageStyle> 21 dismissSwipePan: PanGesture 22 } 23 24 const ImageItem = (_props: Props) => {
··· 1 // default implementation fallback for web 2 3 import React from 'react' 4 + import {View} from 'react-native' 5 import {PanGesture} from 'react-native-gesture-handler' 6 + import {SharedValue} from 'react-native-reanimated' 7 8 + import { 9 + Dimensions as ImageDimensions, 10 + ImageSource, 11 + Transform, 12 + } from '../../@types' 13 14 type Props = { 15 imageSrc: ImageSource ··· 18 onZoom: (scaled: boolean) => void 19 isScrollViewBeingDragged: boolean 20 showControls: boolean 21 + measureSafeArea: () => { 22 + x: number 23 + y: number 24 + width: number 25 + height: number 26 + } 27 imageAspect: number | undefined 28 imageDimensions: ImageDimensions | undefined 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 + > 39 } 40 41 const ImageItem = (_props: Props) => {
+250 -28
src/view/com/lightbox/ImageViewing/index.tsx
··· 9 // https://github.com/jobtoday/react-native-image-viewing 10 11 import React, {useCallback, useState} from 'react' 12 - import {LayoutAnimation, Platform, StyleSheet, View} from 'react-native' 13 import {Gesture} from 'react-native-gesture-handler' 14 import PagerView from 'react-native-pager-view' 15 import Animated, { 16 AnimatedRef, 17 cancelAnimation, 18 measure, 19 runOnJS, 20 SharedValue, 21 useAnimatedReaction, 22 useAnimatedRef, 23 useAnimatedStyle, 24 useSharedValue, 25 withDecay, 26 withSpring, 27 } from 'react-native-reanimated' 28 - import {Edge, SafeAreaView} from 'react-native-safe-area-context' 29 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 30 import {Trans} from '@lingui/macro' 31 ··· 36 import {Button} from '#/view/com/util/forms/Button' 37 import {Text} from '#/view/com/util/text/Text' 38 import {ScrollView} from '#/view/com/util/Views' 39 - import {ImageSource} from './@types' 40 import ImageDefaultHeader from './components/ImageDefaultHeader' 41 import ImageItem from './components/ImageItem/ImageItem' 42 43 const EDGES = 44 Platform.OS === 'android' 45 ? (['top', 'bottom', 'left', 'right'] satisfies Edge[]) 46 : (['left', 'right'] satisfies Edge[]) // iOS, so no top/bottom safe area 47 48 export default function ImageViewRoot({ 49 - lightbox, 50 onRequestClose, 51 onPressSave, 52 onPressShare, ··· 56 onPressSave: (uri: string) => void 57 onPressShare: (uri: string) => void 58 }) { 59 const ref = useAnimatedRef<View>() 60 return ( 61 // Keep it always mounted to avoid flicker on the first frame. 62 <SafeAreaView 63 - style={[styles.screen, !lightbox && styles.screenHidden]} 64 edges={EDGES} 65 aria-modal 66 accessibilityViewIsModal 67 - aria-hidden={!lightbox}> 68 <Animated.View ref={ref} style={{flex: 1}} collapsable={false}> 69 - {lightbox && ( 70 <ImageView 71 - key={lightbox.id} 72 - lightbox={lightbox} 73 onRequestClose={onRequestClose} 74 onPressSave={onPressSave} 75 onPressShare={onPressShare} 76 safeAreaRef={ref} 77 /> 78 )} 79 </Animated.View> ··· 86 onRequestClose, 87 onPressSave, 88 onPressShare, 89 safeAreaRef, 90 }: { 91 lightbox: Lightbox 92 onRequestClose: () => void 93 onPressSave: (uri: string) => void 94 onPressShare: (uri: string) => void 95 safeAreaRef: AnimatedRef<View> 96 }) { 97 const {images, index: initialImageIndex} = lightbox 98 const [isScaled, setIsScaled] = useState(false) ··· 104 const isFlyingAway = useSharedValue(false) 105 106 const containerStyle = useAnimatedStyle(() => { 107 - if (isFlyingAway.value) { 108 return {pointerEvents: 'none'} 109 } 110 return {pointerEvents: 'auto'} 111 }) 112 const backdropStyle = useAnimatedStyle(() => { 113 const screenSize = measure(safeAreaRef) 114 let opacity = 1 115 - if (screenSize) { 116 const dragProgress = Math.min( 117 Math.abs(dismissSwipeTranslateY.value) / (screenSize.height / 2), 118 1, 119 ) 120 opacity -= dragProgress 121 } 122 return { 123 - opacity, 124 } 125 }) 126 const animatedHeaderStyle = useAnimatedStyle(() => { 127 const show = showControls && dismissSwipeTranslateY.value === 0 128 return { 129 pointerEvents: show ? 'box-none' : 'none', 130 - opacity: withClampedSpring(show ? 1 : 0), 131 transform: [ 132 { 133 - translateY: withClampedSpring(show ? 0 : -30), 134 }, 135 ], 136 } ··· 140 return { 141 flexGrow: 1, 142 pointerEvents: show ? 'box-none' : 'none', 143 - opacity: withClampedSpring(show ? 1 : 0), 144 transform: [ 145 { 146 - translateY: withClampedSpring(show ? 0 : 30), 147 }, 148 ], 149 } ··· 172 if (isOut && !wasOut) { 173 // Stop the animation from blocking the screen forever. 174 cancelAnimation(dismissSwipeTranslateY) 175 - runOnJS(onRequestClose)() 176 } 177 }, 178 ) ··· 209 isFlyingAway={isFlyingAway} 210 isActive={i === imageIndex} 211 dismissSwipeTranslateY={dismissSwipeTranslateY} 212 /> 213 </View> 214 ))} ··· 247 isActive, 248 showControls, 249 safeAreaRef, 250 dismissSwipeTranslateY, 251 }: { 252 imageSrc: ImageSource ··· 259 isFlyingAway: SharedValue<boolean> 260 showControls: boolean 261 safeAreaRef: AnimatedRef<View> 262 dismissSwipeTranslateY: SharedValue<number> 263 }) { 264 const [imageAspect, imageDimensions] = useImageDimensions({ ··· 266 knownDimensions: imageSrc.dimensions, 267 }) 268 269 const dismissSwipePan = Gesture.Pan() 270 .enabled(isActive && !isScaled) 271 .activeOffsetY([-10, 10]) ··· 273 .maxPointers(1) 274 .onUpdate(e => { 275 'worklet' 276 - if (isFlyingAway.value) { 277 return 278 } 279 dismissSwipeTranslateY.value = e.translationY 280 }) 281 .onEnd(e => { 282 'worklet' 283 - if (isFlyingAway.value) { 284 return 285 } 286 if (Math.abs(e.velocityY) > 1000) { ··· 303 } 304 }) 305 306 - const imageStyle = useAnimatedStyle(() => { 307 - return { 308 - transform: [{translateY: dismissSwipeTranslateY.value}], 309 - } 310 - }) 311 return ( 312 <ImageItem 313 imageSrc={imageSrc} ··· 316 onRequestClose={onRequestClose} 317 isScrollViewBeingDragged={isScrollViewBeingDragged} 318 showControls={showControls} 319 - safeAreaRef={safeAreaRef} 320 imageAspect={imageAspect} 321 imageDimensions={imageDimensions} 322 - imageStyle={imageStyle} 323 dismissSwipePan={dismissSwipePan} 324 /> 325 ) 326 } ··· 476 }, 477 }) 478 479 - function withClampedSpring(value: any) { 480 'worklet' 481 - return withSpring(value, {overshootClamping: true, stiffness: 300}) 482 }
··· 9 // https://github.com/jobtoday/react-native-image-viewing 10 11 import React, {useCallback, useState} from 'react' 12 + import { 13 + LayoutAnimation, 14 + PixelRatio, 15 + Platform, 16 + StyleSheet, 17 + View, 18 + } from 'react-native' 19 import {Gesture} from 'react-native-gesture-handler' 20 import PagerView from 'react-native-pager-view' 21 import Animated, { 22 AnimatedRef, 23 cancelAnimation, 24 + interpolate, 25 measure, 26 runOnJS, 27 SharedValue, 28 useAnimatedReaction, 29 useAnimatedRef, 30 useAnimatedStyle, 31 + useDerivedValue, 32 useSharedValue, 33 withDecay, 34 withSpring, 35 } from 'react-native-reanimated' 36 + import { 37 + Edge, 38 + SafeAreaView, 39 + useSafeAreaFrame, 40 + useSafeAreaInsets, 41 + } from 'react-native-safe-area-context' 42 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 43 import {Trans} from '@lingui/macro' 44 ··· 49 import {Button} from '#/view/com/util/forms/Button' 50 import {Text} from '#/view/com/util/text/Text' 51 import {ScrollView} from '#/view/com/util/Views' 52 + import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army' 53 + import {ImageSource, Transform} from './@types' 54 import ImageDefaultHeader from './components/ImageDefaultHeader' 55 import ImageItem from './components/ImageItem/ImageItem' 56 57 + type Rect = {x: number; y: number; width: number; height: number} 58 + 59 + const PIXEL_RATIO = PixelRatio.get() 60 const EDGES = 61 Platform.OS === 'android' 62 ? (['top', 'bottom', 'left', 'right'] satisfies Edge[]) 63 : (['left', 'right'] satisfies Edge[]) // iOS, so no top/bottom safe area 64 65 + const SLOW_SPRING = {stiffness: 120} 66 + const FAST_SPRING = {stiffness: 700} 67 + 68 export default function ImageViewRoot({ 69 + lightbox: nextLightbox, 70 onRequestClose, 71 onPressSave, 72 onPressShare, ··· 76 onPressSave: (uri: string) => void 77 onPressShare: (uri: string) => void 78 }) { 79 + 'use no memo' 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 + 124 return ( 125 // Keep it always mounted to avoid flicker on the first frame. 126 <SafeAreaView 127 + style={[styles.screen, !activeLightbox && styles.screenHidden]} 128 edges={EDGES} 129 aria-modal 130 accessibilityViewIsModal 131 + aria-hidden={!activeLightbox}> 132 <Animated.View ref={ref} style={{flex: 1}} collapsable={false}> 133 + {activeLightbox && ( 134 <ImageView 135 + key={activeLightbox.id} 136 + lightbox={activeLightbox} 137 onRequestClose={onRequestClose} 138 onPressSave={onPressSave} 139 onPressShare={onPressShare} 140 + onFlyAway={onFlyAway} 141 safeAreaRef={ref} 142 + openProgress={openProgress} 143 /> 144 )} 145 </Animated.View> ··· 152 onRequestClose, 153 onPressSave, 154 onPressShare, 155 + onFlyAway, 156 safeAreaRef, 157 + openProgress, 158 }: { 159 lightbox: Lightbox 160 onRequestClose: () => void 161 onPressSave: (uri: string) => void 162 onPressShare: (uri: string) => void 163 + onFlyAway: () => void 164 safeAreaRef: AnimatedRef<View> 165 + openProgress: SharedValue<number> 166 }) { 167 const {images, index: initialImageIndex} = lightbox 168 const [isScaled, setIsScaled] = useState(false) ··· 174 const isFlyingAway = useSharedValue(false) 175 176 const containerStyle = useAnimatedStyle(() => { 177 + if (openProgress.value < 1 || isFlyingAway.value) { 178 return {pointerEvents: 'none'} 179 } 180 return {pointerEvents: 'auto'} 181 }) 182 + 183 const backdropStyle = useAnimatedStyle(() => { 184 const screenSize = measure(safeAreaRef) 185 let opacity = 1 186 + if (openProgress.value < 1) { 187 + opacity = Math.sqrt(openProgress.value) 188 + } else if (screenSize) { 189 const dragProgress = Math.min( 190 Math.abs(dismissSwipeTranslateY.value) / (screenSize.height / 2), 191 1, 192 ) 193 opacity -= dragProgress 194 } 195 + const factor = isIOS ? 100 : 50 196 return { 197 + opacity: Math.round(opacity * factor) / factor, 198 } 199 }) 200 + 201 const animatedHeaderStyle = useAnimatedStyle(() => { 202 const show = showControls && dismissSwipeTranslateY.value === 0 203 return { 204 pointerEvents: show ? 'box-none' : 'none', 205 + opacity: withClampedSpring( 206 + show && openProgress.value === 1 ? 1 : 0, 207 + FAST_SPRING, 208 + ), 209 transform: [ 210 { 211 + translateY: withClampedSpring(show ? 0 : -30, FAST_SPRING), 212 }, 213 ], 214 } ··· 218 return { 219 flexGrow: 1, 220 pointerEvents: show ? 'box-none' : 'none', 221 + opacity: withClampedSpring( 222 + show && openProgress.value === 1 ? 1 : 0, 223 + FAST_SPRING, 224 + ), 225 transform: [ 226 { 227 + translateY: withClampedSpring(show ? 0 : 30, FAST_SPRING), 228 }, 229 ], 230 } ··· 253 if (isOut && !wasOut) { 254 // Stop the animation from blocking the screen forever. 255 cancelAnimation(dismissSwipeTranslateY) 256 + onFlyAway() 257 } 258 }, 259 ) ··· 290 isFlyingAway={isFlyingAway} 291 isActive={i === imageIndex} 292 dismissSwipeTranslateY={dismissSwipeTranslateY} 293 + openProgress={openProgress} 294 /> 295 </View> 296 ))} ··· 329 isActive, 330 showControls, 331 safeAreaRef, 332 + openProgress, 333 dismissSwipeTranslateY, 334 }: { 335 imageSrc: ImageSource ··· 342 isFlyingAway: SharedValue<boolean> 343 showControls: boolean 344 safeAreaRef: AnimatedRef<View> 345 + openProgress: SharedValue<number> 346 dismissSwipeTranslateY: SharedValue<number> 347 }) { 348 const [imageAspect, imageDimensions] = useImageDimensions({ ··· 350 knownDimensions: imageSrc.dimensions, 351 }) 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 + 412 const dismissSwipePan = Gesture.Pan() 413 .enabled(isActive && !isScaled) 414 .activeOffsetY([-10, 10]) ··· 416 .maxPointers(1) 417 .onUpdate(e => { 418 'worklet' 419 + if (openProgress.value !== 1 || isFlyingAway.value) { 420 return 421 } 422 dismissSwipeTranslateY.value = e.translationY 423 }) 424 .onEnd(e => { 425 'worklet' 426 + if (openProgress.value !== 1 || isFlyingAway.value) { 427 return 428 } 429 if (Math.abs(e.velocityY) > 1000) { ··· 446 } 447 }) 448 449 return ( 450 <ImageItem 451 imageSrc={imageSrc} ··· 454 onRequestClose={onRequestClose} 455 isScrollViewBeingDragged={isScrollViewBeingDragged} 456 showControls={showControls} 457 + measureSafeArea={measureSafeArea} 458 imageAspect={imageAspect} 459 imageDimensions={imageDimensions} 460 dismissSwipePan={dismissSwipePan} 461 + transforms={transforms} 462 /> 463 ) 464 } ··· 614 }, 615 }) 616 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}) { 702 'worklet' 703 + return withSpring(value, {overshootClamping: true, stiffness}) 704 }
+42 -21
src/view/com/profile/ProfileSubpageHeader.tsx
··· 1 import React from 'react' 2 import {Pressable, StyleSheet, View} from 'react-native' 3 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 import {msg, Trans} from '@lingui/macro' 5 import {useLingui} from '@lingui/react' ··· 53 const {openLightbox} = useLightboxControls() 54 const pal = usePalette('default') 55 const canGoBack = navigation.canGoBack() 56 57 const onPressBack = React.useCallback(() => { 58 if (navigation.canGoBack()) { ··· 66 setDrawerOpen(true) 67 }, [setDrawerOpen]) 68 69 - const onPressAvi = React.useCallback(() => { 70 - if ( 71 - avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) 72 - ) { 73 openLightbox({ 74 images: [ 75 { 76 - uri: avatar, 77 - thumbUri: avatar, 78 dimensions: { 79 // It's fine if it's actually smaller but we know it's 1:1. 80 height: 1000, ··· 84 }, 85 ], 86 index: 0, 87 - thumbDims: null, 88 }) 89 } 90 - }, [openLightbox, avatar]) 91 92 return ( 93 <CenteredView style={pal.view}> ··· 135 paddingBottom: 6, 136 paddingHorizontal: isMobile ? 12 : 14, 137 }}> 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> 151 <View style={{flex: 1}}> 152 {isLoading ? ( 153 <LoadingPlaceholder
··· 1 import React from 'react' 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' 10 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 11 import {msg, Trans} from '@lingui/macro' 12 import {useLingui} from '@lingui/react' ··· 60 const {openLightbox} = useLightboxControls() 61 const pal = usePalette('default') 62 const canGoBack = navigation.canGoBack() 63 + const aviRef = useAnimatedRef() 64 65 const onPressBack = React.useCallback(() => { 66 if (navigation.canGoBack()) { ··· 74 setDrawerOpen(true) 75 }, [setDrawerOpen]) 76 77 + const _openLightbox = React.useCallback( 78 + (uri: string, thumbRect: MeasuredDimensions | null) => { 79 openLightbox({ 80 images: [ 81 { 82 + uri, 83 + thumbUri: uri, 84 + thumbRect, 85 dimensions: { 86 // It's fine if it's actually smaller but we know it's 1:1. 87 height: 1000, ··· 91 }, 92 ], 93 index: 0, 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 + })() 108 } 109 + }, [_openLightbox, avatar, aviRef]) 110 111 return ( 112 <CenteredView style={pal.view}> ··· 154 paddingBottom: 6, 155 paddingHorizontal: isMobile ? 12 : 14, 156 }}> 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> 172 <View style={{flex: 1}}> 173 {isLoading ? ( 174 <LoadingPlaceholder
+8 -5
src/view/com/util/images/AutoSizedImage.tsx
··· 1 import React from 'react' 2 import {DimensionValue, Pressable, View} from 'react-native' 3 import {Image} from 'expo-image' 4 import {AppBskyEmbedImages} from '@atproto/api' 5 import {msg} from '@lingui/macro' ··· 92 image: AppBskyEmbedImages.ViewImage 93 crop?: 'none' | 'square' | 'constrained' 94 hideBadge?: boolean 95 - onPress?: () => void 96 onLongPress?: () => void 97 onPressIn?: () => void 98 }) { ··· 107 src: image.thumb, 108 knownDimensions: image.aspectRatio ?? null, 109 }) 110 const cropDisabled = crop === 'none' 111 const isCropped = rawIsCropped && !cropDisabled 112 const hasAlt = !!image.alt 113 114 const contents = ( 115 - <> 116 <Image 117 style={[a.w_full, a.h_full]} 118 source={image.thumb} ··· 185 )} 186 </View> 187 ) : null} 188 - </> 189 ) 190 191 if (cropDisabled) { 192 return ( 193 <Pressable 194 - onPress={onPress} 195 onLongPress={onLongPress} 196 onPressIn={onPressIn} 197 // alt here is what screen readers actually use ··· 213 fullBleed={crop === 'square'} 214 aspectRatio={constrained ?? 1}> 215 <Pressable 216 - onPress={onPress} 217 onLongPress={onLongPress} 218 onPressIn={onPressIn} 219 // alt here is what screen readers actually use
··· 1 import React from 'react' 2 import {DimensionValue, Pressable, View} from 'react-native' 3 + import Animated, {AnimatedRef, useAnimatedRef} from 'react-native-reanimated' 4 import {Image} from 'expo-image' 5 import {AppBskyEmbedImages} from '@atproto/api' 6 import {msg} from '@lingui/macro' ··· 93 image: AppBskyEmbedImages.ViewImage 94 crop?: 'none' | 'square' | 'constrained' 95 hideBadge?: boolean 96 + onPress?: (containerRef: AnimatedRef<React.Component<{}, {}, any>>) => void 97 onLongPress?: () => void 98 onPressIn?: () => void 99 }) { ··· 108 src: image.thumb, 109 knownDimensions: image.aspectRatio ?? null, 110 }) 111 + const containerRef = useAnimatedRef() 112 + 113 const cropDisabled = crop === 'none' 114 const isCropped = rawIsCropped && !cropDisabled 115 const hasAlt = !!image.alt 116 117 const contents = ( 118 + <Animated.View ref={containerRef} collapsable={false}> 119 <Image 120 style={[a.w_full, a.h_full]} 121 source={image.thumb} ··· 188 )} 189 </View> 190 ) : null} 191 + </Animated.View> 192 ) 193 194 if (cropDisabled) { 195 return ( 196 <Pressable 197 + onPress={() => onPress?.(containerRef)} 198 onLongPress={onLongPress} 199 onPressIn={onPressIn} 200 // alt here is what screen readers actually use ··· 216 fullBleed={crop === 'square'} 217 aspectRatio={constrained ?? 1}> 218 <Pressable 219 + onPress={() => onPress?.(containerRef)} 220 onLongPress={onLongPress} 221 onPressIn={onPressIn} 222 // alt here is what screen readers actually use
+9 -5
src/view/com/util/images/Gallery.tsx
··· 1 import React from 'react' 2 import {Pressable, StyleProp, View, ViewStyle} from 'react-native' 3 - import Animated, {AnimatedRef, useAnimatedRef} from 'react-native-reanimated' 4 import {Image, ImageStyle} from 'expo-image' 5 import {AppBskyEmbedImages} from '@atproto/api' 6 import {msg} from '@lingui/macro' ··· 19 index: number 20 onPress?: ( 21 index: number, 22 - containerRef: AnimatedRef<React.Component<{}, {}, any>>, 23 ) => void 24 onLongPress?: EventFunction 25 onPressIn?: EventFunction 26 imageStyle?: StyleProp<ImageStyle> 27 viewContext?: PostEmbedViewContext 28 insetBorderStyle?: StyleProp<ViewStyle> 29 } 30 31 export function GalleryItem({ ··· 37 onLongPress, 38 viewContext, 39 insetBorderStyle, 40 }: Props) { 41 const t = useTheme() 42 const {_} = useLingui() ··· 45 const hasAlt = !!image.alt 46 const hideBadges = 47 viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia 48 - const containerRef = useAnimatedRef() 49 return ( 50 - <Animated.View style={a.flex_1} ref={containerRef}> 51 <Pressable 52 - onPress={onPress ? () => onPress(index, containerRef) : undefined} 53 onPressIn={onPressIn ? () => onPressIn(index) : undefined} 54 onLongPress={onLongPress ? () => onLongPress(index) : undefined} 55 style={[
··· 1 import React from 'react' 2 import {Pressable, StyleProp, View, ViewStyle} from 'react-native' 3 + import Animated, {AnimatedRef} from 'react-native-reanimated' 4 import {Image, ImageStyle} from 'expo-image' 5 import {AppBskyEmbedImages} from '@atproto/api' 6 import {msg} from '@lingui/macro' ··· 19 index: number 20 onPress?: ( 21 index: number, 22 + containerRefs: AnimatedRef<React.Component<{}, {}, any>>[], 23 ) => void 24 onLongPress?: EventFunction 25 onPressIn?: EventFunction 26 imageStyle?: StyleProp<ImageStyle> 27 viewContext?: PostEmbedViewContext 28 insetBorderStyle?: StyleProp<ViewStyle> 29 + containerRefs: AnimatedRef<React.Component<{}, {}, any>>[] 30 } 31 32 export function GalleryItem({ ··· 38 onLongPress, 39 viewContext, 40 insetBorderStyle, 41 + containerRefs, 42 }: Props) { 43 const t = useTheme() 44 const {_} = useLingui() ··· 47 const hasAlt = !!image.alt 48 const hideBadges = 49 viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia 50 return ( 51 + <Animated.View 52 + style={a.flex_1} 53 + ref={containerRefs[index]} 54 + collapsable={false}> 55 <Pressable 56 + onPress={onPress ? () => onPress(index, containerRefs) : undefined} 57 onPressIn={onPressIn ? () => onPressIn(index) : undefined} 58 onLongPress={onLongPress ? () => onLongPress(index) : undefined} 59 style={[
+31 -6
src/view/com/util/images/ImageLayoutGrid.tsx
··· 1 import React from 'react' 2 import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' 3 - import {AnimatedRef} from 'react-native-reanimated' 4 import {AppBskyEmbedImages} from '@atproto/api' 5 6 import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' ··· 11 images: AppBskyEmbedImages.ViewImage[] 12 onPress?: ( 13 index: number, 14 - containerRef: AnimatedRef<React.Component<{}, {}, any>>, 15 ) => void 16 onLongPress?: (index: number) => void 17 onPressIn?: (index: number) => void ··· 41 images: AppBskyEmbedImages.ViewImage[] 42 onPress?: ( 43 index: number, 44 - containerRef: AnimatedRef<React.Component<{}, {}, any>>, 45 ) => void 46 onLongPress?: (index: number) => void 47 onPressIn?: (index: number) => void ··· 53 const gap = props.gap 54 const count = props.images.length 55 56 switch (count) { 57 - case 2: 58 return ( 59 <View style={[a.flex_1, a.flex_row, gap]}> 60 <View style={[a.flex_1, {aspectRatio: 1}]}> ··· 62 {...props} 63 index={0} 64 insetBorderStyle={noCorners(['topRight', 'bottomRight'])} 65 /> 66 </View> 67 <View style={[a.flex_1, {aspectRatio: 1}]}> ··· 69 {...props} 70 index={1} 71 insetBorderStyle={noCorners(['topLeft', 'bottomLeft'])} 72 /> 73 </View> 74 </View> 75 ) 76 77 - case 3: 78 return ( 79 <View style={[a.flex_1, a.flex_row, gap]}> 80 <View style={[a.flex_1, {aspectRatio: 1}]}> ··· 82 {...props} 83 index={0} 84 insetBorderStyle={noCorners(['topRight', 'bottomRight'])} 85 /> 86 </View> 87 <View style={[a.flex_1, {aspectRatio: 1}, gap]}> ··· 94 'bottomLeft', 95 'bottomRight', 96 ])} 97 /> 98 </View> 99 <View style={[a.flex_1]}> ··· 105 'bottomLeft', 106 'topRight', 107 ])} 108 /> 109 </View> 110 </View> 111 </View> 112 ) 113 114 - case 4: 115 return ( 116 <> 117 <View style={[a.flex_row, gap]}> ··· 124 'topRight', 125 'bottomRight', 126 ])} 127 /> 128 </View> 129 <View style={[a.flex_1, {aspectRatio: 1.5}]}> ··· 135 'bottomLeft', 136 'bottomRight', 137 ])} 138 /> 139 </View> 140 </View> ··· 148 'topRight', 149 'bottomRight', 150 ])} 151 /> 152 </View> 153 <View style={[a.flex_1, {aspectRatio: 1.5}]}> ··· 159 'bottomLeft', 160 'topRight', 161 ])} 162 /> 163 </View> 164 </View> 165 </> 166 ) 167 168 default: 169 return null
··· 1 import React from 'react' 2 import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' 3 + import {AnimatedRef, useAnimatedRef} from 'react-native-reanimated' 4 import {AppBskyEmbedImages} from '@atproto/api' 5 6 import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' ··· 11 images: AppBskyEmbedImages.ViewImage[] 12 onPress?: ( 13 index: number, 14 + containerRefs: AnimatedRef<React.Component<{}, {}, any>>[], 15 ) => void 16 onLongPress?: (index: number) => void 17 onPressIn?: (index: number) => void ··· 41 images: AppBskyEmbedImages.ViewImage[] 42 onPress?: ( 43 index: number, 44 + containerRefs: AnimatedRef<React.Component<{}, {}, any>>[], 45 ) => void 46 onLongPress?: (index: number) => void 47 onPressIn?: (index: number) => void ··· 53 const gap = props.gap 54 const count = props.images.length 55 56 + const containerRef1 = useAnimatedRef() 57 + const containerRef2 = useAnimatedRef() 58 + const containerRef3 = useAnimatedRef() 59 + const containerRef4 = useAnimatedRef() 60 + 61 switch (count) { 62 + case 2: { 63 + const containerRefs = [containerRef1, containerRef2] 64 return ( 65 <View style={[a.flex_1, a.flex_row, gap]}> 66 <View style={[a.flex_1, {aspectRatio: 1}]}> ··· 68 {...props} 69 index={0} 70 insetBorderStyle={noCorners(['topRight', 'bottomRight'])} 71 + containerRefs={containerRefs} 72 /> 73 </View> 74 <View style={[a.flex_1, {aspectRatio: 1}]}> ··· 76 {...props} 77 index={1} 78 insetBorderStyle={noCorners(['topLeft', 'bottomLeft'])} 79 + containerRefs={containerRefs} 80 /> 81 </View> 82 </View> 83 ) 84 + } 85 86 + case 3: { 87 + const containerRefs = [containerRef1, containerRef2, containerRef3] 88 return ( 89 <View style={[a.flex_1, a.flex_row, gap]}> 90 <View style={[a.flex_1, {aspectRatio: 1}]}> ··· 92 {...props} 93 index={0} 94 insetBorderStyle={noCorners(['topRight', 'bottomRight'])} 95 + containerRefs={containerRefs} 96 /> 97 </View> 98 <View style={[a.flex_1, {aspectRatio: 1}, gap]}> ··· 105 'bottomLeft', 106 'bottomRight', 107 ])} 108 + containerRefs={containerRefs} 109 /> 110 </View> 111 <View style={[a.flex_1]}> ··· 117 'bottomLeft', 118 'topRight', 119 ])} 120 + containerRefs={containerRefs} 121 /> 122 </View> 123 </View> 124 </View> 125 ) 126 + } 127 128 + case 4: { 129 + const containerRefs = [ 130 + containerRef1, 131 + containerRef2, 132 + containerRef3, 133 + containerRef4, 134 + ] 135 return ( 136 <> 137 <View style={[a.flex_row, gap]}> ··· 144 'topRight', 145 'bottomRight', 146 ])} 147 + containerRefs={containerRefs} 148 /> 149 </View> 150 <View style={[a.flex_1, {aspectRatio: 1.5}]}> ··· 156 'bottomLeft', 157 'bottomRight', 158 ])} 159 + containerRefs={containerRefs} 160 /> 161 </View> 162 </View> ··· 170 'topRight', 171 'bottomRight', 172 ])} 173 + containerRefs={containerRefs} 174 /> 175 </View> 176 <View style={[a.flex_1, {aspectRatio: 1.5}]}> ··· 182 'bottomLeft', 183 'topRight', 184 ])} 185 + containerRefs={containerRefs} 186 /> 187 </View> 188 </View> 189 </> 190 ) 191 + } 192 193 default: 194 return null
+10 -12
src/view/com/util/post-embeds/index.tsx
··· 6 View, 7 ViewStyle, 8 } from 'react-native' 9 - import Animated, { 10 AnimatedRef, 11 measure, 12 MeasuredDimensions, 13 runOnJS, 14 runOnUI, 15 - useAnimatedRef, 16 } from 'react-native-reanimated' 17 import {Image} from 'expo-image' 18 import { ··· 69 viewContext?: PostEmbedViewContext 70 }) { 71 const {openLightbox} = useLightboxControls() 72 - const containerRef = useAnimatedRef() 73 74 // quote post with media 75 // = ··· 149 })) 150 const _openLightbox = ( 151 index: number, 152 - thumbDims: MeasuredDimensions | null, 153 ) => { 154 openLightbox({ 155 - images: items.map(item => ({ 156 ...item, 157 type: 'image', 158 })), 159 index, 160 - thumbDims, 161 }) 162 } 163 const onPress = ( 164 index: number, 165 - ref: AnimatedRef<React.Component<{}, {}, any>>, 166 ) => { 167 runOnUI(() => { 168 'worklet' 169 - const dims = measure(ref) 170 - runOnJS(_openLightbox)(index, dims) 171 })() 172 } 173 const onPressIn = (_: number) => { ··· 180 const image = images[0] 181 return ( 182 <ContentHider modui={moderation?.ui('contentMedia')}> 183 - <Animated.View ref={containerRef} style={[a.mt_sm, style]}> 184 <AutoSizedImage 185 crop={ 186 viewContext === PostEmbedViewContext.ThreadHighlighted ··· 191 : 'constrained' 192 } 193 image={image} 194 - onPress={() => onPress(0, containerRef)} 195 onPressIn={() => onPressIn(0)} 196 hideBadge={ 197 viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia 198 } 199 /> 200 - </Animated.View> 201 </ContentHider> 202 ) 203 }
··· 6 View, 7 ViewStyle, 8 } from 'react-native' 9 + import { 10 AnimatedRef, 11 measure, 12 MeasuredDimensions, 13 runOnJS, 14 runOnUI, 15 } from 'react-native-reanimated' 16 import {Image} from 'expo-image' 17 import { ··· 68 viewContext?: PostEmbedViewContext 69 }) { 70 const {openLightbox} = useLightboxControls() 71 72 // quote post with media 73 // = ··· 147 })) 148 const _openLightbox = ( 149 index: number, 150 + thumbRects: (MeasuredDimensions | null)[], 151 ) => { 152 openLightbox({ 153 + images: items.map((item, i) => ({ 154 ...item, 155 + thumbRect: thumbRects[i] ?? null, 156 type: 'image', 157 })), 158 index, 159 }) 160 } 161 const onPress = ( 162 index: number, 163 + refs: AnimatedRef<React.Component<{}, {}, any>>[], 164 ) => { 165 runOnUI(() => { 166 'worklet' 167 + const rects = refs.map(ref => (ref ? measure(ref) : null)) 168 + runOnJS(_openLightbox)(index, rects) 169 })() 170 } 171 const onPressIn = (_: number) => { ··· 178 const image = images[0] 179 return ( 180 <ContentHider modui={moderation?.ui('contentMedia')}> 181 + <View style={[a.mt_sm, style]}> 182 <AutoSizedImage 183 crop={ 184 viewContext === PostEmbedViewContext.ThreadHighlighted ··· 189 : 'constrained' 190 } 191 image={image} 192 + onPress={containerRef => onPress(0, [containerRef])} 193 onPressIn={() => onPressIn(0)} 194 hideBadge={ 195 viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia 196 } 197 /> 198 + </View> 199 </ContentHider> 200 ) 201 }