From 206df2ab801d211a412f9ce3694d90bdd053caaa Mon Sep 17 00:00:00 2001
From: dan <dan.abramov@gmail.com>
Date: Wed, 6 Nov 2024 00:21:35 +0000
Subject: 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
---
 src/view/com/lightbox/ImageViewing/index.tsx | 289 +++++++++++++++------------
 1 file changed, 157 insertions(+), 132 deletions(-)

(limited to 'src/view/com/lightbox/ImageViewing/index.tsx')

diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx
index 40df4c819..791701bca 100644
--- a/src/view/com/lightbox/ImageViewing/index.tsx
+++ b/src/view/com/lightbox/ImageViewing/index.tsx
@@ -8,24 +8,22 @@
 // Original code copied and simplified from the link below as the codebase is currently not maintained:
 // https://github.com/jobtoday/react-native-image-viewing
 
-import React, {useCallback, useMemo, useState} from 'react'
-import {
-  Dimensions,
-  LayoutAnimation,
-  Platform,
-  StyleSheet,
-  View,
-} from 'react-native'
+import React, {useCallback, useState} from 'react'
+import {LayoutAnimation, Platform, StyleSheet, View} from 'react-native'
 import PagerView from 'react-native-pager-view'
-import {MeasuredDimensions} from 'react-native-reanimated'
-import Animated, {useAnimatedStyle, withSpring} from 'react-native-reanimated'
-import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import Animated, {
+  AnimatedRef,
+  useAnimatedRef,
+  useAnimatedStyle,
+  withSpring,
+} from 'react-native-reanimated'
 import {Edge, SafeAreaView} from 'react-native-safe-area-context'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Trans} from '@lingui/macro'
 
 import {colors, s} from '#/lib/styles'
 import {isIOS} from '#/platform/detection'
+import {Lightbox} from '#/state/lightbox'
 import {Button} from '#/view/com/util/forms/Button'
 import {Text} from '#/view/com/util/text/Text'
 import {ScrollView} from '#/view/com/util/Views'
@@ -33,37 +31,68 @@ import {ImageSource} from './@types'
 import ImageDefaultHeader from './components/ImageDefaultHeader'
 import ImageItem from './components/ImageItem/ImageItem'
 
