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