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