Animate lightbox in and out

This commit is contained in:
Dan Abramov
2024-11-07 10:40:25 +00:00
parent d7fa2132bb
commit 253954d66d
3 changed files with 218 additions and 60 deletions
@@ -302,11 +302,6 @@ const ImageItem = ({
committedTransform.value = withClampedSpring(finalTransform) committedTransform.value = withClampedSpring(finalTransform)
}) })
const innerStyle = useAnimatedStyle(() => ({
width: '100%',
aspectRatio: imageAspect,
}))
const composedGesture = isScrollViewBeingDragged const composedGesture = isScrollViewBeingDragged
? // If the parent is not at rest, provide a no-op gesture. ? // If the parent is not at rest, provide a no-op gesture.
Gesture.Manual() Gesture.Manual()
@@ -317,12 +312,9 @@ const ImageItem = ({
singleTap, singleTap,
) )
const type = imageSrc.type
const borderRadius =
type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0
return ( return (
<GestureDetector gesture={composedGesture}> <GestureDetector gesture={composedGesture}>
<Animated.View style={imageStyle} renderToHardwareTextureAndroid> <Animated.View renderToHardwareTextureAndroid>
<Animated.View <Animated.View
ref={containerRef} ref={containerRef}
// Necessary to make opacity work for both children together. // Necessary to make opacity work for both children together.
@@ -330,11 +322,11 @@ const ImageItem = ({
style={[styles.container, animatedStyle]}> style={[styles.container, animatedStyle]}>
<ActivityIndicator size="small" color="#FFF" style={styles.loading} /> <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
<AnimatedImage <AnimatedImage
contentFit="contain" contentFit="cover"
source={{uri: imageSrc.uri}} source={{uri: imageSrc.uri}}
placeholderContentFit="contain" placeholderContentFit="cover"
placeholder={{uri: imageSrc.thumbUri}} placeholder={{uri: imageSrc.thumbUri}}
style={[innerStyle, {borderRadius}]} style={imageStyle}
accessibilityLabel={imageSrc.alt} accessibilityLabel={imageSrc.alt}
accessibilityHint="" accessibilityHint=""
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
@@ -18,7 +18,6 @@ import Animated, {
measure, measure,
runOnJS, runOnJS,
useAnimatedRef, useAnimatedRef,
useAnimatedStyle,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {useSafeAreaFrame} from 'react-native-safe-area-context' import {useSafeAreaFrame} from 'react-native-safe-area-context'
import {Image, ImageStyle} from 'expo-image' import {Image, ImageStyle} from 'expo-image'
@@ -67,16 +66,6 @@ const ImageItem = ({
: 1, : 1,
) )
const animatedStyle = useAnimatedStyle(() => {
const screenSize = measure(safeAreaRef) ?? screenSizeDelayedForJSThreadOnly
return {
width: screenSize.width,
maxHeight: screenSize.height,
alignSelf: 'center',
aspectRatio: imageAspect,
}
})
const scrollHandler = useAnimatedScrollHandler({ const scrollHandler = useAnimatedScrollHandler({
onScroll(e) { onScroll(e) {
const nextIsScaled = e.zoomScale > 1 const nextIsScaled = e.zoomScale > 1
@@ -143,9 +132,6 @@ const ImageItem = ({
singleTap, singleTap,
) )
const type = imageSrc.type
const borderRadius =
type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0
return ( return (
<GestureDetector gesture={composedGesture}> <GestureDetector gesture={composedGesture}>
<Animated.ScrollView <Animated.ScrollView
@@ -158,15 +144,14 @@ const ImageItem = ({
onScroll={scrollHandler} onScroll={scrollHandler}
bounces={scaled} bounces={scaled}
bouncesZoom={true} bouncesZoom={true}
style={imageStyle}
centerContent> centerContent>
<ActivityIndicator size="small" color="#FFF" style={styles.loading} /> <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
<AnimatedImage <AnimatedImage
contentFit="contain" contentFit="cover"
source={{uri: imageSrc.uri}} source={{uri: imageSrc.uri}}
placeholderContentFit="contain" placeholderContentFit="cover"
placeholder={{uri: imageSrc.thumbUri}} placeholder={{uri: imageSrc.thumbUri}}
style={[animatedStyle, {borderRadius}]} style={imageStyle}
accessibilityLabel={imageSrc.alt} accessibilityLabel={imageSrc.alt}
accessibilityHint="" accessibilityHint=""
enableLiveTextInteraction={showControls && !scaled} enableLiveTextInteraction={showControls && !scaled}
+198 -17
View File
@@ -9,18 +9,26 @@
// https://github.com/jobtoday/react-native-image-viewing // https://github.com/jobtoday/react-native-image-viewing
import React, {useCallback, useState} from 'react' import React, {useCallback, useState} from 'react'
import {LayoutAnimation, Platform, StyleSheet, View} from 'react-native' import {
LayoutAnimation,
PixelRatio,
Platform,
StyleSheet,
View,
} from 'react-native'
import {Gesture} from 'react-native-gesture-handler' import {Gesture} from 'react-native-gesture-handler'
import PagerView from 'react-native-pager-view' import PagerView from 'react-native-pager-view'
import Animated, { import Animated, {
AnimatedRef, AnimatedRef,
cancelAnimation, cancelAnimation,
interpolate,
measure, measure,
runOnJS, runOnJS,
SharedValue, SharedValue,
useAnimatedReaction, useAnimatedReaction,
useAnimatedRef, useAnimatedRef,
useAnimatedStyle, useAnimatedStyle,
useDerivedValue,
useSharedValue, useSharedValue,
withDecay, withDecay,
withSpring, withSpring,
@@ -36,17 +44,19 @@ import {Lightbox} from '#/state/lightbox'
import {Button} from '#/view/com/util/forms/Button' import {Button} from '#/view/com/util/forms/Button'
import {Text} from '#/view/com/util/text/Text' import {Text} from '#/view/com/util/text/Text'
import {ScrollView} from '#/view/com/util/Views' import {ScrollView} from '#/view/com/util/Views'
import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army'
import {ImageSource} from './@types' import {ImageSource} from './@types'
import ImageDefaultHeader from './components/ImageDefaultHeader' import ImageDefaultHeader from './components/ImageDefaultHeader'
import ImageItem from './components/ImageItem/ImageItem' import ImageItem from './components/ImageItem/ImageItem'
const PIXEL_RATIO = PixelRatio.get()
const EDGES = const EDGES =
Platform.OS === 'android' Platform.OS === 'android'
? (['top', 'bottom', 'left', 'right'] satisfies Edge[]) ? (['top', 'bottom', 'left', 'right'] satisfies Edge[])
: (['left', 'right'] satisfies Edge[]) // iOS, so no top/bottom safe area : (['left', 'right'] satisfies Edge[]) // iOS, so no top/bottom safe area
export default function ImageViewRoot({ export default function ImageViewRoot({
lightbox, lightbox: nextLightbox,
onRequestClose, onRequestClose,
onPressSave, onPressSave,
onPressShare, onPressShare,
@@ -57,23 +67,67 @@ export default function ImageViewRoot({
onPressShare: (uri: string) => void onPressShare: (uri: string) => void
}) { }) {
const ref = useAnimatedRef<View>() const ref = useAnimatedRef<View>()
const [activeLightbox, setActiveLightbox] = useState(nextLightbox)
const openProgress = useSharedValue(0)
if (!activeLightbox && nextLightbox) {
setActiveLightbox(nextLightbox)
}
React.useEffect(() => {
if (!nextLightbox) {
return
}
const canAnimate =
!PlatformInfo.getIsReducedMotionEnabled() &&
nextLightbox.images.every(img => img.dimensions && img.thumbRect)
if (canAnimate) {
openProgress.value = withClampedSpring(1)
return () => {
openProgress.value = withClampedSpring(0)
}
} else {
openProgress.value = 1
return () => {
openProgress.value = 0
}
}
}, [nextLightbox, openProgress])
useAnimatedReaction(
() => openProgress.value === 0,
(isGone, wasGone) => {
if (isGone && !wasGone) {
runOnJS(setActiveLightbox)(null)
}
},
)
const onFlyAway = React.useCallback(() => {
'worklet'
openProgress.value = 0
runOnJS(onRequestClose)()
}, [onRequestClose, openProgress])
return ( return (
// Keep it always mounted to avoid flicker on the first frame. // Keep it always mounted to avoid flicker on the first frame.
<SafeAreaView <SafeAreaView
style={[styles.screen, !lightbox && styles.screenHidden]} style={[styles.screen, !activeLightbox && styles.screenHidden]}
edges={EDGES} edges={EDGES}
aria-modal aria-modal
accessibilityViewIsModal accessibilityViewIsModal
aria-hidden={!lightbox}> aria-hidden={!activeLightbox}>
<Animated.View ref={ref} style={{flex: 1}} collapsable={false}> <Animated.View ref={ref} style={{flex: 1}} collapsable={false}>
{lightbox && ( {activeLightbox && (
<ImageView <ImageView
key={lightbox.id} key={activeLightbox.id}
lightbox={lightbox} lightbox={activeLightbox}
onRequestClose={onRequestClose} onRequestClose={onRequestClose}
onPressSave={onPressSave} onPressSave={onPressSave}
onPressShare={onPressShare} onPressShare={onPressShare}
onFlyAway={onFlyAway}
safeAreaRef={ref} safeAreaRef={ref}
openProgress={openProgress}
/> />
)} )}
</Animated.View> </Animated.View>
@@ -86,13 +140,17 @@ function ImageView({
onRequestClose, onRequestClose,
onPressSave, onPressSave,
onPressShare, onPressShare,
onFlyAway,
safeAreaRef, safeAreaRef,
openProgress,
}: { }: {
lightbox: Lightbox lightbox: Lightbox
onRequestClose: () => void onRequestClose: () => void
onPressSave: (uri: string) => void onPressSave: (uri: string) => void
onPressShare: (uri: string) => void onPressShare: (uri: string) => void
onFlyAway: () => void
safeAreaRef: AnimatedRef<View> safeAreaRef: AnimatedRef<View>
openProgress: SharedValue<number>
}) { }) {
const {images, index: initialImageIndex} = lightbox const {images, index: initialImageIndex} = lightbox
const [isScaled, setIsScaled] = useState(false) const [isScaled, setIsScaled] = useState(false)
@@ -104,7 +162,7 @@ function ImageView({
const isFlyingAway = useSharedValue(false) const isFlyingAway = useSharedValue(false)
const containerStyle = useAnimatedStyle(() => { const containerStyle = useAnimatedStyle(() => {
if (isFlyingAway.value) { if (openProgress.value < 1 || isFlyingAway.value) {
return {pointerEvents: 'none'} return {pointerEvents: 'none'}
} }
return {pointerEvents: 'auto'} return {pointerEvents: 'auto'}
@@ -112,7 +170,9 @@ function ImageView({
const backdropStyle = useAnimatedStyle(() => { const backdropStyle = useAnimatedStyle(() => {
const screenSize = measure(safeAreaRef) const screenSize = measure(safeAreaRef)
let opacity = 1 let opacity = 1
if (screenSize) { if (openProgress.value < 1) {
opacity = Math.sqrt(openProgress.value)
} else if (screenSize) {
const dragProgress = Math.min( const dragProgress = Math.min(
Math.abs(dismissSwipeTranslateY.value) / (screenSize.height / 2), Math.abs(dismissSwipeTranslateY.value) / (screenSize.height / 2),
1, 1,
@@ -127,7 +187,7 @@ function ImageView({
const show = showControls && dismissSwipeTranslateY.value === 0 const show = showControls && dismissSwipeTranslateY.value === 0
return { return {
pointerEvents: show ? 'box-none' : 'none', pointerEvents: show ? 'box-none' : 'none',
opacity: withClampedSpring(show ? 1 : 0), opacity: withClampedSpring(show && openProgress.value === 1 ? 1 : 0),
transform: [ transform: [
{ {
translateY: withClampedSpring(show ? 0 : -30), translateY: withClampedSpring(show ? 0 : -30),
@@ -140,7 +200,7 @@ function ImageView({
return { return {
flexGrow: 1, flexGrow: 1,
pointerEvents: show ? 'box-none' : 'none', pointerEvents: show ? 'box-none' : 'none',
opacity: withClampedSpring(show ? 1 : 0), opacity: withClampedSpring(show && openProgress.value === 1 ? 1 : 0),
transform: [ transform: [
{ {
translateY: withClampedSpring(show ? 0 : 30), translateY: withClampedSpring(show ? 0 : 30),
@@ -202,6 +262,7 @@ function ImageView({
onZoom={onZoom} onZoom={onZoom}
imageSrc={imageSrc} imageSrc={imageSrc}
onRequestClose={onRequestClose} onRequestClose={onRequestClose}
onFlyAway={onFlyAway}
isScrollViewBeingDragged={isDragging} isScrollViewBeingDragged={isDragging}
showControls={showControls} showControls={showControls}
safeAreaRef={safeAreaRef} safeAreaRef={safeAreaRef}
@@ -209,6 +270,7 @@ function ImageView({
isFlyingAway={isFlyingAway} isFlyingAway={isFlyingAway}
isActive={i === imageIndex} isActive={i === imageIndex}
dismissSwipeTranslateY={dismissSwipeTranslateY} dismissSwipeTranslateY={dismissSwipeTranslateY}
openProgress={openProgress}
/> />
</View> </View>
))} ))}
@@ -241,24 +303,28 @@ function LightboxImage({
onTap, onTap,
onZoom, onZoom,
onRequestClose, onRequestClose,
onFlyAway,
isScrollViewBeingDragged, isScrollViewBeingDragged,
isScaled, isScaled,
isFlyingAway, isFlyingAway,
isActive, isActive,
showControls, showControls,
safeAreaRef, safeAreaRef,
openProgress,
dismissSwipeTranslateY, dismissSwipeTranslateY,
}: { }: {
imageSrc: ImageSource imageSrc: ImageSource
onRequestClose: () => void onRequestClose: () => void
onTap: () => void onTap: () => void
onZoom: (scaled: boolean) => void onZoom: (scaled: boolean) => void
onFlyAway: () => void
isScrollViewBeingDragged: boolean isScrollViewBeingDragged: boolean
isScaled: boolean isScaled: boolean
isActive: boolean isActive: boolean
isFlyingAway: SharedValue<boolean> isFlyingAway: SharedValue<boolean>
showControls: boolean showControls: boolean
safeAreaRef: AnimatedRef<View> safeAreaRef: AnimatedRef<View>
openProgress: SharedValue<number>
dismissSwipeTranslateY: SharedValue<number> dismissSwipeTranslateY: SharedValue<number>
}) { }) {
const [imageAspect, imageDimensions] = useImageDimensions({ const [imageAspect, imageDimensions] = useImageDimensions({
@@ -266,6 +332,51 @@ function LightboxImage({
knownDimensions: imageSrc.dimensions, knownDimensions: imageSrc.dimensions,
}) })
const {thumbRect, dimensions} = imageSrc
const interpolation = useDerivedValue(() => {
'worklet'
const screenSize = measure(safeAreaRef)
if (!screenSize) {
return {transform: [], width: 0, height: 0}
}
const finalWidth = screenSize.width
const finalHeight = imageAspect ? screenSize.width / imageAspect : undefined
if (isActive && thumbRect && dimensions && openProgress.value < 1) {
return interpolateTransform(
openProgress.value,
thumbRect,
screenSize,
dimensions,
)
}
const translateY = isActive ? dismissSwipeTranslateY.value : 0
return {
transform: [{translateY}],
width: finalWidth,
height: finalHeight,
}
})
const containerStyle = useAnimatedStyle(() => {
const {transform} = interpolation.value
return {
flex: 1,
transform,
}
})
const type = imageSrc.type
const borderRadius =
type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0
const imageStyle = useAnimatedStyle(() => {
const {width, height} = interpolation.value
return {
borderRadius,
width,
height,
}
})
const dismissSwipePan = Gesture.Pan() const dismissSwipePan = Gesture.Pan()
.enabled(isActive && !isScaled) .enabled(isActive && !isScaled)
.activeOffsetY([-10, 10]) .activeOffsetY([-10, 10])
@@ -303,12 +414,8 @@ function LightboxImage({
} }
}) })
const imageStyle = useAnimatedStyle(() => {
return {
transform: [{translateY: dismissSwipeTranslateY.value}],
}
})
return ( return (
<Animated.View style={containerStyle}>
<ImageItem <ImageItem
imageSrc={imageSrc} imageSrc={imageSrc}
onTap={onTap} onTap={onTap}
@@ -322,6 +429,7 @@ function LightboxImage({
imageStyle={imageStyle} imageStyle={imageStyle}
dismissSwipePan={dismissSwipePan} dismissSwipePan={dismissSwipePan}
/> />
</Animated.View>
) )
} }
@@ -476,7 +584,80 @@ const styles = StyleSheet.create({
}, },
}) })
function interpolatePx(
px: number,
inputRange: readonly number[],
outputRange: readonly number[],
) {
'worklet'
const value = interpolate(px, inputRange, outputRange)
return Math.round(value * PIXEL_RATIO) / PIXEL_RATIO
}
function interpolateTransform(
progress: number,
thumbnailDims: {
pageX: number
width: number
pageY: number
height: number
},
screenSize: {width: number; height: number},
imageDims: {width: number; height: number},
) {
'worklet'
const imageAspect = imageDims.width / imageDims.height
const thumbAspect = thumbnailDims.width / thumbnailDims.height
let uncroppedInitialWidth
let uncroppedInitialHeight
if (imageAspect > thumbAspect) {
uncroppedInitialWidth = thumbnailDims.height * imageAspect
uncroppedInitialHeight = thumbnailDims.height
} else {
uncroppedInitialWidth = thumbnailDims.width
uncroppedInitialHeight = thumbnailDims.width / imageAspect
}
const finalWidth = screenSize.width
const finalHeight = screenSize.width / imageAspect
const initialScale = Math.min(
uncroppedInitialWidth / finalWidth,
uncroppedInitialHeight / finalHeight,
)
const croppedFinalWidth = thumbnailDims.width / initialScale
const croppedFinalHeight = thumbnailDims.height / initialScale
const screenCenterX = screenSize.width / 2
const screenCenterY = screenSize.height / 2
const thumbnailCenterX = thumbnailDims.pageX + thumbnailDims.width / 2
const thumbnailCenterY = thumbnailDims.pageY + thumbnailDims.height / 2
const initialTranslateX = thumbnailCenterX - screenCenterX
const initialTranslateY = thumbnailCenterY - screenCenterY
const scale = interpolate(progress, [0, 1], [initialScale, 1])
const translateX = interpolatePx(progress, [0, 1], [initialTranslateX, 0])
const translateY = interpolatePx(progress, [0, 1], [initialTranslateY, 0])
const width = interpolatePx(progress, [0, 1], [croppedFinalWidth, finalWidth])
const height = interpolatePx(
progress,
[0, 1],
[croppedFinalHeight, finalHeight],
)
const cropTranslateX = interpolatePx(
progress,
[0, 1],
[(finalWidth - croppedFinalWidth) / 2, 0],
)
return {
transform: [
{translateX},
{translateY},
{scale},
{translateX: cropTranslateX},
],
width,
height,
}
}
function withClampedSpring(value: any) { function withClampedSpring(value: any) {
'worklet' 'worklet'
return withSpring(value, {overshootClamping: true, stiffness: 300}) return withSpring(value, {overshootClamping: true, stiffness: 120})
} }