Revert "temp rm pagerview"

This reverts commit 30c2394271.
This commit is contained in:
Samuel Newman
2025-09-12 13:32:15 +03:00
parent 30c2394271
commit 744e141703
4 changed files with 417 additions and 13 deletions
+1
View File
@@ -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",
+306 -7
View File
@@ -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
/>
<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, i) => (
<View key={imageSrc.uri}>
<LightboxImage
onTap={onTap}
onZoom={onZoom}
imageSrc={imageSrc}
onRequestClose={onRequestClose}
isScrollViewBeingDragged={isDragging}
showControls={showControls}
safeAreaRef={safeAreaRef}
isScaled={isScaled}
isFlyingAway={isFlyingAway}
isActive={i === imageIndex}
dismissSwipeTranslateY={dismissSwipeTranslateY}
openProgress={openProgress}
/>
</View>
))}
</PagerView>
<View style={styles.controls}>
<Animated.View
style={animatedHeaderStyle}
@@ -332,6 +389,164 @@ function ImageView({
)
}
function LightboxImage({
imageSrc,
onTap,
onZoom,
onRequestClose,
isScrollViewBeingDragged,
isScaled,
isFlyingAway,
isActive,
showControls,
safeAreaRef,
openProgress,
dismissSwipeTranslateY,
}: {
imageSrc: ImageSource
onRequestClose: () => void
onTap: () => void
onZoom: (scaled: boolean) => void
isScrollViewBeingDragged: boolean
isScaled: boolean
isActive: boolean
isFlyingAway: SharedValue<boolean>
showControls: boolean
safeAreaRef: AnimatedRef<View>
openProgress: SharedValue<number>
dismissSwipeTranslateY: SharedValue<number>
}) {
const [fetchedDims, setFetchedDims] = React.useState<Dimensions | null>(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 (
<ImageItem
imageSrc={imageSrc}
onTap={onTap}
onZoom={onZoom}
onRequestClose={onRequestClose}
onLoad={setFetchedDims}
isScrollViewBeingDragged={isScrollViewBeingDragged}
showControls={showControls}
measureSafeArea={measureSafeArea}
imageAspect={imageAspect}
imageDimensions={dims ?? undefined}
dismissSwipePan={dismissSwipePan}
transforms={transforms}
/>
)
}
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})
+105 -6
View File
@@ -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<Props>) {
const [selectedPage] = useState(initialPage)
const pagerView = useRef<any>(null)
const [selectedPage, setSelectedPage] = useState(initialPage)
const pagerView = useRef<PagerView>(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 (
<View testID={testID} style={[a.flex_1, native(a.overflow_hidden)]}>
@@ -93,7 +151,15 @@ export function Pager({
dragProgress,
dragState,
})}
<DrawerGestureRequireFail>{children}</DrawerGestureRequireFail>
<DrawerGestureRequireFail>
<MemoizedAnimatedPagerView
ref={pagerView}
style={a.flex_1}
initialPage={initialPage}
onPageScroll={handlePageScroll}>
{children}
</MemoizedAnimatedPagerView>
</DrawerGestureRequireFail>
</View>
)
}
@@ -111,3 +177,36 @@ function DrawerGestureRequireFail({children}: {children: React.ReactNode}) {
return <GestureDetector gesture={nativeGesture}>{children}</GestureDetector>
}
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,
)
}
+5
View File
@@ -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"