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 {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
import {
ActivityIndicator,
Dimensions,
StyleProp,
StyleSheet,
} from 'react-native'
import {
Gesture,
GestureDetector,
@@ -13,7 +18,7 @@ import Animated, {
useSharedValue,
withSpring,
} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {Image, ImageStyle} from 'expo-image'
import {useImageDimensions} from '#/lib/media/image-sizes'
import type {Dimensions as ImageDimensions, ImageSource} from '../../@types'
@@ -27,6 +32,8 @@ import {
TransformMatrix,
} from '../../transforms'
const AnimatedImage = Animated.createAnimatedComponent(Image)
const windowDim = Dimensions.get('window')
const screenDim = Dimensions.get('screen')
const statusBarHeight = windowDim.height - screenDim.height
@@ -47,13 +54,16 @@ type Props = {
isPagingAndroid: boolean
showControls: boolean
dismissSwipePan: PanGesture
imageStyle: StyleProp<ImageStyle>
}
const ImageItem = ({
imageSrc,
onTap,
onZoom,
isPagingAndroid,
dismissSwipePan,
imageStyle,
}: Props) => {
const [isScaled, setIsScaled] = useState(false)
const [imageAspect, imageDimensions] = useImageDimensions({
@@ -307,21 +317,24 @@ const ImageItem = ({
renderToHardwareTextureAndroid
style={[styles.container, animatedStyle, {}]}>
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
<Image
contentFit="contain"
<AnimatedImage
contentFit="cover"
source={{uri: imageSrc.uri}}
placeholderContentFit="contain"
placeholderContentFit="cover"
placeholder={{uri: imageSrc.thumbUri}}
style={{
width: SCREEN.width,
height: imageAspect ? SCREEN.width / imageAspect : undefined,
borderRadius:
imageSrc.type === 'circle-avi'
? SCREEN.width / 2
: imageSrc.type === 'rect-avi'
? 20
: 0,
}}
style={[
{
width: SCREEN.width,
height: imageAspect ? SCREEN.width / imageAspect : undefined,
borderRadius:
imageSrc.type === 'circle-avi'
? SCREEN.width / 2
: imageSrc.type === 'rect-avi'
? 20
: 0,
},
imageStyle,
]}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
@@ -7,19 +7,26 @@
*/
import React, {useState} from 'react'
import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
import {
ActivityIndicator,
Dimensions,
StyleProp,
StyleSheet,
} from 'react-native'
import {
Gesture,
GestureDetector,
PanGesture,
} from 'react-native-gesture-handler'
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 {useImageDimensions} from '#/lib/media/image-sizes'
import {ImageSource} from '../../@types'
const AnimatedImage = Animated.createAnimatedComponent(Image)
const SCREEN = Dimensions.get('screen')
const MAX_ORIGINAL_IMAGE_ZOOM = 2
const MIN_DOUBLE_TAP_SCALE = 2
@@ -32,6 +39,7 @@ type Props = {
isPagingAndroid: boolean // Not available on iOS
showControls: boolean
dismissSwipePan: PanGesture | null
imageStyle: StyleProp<ImageStyle>
}
const ImageItem = ({
@@ -40,6 +48,7 @@ const ImageItem = ({
onZoom,
showControls,
dismissSwipePan,
imageStyle,
}: Props) => {
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
@@ -145,21 +154,24 @@ const ImageItem = ({
bounces={scaled}
centerContent>
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
<Image
contentFit="contain"
<AnimatedImage
contentFit="cover"
source={{uri: imageSrc.uri}}
placeholderContentFit="contain"
placeholderContentFit="cover"
placeholder={{uri: imageSrc.thumbUri}}
style={{
width: SCREEN.width,
height: imageAspect ? SCREEN.width / imageAspect : undefined,
borderRadius:
imageSrc.type === 'circle-avi'
? SCREEN.width / 2
: imageSrc.type === 'rect-avi'
? 20
: 0,
}}
style={[
{
width: SCREEN.width,
height: imageAspect ? SCREEN.width / imageAspect : undefined,
borderRadius:
imageSrc.type === 'circle-avi'
? SCREEN.width / 2
: imageSrc.type === 'rect-avi'
? 20
: 0,
},
imageStyle,
]}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
enableLiveTextInteraction={showControls && !scaled}
@@ -1,8 +1,9 @@
// default implementation fallback for web
import React from 'react'
import {View} from 'react-native'
import {StyleProp,View} from 'react-native'
import {PanGesture} from 'react-native-gesture-handler'
import {ImageStyle} from 'expo-image'
import {ImageSource} from '../../@types'
@@ -14,6 +15,7 @@ type Props = {
isPagingAndroid: boolean
showControls: boolean
dismissSwipePan: PanGesture | null
imageStyle: StyleProp<ImageStyle>
}
const ImageItem = (_props: Props) => {
+83 -31
View File
@@ -12,6 +12,7 @@ import React, {useCallback, useMemo, useState} from 'react'
import {
Dimensions,
LayoutAnimation,
PixelRatio,
Platform,
StyleSheet,
View,
@@ -23,6 +24,7 @@ import {
interpolate,
SharedValue,
useAnimatedReaction,
useDerivedValue,
} from 'react-native-reanimated'
import Animated, {
runOnJS,
@@ -48,7 +50,7 @@ import ImageDefaultHeader from './components/ImageDefaultHeader'
import ImageItem from './components/ImageItem/ImageItem'
const SCREEN = Dimensions.get('screen')
const PIXEL_RATIO = PixelRatio.get()
const SCREEN_HEIGHT = Dimensions.get('window').height
function ImageViewing({
@@ -106,12 +108,7 @@ function ImageViewing({
return {pointerEvents: 'auto'}
})
const activeImageStyle = useAnimatedStyle(() => {
if (openProgress.value === 1 || dismissSwipeTranslateY.value !== 0) {
return {
transform: [{translateY: dismissSwipeTranslateY.value}],
}
}
const activeInterpolation = useDerivedValue(() => {
const image = images[imageIndex]
if (image.thumbRect && image.dimensions) {
const interpolatedTransform = interpolateTransform(
@@ -120,8 +117,20 @@ function ImageViewing({
SCREEN,
image.dimensions,
)
return interpolatedTransform
}
})
const activeImageContainerStyle = useAnimatedStyle(() => {
if (openProgress.value === 1 || dismissSwipeTranslateY.value !== 0) {
return {
transform: interpolatedTransform,
transform: [{translateY: dismissSwipeTranslateY.value}],
}
}
const interpolation = activeInterpolation.value
if (interpolation) {
return {
transform: interpolation.transform,
}
}
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()
.enabled(!isScaled)
.activeOffsetY([-10, 10])
@@ -230,7 +250,7 @@ function ImageViewing({
{images.map((imageSrc, i) => (
<Animated.View
key={imageSrc.uri}
style={imageIndex === i ? activeImageStyle : null}>
style={imageIndex === i ? activeImageContainerStyle : null}>
<ImageItem
onTap={onTap}
onZoom={onZoom}
@@ -239,6 +259,7 @@ function ImageViewing({
isPagingAndroid={isPagingAndroid}
showControls={showControls}
dismissSwipePan={imageIndex === i ? dismissSwipePan : null}
imageStyle={imageIndex === i ? activeImageStyle : null}
/>
</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(
progress: number,
thumbnailDims: {
@@ -349,34 +380,55 @@ function interpolateTransform(
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 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])
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({
@@ -501,7 +553,7 @@ function ImageViewingRoot({
function withClampedSpring(value: any) {
'worklet'
return withSpring(value, {overshootClamping: true, stiffness: 150})
return withSpring(value, {overshootClamping: true, stiffness: 120})
}
export default ImageViewingRoot