Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 82f42e734c50b34de31e8aff1e7ced248ab6e96f 819 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 { 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}