about summary refs log tree commit diff
path: root/src/view/com/lightbox/ImageViewing
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/lightbox/ImageViewing')
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx1
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx87
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx34
-rw-r--r--src/view/com/lightbox/ImageViewing/index.tsx95
4 files changed, 127 insertions, 90 deletions
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx
index e7caa58a8..7a37c7e41 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx
@@ -5,7 +5,6 @@
  * LICENSE file in the root directory of this source tree.
  *
  */
-import React from 'react'
 import {
   SafeAreaView,
   StyleSheet,
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
index 260787d2f..8e046e5ba 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
@@ -87,11 +87,11 @@ const ImageItem = ({
   // Note: DO NOT move any logic reading animated values outside this function.
   useAnimatedReaction(
     () => {
-      if (pinchScale.value !== 1) {
+      if (pinchScale.get() !== 1) {
         // We're currently pinching.
         return true
       }
-      const [, , committedScale] = readTransform(committedTransform.value)
+      const [, , committedScale] = readTransform(committedTransform.get())
       if (committedScale !== 1) {
         // We started from a pinched in state.
         return true
@@ -147,10 +147,10 @@ const ImageItem = ({
     .onStart(e => {
       'worklet'
       const screenSize = measureSafeArea()
-      pinchOrigin.value = {
+      pinchOrigin.set({
         x: e.focalX - screenSize.width / 2,
         y: e.focalY - screenSize.height / 2,
-      }
+      })
     })
     .onChange(e => {
       'worklet'
@@ -160,7 +160,7 @@ const ImageItem = ({
       }
       // Don't let the picture zoom in so close that it gets blurry.
       // Also, like in stock Android apps, don't let the user zoom out further than 1:1.
-      const [, , committedScale] = readTransform(committedTransform.value)
+      const [, , committedScale] = readTransform(committedTransform.get())
       const maxCommittedScale = Math.max(
         MIN_SCREEN_ZOOM,
         (imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM,
@@ -171,20 +171,21 @@ const ImageItem = ({
         Math.max(minPinchScale, e.scale),
         maxPinchScale,
       )
-      pinchScale.value = nextPinchScale
+      pinchScale.set(nextPinchScale)
 
       // Zooming out close to the corner could push us out of bounds, which we don't want on Android.
       // Calculate where we'll end up so we know how much to translate back to stay in bounds.
       const t = createTransform()
-      prependPan(t, panTranslation.value)
-      prependPinch(t, nextPinchScale, pinchOrigin.value, pinchTranslation.value)
-      prependTransform(t, committedTransform.value)
+      prependPan(t, panTranslation.get())
+      prependPinch(t, nextPinchScale, pinchOrigin.get(), pinchTranslation.get())
+      prependTransform(t, committedTransform.get())
       const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize)
       if (dx !== 0 || dy !== 0) {
-        pinchTranslation.value = {
-          x: pinchTranslation.value.x + dx,
-          y: pinchTranslation.value.y + dy,
-        }
+        const pt = pinchTranslation.get()
+        pinchTranslation.set({
+          x: pt.x + dx,
+          y: pt.y + dy,
+        })
       }
     })
     .onEnd(() => {
@@ -193,18 +194,18 @@ const ImageItem = ({
       let t = createTransform()
       prependPinch(
         t,
-        pinchScale.value,
-        pinchOrigin.value,
-        pinchTranslation.value,
+        pinchScale.get(),
+        pinchOrigin.get(),
+        pinchTranslation.get(),
       )
-      prependTransform(t, committedTransform.value)
+      prependTransform(t, committedTransform.get())
       applyRounding(t)
-      committedTransform.value = t
+      committedTransform.set(t)
 
       // Reset just the pinch.
-      pinchScale.value = 1
-      pinchOrigin.value = {x: 0, y: 0}
-      pinchTranslation.value = {x: 0, y: 0}
+      pinchScale.set(1)
+      pinchOrigin.set({x: 0, y: 0})
+      pinchTranslation.set({x: 0, y: 0})
     })
 
   const pan = Gesture.Pan()
@@ -223,29 +224,29 @@ const ImageItem = ({
       prependPan(t, nextPanTranslation)
       prependPinch(
         t,
-        pinchScale.value,
-        pinchOrigin.value,
-        pinchTranslation.value,
+        pinchScale.get(),
+        pinchOrigin.get(),
+        pinchTranslation.get(),
       )
-      prependTransform(t, committedTransform.value)
+      prependTransform(t, committedTransform.get())
 
       // Prevent panning from going out of bounds.
       const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize)
       nextPanTranslation.x += dx
       nextPanTranslation.y += dy
-      panTranslation.value = nextPanTranslation
+      panTranslation.set(nextPanTranslation)
     })
     .onEnd(() => {
       'worklet'
       // Commit just the pan.
       let t = createTransform()
-      prependPan(t, panTranslation.value)
-      prependTransform(t, committedTransform.value)
+      prependPan(t, panTranslation.get())
+      prependTransform(t, committedTransform.get())
       applyRounding(t)
-      committedTransform.value = t
+      committedTransform.set(t)
 
       // Reset just the pan.
-      panTranslation.value = {x: 0, y: 0}
+      panTranslation.set({x: 0, y: 0})
     })
 
   const singleTap = Gesture.Tap().onEnd(() => {
@@ -261,11 +262,11 @@ const ImageItem = ({
       if (!imageDimensions || !imageAspect) {
         return
       }
-      const [, , committedScale] = readTransform(committedTransform.value)
+      const [, , committedScale] = readTransform(committedTransform.get())
       if (committedScale !== 1) {
         // Go back to 1:1 using the identity vector.
         let t = createTransform()
-        committedTransform.value = withClampedSpring(t)
+        committedTransform.set(withClampedSpring(t))
         return
       }
 
@@ -299,7 +300,7 @@ const ImageItem = ({
       )
       const finalTransform = createTransform()
       prependPinch(finalTransform, scale, origin, {x: dx, y: dy})
-      committedTransform.value = withClampedSpring(finalTransform)
+      committedTransform.set(withClampedSpring(finalTransform))
     })
 
   const composedGesture = isScrollViewBeingDragged
@@ -313,13 +314,13 @@ const ImageItem = ({
       )
 
   const containerStyle = useAnimatedStyle(() => {
-    const {scaleAndMoveTransform, isHidden} = transforms.value
+    const {scaleAndMoveTransform, isHidden} = transforms.get()
     // Apply the active adjustments on top of the committed transform before the gestures.
     // This is matrix multiplication, so operations are applied in the reverse order.
     let t = createTransform()
-    prependPan(t, panTranslation.value)
-    prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
-    prependTransform(t, committedTransform.value)
+    prependPan(t, panTranslation.get())
+    prependPinch(t, pinchScale.get(), pinchOrigin.get(), pinchTranslation.get())
+    prependTransform(t, committedTransform.get())
     const [translateX, translateY, scale] = readTransform(t)
     const manipulationTransform = [
       {translateX},
@@ -338,7 +339,7 @@ const ImageItem = ({
   })
 
   const imageCropStyle = useAnimatedStyle(() => {
-    const {cropFrameTransform} = transforms.value
+    const {cropFrameTransform} = transforms.get()
     return {
       flex: 1,
       overflow: 'hidden',
@@ -347,7 +348,7 @@ const ImageItem = ({
   })
 
   const imageStyle = useAnimatedStyle(() => {
-    const {cropContentTransform} = transforms.value
+    const {cropContentTransform} = transforms.get()
     return {
       flex: 1,
       transform: cropContentTransform,
@@ -359,13 +360,13 @@ const ImageItem = ({
   const [hasLoaded, setHasLoaded] = useState(false)
   useAnimatedReaction(
     () => {
-      return transforms.value.isResting && !hasLoaded
+      return transforms.get().isResting && !hasLoaded
     },
     (show, prevShow) => {
-      if (show && !prevShow) {
-        runOnJS(setShowLoader)(false)
-      } else if (!prevShow && show) {
+      if (!prevShow && show) {
         runOnJS(setShowLoader)(true)
+      } else if (prevShow && !show) {
+        runOnJS(setShowLoader)(false)
       }
     },
   )
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
index f06a59ed6..c103e131b 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
@@ -16,9 +16,11 @@ import {
 import Animated, {
   runOnJS,
   SharedValue,
+  useAnimatedProps,
   useAnimatedReaction,
   useAnimatedRef,
   useAnimatedStyle,
+  useSharedValue,
 } from 'react-native-reanimated'
 import {useSafeAreaFrame} from 'react-native-safe-area-context'
 import {Image} from 'expo-image'
@@ -75,6 +77,7 @@ const ImageItem = ({
 }: Props) => {
   const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
   const [scaled, setScaled] = useState(false)
+  const isDragging = useSharedValue(false)
   const screenSizeDelayedForJSThreadOnly = useSafeAreaFrame()
   const maxZoomScale = Math.max(
     MIN_SCREEN_ZOOM,
@@ -86,11 +89,20 @@ const ImageItem = ({
 
   const scrollHandler = useAnimatedScrollHandler({
     onScroll(e) {
+      'worklet'
       const nextIsScaled = e.zoomScale > 1
       if (scaled !== nextIsScaled) {
         runOnJS(handleZoom)(nextIsScaled)
       }
     },
+    onBeginDrag() {
+      'worklet'
+      isDragging.value = true
+    },
+    onEndDrag() {
+      'worklet'
+      isDragging.value = false
+    },
   })
 
   function handleZoom(nextIsScaled: boolean) {
@@ -148,7 +160,7 @@ const ImageItem = ({
   )
 
   const containerStyle = useAnimatedStyle(() => {
-    const {scaleAndMoveTransform, isHidden} = transforms.value
+    const {scaleAndMoveTransform, isHidden} = transforms.get()
     return {
       flex: 1,
       transform: scaleAndMoveTransform,
@@ -158,7 +170,7 @@ const ImageItem = ({
 
   const imageCropStyle = useAnimatedStyle(() => {
     const screenSize = measureSafeArea()
-    const {cropFrameTransform} = transforms.value
+    const {cropFrameTransform} = transforms.get()
     return {
       overflow: 'hidden',
       transform: cropFrameTransform,
@@ -171,7 +183,7 @@ const ImageItem = ({
   })
 
   const imageStyle = useAnimatedStyle(() => {
-    const {cropContentTransform} = transforms.value
+    const {cropContentTransform} = transforms.get()
     return {
       transform: cropContentTransform,
       width: '100%',
@@ -184,13 +196,13 @@ const ImageItem = ({
   const [hasLoaded, setHasLoaded] = useState(false)
   useAnimatedReaction(
     () => {
-      return transforms.value.isResting && !hasLoaded
+      return transforms.get().isResting && !hasLoaded
     },
     (show, prevShow) => {
-      if (show && !prevShow) {
-        runOnJS(setShowLoader)(false)
-      } else if (!prevShow && show) {
+      if (!prevShow && show) {
         runOnJS(setShowLoader)(true)
+      } else if (prevShow && !show) {
+        runOnJS(setShowLoader)(false)
       }
     },
   )
@@ -199,6 +211,11 @@ const ImageItem = ({
   const borderRadius =
     type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0
 
+  const scrollViewProps = useAnimatedProps(() => ({
+    // Don't allow bounce at 1:1 rest so it can be swiped away.
+    bounces: scaled || isDragging.value,
+  }))
+
   return (
     <GestureDetector gesture={composedGesture}>
       <Animated.ScrollView
@@ -210,8 +227,7 @@ const ImageItem = ({
         maximumZoomScale={maxZoomScale}
         onScroll={scrollHandler}
         style={containerStyle}
-        bounces={scaled}
-        bouncesZoom={true}
+        animatedProps={scrollViewProps}
         centerContent>
         {showLoader && (
           <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx
index ab8306b36..4ba056eb0 100644
--- a/src/view/com/lightbox/ImageViewing/index.tsx
+++ b/src/view/com/lightbox/ImageViewing/index.tsx
@@ -32,6 +32,7 @@ import Animated, {
   useSharedValue,
   withDecay,
   withSpring,
+  WithSpringConfig,
 } from 'react-native-reanimated'
 import {
   Edge,
@@ -62,8 +63,18 @@ const EDGES =
     ? (['top', 'bottom', 'left', 'right'] satisfies Edge[])
     : (['left', 'right'] satisfies Edge[]) // iOS, so no top/bottom safe area
 
-const SLOW_SPRING = {stiffness: 120}
-const FAST_SPRING = {stiffness: 700}
+const SLOW_SPRING: WithSpringConfig = {
+  mass: isIOS ? 1.25 : 0.75,
+  damping: 300,
+  stiffness: 800,
+  restDisplacementThreshold: 0.01,
+}
+const FAST_SPRING: WithSpringConfig = {
+  mass: isIOS ? 1.25 : 0.75,
+  damping: 150,
+  stiffness: 900,
+  restDisplacementThreshold: 0.01,
+}
 
 export default function ImageViewRoot({
   lightbox: nextLightbox,
@@ -98,18 +109,22 @@ export default function ImageViewRoot({
 
     // https://github.com/software-mansion/react-native-reanimated/issues/6677
     requestAnimationFrame(() => {
-      openProgress.value = canAnimate ? withClampedSpring(1, SLOW_SPRING) : 1
+      openProgress.set(() =>
+        canAnimate ? withClampedSpring(1, SLOW_SPRING) : 1,
+      )
     })
     return () => {
       // https://github.com/software-mansion/react-native-reanimated/issues/6677
       requestAnimationFrame(() => {
-        openProgress.value = canAnimate ? withClampedSpring(0, SLOW_SPRING) : 0
+        openProgress.set(() =>
+          canAnimate ? withClampedSpring(0, SLOW_SPRING) : 0,
+        )
       })
     }
   }, [nextLightbox, openProgress])
 
   useAnimatedReaction(
-    () => openProgress.value === 0,
+    () => openProgress.get() === 0,
     (isGone, wasGone) => {
       if (isGone && !wasGone) {
         runOnJS(setActiveLightbox)(null)
@@ -119,7 +134,7 @@ export default function ImageViewRoot({
 
   const onFlyAway = React.useCallback(() => {
     'worklet'
-    openProgress.value = 0
+    openProgress.set(0)
     runOnJS(onRequestClose)()
   }, [onRequestClose, openProgress])
 
@@ -176,7 +191,7 @@ function ImageView({
   const isFlyingAway = useSharedValue(false)
 
   const containerStyle = useAnimatedStyle(() => {
-    if (openProgress.value < 1 || isFlyingAway.value) {
+    if (openProgress.get() < 1 || isFlyingAway.get()) {
       return {pointerEvents: 'none'}
     }
     return {pointerEvents: 'auto'}
@@ -185,11 +200,12 @@ function ImageView({
   const backdropStyle = useAnimatedStyle(() => {
     const screenSize = measure(safeAreaRef)
     let opacity = 1
-    if (openProgress.value < 1) {
-      opacity = Math.sqrt(openProgress.value)
+    const openProgressValue = openProgress.get()
+    if (openProgressValue < 1) {
+      opacity = Math.sqrt(openProgressValue)
     } else if (screenSize) {
       const dragProgress = Math.min(
-        Math.abs(dismissSwipeTranslateY.value) / (screenSize.height / 2),
+        Math.abs(dismissSwipeTranslateY.get()) / (screenSize.height / 2),
         1,
       )
       opacity -= dragProgress
@@ -201,11 +217,11 @@ function ImageView({
   })
 
   const animatedHeaderStyle = useAnimatedStyle(() => {
-    const show = showControls && dismissSwipeTranslateY.value === 0
+    const show = showControls && dismissSwipeTranslateY.get() === 0
     return {
       pointerEvents: show ? 'box-none' : 'none',
       opacity: withClampedSpring(
-        show && openProgress.value === 1 ? 1 : 0,
+        show && openProgress.get() === 1 ? 1 : 0,
         FAST_SPRING,
       ),
       transform: [
@@ -216,12 +232,12 @@ function ImageView({
     }
   })
   const animatedFooterStyle = useAnimatedStyle(() => {
-    const show = showControls && dismissSwipeTranslateY.value === 0
+    const show = showControls && dismissSwipeTranslateY.get() === 0
     return {
       flexGrow: 1,
       pointerEvents: show ? 'box-none' : 'none',
       opacity: withClampedSpring(
-        show && openProgress.value === 1 ? 1 : 0,
+        show && openProgress.get() === 1 ? 1 : 0,
         FAST_SPRING,
       ),
       transform: [
@@ -248,7 +264,7 @@ function ImageView({
       const screenSize = measure(safeAreaRef)
       return (
         !screenSize ||
-        Math.abs(dismissSwipeTranslateY.value) > screenSize.height
+        Math.abs(dismissSwipeTranslateY.get()) > screenSize.height
       )
     },
     (isOut, wasOut) => {
@@ -386,10 +402,11 @@ function LightboxImage({
   const transforms = useDerivedValue(() => {
     'worklet'
     const safeArea = measureSafeArea()
+    const openProgressValue = openProgress.get()
     const dismissTranslateY =
-      isActive && openProgress.value === 1 ? dismissSwipeTranslateY.value : 0
+      isActive && openProgressValue === 1 ? dismissSwipeTranslateY.get() : 0
 
-    if (openProgress.value === 0 && isFlyingAway.value) {
+    if (openProgressValue === 0 && isFlyingAway.get()) {
       return {
         isHidden: true,
         isResting: false,
@@ -399,9 +416,9 @@ function LightboxImage({
       }
     }
 
-    if (isActive && thumbRect && imageAspect && openProgress.value < 1) {
+    if (isActive && thumbRect && imageAspect && openProgressValue < 1) {
       return interpolateTransform(
-        openProgress.value,
+        openProgressValue,
         thumbRect,
         safeArea,
         imageAspect,
@@ -423,33 +440,37 @@ function LightboxImage({
     .maxPointers(1)
     .onUpdate(e => {
       'worklet'
-      if (openProgress.value !== 1 || isFlyingAway.value) {
+      if (openProgress.get() !== 1 || isFlyingAway.get()) {
         return
       }
-      dismissSwipeTranslateY.value = e.translationY
+      dismissSwipeTranslateY.set(e.translationY)
     })
     .onEnd(e => {
       'worklet'
-      if (openProgress.value !== 1 || isFlyingAway.value) {
+      if (openProgress.get() !== 1 || isFlyingAway.get()) {
         return
       }
-      if (Math.abs(e.velocityY) > 1000) {
-        isFlyingAway.value = true
-        if (dismissSwipeTranslateY.value === 0) {
+      if (Math.abs(e.velocityY) > 200) {
+        isFlyingAway.set(true)
+        if (dismissSwipeTranslateY.get() === 0) {
           // HACK: If the initial value is 0, withDecay() animation doesn't start.
           // This is a bug in Reanimated, but for now we'll work around it like this.
-          dismissSwipeTranslateY.value = 1
+          dismissSwipeTranslateY.set(1)
         }
-        dismissSwipeTranslateY.value = withDecay({
-          velocity: e.velocityY,
-          velocityFactor: Math.max(3000 / Math.abs(e.velocityY), 1), // Speed up if it's too slow.
-          deceleration: 1, // Danger! This relies on the reaction below stopping it.
-        })
+        dismissSwipeTranslateY.set(() =>
+          withDecay({
+            velocity: e.velocityY,
+            velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1), // Speed up if it's too slow.
+            deceleration: 1, // Danger! This relies on the reaction below stopping it.
+          }),
+        )
       } else {
-        dismissSwipeTranslateY.value = withSpring(0, {
-          stiffness: 700,
-          damping: 50,
-        })
+        dismissSwipeTranslateY.set(() =>
+          withSpring(0, {
+            stiffness: 700,
+            damping: 50,
+          }),
+        )
       }
     })
 
@@ -706,7 +727,7 @@ function interpolateTransform(
   }
 }
 
-function withClampedSpring(value: any, {stiffness}: {stiffness: number}) {
+function withClampedSpring(value: any, config: WithSpringConfig) {
   'worklet'
-  return withSpring(value, {overshootClamping: true, stiffness})
+  return withSpring(value, {...config, overshootClamping: true})
 }