forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1/**
2 * Copyright (c) JOB TODAY S.A. and its affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 */
8// Original code copied and simplified from the link below as the codebase is currently not maintained:
9// https://github.com/jobtoday/react-native-image-viewing
10
11import React, {useCallback, useEffect, useMemo, useState} from 'react'
12import {LayoutAnimation, PixelRatio, StyleSheet, View} from 'react-native'
13import {SystemBars} from 'react-native-edge-to-edge'
14import {Gesture} from 'react-native-gesture-handler'
15import PagerView from 'react-native-pager-view'
16import Animated, {
17 type AnimatedRef,
18 cancelAnimation,
19 interpolate,
20 measure,
21 ReduceMotion,
22 runOnJS,
23 type SharedValue,
24 useAnimatedReaction,
25 useAnimatedRef,
26 useAnimatedStyle,
27 useDerivedValue,
28 useSharedValue,
29 withDecay,
30 withSpring,
31 type WithSpringConfig,
32} from 'react-native-reanimated'
33import {
34 SafeAreaView,
35 useSafeAreaFrame,
36 useSafeAreaInsets,
37} from 'react-native-safe-area-context'
38import * as ScreenOrientation from 'expo-screen-orientation'
39import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
40import {Trans} from '@lingui/macro'
41
42import {type Dimensions} from '#/lib/media/types'
43import {colors, s} from '#/lib/styles'
44import {type Lightbox} from '#/state/lightbox'
45import {Button} from '#/view/com/util/forms/Button'
46import {Text} from '#/view/com/util/text/Text'
47import {ScrollView} from '#/view/com/util/Views'
48import {useTheme} from '#/alf'
49import {setSystemUITheme} from '#/alf/util/systemUI'
50import {IS_IOS} from '#/env'
51import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army'
52import {type ImageSource, type Transform} from './@types'
53import ImageDefaultHeader from './components/ImageDefaultHeader'
54import ImageItem from './components/ImageItem/ImageItem'
55
56type Rect = {x: number; y: number; width: number; height: number}
57
58const PORTRAIT_UP = ScreenOrientation.OrientationLock.PORTRAIT_UP
59const PIXEL_RATIO = PixelRatio.get()
60
61const SLOW_SPRING: WithSpringConfig = {
62 mass: IS_IOS ? 1.25 : 0.75,
63 damping: 300,
64 stiffness: 800,
65 restDisplacementThreshold: 0.01,
66}
67const FAST_SPRING: WithSpringConfig = {
68 mass: IS_IOS ? 1.25 : 0.75,
69 damping: 150,
70 stiffness: 900,
71 restDisplacementThreshold: 0.01,
72}
73
74function canAnimate(lightbox: Lightbox): boolean {
75 return (
76 !PlatformInfo.getIsReducedMotionEnabled() &&
77 lightbox.images.every(
78 img => img.thumbRect && (img.dimensions || img.thumbDimensions),
79 )
80 )
81}
82
83export default function ImageViewRoot({
84 lightbox: nextLightbox,
85 onRequestClose,
86 onPressSave,
87 onPressShare,
88}: {
89 lightbox: Lightbox | null
90 onRequestClose: () => void
91 onPressSave: (uri: string) => void
92 onPressShare: (uri: string) => void
93}) {
94 'use no memo'
95 const ref = useAnimatedRef<View>()
96 const [activeLightbox, setActiveLightbox] = useState(nextLightbox)
97 const [orientation, setOrientation] = useState<'portrait' | 'landscape'>(
98 'portrait',
99 )
100 const openProgress = useSharedValue(0)
101
102 if (!activeLightbox && nextLightbox) {
103 setActiveLightbox(nextLightbox)
104 }
105
106 React.useEffect(() => {
107 if (!nextLightbox) {
108 return
109 }
110
111 const isAnimated = canAnimate(nextLightbox)
112
113 // https://github.com/software-mansion/react-native-reanimated/issues/6677
114 rAF_FIXED(() => {
115 openProgress.set(() =>
116 isAnimated ? withClampedSpring(1, SLOW_SPRING) : 1,
117 )
118 })
119 return () => {
120 // https://github.com/software-mansion/react-native-reanimated/issues/6677
121 rAF_FIXED(() => {
122 openProgress.set(() =>
123 isAnimated ? withClampedSpring(0, SLOW_SPRING) : 0,
124 )
125 })
126 }
127 }, [nextLightbox, openProgress])
128
129 useAnimatedReaction(
130 () => openProgress.get() === 0,
131 (isGone, wasGone) => {
132 if (isGone && !wasGone) {
133 runOnJS(setActiveLightbox)(null)
134 }
135 },
136 )
137
138 // Delay the unlock until after we've finished the scale up animation.
139 // It's complicated to do the same for locking it back so we don't attempt that.
140 useAnimatedReaction(
141 () => openProgress.get() === 1,
142 (isOpen, wasOpen) => {
143 if (isOpen && !wasOpen) {
144 runOnJS(ScreenOrientation.unlockAsync)()
145 } else if (!isOpen && wasOpen) {
146 // default is PORTRAIT_UP - set via config plugin in app.config.js -sfn
147 runOnJS(ScreenOrientation.lockAsync)(PORTRAIT_UP)
148 }
149 },
150 )
151
152 const onFlyAway = React.useCallback(() => {
153 'worklet'
154 openProgress.set(0)
155 runOnJS(onRequestClose)()
156 }, [onRequestClose, openProgress])
157
158 return (
159 // Keep it always mounted to avoid flicker on the first frame.
160 <View
161 style={[styles.screen, !activeLightbox && styles.screenHidden]}
162 aria-modal
163 accessibilityViewIsModal
164 aria-hidden={!activeLightbox}>
165 <Animated.View
166 ref={ref}
167 style={{flex: 1}}
168 collapsable={false}
169 onLayout={e => {
170 const layout = e.nativeEvent.layout
171 setOrientation(
172 layout.height > layout.width ? 'portrait' : 'landscape',
173 )
174 }}>
175 {activeLightbox && (
176 <ImageView
177 key={activeLightbox.id + '-' + orientation}
178 lightbox={activeLightbox}
179 orientation={orientation}
180 onRequestClose={onRequestClose}
181 onPressSave={onPressSave}
182 onPressShare={onPressShare}
183 onFlyAway={onFlyAway}
184 safeAreaRef={ref}
185 openProgress={openProgress}
186 />
187 )}
188 </Animated.View>
189 </View>
190 )
191}
192
193function ImageView({
194 lightbox,
195 orientation,
196 onRequestClose,
197 onPressSave,
198 onPressShare,
199 onFlyAway,
200 safeAreaRef,
201 openProgress,
202}: {
203 lightbox: Lightbox
204 orientation: 'portrait' | 'landscape'
205 onRequestClose: () => void
206 onPressSave: (uri: string) => void
207 onPressShare: (uri: string) => void
208 onFlyAway: () => void
209 safeAreaRef: AnimatedRef<View>
210 openProgress: SharedValue<number>
211}) {
212 const {images, index: initialImageIndex} = lightbox
213 const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox])
214 const [isScaled, setIsScaled] = useState(false)
215 const [isDragging, setIsDragging] = useState(false)
216 const [imageIndex, setImageIndex] = useState(initialImageIndex)
217 const [showControls, setShowControls] = useState(true)
218 const [isAltExpanded, setAltExpanded] = React.useState(false)
219 const dismissSwipeTranslateY = useSharedValue(0)
220 const isFlyingAway = useSharedValue(false)
221
222 const containerStyle = useAnimatedStyle(() => {
223 if (openProgress.get() < 1) {
224 return {
225 pointerEvents: 'none',
226 opacity: isAnimated ? 1 : 0,
227 }
228 }
229 if (isFlyingAway.get()) {
230 return {
231 pointerEvents: 'none',
232 opacity: 1,
233 }
234 }
235 return {pointerEvents: 'auto', opacity: 1}
236 })
237
238 const backdropStyle = useAnimatedStyle(() => {
239 const screenSize = measure(safeAreaRef)
240 let opacity = 1
241 const openProgressValue = openProgress.get()
242 if (openProgressValue < 1) {
243 opacity = Math.sqrt(openProgressValue)
244 } else if (screenSize && orientation === 'portrait') {
245 const dragProgress = Math.min(
246 Math.abs(dismissSwipeTranslateY.get()) / (screenSize.height / 2),
247 1,
248 )
249 opacity -= dragProgress
250 }
251 const factor = IS_IOS ? 100 : 50
252 return {
253 opacity: Math.round(opacity * factor) / factor,
254 }
255 })
256
257 const animatedHeaderStyle = useAnimatedStyle(() => {
258 const show = showControls && dismissSwipeTranslateY.get() === 0
259 return {
260 pointerEvents: show ? 'box-none' : 'none',
261 opacity: withClampedSpring(
262 show && openProgress.get() === 1 ? 1 : 0,
263 FAST_SPRING,
264 ),
265 transform: [
266 {
267 translateY: withClampedSpring(show ? 0 : -30, FAST_SPRING),
268 },
269 ],
270 }
271 })
272 const animatedFooterStyle = useAnimatedStyle(() => {
273 const show = showControls && dismissSwipeTranslateY.get() === 0
274 return {
275 flexGrow: 1,
276 pointerEvents: show ? 'box-none' : 'none',
277 opacity: withClampedSpring(
278 show && openProgress.get() === 1 ? 1 : 0,
279 FAST_SPRING,
280 ),
281 transform: [
282 {
283 translateY: withClampedSpring(show ? 0 : 30, FAST_SPRING),
284 },
285 ],
286 }
287 })
288
289 const onTap = useCallback(() => {
290 setShowControls(show => !show)
291 }, [])
292
293 const onZoom = useCallback((nextIsScaled: boolean) => {
294 setIsScaled(nextIsScaled)
295 if (nextIsScaled) {
296 setShowControls(false)
297 }
298 }, [])
299
300 useAnimatedReaction(
301 () => {
302 const screenSize = measure(safeAreaRef)
303 return (
304 !screenSize ||
305 Math.abs(dismissSwipeTranslateY.get()) > screenSize.height
306 )
307 },
308 (isOut, wasOut) => {
309 if (isOut && !wasOut) {
310 // Stop the animation from blocking the screen forever.
311 cancelAnimation(dismissSwipeTranslateY)
312 onFlyAway()
313 }
314 },
315 )
316
317 // style system ui on android
318 const t = useTheme()
319 useEffect(() => {
320 setSystemUITheme('lightbox', t)
321 return () => {
322 setSystemUITheme('theme', t)
323 }
324 }, [t])
325
326 return (
327 <Animated.View style={[styles.container, containerStyle]}>
328 <SystemBars
329 style={{statusBar: 'light', navigationBar: 'light'}}
330 hidden={{
331 statusBar: isScaled || !showControls,
332 navigationBar: false,
333 }}
334 />
335 <Animated.View
336 style={[styles.backdrop, backdropStyle]}
337 renderToHardwareTextureAndroid
338 />
339 <PagerView
340 scrollEnabled={!isScaled}
341 initialPage={initialImageIndex}
342 onPageSelected={e => {
343 setImageIndex(e.nativeEvent.position)
344 setIsScaled(false)
345 }}
346 onPageScrollStateChanged={e => {
347 setIsDragging(e.nativeEvent.pageScrollState !== 'idle')
348 }}
349 overdrag={true}
350 style={styles.pager}>
351 {images.map((imageSrc, i) => (
352 <View key={imageSrc.uri}>
353 <LightboxImage
354 onTap={onTap}
355 onZoom={onZoom}
356 imageSrc={imageSrc}
357 onRequestClose={onRequestClose}
358 isScrollViewBeingDragged={isDragging}
359 showControls={showControls}
360 safeAreaRef={safeAreaRef}
361 isScaled={isScaled}
362 isFlyingAway={isFlyingAway}
363 isActive={i === imageIndex}
364 dismissSwipeTranslateY={dismissSwipeTranslateY}
365 openProgress={openProgress}
366 />
367 </View>
368 ))}
369 </PagerView>
370 <View style={styles.controls}>
371 <Animated.View
372 style={animatedHeaderStyle}
373 renderToHardwareTextureAndroid>
374 <ImageDefaultHeader onRequestClose={onRequestClose} />
375 </Animated.View>
376 <Animated.View
377 style={animatedFooterStyle}
378 renderToHardwareTextureAndroid={!isAltExpanded}>
379 <LightboxFooter
380 images={images}
381 index={imageIndex}
382 isAltExpanded={isAltExpanded}
383 toggleAltExpanded={() => setAltExpanded(e => !e)}
384 onPressSave={onPressSave}
385 onPressShare={onPressShare}
386 />
387 </Animated.View>
388 </View>
389 </Animated.View>
390 )
391}
392
393function LightboxImage({
394 imageSrc,
395 onTap,
396 onZoom,
397 onRequestClose,
398 isScrollViewBeingDragged,
399 isScaled,
400 isFlyingAway,
401 isActive,
402 showControls,
403 safeAreaRef,
404 openProgress,
405 dismissSwipeTranslateY,
406}: {
407 imageSrc: ImageSource
408 onRequestClose: () => void
409 onTap: () => void
410 onZoom: (scaled: boolean) => void
411 isScrollViewBeingDragged: boolean
412 isScaled: boolean
413 isActive: boolean
414 isFlyingAway: SharedValue<boolean>
415 showControls: boolean
416 safeAreaRef: AnimatedRef<View>
417 openProgress: SharedValue<number>
418 dismissSwipeTranslateY: SharedValue<number>
419}) {
420 const [fetchedDims, setFetchedDims] = React.useState<Dimensions | null>(null)
421 const dims = fetchedDims ?? imageSrc.dimensions ?? imageSrc.thumbDimensions
422 let imageAspect: number | undefined
423 if (dims) {
424 imageAspect = dims.width / dims.height
425 if (Number.isNaN(imageAspect)) {
426 imageAspect = undefined
427 }
428 }
429
430 const safeFrameDelayedForJSThreadOnly = useSafeAreaFrame()
431 const safeInsetsDelayedForJSThreadOnly = useSafeAreaInsets()
432 const measureSafeArea = React.useCallback(() => {
433 'worklet'
434 let safeArea: Rect | null = measure(safeAreaRef)
435 if (!safeArea) {
436 if (_WORKLET) {
437 console.error('Expected to always be able to measure safe area.')
438 }
439 const frame = safeFrameDelayedForJSThreadOnly
440 const insets = safeInsetsDelayedForJSThreadOnly
441 safeArea = {
442 x: frame.x + insets.left,
443 y: frame.y + insets.top,
444 width: frame.width - insets.left - insets.right,
445 height: frame.height - insets.top - insets.bottom,
446 }
447 }
448 return safeArea
449 }, [
450 safeFrameDelayedForJSThreadOnly,
451 safeInsetsDelayedForJSThreadOnly,
452 safeAreaRef,
453 ])
454
455 const {thumbRect} = imageSrc
456 const transforms = useDerivedValue(() => {
457 'worklet'
458 const safeArea = measureSafeArea()
459 const openProgressValue = openProgress.get()
460 const dismissTranslateY =
461 isActive && openProgressValue === 1 ? dismissSwipeTranslateY.get() : 0
462
463 if (openProgressValue === 0 && isFlyingAway.get()) {
464 return {
465 isHidden: true,
466 isResting: false,
467 scaleAndMoveTransform: [],
468 cropFrameTransform: [],
469 cropContentTransform: [],
470 }
471 }
472
473 if (isActive && thumbRect && imageAspect && openProgressValue < 1) {
474 return interpolateTransform(
475 openProgressValue,
476 thumbRect,
477 safeArea,
478 imageAspect,
479 )
480 }
481 return {
482 isHidden: false,
483 isResting: dismissTranslateY === 0,
484 scaleAndMoveTransform: [{translateY: dismissTranslateY}],
485 cropFrameTransform: [],
486 cropContentTransform: [],
487 }
488 })
489
490 const dismissSwipePan = Gesture.Pan()
491 .enabled(isActive && !isScaled)
492 .activeOffsetY([-10, 10])
493 .failOffsetX([-10, 10])
494 .maxPointers(1)
495 .onUpdate(e => {
496 'worklet'
497 if (openProgress.get() !== 1 || isFlyingAway.get()) {
498 return
499 }
500 dismissSwipeTranslateY.set(e.translationY)
501 })
502 .onEnd(e => {
503 'worklet'
504 if (openProgress.get() !== 1 || isFlyingAway.get()) {
505 return
506 }
507 if (Math.abs(e.velocityY) > 200) {
508 isFlyingAway.set(true)
509 if (dismissSwipeTranslateY.get() === 0) {
510 // HACK: If the initial value is 0, withDecay() animation doesn't start.
511 // This is a bug in Reanimated, but for now we'll work around it like this.
512 dismissSwipeTranslateY.set(1)
513 }
514 dismissSwipeTranslateY.set(() => {
515 'worklet'
516 return withDecay({
517 velocity: e.velocityY,
518 velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1), // Speed up if it's too slow.
519 deceleration: 1, // Danger! This relies on the reaction below stopping it.
520 reduceMotion: ReduceMotion.Never, // If this animation doesn't run, the image gets stuck - therefore override Reduce Motion
521 })
522 })
523 } else {
524 dismissSwipeTranslateY.set(() => {
525 'worklet'
526 return withSpring(0, {
527 stiffness: 700,
528 damping: 50,
529 reduceMotion: ReduceMotion.Never,
530 })
531 })
532 }
533 })
534
535 return (
536 <ImageItem
537 imageSrc={imageSrc}
538 onTap={onTap}
539 onZoom={onZoom}
540 onRequestClose={onRequestClose}
541 onLoad={setFetchedDims}
542 isScrollViewBeingDragged={isScrollViewBeingDragged}
543 showControls={showControls}
544 measureSafeArea={measureSafeArea}
545 imageAspect={imageAspect}
546 imageDimensions={dims ?? undefined}
547 dismissSwipePan={dismissSwipePan}
548 transforms={transforms}
549 />
550 )
551}
552
553function LightboxFooter({
554 images,
555 index,
556 isAltExpanded,
557 toggleAltExpanded,
558 onPressSave,
559 onPressShare,
560}: {
561 images: ImageSource[]
562 index: number
563 isAltExpanded: boolean
564 toggleAltExpanded: () => void
565 onPressSave: (uri: string) => void
566 onPressShare: (uri: string) => void
567}) {
568 const {alt: altText, uri} = images[index]
569 const isMomentumScrolling = React.useRef(false)
570 return (
571 <ScrollView
572 style={styles.footerScrollView}
573 scrollEnabled={isAltExpanded}
574 onMomentumScrollBegin={() => {
575 isMomentumScrolling.current = true
576 }}
577 onMomentumScrollEnd={() => {
578 isMomentumScrolling.current = false
579 }}
580 contentContainerStyle={{
581 paddingVertical: 12,
582 paddingHorizontal: 24,
583 }}>
584 <SafeAreaView edges={['bottom']}>
585 {altText ? (
586 <View accessibilityRole="button" style={styles.footerText}>
587 <Text
588 style={[s.gray3]}
589 numberOfLines={isAltExpanded ? undefined : 3}
590 selectable
591 onPress={() => {
592 if (isMomentumScrolling.current) {
593 return
594 }
595 LayoutAnimation.configureNext({
596 duration: 450,
597 update: {type: 'spring', springDamping: 1},
598 })
599 toggleAltExpanded()
600 }}
601 onLongPress={() => {}}
602 emoji>
603 {altText}
604 </Text>
605 </View>
606 ) : null}
607 <View style={styles.footerBtns}>
608 <Button
609 type="primary-outline"
610 style={styles.footerBtn}
611 onPress={() => onPressSave(uri)}>
612 <FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
613 <Text type="xl" style={s.white}>
614 <Trans context="action">Save</Trans>
615 </Text>
616 </Button>
617 <Button
618 type="primary-outline"
619 style={styles.footerBtn}
620 onPress={() => onPressShare(uri)}>
621 <FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
622 <Text type="xl" style={s.white}>
623 <Trans context="action">Share</Trans>
624 </Text>
625 </Button>
626 </View>
627 </SafeAreaView>
628 </ScrollView>
629 )
630}
631
632const styles = StyleSheet.create({
633 screen: {
634 position: 'absolute',
635 top: 0,
636 left: 0,
637 bottom: 0,
638 right: 0,
639 },
640 screenHidden: {
641 opacity: 0,
642 pointerEvents: 'none',
643 },
644 container: {
645 flex: 1,
646 },
647 backdrop: {
648 backgroundColor: '#000',
649 position: 'absolute',
650 top: 0,
651 bottom: 0,
652 left: 0,
653 right: 0,
654 },
655 controls: {
656 position: 'absolute',
657 top: 0,
658 bottom: 0,
659 left: 0,
660 right: 0,
661 gap: 20,
662 zIndex: 1,
663 pointerEvents: 'box-none',
664 },
665 pager: {
666 flex: 1,
667 },
668 header: {
669 position: 'absolute',
670 width: '100%',
671 top: 0,
672 pointerEvents: 'box-none',
673 },
674 footer: {
675 position: 'absolute',
676 width: '100%',
677 maxHeight: '100%',
678 bottom: 0,
679 },
680 footerScrollView: {
681 backgroundColor: '#000d',
682 flex: 1,
683 position: 'absolute',
684 bottom: 0,
685 width: '100%',
686 maxHeight: '100%',
687 },
688 footerText: {
689 paddingBottom: IS_IOS ? 20 : 16,
690 },
691 footerBtns: {
692 flexDirection: 'row',
693 justifyContent: 'center',
694 gap: 8,
695 },
696 footerBtn: {
697 flexDirection: 'row',
698 alignItems: 'center',
699 gap: 8,
700 backgroundColor: 'transparent',
701 borderColor: colors.white,
702 },
703})
704
705function interpolatePx(
706 px: number,
707 inputRange: readonly number[],
708 outputRange: readonly number[],
709) {
710 'worklet'
711 const value = interpolate(px, inputRange, outputRange)
712 return Math.round(value * PIXEL_RATIO) / PIXEL_RATIO
713}
714
715function interpolateTransform(
716 progress: number,
717 thumbnailDims: {
718 pageX: number
719 width: number
720 pageY: number
721 height: number
722 },
723 safeArea: {width: number; height: number; x: number; y: number},
724 imageAspect: number,
725): {
726 scaleAndMoveTransform: Transform
727 cropFrameTransform: Transform
728 cropContentTransform: Transform
729 isResting: boolean
730 isHidden: boolean
731} {
732 'worklet'
733 const thumbAspect = thumbnailDims.width / thumbnailDims.height
734 let uncroppedInitialWidth
735 let uncroppedInitialHeight
736 if (imageAspect > thumbAspect) {
737 uncroppedInitialWidth = thumbnailDims.height * imageAspect
738 uncroppedInitialHeight = thumbnailDims.height
739 } else {
740 uncroppedInitialWidth = thumbnailDims.width
741 uncroppedInitialHeight = thumbnailDims.width / imageAspect
742 }
743 const safeAreaAspect = safeArea.width / safeArea.height
744 let finalWidth
745 let finalHeight
746 if (safeAreaAspect > imageAspect) {
747 finalWidth = safeArea.height * imageAspect
748 finalHeight = safeArea.height
749 } else {
750 finalWidth = safeArea.width
751 finalHeight = safeArea.width / imageAspect
752 }
753 const initialScale = Math.min(
754 uncroppedInitialWidth / finalWidth,
755 uncroppedInitialHeight / finalHeight,
756 )
757 const croppedFinalWidth = thumbnailDims.width / initialScale
758 const croppedFinalHeight = thumbnailDims.height / initialScale
759 const screenCenterX = safeArea.width / 2
760 const screenCenterY = safeArea.height / 2
761 const thumbnailSafeAreaX = thumbnailDims.pageX - safeArea.x
762 const thumbnailSafeAreaY = thumbnailDims.pageY - safeArea.y
763 const thumbnailCenterX = thumbnailSafeAreaX + thumbnailDims.width / 2
764 const thumbnailCenterY = thumbnailSafeAreaY + thumbnailDims.height / 2
765 const initialTranslateX = thumbnailCenterX - screenCenterX
766 const initialTranslateY = thumbnailCenterY - screenCenterY
767 const scale = interpolate(progress, [0, 1], [initialScale, 1])
768 const translateX = interpolatePx(progress, [0, 1], [initialTranslateX, 0])
769 const translateY = interpolatePx(progress, [0, 1], [initialTranslateY, 0])
770 const cropScaleX = interpolate(
771 progress,
772 [0, 1],
773 [croppedFinalWidth / finalWidth, 1],
774 )
775 const cropScaleY = interpolate(
776 progress,
777 [0, 1],
778 [croppedFinalHeight / finalHeight, 1],
779 )
780 return {
781 isHidden: false,
782 isResting: progress === 1,
783 scaleAndMoveTransform: [{translateX}, {translateY}, {scale}],
784 cropFrameTransform: [{scaleX: cropScaleX}, {scaleY: cropScaleY}],
785 cropContentTransform: [{scaleX: 1 / cropScaleX}, {scaleY: 1 / cropScaleY}],
786 }
787}
788
789function withClampedSpring(value: any, config: WithSpringConfig) {
790 'worklet'
791 return withSpring(value, {...config, overshootClamping: true})
792}
793
794// We have to do this because we can't trust RN's rAF to fire in order.
795// https://github.com/facebook/react-native/issues/48005
796let isFrameScheduled = false
797let pendingFrameCallbacks: Array<() => void> = []
798function rAF_FIXED(callback: () => void) {
799 pendingFrameCallbacks.push(callback)
800 if (!isFrameScheduled) {
801 isFrameScheduled = true
802 requestAnimationFrame(() => {
803 const callbacks = pendingFrameCallbacks.slice()
804 isFrameScheduled = false
805 pendingFrameCallbacks = []
806 let hasError = false
807 let error
808 for (let i = 0; i < callbacks.length; i++) {
809 try {
810 callbacks[i]()
811 } catch (e) {
812 hasError = true
813 error = e
814 }
815 }
816 if (hasError) {
817 throw error
818 }
819 })
820 }
821}