diff --git a/package.json b/package.json
index 40b0f800be..b816b1c4a1 100644
--- a/package.json
+++ b/package.json
@@ -192,6 +192,7 @@
"react-native-get-random-values": "~1.11.0",
"react-native-keyboard-controller": "1.18.5",
"react-native-mmkv": "^2.12.2",
+ "react-native-pager-view": "6.8.0",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.3",
"react-native-reanimated": "^3.19.1",
diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx
index 6c1531ea36..bb3c39f594 100644
--- a/src/view/com/lightbox/ImageViewing/index.tsx
+++ b/src/view/com/lightbox/ImageViewing/index.tsx
@@ -8,27 +8,37 @@
// 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, {useEffect, useMemo, useState} from 'react'
-import {LayoutAnimation, StyleSheet, View} from 'react-native'
+import React, {useCallback, useEffect, useMemo, useState} from 'react'
+import {LayoutAnimation, PixelRatio, StyleSheet, View} from 'react-native'
import {SystemBars} from 'react-native-edge-to-edge'
+import {Gesture} from 'react-native-gesture-handler'
+import PagerView from 'react-native-pager-view'
import Animated, {
type AnimatedRef,
cancelAnimation,
+ interpolate,
measure,
runOnJS,
type SharedValue,
useAnimatedReaction,
useAnimatedRef,
useAnimatedStyle,
+ useDerivedValue,
useSharedValue,
+ withDecay,
withSpring,
type WithSpringConfig,
} from 'react-native-reanimated'
-import {SafeAreaView} from 'react-native-safe-area-context'
+import {
+ SafeAreaView,
+ useSafeAreaFrame,
+ useSafeAreaInsets,
+} from 'react-native-safe-area-context'
import * as ScreenOrientation from 'expo-screen-orientation'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Trans} from '@lingui/macro'
+import {type Dimensions} from '#/lib/media/types'
import {colors, s} from '#/lib/styles'
import {isIOS} from '#/platform/detection'
import {type Lightbox} from '#/state/lightbox'
@@ -38,10 +48,14 @@ import {ScrollView} from '#/view/com/util/Views'
import {useTheme} from '#/alf'
import {setSystemUITheme} from '#/alf/util/systemUI'
import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army'
-import {type ImageSource} from './@types'
+import {type ImageSource, type Transform} from './@types'
import ImageDefaultHeader from './components/ImageDefaultHeader'
+import ImageItem from './components/ImageItem/ImageItem'
+
+type Rect = {x: number; y: number; width: number; height: number}
const PORTRAIT_UP = ScreenOrientation.OrientationLock.PORTRAIT_UP
+const PIXEL_RATIO = PixelRatio.get()
const SLOW_SPRING: WithSpringConfig = {
mass: isIOS ? 1.25 : 0.75,
@@ -196,9 +210,10 @@ function ImageView({
}) {
const {images, index: initialImageIndex} = lightbox
const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox])
- const [isScaled] = useState(false)
- const [imageIndex] = useState(initialImageIndex)
- const [showControls] = useState(true)
+ const [isScaled, setIsScaled] = useState(false)
+ const [isDragging, setIsDragging] = useState(false)
+ const [imageIndex, setImageIndex] = useState(initialImageIndex)
+ const [showControls, setShowControls] = useState(true)
const [isAltExpanded, setAltExpanded] = React.useState(false)
const dismissSwipeTranslateY = useSharedValue(0)
const isFlyingAway = useSharedValue(false)
@@ -270,6 +285,17 @@ function ImageView({
}
})
+ const onTap = useCallback(() => {
+ setShowControls(show => !show)
+ }, [])
+
+ const onZoom = useCallback((nextIsScaled: boolean) => {
+ setIsScaled(nextIsScaled)
+ if (nextIsScaled) {
+ setShowControls(false)
+ }
+ }, [])
+
useAnimatedReaction(
() => {
const screenSize = measure(safeAreaRef)
@@ -309,6 +335,37 @@ function ImageView({
style={[styles.backdrop, backdropStyle]}
renderToHardwareTextureAndroid
/>
+ {
+ setImageIndex(e.nativeEvent.position)
+ setIsScaled(false)
+ }}
+ onPageScrollStateChanged={e => {
+ setIsDragging(e.nativeEvent.pageScrollState !== 'idle')
+ }}
+ overdrag={true}
+ style={styles.pager}>
+ {images.map((imageSrc, i) => (
+
+
+
+ ))}
+
void
+ onTap: () => void
+ onZoom: (scaled: boolean) => void
+ isScrollViewBeingDragged: boolean
+ isScaled: boolean
+ isActive: boolean
+ isFlyingAway: SharedValue
+ showControls: boolean
+ safeAreaRef: AnimatedRef
+ openProgress: SharedValue
+ dismissSwipeTranslateY: SharedValue
+}) {
+ const [fetchedDims, setFetchedDims] = React.useState(null)
+ const dims = fetchedDims ?? imageSrc.dimensions ?? imageSrc.thumbDimensions
+ let imageAspect: number | undefined
+ if (dims) {
+ imageAspect = dims.width / dims.height
+ if (Number.isNaN(imageAspect)) {
+ imageAspect = undefined
+ }
+ }
+
+ const safeFrameDelayedForJSThreadOnly = useSafeAreaFrame()
+ const safeInsetsDelayedForJSThreadOnly = useSafeAreaInsets()
+ const measureSafeArea = React.useCallback(() => {
+ 'worklet'
+ let safeArea: Rect | null = measure(safeAreaRef)
+ if (!safeArea) {
+ if (_WORKLET) {
+ console.error('Expected to always be able to measure safe area.')
+ }
+ const frame = safeFrameDelayedForJSThreadOnly
+ const insets = safeInsetsDelayedForJSThreadOnly
+ safeArea = {
+ x: frame.x + insets.left,
+ y: frame.y + insets.top,
+ width: frame.width - insets.left - insets.right,
+ height: frame.height - insets.top - insets.bottom,
+ }
+ }
+ return safeArea
+ }, [
+ safeFrameDelayedForJSThreadOnly,
+ safeInsetsDelayedForJSThreadOnly,
+ safeAreaRef,
+ ])
+
+ const {thumbRect} = imageSrc
+ const transforms = useDerivedValue(() => {
+ 'worklet'
+ const safeArea = measureSafeArea()
+ const openProgressValue = openProgress.get()
+ const dismissTranslateY =
+ isActive && openProgressValue === 1 ? dismissSwipeTranslateY.get() : 0
+
+ if (openProgressValue === 0 && isFlyingAway.get()) {
+ return {
+ isHidden: true,
+ isResting: false,
+ scaleAndMoveTransform: [],
+ cropFrameTransform: [],
+ cropContentTransform: [],
+ }
+ }
+
+ if (isActive && thumbRect && imageAspect && openProgressValue < 1) {
+ return interpolateTransform(
+ openProgressValue,
+ thumbRect,
+ safeArea,
+ imageAspect,
+ )
+ }
+ return {
+ isHidden: false,
+ isResting: dismissTranslateY === 0,
+ scaleAndMoveTransform: [{translateY: dismissTranslateY}],
+ cropFrameTransform: [],
+ cropContentTransform: [],
+ }
+ })
+
+ const dismissSwipePan = Gesture.Pan()
+ .enabled(isActive && !isScaled)
+ .activeOffsetY([-10, 10])
+ .failOffsetX([-10, 10])
+ .maxPointers(1)
+ .onUpdate(e => {
+ 'worklet'
+ if (openProgress.get() !== 1 || isFlyingAway.get()) {
+ return
+ }
+ dismissSwipeTranslateY.set(e.translationY)
+ })
+ .onEnd(e => {
+ 'worklet'
+ if (openProgress.get() !== 1 || isFlyingAway.get()) {
+ return
+ }
+ 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.set(1)
+ }
+ dismissSwipeTranslateY.set(() => {
+ 'worklet'
+ return 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.set(() => {
+ 'worklet'
+ return withSpring(0, {
+ stiffness: 700,
+ damping: 50,
+ })
+ })
+ }
+ })
+
+ return (
+
+ )
+}
+
function LightboxFooter({
images,
index,
@@ -483,6 +698,90 @@ const styles = StyleSheet.create({
},
})
+function interpolatePx(
+ px: number,
+ inputRange: readonly number[],
+ outputRange: readonly number[],
+) {
+ 'worklet'
+ const value = interpolate(px, inputRange, outputRange)
+ return Math.round(value * PIXEL_RATIO) / PIXEL_RATIO
+}
+
+function interpolateTransform(
+ progress: number,
+ thumbnailDims: {
+ pageX: number
+ width: number
+ pageY: number
+ height: number
+ },
+ safeArea: {width: number; height: number; x: number; y: number},
+ imageAspect: number,
+): {
+ scaleAndMoveTransform: Transform
+ cropFrameTransform: Transform
+ cropContentTransform: Transform
+ isResting: boolean
+ isHidden: boolean
+} {
+ 'worklet'
+ const thumbAspect = thumbnailDims.width / thumbnailDims.height
+ let uncroppedInitialWidth
+ let uncroppedInitialHeight
+ if (imageAspect > thumbAspect) {
+ uncroppedInitialWidth = thumbnailDims.height * imageAspect
+ uncroppedInitialHeight = thumbnailDims.height
+ } else {
+ uncroppedInitialWidth = thumbnailDims.width
+ uncroppedInitialHeight = thumbnailDims.width / imageAspect
+ }
+ const safeAreaAspect = safeArea.width / safeArea.height
+ let finalWidth
+ let finalHeight
+ if (safeAreaAspect > imageAspect) {
+ finalWidth = safeArea.height * imageAspect
+ finalHeight = safeArea.height
+ } else {
+ finalWidth = safeArea.width
+ finalHeight = safeArea.width / imageAspect
+ }
+ const initialScale = Math.min(
+ uncroppedInitialWidth / finalWidth,
+ uncroppedInitialHeight / finalHeight,
+ )
+ const croppedFinalWidth = thumbnailDims.width / initialScale
+ const croppedFinalHeight = thumbnailDims.height / initialScale
+ const screenCenterX = safeArea.width / 2
+ const screenCenterY = safeArea.height / 2
+ const thumbnailSafeAreaX = thumbnailDims.pageX - safeArea.x
+ const thumbnailSafeAreaY = thumbnailDims.pageY - safeArea.y
+ const thumbnailCenterX = thumbnailSafeAreaX + thumbnailDims.width / 2
+ const thumbnailCenterY = thumbnailSafeAreaY + thumbnailDims.height / 2
+ const initialTranslateX = thumbnailCenterX - screenCenterX
+ const initialTranslateY = thumbnailCenterY - screenCenterY
+ const scale = interpolate(progress, [0, 1], [initialScale, 1])
+ const translateX = interpolatePx(progress, [0, 1], [initialTranslateX, 0])
+ const translateY = interpolatePx(progress, [0, 1], [initialTranslateY, 0])
+ const cropScaleX = interpolate(
+ progress,
+ [0, 1],
+ [croppedFinalWidth / finalWidth, 1],
+ )
+ const cropScaleY = interpolate(
+ progress,
+ [0, 1],
+ [croppedFinalHeight / finalHeight, 1],
+ )
+ return {
+ isHidden: false,
+ isResting: progress === 1,
+ scaleAndMoveTransform: [{translateX}, {translateY}, {scale}],
+ cropFrameTransform: [{scaleX: cropScaleX}, {scaleY: cropScaleY}],
+ cropContentTransform: [{scaleX: 1 / cropScaleX}, {scaleY: 1 / cropScaleY}],
+ }
+}
+
function withClampedSpring(value: any, config: WithSpringConfig) {
'worklet'
return withSpring(value, {...config, overshootClamping: true})
diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx
index e4ba5cea44..6d0982ad8d 100644
--- a/src/view/com/pager/Pager.tsx
+++ b/src/view/com/pager/Pager.tsx
@@ -1,5 +1,6 @@
import {
type JSX,
+ memo,
useCallback,
useContext,
useImperativeHandle,
@@ -10,13 +11,25 @@ import {
import {View} from 'react-native'
import {DrawerGestureContext} from 'react-native-drawer-layout'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
-import {type SharedValue, useSharedValue} from 'react-native-reanimated'
+import PagerView, {
+ type PagerViewOnPageScrollEventData,
+ type PagerViewOnPageSelectedEvent,
+ type PagerViewOnPageSelectedEventData,
+ type PageScrollStateChangedNativeEventData,
+} from 'react-native-pager-view'
+import Animated, {
+ runOnJS,
+ type SharedValue,
+ useEvent,
+ useHandler,
+ useSharedValue,
+} from 'react-native-reanimated'
import {useFocusEffect} from '@react-navigation/native'
import {useSetDrawerSwipeDisabled} from '#/state/shell'
import {atoms as a, native} from '#/alf'
-export type PageSelectedEvent = any
+export type PageSelectedEvent = PagerViewOnPageSelectedEvent
export interface PagerRef {
setPage: (index: number) => void
@@ -45,18 +58,23 @@ interface Props {
testID?: string
}
+const AnimatedPagerView = Animated.createAnimatedComponent(PagerView)
+const MemoizedAnimatedPagerView = memo(AnimatedPagerView)
+
export function Pager({
ref,
children,
initialPage = 0,
renderTabBar,
+ onPageSelected: parentOnPageSelected,
onTabPressed: parentOnTabPressed,
+ onPageScrollStateChanged: parentOnPageScrollStateChanged,
testID,
}: React.PropsWithChildren) {
- const [selectedPage] = useState(initialPage)
- const pagerView = useRef(null)
+ const [selectedPage, setSelectedPage] = useState(initialPage)
+ const pagerView = useRef(null)
- const [isIdle] = useState(true)
+ const [isIdle, setIsIdle] = useState(true)
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
useFocusEffect(
useCallback(() => {
@@ -74,6 +92,14 @@ export function Pager({
},
}))
+ const onPageSelectedJSThread = useCallback(
+ (nextPosition: number) => {
+ setSelectedPage(nextPosition)
+ parentOnPageSelected?.(nextPosition)
+ },
+ [setSelectedPage, parentOnPageSelected],
+ )
+
const onTabBarSelect = useCallback(
(index: number) => {
parentOnTabPressed?.(index)
@@ -84,6 +110,38 @@ export function Pager({
const dragState = useSharedValue<'idle' | 'settling' | 'dragging'>('idle')
const dragProgress = useSharedValue(selectedPage)
+ const didInit = useSharedValue(false)
+ const handlePageScroll = usePagerHandlers(
+ {
+ onPageScroll(e: PagerViewOnPageScrollEventData) {
+ 'worklet'
+ if (didInit.get() === false) {
+ // On iOS, there's a spurious scroll event with 0 position
+ // even if a different page was supplied as the initial page.
+ // Ignore it and wait for the first confirmed selection instead.
+ return
+ }
+ dragProgress.set(e.offset + e.position)
+ },
+ onPageScrollStateChanged(e: PageScrollStateChangedNativeEventData) {
+ 'worklet'
+ runOnJS(setIsIdle)(e.pageScrollState === 'idle')
+ if (dragState.get() === 'idle' && e.pageScrollState === 'settling') {
+ // This is a programmatic scroll on Android.
+ // Stay "idle" to match iOS and avoid confusing downstream code.
+ return
+ }
+ dragState.set(e.pageScrollState)
+ parentOnPageScrollStateChanged?.(e.pageScrollState)
+ },
+ onPageSelected(e: PagerViewOnPageSelectedEventData) {
+ 'worklet'
+ didInit.set(true)
+ runOnJS(onPageSelectedJSThread)(e.position)
+ },
+ },
+ [parentOnPageScrollStateChanged],
+ )
return (
@@ -93,7 +151,15 @@ export function Pager({
dragProgress,
dragState,
})}
- {children}
+
+
+ {children}
+
+
)
}
@@ -111,3 +177,36 @@ function DrawerGestureRequireFail({children}: {children: React.ReactNode}) {
return {children}
}
+
+function usePagerHandlers(
+ handlers: {
+ onPageScroll: (e: PagerViewOnPageScrollEventData) => void
+ onPageScrollStateChanged: (e: PageScrollStateChangedNativeEventData) => void
+ onPageSelected: (e: PagerViewOnPageSelectedEventData) => void
+ },
+ dependencies: unknown[],
+) {
+ const {doDependenciesDiffer} = useHandler(handlers as any, dependencies)
+ const subscribeForEvents = [
+ 'onPageScroll',
+ 'onPageScrollStateChanged',
+ 'onPageSelected',
+ ]
+ return useEvent(
+ event => {
+ 'worklet'
+ const {onPageScroll, onPageScrollStateChanged, onPageSelected} = handlers
+ if (event.eventName.endsWith('onPageScroll')) {
+ onPageScroll(event as any as PagerViewOnPageScrollEventData)
+ } else if (event.eventName.endsWith('onPageScrollStateChanged')) {
+ onPageScrollStateChanged(
+ event as any as PageScrollStateChangedNativeEventData,
+ )
+ } else if (event.eventName.endsWith('onPageSelected')) {
+ onPageSelected(event as any as PagerViewOnPageSelectedEventData)
+ }
+ },
+ subscribeForEvents,
+ doDependenciesDiffer,
+ )
+}
diff --git a/yarn.lock b/yarn.lock
index 6a3c39187d..84c7e2d713 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -16806,6 +16806,11 @@ react-native-mmkv@^2.12.2:
resolved "https://registry.yarnpkg.com/react-native-mmkv/-/react-native-mmkv-2.12.2.tgz#4bba0f5f04e2cf222494cce3a9794ba6a4894dee"
integrity sha512-6058Aq0p57chPrUutLGe9fYoiDVDNMU2PKV+lLFUJ3GhoHvUrLdsS1PDSCLr00yqzL4WJQ7TTzH+V8cpyrNcfg==
+react-native-pager-view@6.8.0:
+ version "6.8.0"
+ resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.8.0.tgz#5bac05203d911bf9bf039d47db41b1313dbd1a7a"
+ integrity sha512-/wgFV8nB4TLnQ6j9e3TvNrHbPF5TINMZHaXt86GOV0NSJNMVGkWguniJVKrYLm85LL8KVhRkgdh43Rdu7PvW1A==
+
react-native-progress@bluesky-social/react-native-progress:
version "5.0.0"
resolved "https://codeload.github.com/bluesky-social/react-native-progress/tar.gz/5a372f4f2ce5feb26f4f47b6a4d187ab9b923ab4"