Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

[Lightbox] New dismiss gesture (#6135)

* Make iOS scrollview bounded to the image

I've had to remove the dismiss handling because the scroll view no longer scrolls at rest.

* Fix double-tap not working right after a vertical swipe

It seems like for some reason the vertical swipe is still being handled by the scroll view, so double tap gets eaten while it's "coming back". But you don't really see it moving. Weird.

* Add an intermediate LightboxImage component

* Hoist useImageDimensions up

* Implement xplat dismiss gesture

This is now shared between platforms, letting us animate the backdrop and add a consistent "fly away" behavior.

* Optimize Android compositing perf

* Fix supertall images

For example, https://bsky.app/profile/schlagteslinks.bsky.social/post/3l7y4l6yur72e

* Fix oopsie

authored by

dan and committed by
GitHub
5d0610d4 6570f56d

+274 -145
+40 -65
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
··· 1 1 import React, {useState} from 'react' 2 - import {ActivityIndicator, StyleSheet, View} from 'react-native' 3 - import {Gesture, GestureDetector} from 'react-native-gesture-handler' 2 + import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native' 3 + import { 4 + Gesture, 5 + GestureDetector, 6 + PanGesture, 7 + } from 'react-native-gesture-handler' 4 8 import Animated, { 5 9 AnimatedRef, 6 10 measure, ··· 9 13 useAnimatedRef, 10 14 useAnimatedStyle, 11 15 useSharedValue, 12 - withDecay, 13 16 withSpring, 14 17 } from 'react-native-reanimated' 15 - import {Image} from 'expo-image' 18 + import {Image, ImageStyle} from 'expo-image' 16 19 17 - import {useImageDimensions} from '#/lib/media/image-sizes' 18 20 import type {Dimensions as ImageDimensions, ImageSource} from '../../@types' 19 21 import { 20 22 applyRounding, ··· 25 27 readTransform, 26 28 TransformMatrix, 27 29 } from '../../transforms' 30 + 31 + const AnimatedImage = Animated.createAnimatedComponent(Image) 28 32 29 33 const MIN_SCREEN_ZOOM = 2 30 34 const MAX_ORIGINAL_IMAGE_ZOOM = 2 ··· 39 43 isScrollViewBeingDragged: boolean 40 44 showControls: boolean 41 45 safeAreaRef: AnimatedRef<View> 46 + imageAspect: number | undefined 47 + imageDimensions: ImageDimensions | undefined 48 + imageStyle: StyleProp<ImageStyle> 49 + dismissSwipePan: PanGesture 42 50 } 43 51 const ImageItem = ({ 44 52 imageSrc, 45 53 onTap, 46 54 onZoom, 47 - onRequestClose, 48 55 isScrollViewBeingDragged, 49 56 safeAreaRef, 57 + imageAspect, 58 + imageDimensions, 59 + imageStyle, 60 + dismissSwipePan, 50 61 }: Props) => { 51 62 const [isScaled, setIsScaled] = useState(false) 52 - const [imageAspect, imageDimensions] = useImageDimensions({ 53 - src: imageSrc.uri, 54 - knownDimensions: imageSrc.dimensions, 55 - }) 56 63 const committedTransform = useSharedValue(initialTransform) 57 64 const panTranslation = useSharedValue({x: 0, y: 0}) 58 65 const pinchOrigin = useSharedValue({x: 0, y: 0}) 59 66 const pinchScale = useSharedValue(1) 60 67 const pinchTranslation = useSharedValue({x: 0, y: 0}) 61 - const dismissSwipeTranslateY = useSharedValue(0) 62 68 const containerRef = useAnimatedRef() 63 69 64 70 // Keep track of when we're entering or leaving scaled rendering. ··· 97 103 prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value) 98 104 prependTransform(t, committedTransform.value) 99 105 const [translateX, translateY, scale] = readTransform(t) 100 - 101 - const dismissDistance = dismissSwipeTranslateY.value 102 - const screenSize = measure(safeAreaRef) 103 - const dismissProgress = screenSize 104 - ? Math.min(Math.abs(dismissDistance) / (screenSize.height / 2), 1) 105 - : 0 106 106 return { 107 - opacity: 1 - dismissProgress, 108 - transform: [ 109 - {translateX}, 110 - {translateY: translateY + dismissDistance}, 111 - {scale}, 112 - ], 107 + transform: [{translateX}, {translateY: translateY}, {scale}], 113 108 } 114 109 }) 115 110 ··· 307 302 committedTransform.value = withClampedSpring(finalTransform) 308 303 }) 309 304 310 - const dismissSwipePan = Gesture.Pan() 311 - .enabled(!isScaled) 312 - .activeOffsetY([-10, 10]) 313 - .failOffsetX([-10, 10]) 314 - .maxPointers(1) 315 - .onUpdate(e => { 316 - 'worklet' 317 - dismissSwipeTranslateY.value = e.translationY 318 - }) 319 - .onEnd(e => { 320 - 'worklet' 321 - if (Math.abs(e.velocityY) > 1000) { 322 - dismissSwipeTranslateY.value = withDecay({velocity: e.velocityY}) 323 - runOnJS(onRequestClose)() 324 - } else { 325 - dismissSwipeTranslateY.value = withSpring(0, { 326 - stiffness: 700, 327 - damping: 50, 328 - }) 329 - } 330 - }) 331 - 332 305 const composedGesture = isScrollViewBeingDragged 333 306 ? // If the parent is not at rest, provide a no-op gesture. 334 307 Gesture.Manual() ··· 340 313 ) 341 314 342 315 return ( 343 - <Animated.View 344 - ref={containerRef} 345 - // Necessary to make opacity work for both children together. 346 - renderToHardwareTextureAndroid 347 - style={[styles.container, animatedStyle]}> 348 - <ActivityIndicator size="small" color="#FFF" style={styles.loading} /> 349 - <GestureDetector gesture={composedGesture}> 350 - <Image 351 - contentFit="contain" 352 - source={{uri: imageSrc.uri}} 353 - placeholderContentFit="contain" 354 - placeholder={{uri: imageSrc.thumbUri}} 355 - style={styles.image} 356 - accessibilityLabel={imageSrc.alt} 357 - accessibilityHint="" 358 - accessibilityIgnoresInvertColors 359 - cachePolicy="memory" 360 - /> 361 - </GestureDetector> 362 - </Animated.View> 316 + <GestureDetector gesture={composedGesture}> 317 + <Animated.View style={imageStyle} renderToHardwareTextureAndroid> 318 + <Animated.View 319 + ref={containerRef} 320 + // Necessary to make opacity work for both children together. 321 + renderToHardwareTextureAndroid 322 + style={[styles.container, animatedStyle]}> 323 + <ActivityIndicator size="small" color="#FFF" style={styles.loading} /> 324 + <AnimatedImage 325 + contentFit="contain" 326 + source={{uri: imageSrc.uri}} 327 + placeholderContentFit="contain" 328 + placeholder={{uri: imageSrc.thumbUri}} 329 + style={[styles.image]} 330 + accessibilityLabel={imageSrc.alt} 331 + accessibilityHint="" 332 + accessibilityIgnoresInvertColors 333 + cachePolicy="memory" 334 + /> 335 + </Animated.View> 336 + </Animated.View> 337 + </GestureDetector> 363 338 ) 364 339 } 365 340
+44 -51
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
··· 7 7 */ 8 8 9 9 import React, {useState} from 'react' 10 - import {ActivityIndicator, StyleSheet, View} from 'react-native' 11 - import {Gesture, GestureDetector} from 'react-native-gesture-handler' 10 + import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native' 11 + import { 12 + Gesture, 13 + GestureDetector, 14 + PanGesture, 15 + } from 'react-native-gesture-handler' 12 16 import Animated, { 13 17 AnimatedRef, 14 - interpolate, 15 18 measure, 16 19 runOnJS, 17 20 useAnimatedRef, 18 21 useAnimatedStyle, 19 - useSharedValue, 20 22 } from 'react-native-reanimated' 21 23 import {useSafeAreaFrame} from 'react-native-safe-area-context' 22 - import {Image} from 'expo-image' 24 + import {Image, ImageStyle} from 'expo-image' 23 25 24 26 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' 25 - import {useImageDimensions} from '#/lib/media/image-sizes' 26 - import {ImageSource} from '../../@types' 27 + import {Dimensions as ImageDimensions, ImageSource} from '../../@types' 27 28 28 - const SWIPE_CLOSE_OFFSET = 75 29 - const SWIPE_CLOSE_VELOCITY = 1 29 + const AnimatedImage = Animated.createAnimatedComponent(Image) 30 + 30 31 const MAX_ORIGINAL_IMAGE_ZOOM = 2 31 32 const MIN_SCREEN_ZOOM = 2 32 33 ··· 38 39 isScrollViewBeingDragged: boolean 39 40 showControls: boolean 40 41 safeAreaRef: AnimatedRef<View> 42 + imageAspect: number | undefined 43 + imageDimensions: ImageDimensions | undefined 44 + imageStyle: StyleProp<ImageStyle> 45 + dismissSwipePan: PanGesture 41 46 } 42 47 43 48 const ImageItem = ({ 44 49 imageSrc, 45 50 onTap, 46 51 onZoom, 47 - onRequestClose, 48 52 showControls, 49 53 safeAreaRef, 54 + imageAspect, 55 + imageDimensions, 56 + imageStyle, 57 + dismissSwipePan, 50 58 }: Props) => { 51 59 const scrollViewRef = useAnimatedRef<Animated.ScrollView>() 52 - const translationY = useSharedValue(0) 53 60 const [scaled, setScaled] = useState(false) 54 61 const screenSizeDelayedForJSThreadOnly = useSafeAreaFrame() 55 - const [imageAspect, imageDimensions] = useImageDimensions({ 56 - src: imageSrc.uri, 57 - knownDimensions: imageSrc.dimensions, 58 - }) 59 62 const maxZoomScale = Math.max( 60 63 MIN_SCREEN_ZOOM, 61 64 imageDimensions ··· 65 68 ) 66 69 67 70 const animatedStyle = useAnimatedStyle(() => { 71 + const screenSize = measure(safeAreaRef) ?? screenSizeDelayedForJSThreadOnly 68 72 return { 69 - flex: 1, 70 - opacity: interpolate( 71 - translationY.value, 72 - [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], 73 - [0.5, 1, 0.5], 74 - ), 73 + width: screenSize.width, 74 + maxHeight: screenSize.height, 75 + alignSelf: 'center', 76 + aspectRatio: imageAspect, 75 77 } 76 78 }) 77 79 78 80 const scrollHandler = useAnimatedScrollHandler({ 79 81 onScroll(e) { 80 82 const nextIsScaled = e.zoomScale > 1 81 - translationY.value = nextIsScaled ? 0 : e.contentOffset.y 82 83 if (scaled !== nextIsScaled) { 83 84 runOnJS(handleZoom)(nextIsScaled) 84 85 } 85 86 }, 86 - onEndDrag(e) { 87 - const velocityY = e.velocity?.y ?? 0 88 - const nextIsScaled = e.zoomScale > 1 89 - if (scaled !== nextIsScaled) { 90 - runOnJS(handleZoom)(nextIsScaled) 91 - } 92 - if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) { 93 - runOnJS(onRequestClose)() 94 - } 95 - }, 96 87 }) 97 88 98 89 function handleZoom(nextIsScaled: boolean) { ··· 146 137 runOnJS(zoomTo)(nextZoomRect) 147 138 }) 148 139 149 - const composedGesture = Gesture.Exclusive(doubleTap, singleTap) 140 + const composedGesture = Gesture.Exclusive( 141 + dismissSwipePan, 142 + doubleTap, 143 + singleTap, 144 + ) 150 145 151 146 return ( 152 147 <GestureDetector gesture={composedGesture}> ··· 158 153 showsVerticalScrollIndicator={false} 159 154 maximumZoomScale={maxZoomScale} 160 155 onScroll={scrollHandler} 161 - contentContainerStyle={styles.scrollContainer}> 162 - <Animated.View style={animatedStyle}> 163 - <ActivityIndicator size="small" color="#FFF" style={styles.loading} /> 164 - <Image 165 - contentFit="contain" 166 - source={{uri: imageSrc.uri}} 167 - placeholderContentFit="contain" 168 - placeholder={{uri: imageSrc.thumbUri}} 169 - style={styles.image} 170 - accessibilityLabel={imageSrc.alt} 171 - accessibilityHint="" 172 - enableLiveTextInteraction={showControls && !scaled} 173 - accessibilityIgnoresInvertColors 174 - /> 175 - </Animated.View> 156 + bounces={scaled} 157 + bouncesZoom={true} 158 + style={imageStyle} 159 + centerContent> 160 + <ActivityIndicator size="small" color="#FFF" style={styles.loading} /> 161 + <AnimatedImage 162 + contentFit="contain" 163 + source={{uri: imageSrc.uri}} 164 + placeholderContentFit="contain" 165 + placeholder={{uri: imageSrc.thumbUri}} 166 + style={animatedStyle} 167 + accessibilityLabel={imageSrc.alt} 168 + accessibilityHint="" 169 + enableLiveTextInteraction={showControls && !scaled} 170 + accessibilityIgnoresInvertColors 171 + /> 176 172 </Animated.ScrollView> 177 173 </GestureDetector> 178 174 ) ··· 185 181 left: 0, 186 182 right: 0, 187 183 bottom: 0, 188 - }, 189 - scrollContainer: { 190 - flex: 1, 191 184 }, 192 185 image: { 193 186 flex: 1,
+7 -2
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 {View} from 'react-native' 4 + import {ImageStyle, StyleProp, View} from 'react-native' 5 + import {PanGesture} from 'react-native-gesture-handler' 5 6 import {AnimatedRef} from 'react-native-reanimated' 6 7 7 - import {ImageSource} from '../../@types' 8 + import {Dimensions as ImageDimensions, ImageSource} from '../../@types' 8 9 9 10 type Props = { 10 11 imageSrc: ImageSource ··· 14 15 isScrollViewBeingDragged: boolean 15 16 showControls: boolean 16 17 safeAreaRef: AnimatedRef<View> 18 + imageAspect: number | undefined 19 + imageDimensions: ImageDimensions | undefined 20 + imageStyle: StyleProp<ImageStyle> 21 + dismissSwipePan: PanGesture 17 22 } 18 23 19 24 const ImageItem = (_props: Props) => {
+183 -27
src/view/com/lightbox/ImageViewing/index.tsx
··· 10 10 11 11 import React, {useCallback, useState} from 'react' 12 12 import {LayoutAnimation, Platform, StyleSheet, View} from 'react-native' 13 + import {Gesture} from 'react-native-gesture-handler' 13 14 import PagerView from 'react-native-pager-view' 14 15 import Animated, { 15 16 AnimatedRef, 17 + cancelAnimation, 18 + measure, 19 + runOnJS, 20 + SharedValue, 21 + useAnimatedReaction, 16 22 useAnimatedRef, 17 23 useAnimatedStyle, 24 + useSharedValue, 25 + withDecay, 18 26 withSpring, 19 27 } from 'react-native-reanimated' 20 28 import {Edge, SafeAreaView} from 'react-native-safe-area-context' 21 29 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 22 30 import {Trans} from '@lingui/macro' 23 31 32 + import {useImageDimensions} from '#/lib/media/image-sizes' 24 33 import {colors, s} from '#/lib/styles' 25 34 import {isIOS} from '#/platform/detection' 26 35 import {Lightbox} from '#/state/lightbox' ··· 90 99 const [isDragging, setIsDragging] = useState(false) 91 100 const [imageIndex, setImageIndex] = useState(initialImageIndex) 92 101 const [showControls, setShowControls] = useState(true) 102 + const [isAltExpanded, setAltExpanded] = React.useState(false) 103 + const dismissSwipeTranslateY = useSharedValue(0) 104 + const isFlyingAway = useSharedValue(false) 93 105 94 - const animatedHeaderStyle = useAnimatedStyle(() => ({ 95 - pointerEvents: showControls ? 'box-none' : 'none', 96 - opacity: withClampedSpring(showControls ? 1 : 0), 97 - transform: [ 98 - { 99 - translateY: withClampedSpring(showControls ? 0 : -30), 100 - }, 101 - ], 102 - })) 103 - const animatedFooterStyle = useAnimatedStyle(() => ({ 104 - flexGrow: 1, 105 - pointerEvents: showControls ? 'box-none' : 'none', 106 - opacity: withClampedSpring(showControls ? 1 : 0), 107 - transform: [ 108 - { 109 - translateY: withClampedSpring(showControls ? 0 : 30), 110 - }, 111 - ], 112 - })) 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 + } 137 + }) 138 + const animatedFooterStyle = useAnimatedStyle(() => { 139 + const show = showControls && dismissSwipeTranslateY.value === 0 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 + } 150 + }) 113 151 114 152 const onTap = useCallback(() => { 115 153 setShowControls(show => !show) ··· 123 161 }, []) 124 162 125 163 return ( 126 - <View style={[styles.container]}> 164 + <Animated.View style={[styles.container, containerStyle]}> 165 + <Animated.View 166 + style={[styles.backdrop, backdropStyle]} 167 + renderToHardwareTextureAndroid 168 + /> 127 169 <PagerView 128 170 scrollEnabled={!isScaled} 129 171 initialPage={initialImageIndex} ··· 136 178 }} 137 179 overdrag={true} 138 180 style={styles.pager}> 139 - {images.map(imageSrc => ( 181 + {images.map((imageSrc, i) => ( 140 182 <View key={imageSrc.uri}> 141 - <ImageItem 183 + <LightboxImage 142 184 onTap={onTap} 143 185 onZoom={onZoom} 144 186 imageSrc={imageSrc} ··· 146 188 isScrollViewBeingDragged={isDragging} 147 189 showControls={showControls} 148 190 safeAreaRef={safeAreaRef} 191 + isScaled={isScaled} 192 + isFlyingAway={isFlyingAway} 193 + isActive={i === imageIndex} 194 + dismissSwipeTranslateY={dismissSwipeTranslateY} 149 195 /> 150 196 </View> 151 197 ))} 152 198 </PagerView> 153 199 <View style={styles.controls}> 154 - <Animated.View style={animatedHeaderStyle}> 200 + <Animated.View 201 + style={animatedHeaderStyle} 202 + renderToHardwareTextureAndroid> 155 203 <ImageDefaultHeader onRequestClose={onRequestClose} /> 156 204 </Animated.View> 157 - <Animated.View style={animatedFooterStyle}> 205 + <Animated.View 206 + style={animatedFooterStyle} 207 + renderToHardwareTextureAndroid={!isAltExpanded}> 158 208 <LightboxFooter 159 209 images={images} 160 210 index={imageIndex} 211 + isAltExpanded={isAltExpanded} 212 + toggleAltExpanded={() => setAltExpanded(e => !e)} 161 213 onPressSave={onPressSave} 162 214 onPressShare={onPressShare} 163 215 /> 164 216 </Animated.View> 165 217 </View> 166 - </View> 218 + </Animated.View> 219 + ) 220 + } 221 + 222 + function LightboxImage({ 223 + imageSrc, 224 + onTap, 225 + onZoom, 226 + onRequestClose, 227 + isScrollViewBeingDragged, 228 + isScaled, 229 + isFlyingAway, 230 + isActive, 231 + showControls, 232 + safeAreaRef, 233 + dismissSwipeTranslateY, 234 + }: { 235 + imageSrc: ImageSource 236 + onRequestClose: () => void 237 + onTap: () => void 238 + onZoom: (scaled: boolean) => void 239 + isScrollViewBeingDragged: boolean 240 + isScaled: boolean 241 + isActive: boolean 242 + isFlyingAway: SharedValue<boolean> 243 + showControls: boolean 244 + safeAreaRef: AnimatedRef<View> 245 + dismissSwipeTranslateY: SharedValue<number> 246 + }) { 247 + const [imageAspect, imageDimensions] = useImageDimensions({ 248 + src: imageSrc.uri, 249 + knownDimensions: imageSrc.dimensions, 250 + }) 251 + 252 + const dismissSwipePan = Gesture.Pan() 253 + .enabled(isActive && !isScaled) 254 + .activeOffsetY([-10, 10]) 255 + .failOffsetX([-10, 10]) 256 + .maxPointers(1) 257 + .onUpdate(e => { 258 + 'worklet' 259 + dismissSwipeTranslateY.value = e.translationY 260 + }) 261 + .onEnd(e => { 262 + 'worklet' 263 + if (Math.abs(e.velocityY) > 1000) { 264 + isFlyingAway.value = true 265 + dismissSwipeTranslateY.value = withDecay({ 266 + velocity: e.velocityY, 267 + velocityFactor: Math.max(3000 / Math.abs(e.velocityY), 1), // Speed up if it's too slow. 268 + deceleration: 1, // Danger! This relies on the reaction below stopping it. 269 + }) 270 + } else { 271 + dismissSwipeTranslateY.value = withSpring(0, { 272 + stiffness: 700, 273 + damping: 50, 274 + }) 275 + } 276 + }) 277 + useAnimatedReaction( 278 + () => { 279 + const screenSize = measure(safeAreaRef) 280 + return ( 281 + !screenSize || 282 + Math.abs(dismissSwipeTranslateY.value) > screenSize.height 283 + ) 284 + }, 285 + (isOut, wasOut) => { 286 + if (isOut && !wasOut) { 287 + // Stop the animation from blocking the screen forever. 288 + cancelAnimation(dismissSwipeTranslateY) 289 + runOnJS(onRequestClose)() 290 + } 291 + }, 292 + ) 293 + 294 + const imageStyle = useAnimatedStyle(() => { 295 + return { 296 + transform: [{translateY: dismissSwipeTranslateY.value}], 297 + } 298 + }) 299 + return ( 300 + <ImageItem 301 + imageSrc={imageSrc} 302 + onTap={onTap} 303 + onZoom={onZoom} 304 + onRequestClose={onRequestClose} 305 + isScrollViewBeingDragged={isScrollViewBeingDragged} 306 + showControls={showControls} 307 + safeAreaRef={safeAreaRef} 308 + imageAspect={imageAspect} 309 + imageDimensions={imageDimensions} 310 + imageStyle={imageStyle} 311 + dismissSwipePan={dismissSwipePan} 312 + /> 167 313 ) 168 314 } 169 315 170 316 function LightboxFooter({ 171 317 images, 172 318 index, 319 + isAltExpanded, 320 + toggleAltExpanded, 173 321 onPressSave, 174 322 onPressShare, 175 323 }: { 176 324 images: ImageSource[] 177 325 index: number 326 + isAltExpanded: boolean 327 + toggleAltExpanded: () => void 178 328 onPressSave: (uri: string) => void 179 329 onPressShare: (uri: string) => void 180 330 }) { 181 331 const {alt: altText, uri} = images[index] 182 - const [isAltExpanded, setAltExpanded] = React.useState(false) 183 332 const isMomentumScrolling = React.useRef(false) 184 333 return ( 185 334 <ScrollView ··· 210 359 duration: 450, 211 360 update: {type: 'spring', springDamping: 1}, 212 361 }) 213 - setAltExpanded(prev => !prev) 362 + toggleAltExpanded() 214 363 }} 215 364 onLongPress={() => {}}> 216 365 {altText} ··· 256 405 }, 257 406 container: { 258 407 flex: 1, 408 + }, 409 + backdrop: { 259 410 backgroundColor: '#000', 411 + position: 'absolute', 412 + top: 0, 413 + bottom: 0, 414 + left: 0, 415 + right: 0, 260 416 }, 261 417 controls: { 262 418 position: 'absolute',