Animate cropped borders
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user