Animate cropped borders

This commit is contained in:
Dan Abramov
2024-11-03 03:41:07 +00:00
parent caeb86a368
commit 8e96154a4d
4 changed files with 141 additions and 62 deletions
@@ -1,5 +1,10 @@
import React, {useState} from 'react' import React, {useState} from 'react'
import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native' import {
ActivityIndicator,
Dimensions,
StyleProp,
StyleSheet,
} from 'react-native'
import { import {
Gesture, Gesture,
GestureDetector, GestureDetector,
@@ -13,7 +18,7 @@ import Animated, {
useSharedValue, useSharedValue,
withSpring, withSpring,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {Image} from 'expo-image' import {Image, ImageStyle} from 'expo-image'
import {useImageDimensions} from '#/lib/media/image-sizes' import {useImageDimensions} from '#/lib/media/image-sizes'
import type {Dimensions as ImageDimensions, ImageSource} from '../../@types' import type {Dimensions as ImageDimensions, ImageSource} from '../../@types'
@@ -27,6 +32,8 @@ import {
TransformMatrix, TransformMatrix,
} from '../../transforms' } from '../../transforms'
const AnimatedImage = Animated.createAnimatedComponent(Image)
const windowDim = Dimensions.get('window') const windowDim = Dimensions.get('window')
const screenDim = Dimensions.get('screen') const screenDim = Dimensions.get('screen')
const statusBarHeight = windowDim.height - screenDim.height const statusBarHeight = windowDim.height - screenDim.height
@@ -47,13 +54,16 @@ type Props = {
isPagingAndroid: boolean isPagingAndroid: boolean
showControls: boolean showControls: boolean
dismissSwipePan: PanGesture dismissSwipePan: PanGesture
imageStyle: StyleProp<ImageStyle>
} }
const ImageItem = ({ const ImageItem = ({
imageSrc, imageSrc,
onTap, onTap,
onZoom, onZoom,
isPagingAndroid, isPagingAndroid,
dismissSwipePan, dismissSwipePan,
imageStyle,
}: Props) => { }: Props) => {
const [isScaled, setIsScaled] = useState(false) const [isScaled, setIsScaled] = useState(false)
const [imageAspect, imageDimensions] = useImageDimensions({ const [imageAspect, imageDimensions] = useImageDimensions({
@@ -307,21 +317,24 @@ const ImageItem = ({
renderToHardwareTextureAndroid renderToHardwareTextureAndroid
style={[styles.container, animatedStyle, {}]}> style={[styles.container, animatedStyle, {}]}>
<ActivityIndicator size="small" color="#FFF" style={styles.loading} /> <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
<Image <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={{ style={[
width: SCREEN.width, {
height: imageAspect ? SCREEN.width / imageAspect : undefined, width: SCREEN.width,
borderRadius: height: imageAspect ? SCREEN.width / imageAspect : undefined,
imageSrc.type === 'circle-avi' borderRadius:
? SCREEN.width / 2 imageSrc.type === 'circle-avi'
: imageSrc.type === 'rect-avi' ? SCREEN.width / 2
? 20 : imageSrc.type === 'rect-avi'
: 0, ? 20
}} : 0,
},
imageStyle,
]}
accessibilityLabel={imageSrc.alt} accessibilityLabel={imageSrc.alt}
accessibilityHint="" accessibilityHint=""
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
@@ -7,19 +7,26 @@
*/ */
import React, {useState} from 'react' import React, {useState} from 'react'
import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native' import {
ActivityIndicator,
Dimensions,
StyleProp,
StyleSheet,
} from 'react-native'
import { import {
Gesture, Gesture,
GestureDetector, GestureDetector,
PanGesture, PanGesture,
} from 'react-native-gesture-handler' } from 'react-native-gesture-handler'
import Animated, {runOnJS, useAnimatedRef} from 'react-native-reanimated' import Animated, {runOnJS, useAnimatedRef} from 'react-native-reanimated'
import {Image} from 'expo-image' import {Image, ImageStyle} from 'expo-image'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {useImageDimensions} from '#/lib/media/image-sizes' import {useImageDimensions} from '#/lib/media/image-sizes'
import {ImageSource} from '../../@types' import {ImageSource} from '../../@types'
const AnimatedImage = Animated.createAnimatedComponent(Image)
const SCREEN = Dimensions.get('screen') const SCREEN = Dimensions.get('screen')
const MAX_ORIGINAL_IMAGE_ZOOM = 2 const MAX_ORIGINAL_IMAGE_ZOOM = 2
const MIN_DOUBLE_TAP_SCALE = 2 const MIN_DOUBLE_TAP_SCALE = 2
@@ -32,6 +39,7 @@ type Props = {
isPagingAndroid: boolean // Not available on iOS isPagingAndroid: boolean // Not available on iOS
showControls: boolean showControls: boolean
dismissSwipePan: PanGesture | null dismissSwipePan: PanGesture | null
imageStyle: StyleProp<ImageStyle>
} }
const ImageItem = ({ const ImageItem = ({
@@ -40,6 +48,7 @@ const ImageItem = ({
onZoom, onZoom,
showControls, showControls,
dismissSwipePan, dismissSwipePan,
imageStyle,
}: Props) => { }: Props) => {
const scrollViewRef = useAnimatedRef<Animated.ScrollView>() const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
@@ -145,21 +154,24 @@ const ImageItem = ({
bounces={scaled} bounces={scaled}
centerContent> centerContent>
<ActivityIndicator size="small" color="#FFF" style={styles.loading} /> <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
<Image <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={{ style={[
width: SCREEN.width, {
height: imageAspect ? SCREEN.width / imageAspect : undefined, width: SCREEN.width,
borderRadius: height: imageAspect ? SCREEN.width / imageAspect : undefined,
imageSrc.type === 'circle-avi' borderRadius:
? SCREEN.width / 2 imageSrc.type === 'circle-avi'
: imageSrc.type === 'rect-avi' ? SCREEN.width / 2
? 20 : imageSrc.type === 'rect-avi'
: 0, ? 20
}} : 0,
},
imageStyle,
]}
accessibilityLabel={imageSrc.alt} accessibilityLabel={imageSrc.alt}
accessibilityHint="" accessibilityHint=""
enableLiveTextInteraction={showControls && !scaled} enableLiveTextInteraction={showControls && !scaled}
@@ -1,8 +1,9 @@
// default implementation fallback for web // default implementation fallback for web
import React from 'react' import React from 'react'
import {View} from 'react-native' import {StyleProp,View} from 'react-native'
import {PanGesture} from 'react-native-gesture-handler' import {PanGesture} from 'react-native-gesture-handler'
import {ImageStyle} from 'expo-image'
import {ImageSource} from '../../@types' import {ImageSource} from '../../@types'
@@ -14,6 +15,7 @@ type Props = {
isPagingAndroid: boolean isPagingAndroid: boolean
showControls: boolean showControls: boolean
dismissSwipePan: PanGesture | null dismissSwipePan: PanGesture | null
imageStyle: StyleProp<ImageStyle>
} }
const ImageItem = (_props: Props) => { const ImageItem = (_props: Props) => {
+83 -31
View File
@@ -12,6 +12,7 @@ import React, {useCallback, useMemo, useState} from 'react'
import { import {
Dimensions, Dimensions,
LayoutAnimation, LayoutAnimation,
PixelRatio,
Platform, Platform,
StyleSheet, StyleSheet,
View, View,
@@ -23,6 +24,7 @@ import {
interpolate, interpolate,
SharedValue, SharedValue,
useAnimatedReaction, useAnimatedReaction,
useDerivedValue,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import Animated, { import Animated, {
runOnJS, runOnJS,
@@ -48,7 +50,7 @@ import ImageDefaultHeader from './components/ImageDefaultHeader'
import ImageItem from './components/ImageItem/ImageItem' import ImageItem from './components/ImageItem/ImageItem'
const SCREEN = Dimensions.get('screen') const SCREEN = Dimensions.get('screen')
const PIXEL_RATIO = PixelRatio.get()
const SCREEN_HEIGHT = Dimensions.get('window').height const SCREEN_HEIGHT = Dimensions.get('window').height
function ImageViewing({ function ImageViewing({
@@ -106,12 +108,7 @@ function ImageViewing({
return {pointerEvents: 'auto'} return {pointerEvents: 'auto'}
}) })
const activeImageStyle = useAnimatedStyle(() => { const activeInterpolation = useDerivedValue(() => {
if (openProgress.value === 1 || dismissSwipeTranslateY.value !== 0) {
return {
transform: [{translateY: dismissSwipeTranslateY.value}],
}
}
const image = images[imageIndex] const image = images[imageIndex]
if (image.thumbRect && image.dimensions) { if (image.thumbRect && image.dimensions) {
const interpolatedTransform = interpolateTransform( const interpolatedTransform = interpolateTransform(
@@ -120,8 +117,20 @@ function ImageViewing({
SCREEN, SCREEN,
image.dimensions, image.dimensions,
) )
return interpolatedTransform
}
})
const activeImageContainerStyle = useAnimatedStyle(() => {
if (openProgress.value === 1 || dismissSwipeTranslateY.value !== 0) {
return { return {
transform: interpolatedTransform, transform: [{translateY: dismissSwipeTranslateY.value}],
}
}
const interpolation = activeInterpolation.value
if (interpolation) {
return {
transform: interpolation.transform,
} }
} }
return { return {
@@ -129,6 +138,17 @@ function ImageViewing({
} }
}) })
const activeImageStyle = useAnimatedStyle(() => {
const interpolation = activeInterpolation.value
if (interpolation) {
return {
height: interpolation.height,
width: interpolation.width,
}
}
return {}
})
const dismissSwipePan = Gesture.Pan() const dismissSwipePan = Gesture.Pan()
.enabled(!isScaled) .enabled(!isScaled)
.activeOffsetY([-10, 10]) .activeOffsetY([-10, 10])
@@ -230,7 +250,7 @@ function ImageViewing({
{images.map((imageSrc, i) => ( {images.map((imageSrc, i) => (
<Animated.View <Animated.View
key={imageSrc.uri} key={imageSrc.uri}
style={imageIndex === i ? activeImageStyle : null}> style={imageIndex === i ? activeImageContainerStyle : null}>
<ImageItem <ImageItem
onTap={onTap} onTap={onTap}
onZoom={onZoom} onZoom={onZoom}
@@ -239,6 +259,7 @@ function ImageViewing({
isPagingAndroid={isPagingAndroid} isPagingAndroid={isPagingAndroid}
showControls={showControls} showControls={showControls}
dismissSwipePan={imageIndex === i ? dismissSwipePan : null} dismissSwipePan={imageIndex === i ? dismissSwipePan : null}
imageStyle={imageIndex === i ? activeImageStyle : null}
/> />
</Animated.View> </Animated.View>
))} ))}
@@ -337,6 +358,16 @@ function LightboxFooter({
) )
} }
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( function interpolateTransform(
progress: number, progress: number,
thumbnailDims: { thumbnailDims: {
@@ -349,34 +380,55 @@ function interpolateTransform(
imageDims: {width: number; height: number}, imageDims: {width: number; height: number},
) { ) {
'worklet' '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 screenCenterX = screenSize.width / 2
const screenCenterY = screenSize.height / 2 const screenCenterY = screenSize.height / 2
const thumbnailCenterX = thumbnailDims.pageX + thumbnailDims.width / 2 const thumbnailCenterX = thumbnailDims.pageX + thumbnailDims.width / 2
const thumbnailCenterY = thumbnailDims.pageY + thumbnailDims.height / 2 const thumbnailCenterY = thumbnailDims.pageY + thumbnailDims.height / 2
const initialTranslateX = thumbnailCenterX - screenCenterX const initialTranslateX = thumbnailCenterX - screenCenterX
const initialTranslateY = thumbnailCenterY - screenCenterY const initialTranslateY = thumbnailCenterY - screenCenterY
const imageAspect = imageDims.width / imageDims.height
const thumbAspect = thumbnailDims.width / thumbnailDims.height
const initialWidth =
imageAspect > thumbAspect
? thumbnailDims.height * imageAspect
: thumbnailDims.width
const initialHeight =
imageAspect > thumbAspect
? thumbnailDims.height
: thumbnailDims.width / imageAspect
const finalWidth = screenSize.width
const finalHeight = screenSize.width / imageAspect
const initialScale = Math.min(
initialWidth / finalWidth,
initialHeight / finalHeight,
)
const translateX = interpolate(progress, [0, 1], [initialTranslateX, 0])
const translateY = interpolate(progress, [0, 1], [initialTranslateY, 0])
const scale = interpolate(progress, [0, 1], [initialScale, 1]) const scale = interpolate(progress, [0, 1], [initialScale, 1])
return [{translateX}, {translateY}, {scale}] 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,
}
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
@@ -501,7 +553,7 @@ function ImageViewingRoot({
function withClampedSpring(value: any) { function withClampedSpring(value: any) {
'worklet' 'worklet'
return withSpring(value, {overshootClamping: true, stiffness: 150}) return withSpring(value, {overshootClamping: true, stiffness: 120})
} }
export default ImageViewingRoot export default ImageViewingRoot