Files
bsky-social-app/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
T
dan 5d0610d419 [Lightbox] New dismiss gesture (#6135)
* Make iOS scrollview bounded to the image

I've had to remove the dismiss handling because the scroll view no longer scrolls at rest.

* Fix double-tap not working right after a vertical swipe

It seems like for some reason the vertical swipe is still being handled by the scroll view, so double tap gets eaten while it's "coming back". But you don't really see it moving. Weird.

* Add an intermediate LightboxImage component

* Hoist useImageDimensions up

* Implement xplat dismiss gesture

This is now shared between platforms, letting us animate the backdrop and add a consistent "fly away" behavior.

* Optimize Android compositing perf

* Fix supertall images

For example, https://bsky.app/profile/schlagteslinks.bsky.social/post/3l7y4l6yur72e

* Fix oopsie
2024-11-08 02:49:32 +00:00

397 lines
12 KiB
TypeScript

import React, {useState} from 'react'
import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native'
import {
Gesture,
GestureDetector,
PanGesture,
} from 'react-native-gesture-handler'
import Animated, {
AnimatedRef,
measure,
runOnJS,
useAnimatedReaction,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated'
import {Image, ImageStyle} from 'expo-image'
import type {Dimensions as ImageDimensions, ImageSource} from '../../@types'
import {
applyRounding,
createTransform,
prependPan,
prependPinch,
prependTransform,
readTransform,
TransformMatrix,
} from '../../transforms'
const AnimatedImage = Animated.createAnimatedComponent(Image)
const MIN_SCREEN_ZOOM = 2
const MAX_ORIGINAL_IMAGE_ZOOM = 2
const initialTransform = createTransform()
type Props = {
imageSrc: ImageSource
onRequestClose: () => void
onTap: () => void
onZoom: (isZoomed: boolean) => void
isScrollViewBeingDragged: boolean
showControls: boolean
safeAreaRef: AnimatedRef<View>
imageAspect: number | undefined
imageDimensions: ImageDimensions | undefined
imageStyle: StyleProp<ImageStyle>
dismissSwipePan: PanGesture
}
const ImageItem = ({
imageSrc,
onTap,
onZoom,
isScrollViewBeingDragged,
safeAreaRef,
imageAspect,
imageDimensions,
imageStyle,
dismissSwipePan,
}: Props) => {
const [isScaled, setIsScaled] = useState(false)
const committedTransform = useSharedValue(initialTransform)
const panTranslation = useSharedValue({x: 0, y: 0})
const pinchOrigin = useSharedValue({x: 0, y: 0})
const pinchScale = useSharedValue(1)
const pinchTranslation = useSharedValue({x: 0, y: 0})
const containerRef = useAnimatedRef()
// Keep track of when we're entering or leaving scaled rendering.
// Note: DO NOT move any logic reading animated values outside this function.
useAnimatedReaction(
() => {
if (pinchScale.value !== 1) {
// We're currently pinching.
return true
}
const [, , committedScale] = readTransform(committedTransform.value)
if (committedScale !== 1) {
// We started from a pinched in state.
return true
}
// We're at rest.
return false
},
(nextIsScaled, prevIsScaled) => {
if (nextIsScaled !== prevIsScaled) {
runOnJS(handleZoom)(nextIsScaled)
}
},
)
function handleZoom(nextIsScaled: boolean) {
setIsScaled(nextIsScaled)
onZoom(nextIsScaled)
}
const animatedStyle = useAnimatedStyle(() => {
// Apply the active adjustments on top of the committed transform before the gestures.
// This is matrix multiplication, so operations are applied in the reverse order.
let t = createTransform()
prependPan(t, panTranslation.value)
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
prependTransform(t, committedTransform.value)
const [translateX, translateY, scale] = readTransform(t)
return {
transform: [{translateX}, {translateY: translateY}, {scale}],
}
})
// On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges.
// If the user tried to pan too hard, this function will provide the negative panning to stay in bounds.
function getExtraTranslationToStayInBounds(
candidateTransform: TransformMatrix,
screenSize: {width: number; height: number},
) {
'worklet'
if (!imageAspect) {
return [0, 0]
}
const [nextTranslateX, nextTranslateY, nextScale] =
readTransform(candidateTransform)
const scaledDimensions = getScaledDimensions(
imageAspect,
nextScale,
screenSize,
)
const clampedTranslateX = clampTranslation(
nextTranslateX,
scaledDimensions.width,
screenSize.width,
)
const clampedTranslateY = clampTranslation(
nextTranslateY,
scaledDimensions.height,
screenSize.height,
)
const dx = clampedTranslateX - nextTranslateX
const dy = clampedTranslateY - nextTranslateY
return [dx, dy]
}
const pinch = Gesture.Pinch()
.onStart(e => {
'worklet'
const screenSize = measure(safeAreaRef)
if (!screenSize) {
return
}
pinchOrigin.value = {
x: e.focalX - screenSize.width / 2,
y: e.focalY - screenSize.height / 2,
}
})
.onChange(e => {
'worklet'
const screenSize = measure(safeAreaRef)
if (!imageDimensions || !screenSize) {
return
}
// Don't let the picture zoom in so close that it gets blurry.
// Also, like in stock Android apps, don't let the user zoom out further than 1:1.
const [, , committedScale] = readTransform(committedTransform.value)
const maxCommittedScale = Math.max(
MIN_SCREEN_ZOOM,
(imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM,
)
const minPinchScale = 1 / committedScale
const maxPinchScale = maxCommittedScale / committedScale
const nextPinchScale = Math.min(
Math.max(minPinchScale, e.scale),
maxPinchScale,
)
pinchScale.value = nextPinchScale
// Zooming out close to the corner could push us out of bounds, which we don't want on Android.
// Calculate where we'll end up so we know how much to translate back to stay in bounds.
const t = createTransform()
prependPan(t, panTranslation.value)
prependPinch(t, nextPinchScale, pinchOrigin.value, pinchTranslation.value)
prependTransform(t, committedTransform.value)
const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize)
if (dx !== 0 || dy !== 0) {
pinchTranslation.value = {
x: pinchTranslation.value.x + dx,
y: pinchTranslation.value.y + dy,
}
}
})
.onEnd(() => {
'worklet'
// Commit just the pinch.
let t = createTransform()
prependPinch(
t,
pinchScale.value,
pinchOrigin.value,
pinchTranslation.value,
)
prependTransform(t, committedTransform.value)
applyRounding(t)
committedTransform.value = t
// Reset just the pinch.
pinchScale.value = 1
pinchOrigin.value = {x: 0, y: 0}
pinchTranslation.value = {x: 0, y: 0}
})
const pan = Gesture.Pan()
.averageTouches(true)
// Unlike .enabled(isScaled), this ensures that an initial pinch can turn into a pan midway:
.minPointers(isScaled ? 1 : 2)
.onChange(e => {
'worklet'
const screenSize = measure(safeAreaRef)
if (!imageDimensions || !screenSize) {
return
}
const nextPanTranslation = {x: e.translationX, y: e.translationY}
let t = createTransform()
prependPan(t, nextPanTranslation)
prependPinch(
t,
pinchScale.value,
pinchOrigin.value,
pinchTranslation.value,
)
prependTransform(t, committedTransform.value)
// Prevent panning from going out of bounds.
const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize)
nextPanTranslation.x += dx
nextPanTranslation.y += dy
panTranslation.value = nextPanTranslation
})
.onEnd(() => {
'worklet'
// Commit just the pan.
let t = createTransform()
prependPan(t, panTranslation.value)
prependTransform(t, committedTransform.value)
applyRounding(t)
committedTransform.value = t
// Reset just the pan.
panTranslation.value = {x: 0, y: 0}
})
const singleTap = Gesture.Tap().onEnd(() => {
'worklet'
runOnJS(onTap)()
})
const doubleTap = Gesture.Tap()
.numberOfTaps(2)
.onEnd(e => {
'worklet'
const screenSize = measure(safeAreaRef)
if (!imageDimensions || !imageAspect || !screenSize) {
return
}
const [, , committedScale] = readTransform(committedTransform.value)
if (committedScale !== 1) {
// Go back to 1:1 using the identity vector.
let t = createTransform()
committedTransform.value = withClampedSpring(t)
return
}
// Try to zoom in so that we get rid of the black bars (whatever the orientation was).
const screenAspect = screenSize.width / screenSize.height
const candidateScale = Math.max(
imageAspect / screenAspect,
screenAspect / imageAspect,
MIN_SCREEN_ZOOM,
)
// But don't zoom in so close that the picture gets blurry.
const maxScale = Math.max(
MIN_SCREEN_ZOOM,
(imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM,
)
const scale = Math.min(candidateScale, maxScale)
// Calculate where we would be if the user pinched into the double tapped point.
// We won't use this transform directly because it may go out of bounds.
const candidateTransform = createTransform()
const origin = {
x: e.absoluteX - screenSize.width / 2,
y: e.absoluteY - screenSize.height / 2,
}
prependPinch(candidateTransform, scale, origin, {x: 0, y: 0})
// Now we know how much we went out of bounds, so we can shoot correctly.
const [dx, dy] = getExtraTranslationToStayInBounds(
candidateTransform,
screenSize,
)
const finalTransform = createTransform()
prependPinch(finalTransform, scale, origin, {x: dx, y: dy})
committedTransform.value = withClampedSpring(finalTransform)
})
const composedGesture = isScrollViewBeingDragged
? // If the parent is not at rest, provide a no-op gesture.
Gesture.Manual()
: Gesture.Exclusive(
dismissSwipePan,
Gesture.Simultaneous(pinch, pan),
doubleTap,
singleTap,
)
return (
<GestureDetector gesture={composedGesture}>
<Animated.View style={imageStyle} renderToHardwareTextureAndroid>
<Animated.View
ref={containerRef}
// Necessary to make opacity work for both children together.
renderToHardwareTextureAndroid
style={[styles.container, animatedStyle]}>
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
<AnimatedImage
contentFit="contain"
source={{uri: imageSrc.uri}}
placeholderContentFit="contain"
placeholder={{uri: imageSrc.thumbUri}}
style={[styles.image]}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
cachePolicy="memory"
/>
</Animated.View>
</Animated.View>
</GestureDetector>
)
}
const styles = StyleSheet.create({
container: {
height: '100%',
overflow: 'hidden',
},
image: {
flex: 1,
},
loading: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
})
function getScaledDimensions(
imageAspect: number,
scale: number,
screenSize: {width: number; height: number},
): ImageDimensions {
'worklet'
const screenAspect = screenSize.width / screenSize.height
const isLandscape = imageAspect > screenAspect
if (isLandscape) {
return {
width: scale * screenSize.width,
height: (scale * screenSize.width) / imageAspect,
}
} else {
return {
width: scale * screenSize.height * imageAspect,
height: scale * screenSize.height,
}
}
}
function clampTranslation(
value: number,
scaledSize: number,
screenSize: number,
): number {
'worklet'
// Figure out how much the user should be allowed to pan, and constrain the translation.
const panDistance = Math.max(0, (scaledSize - screenSize) / 2)
const clampedValue = Math.min(Math.max(-panDistance, value), panDistance)
return clampedValue
}
function withClampedSpring(value: any) {
'worklet'
return withSpring(value, {overshootClamping: true})
}
export default React.memo(ImageItem)