Bluesky app fork with some witchin' additions 馃挮
at main 821 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 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}