Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

Remove SCREEN from lightbox layout (#6124)

* Assign an ID to lightbox and use it as a key

* Consolidate lightbox props into an object

* Remove unused prop

* Move SafeAreaView declaration

* Keep SafeAreaView always mounted

When exploring Android animation, I noticed its content jumps on the first frame. I think this should help prevent that.

* Pass safe area down for measurement

* Remove dependency on SCREEN in Android event handlers

* Remove dependency on SCREEN in iOS event handlers

* Remove dependency on SCREEN on iOS

* Remove dependency on SCREEN on Android

* Remove dependency on JS calc in controls

* Use flex for iOS layout

authored by

dan and committed by
GitHub
206df2ab 6b826fb8

+277 -228
+9 -5
src/state/lightbox.tsx
··· 1 1 import React from 'react' 2 2 import type {MeasuredDimensions} from 'react-native-reanimated' 3 + import {nanoid} from 'nanoid/non-secure' 3 4 4 5 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 5 6 import {ImageSource} from '#/view/com/lightbox/ImageViewing/@types' 6 7 7 - type Lightbox = { 8 + export type Lightbox = { 9 + id: string 8 10 images: ImageSource[] 9 11 thumbDims: MeasuredDimensions | null 10 12 index: number ··· 17 19 }) 18 20 19 21 const LightboxControlContext = React.createContext<{ 20 - openLightbox: (lightbox: Lightbox) => void 22 + openLightbox: (lightbox: Omit<Lightbox, 'id'>) => void 21 23 closeLightbox: () => boolean 22 24 }>({ 23 25 openLightbox: () => {}, ··· 29 31 null, 30 32 ) 31 33 32 - const openLightbox = useNonReactiveCallback((lightbox: Lightbox) => { 33 - setActiveLightbox(lightbox) 34 - }) 34 + const openLightbox = useNonReactiveCallback( 35 + (lightbox: Omit<Lightbox, 'id'>) => { 36 + setActiveLightbox({...lightbox, id: nanoid()}) 37 + }, 38 + ) 35 39 36 40 const closeLightbox = useNonReactiveCallback(() => { 37 41 let wasActive = !!activeLightbox
+48 -35
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
··· 1 1 import React, {useState} from 'react' 2 - import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native' 2 + import {ActivityIndicator, StyleSheet, View} from 'react-native' 3 3 import {Gesture, GestureDetector} from 'react-native-gesture-handler' 4 4 import Animated, { 5 + AnimatedRef, 6 + measure, 5 7 runOnJS, 6 8 useAnimatedReaction, 7 9 useAnimatedRef, ··· 24 26 TransformMatrix, 25 27 } from '../../transforms' 26 28 27 - const windowDim = Dimensions.get('window') 28 - const screenDim = Dimensions.get('screen') 29 - const statusBarHeight = windowDim.height - screenDim.height 30 - const SCREEN = { 31 - width: windowDim.width, 32 - height: windowDim.height + statusBarHeight, 33 - } 34 29 const MIN_DOUBLE_TAP_SCALE = 2 35 30 const MAX_ORIGINAL_IMAGE_ZOOM = 2 36 31 ··· 43 38 onZoom: (isZoomed: boolean) => void 44 39 isScrollViewBeingDragged: boolean 45 40 showControls: boolean 41 + safeAreaRef: AnimatedRef<View> 46 42 } 47 43 const ImageItem = ({ 48 44 imageSrc, ··· 50 46 onZoom, 51 47 onRequestClose, 52 48 isScrollViewBeingDragged, 49 + safeAreaRef, 53 50 }: Props) => { 54 51 const [isScaled, setIsScaled] = useState(false) 55 52 const [imageAspect, imageDimensions] = useImageDimensions({ ··· 102 99 const [translateX, translateY, scale] = readTransform(t) 103 100 104 101 const dismissDistance = dismissSwipeTranslateY.value 105 - const dismissProgress = Math.min( 106 - Math.abs(dismissDistance) / (SCREEN.height / 2), 107 - 1, 108 - ) 102 + const screenSize = measure(safeAreaRef) 103 + const dismissProgress = screenSize 104 + ? Math.min(Math.abs(dismissDistance) / (screenSize.height / 2), 1) 105 + : 0 109 106 return { 110 107 opacity: 1 - dismissProgress, 111 108 transform: [ ··· 120 117 // If the user tried to pan too hard, this function will provide the negative panning to stay in bounds. 121 118 function getExtraTranslationToStayInBounds( 122 119 candidateTransform: TransformMatrix, 120 + screenSize: {width: number; height: number}, 123 121 ) { 124 122 'worklet' 125 123 if (!imageAspect) { ··· 127 125 } 128 126 const [nextTranslateX, nextTranslateY, nextScale] = 129 127 readTransform(candidateTransform) 130 - const scaledDimensions = getScaledDimensions(imageAspect, nextScale) 128 + const scaledDimensions = getScaledDimensions( 129 + imageAspect, 130 + nextScale, 131 + screenSize, 132 + ) 131 133 const clampedTranslateX = clampTranslation( 132 134 nextTranslateX, 133 135 scaledDimensions.width, 134 - SCREEN.width, 136 + screenSize.width, 135 137 ) 136 138 const clampedTranslateY = clampTranslation( 137 139 nextTranslateY, 138 140 scaledDimensions.height, 139 - SCREEN.height, 141 + screenSize.height, 140 142 ) 141 143 const dx = clampedTranslateX - nextTranslateX 142 144 const dy = clampedTranslateY - nextTranslateY ··· 146 148 const pinch = Gesture.Pinch() 147 149 .onStart(e => { 148 150 'worklet' 151 + const screenSize = measure(safeAreaRef) 152 + if (!screenSize) { 153 + return 154 + } 149 155 pinchOrigin.value = { 150 - x: e.focalX - SCREEN.width / 2, 151 - y: e.focalY - SCREEN.height / 2, 156 + x: e.focalX - screenSize.width / 2, 157 + y: e.focalY - screenSize.height / 2, 152 158 } 153 159 }) 154 160 .onChange(e => { 155 161 'worklet' 156 - if (!imageDimensions) { 162 + const screenSize = measure(safeAreaRef) 163 + if (!imageDimensions || !screenSize) { 157 164 return 158 165 } 159 166 // Don't let the picture zoom in so close that it gets blurry. 160 167 // Also, like in stock Android apps, don't let the user zoom out further than 1:1. 161 168 const [, , committedScale] = readTransform(committedTransform.value) 162 169 const maxCommittedScale = 163 - (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM 170 + (imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM 164 171 const minPinchScale = 1 / committedScale 165 172 const maxPinchScale = maxCommittedScale / committedScale 166 173 const nextPinchScale = Math.min( ··· 175 182 prependPan(t, panTranslation.value) 176 183 prependPinch(t, nextPinchScale, pinchOrigin.value, pinchTranslation.value) 177 184 prependTransform(t, committedTransform.value) 178 - const [dx, dy] = getExtraTranslationToStayInBounds(t) 185 + const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize) 179 186 if (dx !== 0 || dy !== 0) { 180 187 pinchTranslation.value = { 181 188 x: pinchTranslation.value.x + dx, ··· 209 216 .minPointers(isScaled ? 1 : 2) 210 217 .onChange(e => { 211 218 'worklet' 212 - if (!imageDimensions) { 219 + const screenSize = measure(safeAreaRef) 220 + if (!imageDimensions || !screenSize) { 213 221 return 214 222 } 223 + 215 224 const nextPanTranslation = {x: e.translationX, y: e.translationY} 216 225 let t = createTransform() 217 226 prependPan(t, nextPanTranslation) ··· 224 233 prependTransform(t, committedTransform.value) 225 234 226 235 // Prevent panning from going out of bounds. 227 - const [dx, dy] = getExtraTranslationToStayInBounds(t) 236 + const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize) 228 237 nextPanTranslation.x += dx 229 238 nextPanTranslation.y += dy 230 239 panTranslation.value = nextPanTranslation ··· 251 260 .numberOfTaps(2) 252 261 .onEnd(e => { 253 262 'worklet' 254 - if (!imageDimensions || !imageAspect) { 263 + const screenSize = measure(safeAreaRef) 264 + if (!imageDimensions || !imageAspect || !screenSize) { 255 265 return 256 266 } 257 267 const [, , committedScale] = readTransform(committedTransform.value) ··· 263 273 } 264 274 265 275 // Try to zoom in so that we get rid of the black bars (whatever the orientation was). 266 - const screenAspect = SCREEN.width / SCREEN.height 276 + const screenAspect = screenSize.width / screenSize.height 267 277 const candidateScale = Math.max( 268 278 imageAspect / screenAspect, 269 279 screenAspect / imageAspect, ··· 271 281 ) 272 282 // But don't zoom in so close that the picture gets blurry. 273 283 const maxScale = 274 - (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM 284 + (imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM 275 285 const scale = Math.min(candidateScale, maxScale) 276 286 277 287 // Calculate where we would be if the user pinched into the double tapped point. 278 288 // We won't use this transform directly because it may go out of bounds. 279 289 const candidateTransform = createTransform() 280 290 const origin = { 281 - x: e.absoluteX - SCREEN.width / 2, 282 - y: e.absoluteY - SCREEN.height / 2, 291 + x: e.absoluteX - screenSize.width / 2, 292 + y: e.absoluteY - screenSize.height / 2, 283 293 } 284 294 prependPinch(candidateTransform, scale, origin, {x: 0, y: 0}) 285 295 286 296 // Now we know how much we went out of bounds, so we can shoot correctly. 287 - const [dx, dy] = getExtraTranslationToStayInBounds(candidateTransform) 297 + const [dx, dy] = getExtraTranslationToStayInBounds( 298 + candidateTransform, 299 + screenSize, 300 + ) 288 301 const finalTransform = createTransform() 289 302 prependPinch(finalTransform, scale, origin, {x: dx, y: dy}) 290 303 committedTransform.value = withClampedSpring(finalTransform) ··· 348 361 349 362 const styles = StyleSheet.create({ 350 363 container: { 351 - width: SCREEN.width, 352 - height: SCREEN.height, 364 + height: '100%', 353 365 overflow: 'hidden', 354 366 }, 355 367 image: { ··· 367 379 function getScaledDimensions( 368 380 imageAspect: number, 369 381 scale: number, 382 + screenSize: {width: number; height: number}, 370 383 ): ImageDimensions { 371 384 'worklet' 372 - const screenAspect = SCREEN.width / SCREEN.height 385 + const screenAspect = screenSize.width / screenSize.height 373 386 const isLandscape = imageAspect > screenAspect 374 387 if (isLandscape) { 375 388 return { 376 - width: scale * SCREEN.width, 377 - height: (scale * SCREEN.width) / imageAspect, 389 + width: scale * screenSize.width, 390 + height: (scale * screenSize.width) / imageAspect, 378 391 } 379 392 } else { 380 393 return { 381 - width: scale * SCREEN.height * imageAspect, 382 - height: scale * SCREEN.height, 394 + width: scale * screenSize.height * imageAspect, 395 + height: scale * screenSize.height, 383 396 } 384 397 } 385 398 }
+60 -48
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
··· 7 7 */ 8 8 9 9 import React, {useState} from 'react' 10 - import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native' 10 + import {ActivityIndicator, StyleSheet, View} from 'react-native' 11 11 import {Gesture, GestureDetector} from 'react-native-gesture-handler' 12 12 import Animated, { 13 + AnimatedRef, 13 14 interpolate, 15 + measure, 14 16 runOnJS, 15 17 useAnimatedRef, 16 18 useAnimatedStyle, 17 19 useSharedValue, 18 20 } from 'react-native-reanimated' 21 + import {useSafeAreaFrame} from 'react-native-safe-area-context' 19 22 import {Image} from 'expo-image' 20 23 21 24 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' ··· 24 27 25 28 const SWIPE_CLOSE_OFFSET = 75 26 29 const SWIPE_CLOSE_VELOCITY = 1 27 - const SCREEN = Dimensions.get('screen') 28 30 const MAX_ORIGINAL_IMAGE_ZOOM = 2 29 31 const MIN_DOUBLE_TAP_SCALE = 2 30 32 ··· 35 37 onZoom: (scaled: boolean) => void 36 38 isScrollViewBeingDragged: boolean 37 39 showControls: boolean 40 + safeAreaRef: AnimatedRef<View> 38 41 } 39 42 40 43 const ImageItem = ({ ··· 43 46 onZoom, 44 47 onRequestClose, 45 48 showControls, 49 + safeAreaRef, 46 50 }: Props) => { 47 51 const scrollViewRef = useAnimatedRef<Animated.ScrollView>() 48 52 const translationY = useSharedValue(0) 49 53 const [scaled, setScaled] = useState(false) 54 + const screenSizeDelayedForJSThreadOnly = useSafeAreaFrame() 50 55 const [imageAspect, imageDimensions] = useImageDimensions({ 51 56 src: imageSrc.uri, 52 57 knownDimensions: imageSrc.dimensions, 53 58 }) 54 59 const maxZoomScale = imageDimensions 55 - ? (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM 60 + ? (imageDimensions.width / screenSizeDelayedForJSThreadOnly.width) * 61 + MAX_ORIGINAL_IMAGE_ZOOM 56 62 : 1 57 63 58 64 const animatedStyle = useAnimatedStyle(() => { 59 65 return { 66 + flex: 1, 60 67 opacity: interpolate( 61 68 translationY.value, 62 69 [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], ··· 90 97 setScaled(nextIsScaled) 91 98 } 92 99 93 - function handleDoubleTap(absoluteX: number, absoluteY: number) { 100 + function zoomTo(nextZoomRect: { 101 + x: number 102 + y: number 103 + width: number 104 + height: number 105 + }) { 94 106 const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() 95 - let nextZoomRect = { 96 - x: 0, 97 - y: 0, 98 - width: SCREEN.width, 99 - height: SCREEN.height, 100 - } 101 - 102 - const willZoom = !scaled 103 - if (willZoom) { 104 - nextZoomRect = getZoomRectAfterDoubleTap( 105 - imageAspect, 106 - absoluteX, 107 - absoluteY, 108 - ) 109 - } 110 - 111 107 // @ts-ignore 112 108 scrollResponderRef?.scrollResponderZoomTo({ 113 109 ...nextZoomRect, // This rect is in screen coordinates ··· 124 120 .numberOfTaps(2) 125 121 .onEnd(e => { 126 122 'worklet' 123 + const screenSize = measure(safeAreaRef) 124 + if (!screenSize) { 125 + return 126 + } 127 127 const {absoluteX, absoluteY} = e 128 - runOnJS(handleDoubleTap)(absoluteX, absoluteY) 128 + let nextZoomRect = { 129 + x: 0, 130 + y: 0, 131 + width: screenSize.width, 132 + height: screenSize.height, 133 + } 134 + const willZoom = !scaled 135 + if (willZoom) { 136 + nextZoomRect = getZoomRectAfterDoubleTap( 137 + imageAspect, 138 + absoluteX, 139 + absoluteY, 140 + screenSize, 141 + ) 142 + } 143 + runOnJS(zoomTo)(nextZoomRect) 129 144 }) 130 145 131 146 const composedGesture = Gesture.Exclusive(doubleTap, singleTap) ··· 135 150 <Animated.ScrollView 136 151 // @ts-ignore Something's up with the types here 137 152 ref={scrollViewRef} 138 - style={styles.listItem} 139 153 pinchGestureEnabled 140 154 showsHorizontalScrollIndicator={false} 141 155 showsVerticalScrollIndicator={false} 142 156 maximumZoomScale={maxZoomScale} 143 - onScroll={scrollHandler}> 144 - <Animated.View style={[styles.imageScrollContainer, animatedStyle]}> 157 + onScroll={scrollHandler} 158 + contentContainerStyle={styles.scrollContainer}> 159 + <Animated.View style={animatedStyle}> 145 160 <ActivityIndicator size="small" color="#FFF" style={styles.loading} /> 146 161 <Image 147 162 contentFit="contain" ··· 161 176 } 162 177 163 178 const styles = StyleSheet.create({ 164 - imageScrollContainer: { 165 - height: SCREEN.height, 166 - }, 167 - listItem: { 168 - width: SCREEN.width, 169 - height: SCREEN.height, 170 - }, 171 - image: { 172 - width: SCREEN.width, 173 - height: SCREEN.height, 174 - }, 175 179 loading: { 176 180 position: 'absolute', 177 181 top: 0, ··· 179 183 right: 0, 180 184 bottom: 0, 181 185 }, 186 + scrollContainer: { 187 + flex: 1, 188 + }, 189 + image: { 190 + flex: 1, 191 + }, 182 192 }) 183 193 184 194 const getZoomRectAfterDoubleTap = ( 185 195 imageAspect: number | undefined, 186 196 touchX: number, 187 197 touchY: number, 198 + screenSize: {width: number; height: number}, 188 199 ): { 189 200 x: number 190 201 y: number 191 202 width: number 192 203 height: number 193 204 } => { 205 + 'worklet' 194 206 if (!imageAspect) { 195 207 return { 196 208 x: 0, 197 209 y: 0, 198 - width: SCREEN.width, 199 - height: SCREEN.height, 210 + width: screenSize.width, 211 + height: screenSize.height, 200 212 } 201 213 } 202 214 203 215 // First, let's figure out how much we want to zoom in. 204 216 // We want to try to zoom in at least close enough to get rid of black bars. 205 - const screenAspect = SCREEN.width / SCREEN.height 217 + const screenAspect = screenSize.width / screenSize.height 206 218 const zoom = Math.max( 207 219 imageAspect / screenAspect, 208 220 screenAspect / imageAspect, ··· 213 225 214 226 // Next, we'll be calculating the rectangle to "zoom into" in screen coordinates. 215 227 // We already know the zoom level, so this gives us the rectangle size. 216 - let rectWidth = SCREEN.width / zoom 217 - let rectHeight = SCREEN.height / zoom 228 + let rectWidth = screenSize.width / zoom 229 + let rectHeight = screenSize.height / zoom 218 230 219 231 // Before we settle on the zoomed rect, figure out the safe area it has to be inside. 220 232 // We don't want to introduce new black bars or make existing black bars unbalanced. 221 233 let minX = 0 222 234 let minY = 0 223 - let maxX = SCREEN.width - rectWidth 224 - let maxY = SCREEN.height - rectHeight 235 + let maxX = screenSize.width - rectWidth 236 + let maxY = screenSize.height - rectHeight 225 237 if (imageAspect >= screenAspect) { 226 238 // The image has horizontal black bars. Exclude them from the safe area. 227 - const renderedHeight = SCREEN.width / imageAspect 228 - const horizontalBarHeight = (SCREEN.height - renderedHeight) / 2 239 + const renderedHeight = screenSize.width / imageAspect 240 + const horizontalBarHeight = (screenSize.height - renderedHeight) / 2 229 241 minY += horizontalBarHeight 230 242 maxY -= horizontalBarHeight 231 243 } else { 232 244 // The image has vertical black bars. Exclude them from the safe area. 233 - const renderedWidth = SCREEN.height * imageAspect 234 - const verticalBarWidth = (SCREEN.width - renderedWidth) / 2 245 + const renderedWidth = screenSize.height * imageAspect 246 + const verticalBarWidth = (screenSize.width - renderedWidth) / 2 235 247 minX += verticalBarWidth 236 248 maxX -= verticalBarWidth 237 249 } ··· 246 258 rectX = Math.max(rectX, minX) 247 259 } else { 248 260 // Keep the rect centered on the screen so that black bars are balanced. 249 - rectX = SCREEN.width / 2 - rectWidth / 2 261 + rectX = screenSize.width / 2 - rectWidth / 2 250 262 } 251 263 let rectY 252 264 if (maxY >= minY) { ··· 257 269 rectY = Math.max(rectY, minY) 258 270 } else { 259 271 // Keep the rect centered on the screen so that black bars are balanced. 260 - rectY = SCREEN.height / 2 - rectHeight / 2 272 + rectY = screenSize.height / 2 - rectHeight / 2 261 273 } 262 274 263 275 return {
+2
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
··· 2 2 3 3 import React from 'react' 4 4 import {View} from 'react-native' 5 + import {AnimatedRef} from 'react-native-reanimated' 5 6 6 7 import {ImageSource} from '../../@types' 7 8 ··· 12 13 onZoom: (scaled: boolean) => void 13 14 isScrollViewBeingDragged: boolean 14 15 showControls: boolean 16 + safeAreaRef: AnimatedRef<View> 15 17 } 16 18 17 19 const ImageItem = (_props: Props) => {
+157 -132
src/view/com/lightbox/ImageViewing/index.tsx
··· 8 8 // Original code copied and simplified from the link below as the codebase is currently not maintained: 9 9 // https://github.com/jobtoday/react-native-image-viewing 10 10 11 - import React, {useCallback, useMemo, useState} from 'react' 12 - import { 13 - Dimensions, 14 - LayoutAnimation, 15 - Platform, 16 - StyleSheet, 17 - View, 18 - } from 'react-native' 11 + import React, {useCallback, useState} from 'react' 12 + import {LayoutAnimation, Platform, StyleSheet, View} from 'react-native' 19 13 import PagerView from 'react-native-pager-view' 20 - import {MeasuredDimensions} from 'react-native-reanimated' 21 - import Animated, {useAnimatedStyle, withSpring} from 'react-native-reanimated' 22 - import {useSafeAreaInsets} from 'react-native-safe-area-context' 14 + import Animated, { 15 + AnimatedRef, 16 + useAnimatedRef, 17 + useAnimatedStyle, 18 + withSpring, 19 + } from 'react-native-reanimated' 23 20 import {Edge, SafeAreaView} from 'react-native-safe-area-context' 24 21 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 25 22 import {Trans} from '@lingui/macro' 26 23 27 24 import {colors, s} from '#/lib/styles' 28 25 import {isIOS} from '#/platform/detection' 26 + import {Lightbox} from '#/state/lightbox' 29 27 import {Button} from '#/view/com/util/forms/Button' 30 28 import {Text} from '#/view/com/util/text/Text' 31 29 import {ScrollView} from '#/view/com/util/Views' ··· 33 31 import ImageDefaultHeader from './components/ImageDefaultHeader' 34 32 import ImageItem from './components/ImageItem/ImageItem' 35 33 36 - type Props = { 37 - images: ImageSource[] 38 - thumbDims: MeasuredDimensions | null 39 - initialImageIndex: number 40 - visible: boolean 34 + const EDGES = 35 + Platform.OS === 'android' 36 + ? (['top', 'bottom', 'left', 'right'] satisfies Edge[]) 37 + : (['left', 'right'] satisfies Edge[]) // iOS, so no top/bottom safe area 38 + 39 + export default function ImageViewRoot({ 40 + lightbox, 41 + onRequestClose, 42 + onPressSave, 43 + onPressShare, 44 + }: { 45 + lightbox: Lightbox | null 41 46 onRequestClose: () => void 42 - backgroundColor?: string 43 47 onPressSave: (uri: string) => void 44 48 onPressShare: (uri: string) => void 49 + }) { 50 + const ref = useAnimatedRef<View>() 51 + return ( 52 + // Keep it always mounted to avoid flicker on the first frame. 53 + <SafeAreaView 54 + style={[styles.screen, !lightbox && styles.screenHidden]} 55 + edges={EDGES} 56 + aria-modal 57 + accessibilityViewIsModal 58 + aria-hidden={!lightbox}> 59 + <Animated.View ref={ref} style={{flex: 1}} collapsable={false}> 60 + {lightbox && ( 61 + <ImageView 62 + key={lightbox.id} 63 + lightbox={lightbox} 64 + onRequestClose={onRequestClose} 65 + onPressSave={onPressSave} 66 + onPressShare={onPressShare} 67 + safeAreaRef={ref} 68 + /> 69 + )} 70 + </Animated.View> 71 + </SafeAreaView> 72 + ) 45 73 } 46 74 47 - const SCREEN_HEIGHT = Dimensions.get('window').height 48 - const DEFAULT_BG_COLOR = '#000' 49 - 50 - function ImageViewing({ 51 - images, 52 - thumbDims: _thumbDims, // TODO: Pass down and use for animation. 53 - initialImageIndex, 54 - visible, 75 + function ImageView({ 76 + lightbox, 55 77 onRequestClose, 56 - backgroundColor = DEFAULT_BG_COLOR, 57 78 onPressSave, 58 79 onPressShare, 59 - }: Props) { 80 + safeAreaRef, 81 + }: { 82 + lightbox: Lightbox 83 + onRequestClose: () => void 84 + onPressSave: (uri: string) => void 85 + onPressShare: (uri: string) => void 86 + safeAreaRef: AnimatedRef<View> 87 + }) { 88 + const {images, index: initialImageIndex} = lightbox 60 89 const [isScaled, setIsScaled] = useState(false) 61 90 const [isDragging, setIsDragging] = useState(false) 62 91 const [imageIndex, setImageIndex] = useState(initialImageIndex) 63 92 const [showControls, setShowControls] = useState(true) 64 93 65 94 const animatedHeaderStyle = useAnimatedStyle(() => ({ 66 - pointerEvents: showControls ? 'auto' : 'none', 95 + pointerEvents: showControls ? 'box-none' : 'none', 67 96 opacity: withClampedSpring(showControls ? 1 : 0), 68 97 transform: [ 69 98 { ··· 72 101 ], 73 102 })) 74 103 const animatedFooterStyle = useAnimatedStyle(() => ({ 75 - pointerEvents: showControls ? 'auto' : 'none', 104 + flexGrow: 1, 105 + pointerEvents: showControls ? 'box-none' : 'none', 76 106 opacity: withClampedSpring(showControls ? 1 : 0), 77 107 transform: [ 78 108 { ··· 92 122 } 93 123 }, []) 94 124 95 - const edges = useMemo(() => { 96 - if (Platform.OS === 'android') { 97 - return ['top', 'bottom', 'left', 'right'] satisfies Edge[] 98 - } 99 - return ['left', 'right'] satisfies Edge[] // iOS, so no top/bottom safe area 100 - }, []) 101 - 102 - if (!visible) { 103 - return null 104 - } 105 - 106 125 return ( 107 - <SafeAreaView 108 - style={styles.screen} 109 - edges={edges} 110 - aria-modal 111 - accessibilityViewIsModal> 112 - <View style={[styles.container, {backgroundColor}]}> 113 - <Animated.View style={[styles.header, animatedHeaderStyle]}> 126 + <View style={[styles.container]}> 127 + <PagerView 128 + scrollEnabled={!isScaled} 129 + initialPage={initialImageIndex} 130 + onPageSelected={e => { 131 + setImageIndex(e.nativeEvent.position) 132 + setIsScaled(false) 133 + }} 134 + onPageScrollStateChanged={e => { 135 + setIsDragging(e.nativeEvent.pageScrollState !== 'idle') 136 + }} 137 + overdrag={true} 138 + style={styles.pager}> 139 + {images.map(imageSrc => ( 140 + <View key={imageSrc.uri}> 141 + <ImageItem 142 + onTap={onTap} 143 + onZoom={onZoom} 144 + imageSrc={imageSrc} 145 + onRequestClose={onRequestClose} 146 + isScrollViewBeingDragged={isDragging} 147 + showControls={showControls} 148 + safeAreaRef={safeAreaRef} 149 + /> 150 + </View> 151 + ))} 152 + </PagerView> 153 + <View style={styles.controls}> 154 + <Animated.View style={animatedHeaderStyle}> 114 155 <ImageDefaultHeader onRequestClose={onRequestClose} /> 115 156 </Animated.View> 116 - <PagerView 117 - scrollEnabled={!isScaled} 118 - initialPage={initialImageIndex} 119 - onPageSelected={e => { 120 - setImageIndex(e.nativeEvent.position) 121 - setIsScaled(false) 122 - }} 123 - onPageScrollStateChanged={e => { 124 - setIsDragging(e.nativeEvent.pageScrollState !== 'idle') 125 - }} 126 - overdrag={true} 127 - style={styles.pager}> 128 - {images.map(imageSrc => ( 129 - <View key={imageSrc.uri}> 130 - <ImageItem 131 - onTap={onTap} 132 - onZoom={onZoom} 133 - imageSrc={imageSrc} 134 - onRequestClose={onRequestClose} 135 - isScrollViewBeingDragged={isDragging} 136 - showControls={showControls} 137 - /> 138 - </View> 139 - ))} 140 - </PagerView> 141 - <Animated.View style={[styles.footer, animatedFooterStyle]}> 157 + <Animated.View style={animatedFooterStyle}> 142 158 <LightboxFooter 143 159 images={images} 144 160 index={imageIndex} ··· 147 163 /> 148 164 </Animated.View> 149 165 </View> 150 - </SafeAreaView> 166 + </View> 151 167 ) 152 168 } 153 169 ··· 164 180 }) { 165 181 const {alt: altText, uri} = images[index] 166 182 const [isAltExpanded, setAltExpanded] = React.useState(false) 167 - const insets = useSafeAreaInsets() 168 - const svMaxHeight = SCREEN_HEIGHT - insets.top - 50 169 183 const isMomentumScrolling = React.useRef(false) 170 184 return ( 171 185 <ScrollView 172 - style={[ 173 - { 174 - backgroundColor: '#000d', 175 - }, 176 - {maxHeight: svMaxHeight}, 177 - ]} 186 + style={styles.footerScrollView} 178 187 scrollEnabled={isAltExpanded} 179 188 onMomentumScrollBegin={() => { 180 189 isMomentumScrolling.current = true ··· 183 192 isMomentumScrolling.current = false 184 193 }} 185 194 contentContainerStyle={{ 186 - paddingTop: 16, 187 - paddingBottom: insets.bottom + 10, 195 + paddingVertical: 12, 188 196 paddingHorizontal: 24, 189 197 }}> 190 - {altText ? ( 191 - <View accessibilityRole="button" style={styles.footerText}> 192 - <Text 193 - style={[s.gray3]} 194 - numberOfLines={isAltExpanded ? undefined : 3} 195 - selectable 196 - onPress={() => { 197 - if (isMomentumScrolling.current) { 198 - return 199 - } 200 - LayoutAnimation.configureNext({ 201 - duration: 450, 202 - update: {type: 'spring', springDamping: 1}, 203 - }) 204 - setAltExpanded(prev => !prev) 205 - }} 206 - onLongPress={() => {}}> 207 - {altText} 208 - </Text> 198 + <SafeAreaView edges={['bottom']}> 199 + {altText ? ( 200 + <View accessibilityRole="button" style={styles.footerText}> 201 + <Text 202 + style={[s.gray3]} 203 + numberOfLines={isAltExpanded ? undefined : 3} 204 + selectable 205 + onPress={() => { 206 + if (isMomentumScrolling.current) { 207 + return 208 + } 209 + LayoutAnimation.configureNext({ 210 + duration: 450, 211 + update: {type: 'spring', springDamping: 1}, 212 + }) 213 + setAltExpanded(prev => !prev) 214 + }} 215 + onLongPress={() => {}}> 216 + {altText} 217 + </Text> 218 + </View> 219 + ) : null} 220 + <View style={styles.footerBtns}> 221 + <Button 222 + type="primary-outline" 223 + style={styles.footerBtn} 224 + onPress={() => onPressSave(uri)}> 225 + <FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} /> 226 + <Text type="xl" style={s.white}> 227 + <Trans context="action">Save</Trans> 228 + </Text> 229 + </Button> 230 + <Button 231 + type="primary-outline" 232 + style={styles.footerBtn} 233 + onPress={() => onPressShare(uri)}> 234 + <FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} /> 235 + <Text type="xl" style={s.white}> 236 + <Trans context="action">Share</Trans> 237 + </Text> 238 + </Button> 209 239 </View> 210 - ) : null} 211 - <View style={styles.footerBtns}> 212 - <Button 213 - type="primary-outline" 214 - style={styles.footerBtn} 215 - onPress={() => onPressSave(uri)}> 216 - <FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} /> 217 - <Text type="xl" style={s.white}> 218 - <Trans context="action">Save</Trans> 219 - </Text> 220 - </Button> 221 - <Button 222 - type="primary-outline" 223 - style={styles.footerBtn} 224 - onPress={() => onPressShare(uri)}> 225 - <FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} /> 226 - <Text type="xl" style={s.white}> 227 - <Trans context="action">Share</Trans> 228 - </Text> 229 - </Button> 230 - </View> 240 + </SafeAreaView> 231 241 </ScrollView> 232 242 ) 233 243 } ··· 239 249 left: 0, 240 250 bottom: 0, 241 251 right: 0, 252 + }, 253 + screenHidden: { 254 + opacity: 0, 255 + pointerEvents: 'none', 242 256 }, 243 257 container: { 244 258 flex: 1, 245 259 backgroundColor: '#000', 246 260 }, 261 + controls: { 262 + position: 'absolute', 263 + top: 0, 264 + bottom: 0, 265 + left: 0, 266 + right: 0, 267 + gap: 20, 268 + zIndex: 1, 269 + pointerEvents: 'box-none', 270 + }, 247 271 pager: { 248 272 flex: 1, 249 273 }, 250 274 header: { 251 275 position: 'absolute', 252 276 width: '100%', 253 - zIndex: 1, 254 277 top: 0, 255 278 pointerEvents: 'box-none', 256 279 }, 257 280 footer: { 258 281 position: 'absolute', 259 282 width: '100%', 260 - zIndex: 1, 283 + maxHeight: '100%', 261 284 bottom: 0, 262 285 }, 286 + footerScrollView: { 287 + backgroundColor: '#000d', 288 + flex: 1, 289 + position: 'absolute', 290 + bottom: 0, 291 + width: '100%', 292 + maxHeight: '100%', 293 + }, 263 294 footerText: { 264 295 paddingBottom: isIOS ? 20 : 16, 265 296 }, ··· 277 308 }, 278 309 }) 279 310 280 - const EnhancedImageViewing = (props: Props) => ( 281 - <ImageViewing key={props.initialImageIndex} {...props} /> 282 - ) 283 - 284 311 function withClampedSpring(value: any) { 285 312 'worklet' 286 313 return withSpring(value, {overshootClamping: true, stiffness: 300}) 287 314 } 288 - 289 - export default EnhancedImageViewing
+1 -8
src/view/com/lightbox/Lightbox.tsx
··· 49 49 [permissionResponse, requestPermission, _], 50 50 ) 51 51 52 - if (!activeLightbox) { 53 - return null 54 - } 55 - 56 52 return ( 57 53 <ImageView 58 - images={activeLightbox.images} 59 - initialImageIndex={activeLightbox.index} 60 - thumbDims={activeLightbox.thumbDims} 61 - visible 54 + lightbox={activeLightbox} 62 55 onRequestClose={onClose} 63 56 onPressSave={saveImageToAlbumWithToasts} 64 57 onPressShare={uri => shareImageModal({uri})}