-type Props = {
-  images: ImageSource[]
-  thumbDims: MeasuredDimensions | null
-  initialImageIndex: number
-  visible: boolean
+const EDGES =
+  Platform.OS === 'android'
+    ? (['top', 'bottom', 'left', 'right'] satisfies Edge[])
+    : (['left', 'right'] satisfies Edge[]) // iOS, so no top/bottom safe area
+
+export default function ImageViewRoot({
+  lightbox,
+  onRequestClose,
+  onPressSave,
+  onPressShare,
+}: {
+  lightbox: Lightbox | null
   onRequestClose: () => void
-  backgroundColor?: string
   onPressSave: (uri: string) => void
   onPressShare: (uri: string) => void
+}) {
+  const ref = useAnimatedRef<View>()
+  return (
+    // Keep it always mounted to avoid flicker on the first frame.
+    <SafeAreaView
+      style={[styles.screen, !lightbox && styles.screenHidden]}
+      edges={EDGES}
+      aria-modal
+      accessibilityViewIsModal
+      aria-hidden={!lightbox}>
+      <Animated.View ref={ref} style={{flex: 1}} collapsable={false}>
+        {lightbox && (
+          <ImageView
+            key={lightbox.id}
+            lightbox={lightbox}
+            onRequestClose={onRequestClose}
+            onPressSave={onPressSave}
+            onPressShare={onPressShare}
+            safeAreaRef={ref}
+          />
+        )}
+      </Animated.View>
+    </SafeAreaView>
+  )
 }
 
-const SCREEN_HEIGHT = Dimensions.get('window').height
-const DEFAULT_BG_COLOR = '#000'
-
-function ImageViewing({
-  images,
-  thumbDims: _thumbDims, // TODO: Pass down and use for animation.
-  initialImageIndex,
-  visible,
+function ImageView({
+  lightbox,
   onRequestClose,
-  backgroundColor = DEFAULT_BG_COLOR,
   onPressSave,
   onPressShare,
-}: Props) {
+  safeAreaRef,
+}: {
+  lightbox: Lightbox
+  onRequestClose: () => void
+  onPressSave: (uri: string) => void
+  onPressShare: (uri: string) => void
+  safeAreaRef: AnimatedRef<View>
+}) {
+  const {images, index: initialImageIndex} = lightbox
   const [isScaled, setIsScaled] = useState(false)
   const [isDragging, setIsDragging] = useState(false)
   const [imageIndex, setImageIndex] = useState(initialImageIndex)
   const [showControls, setShowControls] = useState(true)
 
   const animatedHeaderStyle = useAnimatedStyle(() => ({
-    pointerEvents: showControls ? 'auto' : 'none',
+    pointerEvents: showControls ? 'box-none' : 'none',
     opacity: withClampedSpring(showControls ? 1 : 0),
     transform: [
       {
@@ -72,7 +101,8 @@ function ImageViewing({
     ],
   }))
   const animatedFooterStyle = useAnimatedStyle(() => ({
-    pointerEvents: showControls ? 'auto' : 'none',
+    flexGrow: 1,
+    pointerEvents: showControls ? 'box-none' : 'none',
     opacity: withClampedSpring(showControls ? 1 : 0),
     transform: [
       {
@@ -92,53 +122,39 @@ function ImageViewing({
     }
   }, [])
 
-  const edges = useMemo(() => {
-    if (Platform.OS === 'android') {
-      return ['top', 'bottom', 'left', 'right'] satisfies Edge[]
-    }
-    return ['left', 'right'] satisfies Edge[] // iOS, so no top/bottom safe area
-  }, [])
-
-  if (!visible) {
-    return null
-  }
-
   return (
-    <SafeAreaView
-      style={styles.screen}
-      edges={edges}
-      aria-modal
-      accessibilityViewIsModal>
-      <View style={[styles.container, {backgroundColor}]}>
-        <Animated.View style={[styles.header, animatedHeaderStyle]}>
+    <View style={[styles.container]}>
+      <PagerView
+        scrollEnabled={!isScaled}
+        initialPage={initialImageIndex}
+        onPageSelected={e => {
+          setImageIndex(e.nativeEvent.position)
+          setIsScaled(false)
+        }}
+        onPageScrollStateChanged={e => {
+          setIsDragging(e.nativeEvent.pageScrollState !== 'idle')
+        }}
+        overdrag={true}
+        style={styles.pager}>
+        {images.map(imageSrc => (
+          <View key={imageSrc.uri}>
+            <ImageItem
+              onTap={onTap}
+              onZoom={onZoom}
+              imageSrc={imageSrc}
+              onRequestClose={onRequestClose}
+              isScrollViewBeingDragged={isDragging}
+              showControls={showControls}
+              safeAreaRef={safeAreaRef}
+            />
+          </View>
+        ))}
+      </PagerView>
+      <View style={styles.controls}>
+        <Animated.View style={animatedHeaderStyle}>
           <ImageDefaultHeader onRequestClose={onRequestClose} />
         </Animated.View>
-        <PagerView
-          scrollEnabled={!isScaled}
-          initialPage={initialImageIndex}
-          onPageSelected={e => {
-            setImageIndex(e.nativeEvent.position)
-            setIsScaled(false)
-          }}
-          onPageScrollStateChanged={e => {
-            setIsDragging(e.nativeEvent.pageScrollState !== 'idle')
-          }}
-          overdrag={true}
-          style={styles.pager}>
-          {images.map(imageSrc => (
-            <View key={imageSrc.uri}>
-              <ImageItem
-                onTap={onTap}
-                onZoom={onZoom}
-                imageSrc={imageSrc}
-                onRequestClose={onRequestClose}
-                isScrollViewBeingDragged={isDragging}
-                showControls={showControls}
-              />
-            </View>
-          ))}
-        </PagerView>
-        <Animated.View style={[styles.footer, animatedFooterStyle]}>
+        <Animated.View style={animatedFooterStyle}>
           <LightboxFooter
             images={images}
             index={imageIndex}
@@ -147,7 +163,7 @@ function ImageViewing({
           />
         </Animated.View>
       </View>
-    </SafeAreaView>
+    </View>
   )
 }
 
@@ -164,17 +180,10 @@ function LightboxFooter({
 }) {
   const {alt: altText, uri} = images[index]
   const [isAltExpanded, setAltExpanded] = React.useState(false)
-  const insets = useSafeAreaInsets()
-  const svMaxHeight = SCREEN_HEIGHT - insets.top - 50
   const isMomentumScrolling = React.useRef(false)
   return (
     <ScrollView
-      style={[
-        {
-          backgroundColor: '#000d',
-        },
-        {maxHeight: svMaxHeight},
-      ]}
+      style={styles.footerScrollView}
       scrollEnabled={isAltExpanded}
       onMomentumScrollBegin={() => {
         isMomentumScrolling.current = true
@@ -183,51 +192,52 @@ function LightboxFooter({
         isMomentumScrolling.current = false
       }}
       contentContainerStyle={{
-        paddingTop: 16,
-        paddingBottom: insets.bottom + 10,
+        paddingVertical: 12,
         paddingHorizontal: 24,
       }}>
-      {altText ? (
-        <View accessibilityRole="button" style={styles.footerText}>
-          <Text
-            style={[s.gray3]}
-            numberOfLines={isAltExpanded ? undefined : 3}
-            selectable
-            onPress={() => {
-              if (isMomentumScrolling.current) {
-                return
-              }
-              LayoutAnimation.configureNext({
-                duration: 450,
-                update: {type: 'spring', springDamping: 1},
-              })
-              setAltExpanded(prev => !prev)
-            }}
-            onLongPress={() => {}}>
-            {altText}
-          </Text>
+      <SafeAreaView edges={['bottom']}>
+        {altText ? (
+          <View accessibilityRole="button" style={styles.footerText}>
+            <Text
+              style={[s.gray3]}
+              numberOfLines={isAltExpanded ? undefined : 3}
+              selectable
+              onPress={() => {
+                if (isMomentumScrolling.current) {
+                  return
+                }
+                LayoutAnimation.configureNext({
+                  duration: 450,
+                  update: {type: 'spring', springDamping: 1},
+                })
+                setAltExpanded(prev => !prev)
+              }}
+              onLongPress={() => {}}>
+              {altText}
+            </Text>
+          </View>
+        ) : null}
+        <View style={styles.footerBtns}>
+          <Button
+            type="primary-outline"
+            style={styles.footerBtn}
+            onPress={() => onPressSave(uri)}>
+            <FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
+            <Text type="xl" style={s.white}>
+              <Trans context="action">Save</Trans>
+            </Text>
+          </Button>
+          <Button
+            type="primary-outline"
+            style={styles.footerBtn}
+            onPress={() => onPressShare(uri)}>
+            <FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
+            <Text type="xl" style={s.white}>
+              <Trans context="action">Share</Trans>
+            </Text>
+          </Button>
         </View>
-      ) : null}
-      <View style={styles.footerBtns}>
-        <Button
-          type="primary-outline"
-          style={styles.footerBtn}
-          onPress={() => onPressSave(uri)}>
-          <FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
-          <Text type="xl" style={s.white}>
-            <Trans context="action">Save</Trans>
-          </Text>
-        </Button>
-        <Button
-          type="primary-outline"
-          style={styles.footerBtn}
-          onPress={() => onPressShare(uri)}>
-          <FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
-          <Text type="xl" style={s.white}>
-            <Trans context="action">Share</Trans>
-          </Text>
-        </Button>
-      </View>
+      </SafeAreaView>
     </ScrollView>
   )
 }
@@ -240,25 +250,46 @@ const styles = StyleSheet.create({
     bottom: 0,
     right: 0,
   },
+  screenHidden: {
+    opacity: 0,
+    pointerEvents: 'none',
+  },
   container: {
     flex: 1,
     backgroundColor: '#000',
   },
+  controls: {
+    position: 'absolute',
+    top: 0,
+    bottom: 0,
+    left: 0,
+    right: 0,
+    gap: 20,
+    zIndex: 1,
+    pointerEvents: 'box-none',
+  },
   pager: {
     flex: 1,
   },
   header: {
     position: 'absolute',
     width: '100%',
-    zIndex: 1,
     top: 0,
     pointerEvents: 'box-none',
   },
   footer: {
     position: 'absolute',
     width: '100%',
-    zIndex: 1,
+    maxHeight: '100%',
+    bottom: 0,
+  },
+  footerScrollView: {
+    backgroundColor: '#000d',
+    flex: 1,
+    position: 'absolute',
     bottom: 0,
+    width: '100%',
+    maxHeight: '100%',
   },
   footerText: {
     paddingBottom: isIOS ? 20 : 16,
@@ -277,13 +308,7 @@ const styles = StyleSheet.create({
   },
 })
 
-const EnhancedImageViewing = (props: Props) => (
-  <ImageViewing key={props.initialImageIndex} {...props} />
-)
-
 function withClampedSpring(value: any) {
   'worklet'
   return withSpring(value, {overshootClamping: true, stiffness: 300})
 }
-
-export default EnhancedImageViewing
-- 
cgit 1.4.1

