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