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"