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