Refresh lightbox designs (#10330)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,774 @@
|
||||
/**
|
||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
// 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 {useCallback, useEffect, useMemo, useState} from 'react'
|
||||
import {PixelRatio, StyleSheet, useWindowDimensions, 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 AnimatableValue,
|
||||
type AnimatedRef,
|
||||
cancelAnimation,
|
||||
interpolate,
|
||||
measure,
|
||||
type MeasuredDimensions,
|
||||
ReduceMotion,
|
||||
runOnJS,
|
||||
runOnUI,
|
||||
type SharedValue,
|
||||
useAnimatedReaction,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useDerivedValue,
|
||||
useSharedValue,
|
||||
withDecay,
|
||||
withSpring,
|
||||
type WithSpringConfig,
|
||||
} from 'react-native-reanimated'
|
||||
import * as ScreenOrientation from 'expo-screen-orientation'
|
||||
|
||||
import {type Dimensions} from '#/lib/media/types'
|
||||
import {useTheme} from '#/alf'
|
||||
import {setSystemUITheme} from '#/alf/util/systemUI'
|
||||
import {type Lightbox} from '#/components/Lightbox/state'
|
||||
import {IS_IOS} from '#/env'
|
||||
import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army'
|
||||
import {Footer} from '../chrome/Footer'
|
||||
import {Header} from '../chrome/Header'
|
||||
import {
|
||||
type ImageSource,
|
||||
type LightboxTransforms,
|
||||
type Transform,
|
||||
} from '../types'
|
||||
import ImageItem from './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: IS_IOS ? 1.25 : 0.75,
|
||||
damping: 300,
|
||||
stiffness: 800,
|
||||
restDisplacementThreshold: 0.001,
|
||||
}
|
||||
const FAST_SPRING: WithSpringConfig = {
|
||||
mass: IS_IOS ? 1.25 : 0.75,
|
||||
damping: 150,
|
||||
stiffness: 900,
|
||||
restDisplacementThreshold: 0.001,
|
||||
}
|
||||
|
||||
function canAnimate(lightbox: Lightbox): boolean {
|
||||
if (PlatformInfo.getIsReducedMotionEnabled()) {
|
||||
return false
|
||||
}
|
||||
const img = lightbox.images[lightbox.index]
|
||||
return !!img.thumbRect && !!(img.dimensions || img.thumbDimensions)
|
||||
}
|
||||
|
||||
export default function ImageViewRoot({
|
||||
lightbox: nextLightbox,
|
||||
onRequestClose,
|
||||
onPressSave,
|
||||
onPressShare,
|
||||
}: {
|
||||
lightbox: Lightbox | null
|
||||
onRequestClose: () => void
|
||||
onPressSave: (uri: string) => void
|
||||
onPressShare: (uri: string) => void
|
||||
}) {
|
||||
'use no memo'
|
||||
const ref = useAnimatedRef<View>()
|
||||
const [activeLightbox, setActiveLightbox] = useState(nextLightbox)
|
||||
const [orientation, setOrientation] = useState<'portrait' | 'landscape'>(
|
||||
'portrait',
|
||||
)
|
||||
const openProgress = useSharedValue(0)
|
||||
const thumbRects = useSharedValue<Record<number, MeasuredDimensions | null>>(
|
||||
{},
|
||||
)
|
||||
|
||||
if (!activeLightbox && nextLightbox) {
|
||||
setActiveLightbox(nextLightbox)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!nextLightbox) {
|
||||
return
|
||||
}
|
||||
|
||||
const initial: Record<number, MeasuredDimensions | null> = {}
|
||||
nextLightbox.images.forEach((img, i) => {
|
||||
initial[i] = img.thumbRect ?? null
|
||||
})
|
||||
thumbRects.set(initial)
|
||||
|
||||
const isAnimated = canAnimate(nextLightbox)
|
||||
|
||||
// https://github.com/software-mansion/react-native-reanimated/issues/6677
|
||||
rAF_FIXED(() => {
|
||||
openProgress.set(() =>
|
||||
isAnimated ? withClampedSpring(1, SLOW_SPRING) : 1,
|
||||
)
|
||||
})
|
||||
return () => {
|
||||
// https://github.com/software-mansion/react-native-reanimated/issues/6677
|
||||
rAF_FIXED(() => {
|
||||
openProgress.set(() =>
|
||||
isAnimated ? withClampedSpring(0, SLOW_SPRING) : 0,
|
||||
)
|
||||
})
|
||||
}
|
||||
}, [nextLightbox, openProgress, thumbRects])
|
||||
|
||||
const onFullyClosed = useCallback(() => {
|
||||
setActiveLightbox(null)
|
||||
runOnUI(() => {
|
||||
'worklet'
|
||||
thumbRects.set({})
|
||||
})()
|
||||
}, [thumbRects])
|
||||
|
||||
useAnimatedReaction(
|
||||
() => openProgress.get() === 0,
|
||||
(isGone, wasGone) => {
|
||||
if (isGone && !wasGone) {
|
||||
runOnJS(onFullyClosed)()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Delay the unlock until after we've finished the scale up animation.
|
||||
// It's complicated to do the same for locking it back so we don't attempt that.
|
||||
useAnimatedReaction(
|
||||
() => openProgress.get() === 1,
|
||||
(isOpen, wasOpen) => {
|
||||
if (isOpen && !wasOpen) {
|
||||
runOnJS(ScreenOrientation.unlockAsync)()
|
||||
} else if (!isOpen && wasOpen) {
|
||||
// default is PORTRAIT_UP - set via config plugin in app.config.js -sfn
|
||||
runOnJS(ScreenOrientation.lockAsync)(PORTRAIT_UP)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const onFlyAway = useCallback(() => {
|
||||
'worklet'
|
||||
openProgress.set(0)
|
||||
runOnJS(onRequestClose)()
|
||||
}, [onRequestClose, openProgress])
|
||||
|
||||
return (
|
||||
// Keep it always mounted to avoid flicker on the first frame.
|
||||
<View
|
||||
style={[styles.screen, !activeLightbox && styles.screenHidden]}
|
||||
aria-modal
|
||||
accessibilityViewIsModal
|
||||
aria-hidden={!activeLightbox}>
|
||||
<Animated.View
|
||||
ref={ref}
|
||||
style={{flex: 1}}
|
||||
collapsable={false}
|
||||
onLayout={e => {
|
||||
const layout = e.nativeEvent.layout
|
||||
setOrientation(
|
||||
layout.height > layout.width ? 'portrait' : 'landscape',
|
||||
)
|
||||
}}>
|
||||
{activeLightbox && (
|
||||
<ImageView
|
||||
key={activeLightbox.id + '-' + orientation}
|
||||
lightbox={activeLightbox}
|
||||
orientation={orientation}
|
||||
onRequestClose={onRequestClose}
|
||||
onPressSave={onPressSave}
|
||||
onPressShare={onPressShare}
|
||||
onFlyAway={onFlyAway}
|
||||
safeAreaRef={ref}
|
||||
openProgress={openProgress}
|
||||
thumbRects={thumbRects}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ImageView({
|
||||
lightbox,
|
||||
orientation,
|
||||
onRequestClose,
|
||||
onPressSave,
|
||||
onPressShare,
|
||||
onFlyAway,
|
||||
safeAreaRef,
|
||||
openProgress,
|
||||
thumbRects,
|
||||
}: {
|
||||
lightbox: Lightbox
|
||||
orientation: 'portrait' | 'landscape'
|
||||
onRequestClose: () => void
|
||||
onPressSave: (uri: string) => void
|
||||
onPressShare: (uri: string) => void
|
||||
onFlyAway: () => void
|
||||
safeAreaRef: AnimatedRef<View>
|
||||
openProgress: SharedValue<number>
|
||||
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
|
||||
}) {
|
||||
const {images, index: initialImageIndex} = lightbox
|
||||
const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox])
|
||||
const [isScaled, setIsScaled] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [imageIndex, setImageIndex] = useState(initialImageIndex)
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
const [isAltExpanded, setIsAltExpanded] = useState(false)
|
||||
const dismissSwipeTranslateY = useSharedValue(0)
|
||||
const isFlyingAway = useSharedValue(false)
|
||||
|
||||
const containerStyle = useAnimatedStyle(() => {
|
||||
if (openProgress.get() < 1) {
|
||||
return {
|
||||
pointerEvents: 'none',
|
||||
opacity: isAnimated ? 1 : 0,
|
||||
}
|
||||
}
|
||||
if (isFlyingAway.get()) {
|
||||
return {
|
||||
pointerEvents: 'none',
|
||||
opacity: 1,
|
||||
}
|
||||
}
|
||||
return {pointerEvents: 'auto', opacity: 1}
|
||||
})
|
||||
|
||||
const backdropStyle = useAnimatedStyle(() => {
|
||||
const screenSize = measure(safeAreaRef)
|
||||
let opacity = 1
|
||||
const openProgressValue = openProgress.get()
|
||||
if (openProgressValue < 1) {
|
||||
opacity = Math.sqrt(openProgressValue)
|
||||
} else if (screenSize && orientation === 'portrait') {
|
||||
const dragProgress = Math.min(
|
||||
Math.abs(dismissSwipeTranslateY.get()) / (screenSize.height / 2),
|
||||
1,
|
||||
)
|
||||
opacity -= dragProgress
|
||||
}
|
||||
const factor = IS_IOS ? 100 : 50
|
||||
return {
|
||||
opacity: Math.round(opacity * factor) / factor,
|
||||
}
|
||||
})
|
||||
|
||||
const animatedHeaderStyle = useAnimatedStyle(() => {
|
||||
const show = showControls && dismissSwipeTranslateY.get() === 0
|
||||
return {
|
||||
pointerEvents: show ? 'box-none' : 'none',
|
||||
opacity: withClampedSpring(
|
||||
show && openProgress.get() === 1 ? 1 : 0,
|
||||
FAST_SPRING,
|
||||
),
|
||||
transform: [
|
||||
{
|
||||
translateY: withClampedSpring(show ? 0 : -30, FAST_SPRING),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
const animatedFooterStyle = useAnimatedStyle(() => {
|
||||
const show = showControls && dismissSwipeTranslateY.get() === 0
|
||||
return {
|
||||
flexGrow: 1,
|
||||
pointerEvents: show ? 'box-none' : 'none',
|
||||
opacity: withClampedSpring(
|
||||
show && openProgress.get() === 1 ? 1 : 0,
|
||||
FAST_SPRING,
|
||||
),
|
||||
transform: [
|
||||
{
|
||||
translateY: withClampedSpring(show ? 0 : 30, FAST_SPRING),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const handleRequestClose = useCallback(() => {
|
||||
const activeRef = images[imageIndex]?.thumbRef
|
||||
if (isAnimated && activeRef) {
|
||||
runOnUI(() => {
|
||||
'worklet'
|
||||
const rect = measure(activeRef)
|
||||
thumbRects.modify(rects => {
|
||||
'worklet'
|
||||
rects[imageIndex] = rect
|
||||
return rects
|
||||
})
|
||||
runOnJS(onRequestClose)()
|
||||
})()
|
||||
} else {
|
||||
onRequestClose()
|
||||
}
|
||||
}, [isAnimated, images, imageIndex, thumbRects, onRequestClose])
|
||||
|
||||
const onTap = useCallback(() => {
|
||||
setShowControls(show => !show)
|
||||
}, [])
|
||||
|
||||
const onZoom = useCallback((nextIsScaled: boolean) => {
|
||||
setIsScaled(nextIsScaled)
|
||||
if (nextIsScaled) {
|
||||
setShowControls(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useAnimatedReaction(
|
||||
() => {
|
||||
const screenSize = measure(safeAreaRef)
|
||||
return (
|
||||
!screenSize ||
|
||||
Math.abs(dismissSwipeTranslateY.get()) > screenSize.height
|
||||
)
|
||||
},
|
||||
(isOut, wasOut) => {
|
||||
if (isOut && !wasOut) {
|
||||
// Stop the animation from blocking the screen forever.
|
||||
cancelAnimation(dismissSwipeTranslateY)
|
||||
onFlyAway()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// style system ui on android
|
||||
const t = useTheme()
|
||||
useEffect(() => {
|
||||
setSystemUITheme('lightbox', t)
|
||||
return () => {
|
||||
setSystemUITheme('theme', t)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, containerStyle]}>
|
||||
<SystemBars
|
||||
style={{statusBar: 'light', navigationBar: 'light'}}
|
||||
hidden={{
|
||||
statusBar: isScaled || !showControls,
|
||||
navigationBar: false,
|
||||
}}
|
||||
/>
|
||||
<Animated.View
|
||||
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={`${i}-${imageSrc.uri}`}>
|
||||
<LightboxImage
|
||||
onTap={onTap}
|
||||
onZoom={onZoom}
|
||||
imageSrc={imageSrc}
|
||||
onRequestClose={handleRequestClose}
|
||||
isScrollViewBeingDragged={isDragging}
|
||||
showControls={showControls}
|
||||
safeAreaRef={safeAreaRef}
|
||||
isScaled={isScaled}
|
||||
isFlyingAway={isFlyingAway}
|
||||
isActive={i === imageIndex}
|
||||
dismissSwipeTranslateY={dismissSwipeTranslateY}
|
||||
openProgress={openProgress}
|
||||
thumbRects={thumbRects}
|
||||
imageIndex={i}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</PagerView>
|
||||
<View style={styles.controls} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
style={animatedHeaderStyle}
|
||||
pointerEvents="box-none"
|
||||
renderToHardwareTextureAndroid>
|
||||
<Header
|
||||
onRequestClose={handleRequestClose}
|
||||
onPressShare={() => onPressShare(images[imageIndex].uri)}
|
||||
onPressSave={() => onPressSave(images[imageIndex].uri)}
|
||||
imageCount={images.length}
|
||||
activeIndex={imageIndex}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={animatedFooterStyle}
|
||||
pointerEvents="box-none"
|
||||
renderToHardwareTextureAndroid={!isAltExpanded}>
|
||||
<Footer
|
||||
altText={images[imageIndex].alt}
|
||||
isAltExpanded={isAltExpanded}
|
||||
onToggleAltExpanded={() => setIsAltExpanded(e => !e)}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function LightboxImage({
|
||||
imageSrc,
|
||||
onTap,
|
||||
onZoom,
|
||||
onRequestClose,
|
||||
isScrollViewBeingDragged,
|
||||
isScaled,
|
||||
isFlyingAway,
|
||||
isActive,
|
||||
showControls,
|
||||
safeAreaRef,
|
||||
openProgress,
|
||||
dismissSwipeTranslateY,
|
||||
thumbRects,
|
||||
imageIndex,
|
||||
}: {
|
||||
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>
|
||||
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
|
||||
imageIndex: number
|
||||
}) {
|
||||
const [fetchedDims, setFetchedDims] = 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 {
|
||||
width: widthDelayedForJSThreadOnly,
|
||||
height: heightDelayedForJSThreadOnly,
|
||||
} = useWindowDimensions()
|
||||
const measureSafeArea = useCallback(() => {
|
||||
'worklet'
|
||||
let safeArea: Rect | null = measure(safeAreaRef)
|
||||
if (!safeArea) {
|
||||
if (_WORKLET) {
|
||||
console.error('Expected to always be able to measure safe area.')
|
||||
}
|
||||
safeArea = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: widthDelayedForJSThreadOnly,
|
||||
height: heightDelayedForJSThreadOnly,
|
||||
}
|
||||
}
|
||||
return safeArea
|
||||
}, [safeAreaRef, heightDelayedForJSThreadOnly, widthDelayedForJSThreadOnly])
|
||||
|
||||
const {thumbRect: thumbRectJS, thumbBorderRadius} = imageSrc
|
||||
const transforms = useDerivedValue<LightboxTransforms>(() => {
|
||||
'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,
|
||||
borderRadius: 0,
|
||||
scaleAndMoveTransform: [],
|
||||
cropFrameTransform: [],
|
||||
cropContentTransform: [],
|
||||
}
|
||||
}
|
||||
|
||||
if (isActive && imageAspect && openProgressValue < 1) {
|
||||
let thumbRect
|
||||
if (_WORKLET) {
|
||||
thumbRect = thumbRects.get()[imageIndex]
|
||||
} else {
|
||||
thumbRect = thumbRectJS
|
||||
}
|
||||
if (thumbRect) {
|
||||
return interpolateTransform(
|
||||
openProgressValue,
|
||||
thumbRect,
|
||||
safeArea,
|
||||
imageAspect,
|
||||
thumbBorderRadius,
|
||||
)
|
||||
}
|
||||
}
|
||||
return {
|
||||
isHidden: false,
|
||||
isResting: dismissTranslateY === 0,
|
||||
borderRadius: 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.
|
||||
reduceMotion: ReduceMotion.Never, // If this animation doesn't run, the image gets stuck - therefore override Reduce Motion
|
||||
})
|
||||
})
|
||||
} else {
|
||||
dismissSwipeTranslateY.set(() => {
|
||||
'worklet'
|
||||
return withSpring(0, {
|
||||
stiffness: 700,
|
||||
damping: 50,
|
||||
reduceMotion: ReduceMotion.Never,
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
},
|
||||
screenHidden: {
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
backdrop: {
|
||||
backgroundColor: '#000',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
controls: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
gap: 20,
|
||||
zIndex: 1,
|
||||
pointerEvents: 'box-none',
|
||||
},
|
||||
pager: {
|
||||
flex: 1,
|
||||
},
|
||||
})
|
||||
|
||||
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,
|
||||
thumbBorderRadius?: number,
|
||||
): {
|
||||
scaleAndMoveTransform: Transform
|
||||
cropFrameTransform: Transform
|
||||
cropContentTransform: Transform
|
||||
borderRadius: number
|
||||
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],
|
||||
)
|
||||
// The border radius in the source thumbnail needs to be scaled to account
|
||||
// for the crop frame and overall scale so it visually matches at progress=0.
|
||||
const sourceBorderRadius = thumbBorderRadius ?? 0
|
||||
const initialCropScaleX = croppedFinalWidth / finalWidth
|
||||
const borderRadius = interpolate(
|
||||
progress,
|
||||
[0, 1],
|
||||
[sourceBorderRadius / (initialScale * initialCropScaleX), 0],
|
||||
)
|
||||
|
||||
return {
|
||||
isHidden: false,
|
||||
isResting: progress === 1,
|
||||
scaleAndMoveTransform: [{translateX}, {translateY}, {scale}],
|
||||
cropFrameTransform: [{scaleX: cropScaleX}, {scaleY: cropScaleY}],
|
||||
cropContentTransform: [{scaleX: 1 / cropScaleX}, {scaleY: 1 / cropScaleY}],
|
||||
borderRadius,
|
||||
}
|
||||
}
|
||||
|
||||
function withClampedSpring<T extends AnimatableValue>(
|
||||
value: T,
|
||||
config: WithSpringConfig,
|
||||
): T {
|
||||
'worklet'
|
||||
return withSpring(value, {...config, overshootClamping: true})
|
||||
}
|
||||
|
||||
// We have to do this because we can't trust RN's rAF to fire in order.
|
||||
// https://github.com/facebook/react-native/issues/48005
|
||||
let isFrameScheduled = false
|
||||
let pendingFrameCallbacks: Array<() => void> = []
|
||||
function rAF_FIXED(callback: () => void) {
|
||||
pendingFrameCallbacks.push(callback)
|
||||
if (!isFrameScheduled) {
|
||||
isFrameScheduled = true
|
||||
requestAnimationFrame(() => {
|
||||
const callbacks = pendingFrameCallbacks.slice()
|
||||
isFrameScheduled = false
|
||||
pendingFrameCallbacks = []
|
||||
let hasError = false
|
||||
let error
|
||||
for (let i = 0; i < callbacks.length; i++) {
|
||||
try {
|
||||
callbacks[i]()
|
||||
} catch (e) {
|
||||
hasError = true
|
||||
error = e
|
||||
}
|
||||
}
|
||||
if (hasError) {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user