[Lightbox] Open animation (#6159)

* Measure all rects for embeds

* Measure avi rects too

* Animate lightbox in and out

* Account for safe area in the animation

* Tune spring times

* Remove null checks for measurements

* Remove superfluous view

* Block swipe while opening

* Interpolate width/height on native side for Android

* Make it fast by animating only affine transforms

* Fix tall image final state

The initial animation frame is still off on both platforms.

* Try to squeeze perf

* Avoid blank images during animation on iOS

* Fix bad rebase

* Fix a huge memory issue due to expo/expo#24894

* Fix last frame flash

* Fix thum dim calculation for tall images
This commit is contained in:
dan
2024-11-09 22:34:46 +00:00
committed by GitHub
parent e73d5c6c20
commit 2d73c5a24c
12 changed files with 629 additions and 192 deletions
+29 -7
View File
@@ -1,5 +1,12 @@
import React, {memo} from 'react' import React, {memo} from 'react'
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
import Animated, {
measure,
MeasuredDimensions,
runOnJS,
runOnUI,
useAnimatedRef,
} from 'react-native-reanimated'
import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
@@ -42,6 +49,7 @@ let ProfileHeaderShell = ({
const {openLightbox} = useLightboxControls() const {openLightbox} = useLightboxControls()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {isDesktop} = useWebMediaQueries() const {isDesktop} = useWebMediaQueries()
const aviRef = useAnimatedRef()
const onPressBack = React.useCallback(() => { const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) { if (navigation.canGoBack()) {
@@ -51,14 +59,14 @@ let ProfileHeaderShell = ({
} }
}, [navigation]) }, [navigation])
const onPressAvi = React.useCallback(() => { const _openLightbox = React.useCallback(
const modui = moderation.ui('avatar') (uri: string, thumbRect: MeasuredDimensions | null) => {
if (profile.avatar && !(modui.blur && modui.noOverride)) {
openLightbox({ openLightbox({
images: [ images: [
{ {
uri: profile.avatar, uri,
thumbUri: profile.avatar, thumbUri: uri,
thumbRect,
dimensions: { dimensions: {
// It's fine if it's actually smaller but we know it's 1:1. // It's fine if it's actually smaller but we know it's 1:1.
height: 1000, height: 1000,
@@ -68,10 +76,22 @@ let ProfileHeaderShell = ({
}, },
], ],
index: 0, index: 0,
thumbDims: null,
}) })
},
[openLightbox],
)
const onPressAvi = React.useCallback(() => {
const modui = moderation.ui('avatar')
const avatar = profile.avatar
if (avatar && !(modui.blur && modui.noOverride)) {
runOnUI(() => {
'worklet'
const rect = measure(aviRef)
runOnJS(_openLightbox)(avatar, rect)
})()
} }
}, [openLightbox, profile, moderation]) }, [profile, moderation, _openLightbox, aviRef])
const isMe = React.useMemo( const isMe = React.useMemo(
() => currentAccount?.did === profile.did, () => currentAccount?.did === profile.did,
@@ -149,12 +169,14 @@ let ProfileHeaderShell = ({
styles.avi, styles.avi,
profile.associated?.labeler && styles.aviLabeler, profile.associated?.labeler && styles.aviLabeler,
]}> ]}>
<Animated.View ref={aviRef} collapsable={false}>
<UserAvatar <UserAvatar
type={profile.associated?.labeler ? 'labeler' : 'user'} type={profile.associated?.labeler ? 'labeler' : 'user'}
size={90} size={90}
avatar={profile.avatar} avatar={profile.avatar}
moderation={moderation.ui('avatar')} moderation={moderation.ui('avatar')}
/> />
</Animated.View>
</View> </View>
</TouchableWithoutFeedback> </TouchableWithoutFeedback>
</GrowableAvatar> </GrowableAvatar>
-2
View File
@@ -1,5 +1,4 @@
import React from 'react' import React from 'react'
import type {MeasuredDimensions} from 'react-native-reanimated'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
@@ -8,7 +7,6 @@ import {ImageSource} from '#/view/com/lightbox/ImageViewing/@types'
export type Lightbox = { export type Lightbox = {
id: string id: string
images: ImageSource[] images: ImageSource[]
thumbDims: MeasuredDimensions | null
index: number index: number
} }
@@ -6,6 +6,9 @@
* *
*/ */
import {TransformsStyle} from 'react-native'
import {MeasuredDimensions} from 'react-native-reanimated'
export type Dimensions = { export type Dimensions = {
width: number width: number
height: number height: number
@@ -19,7 +22,13 @@ export type Position = {
export type ImageSource = { export type ImageSource = {
uri: string uri: string
thumbUri: string thumbUri: string
thumbRect: MeasuredDimensions | null
alt?: string alt?: string
dimensions: Dimensions | null dimensions: Dimensions | null
type: 'image' | 'circle-avi' | 'rect-avi' type: 'image' | 'circle-avi' | 'rect-avi'
} }
export type Transform = Exclude<
TransformsStyle['transform'],
string | undefined
>
@@ -1,23 +1,26 @@
import React, {useState} from 'react' import React, {useState} from 'react'
import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native' import {ActivityIndicator, StyleSheet} from 'react-native'
import { import {
Gesture, Gesture,
GestureDetector, GestureDetector,
PanGesture, PanGesture,
} from 'react-native-gesture-handler' } from 'react-native-gesture-handler'
import Animated, { import Animated, {
AnimatedRef,
measure,
runOnJS, runOnJS,
SharedValue,
useAnimatedReaction, useAnimatedReaction,
useAnimatedRef, useAnimatedRef,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
withSpring, withSpring,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {Image, ImageStyle} from 'expo-image' import {Image} from 'expo-image'
import type {Dimensions as ImageDimensions, ImageSource} from '../../@types' import type {
Dimensions as ImageDimensions,
ImageSource,
Transform,
} from '../../@types'
import { import {
applyRounding, applyRounding,
createTransform, createTransform,
@@ -28,8 +31,6 @@ import {
TransformMatrix, TransformMatrix,
} from '../../transforms' } from '../../transforms'
const AnimatedImage = Animated.createAnimatedComponent(Image)
const MIN_SCREEN_ZOOM = 2 const MIN_SCREEN_ZOOM = 2
const MAX_ORIGINAL_IMAGE_ZOOM = 2 const MAX_ORIGINAL_IMAGE_ZOOM = 2
@@ -42,22 +43,35 @@ type Props = {
onZoom: (isZoomed: boolean) => void onZoom: (isZoomed: boolean) => void
isScrollViewBeingDragged: boolean isScrollViewBeingDragged: boolean
showControls: boolean showControls: boolean
safeAreaRef: AnimatedRef<View> measureSafeArea: () => {
x: number
y: number
width: number
height: number
}
imageAspect: number | undefined imageAspect: number | undefined
imageDimensions: ImageDimensions | undefined imageDimensions: ImageDimensions | undefined
imageStyle: StyleProp<ImageStyle>
dismissSwipePan: PanGesture dismissSwipePan: PanGesture
transforms: Readonly<
SharedValue<{
scaleAndMoveTransform: Transform
cropFrameTransform: Transform
cropContentTransform: Transform
isResting: boolean
isHidden: boolean
}>
>
} }
const ImageItem = ({ const ImageItem = ({
imageSrc, imageSrc,
onTap, onTap,
onZoom, onZoom,
isScrollViewBeingDragged, isScrollViewBeingDragged,
safeAreaRef, measureSafeArea,
imageAspect, imageAspect,
imageDimensions, imageDimensions,
imageStyle,
dismissSwipePan, dismissSwipePan,
transforms,
}: Props) => { }: Props) => {
const [isScaled, setIsScaled] = useState(false) const [isScaled, setIsScaled] = useState(false)
const committedTransform = useSharedValue(initialTransform) const committedTransform = useSharedValue(initialTransform)
@@ -95,19 +109,6 @@ const ImageItem = ({
onZoom(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. // 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. // If the user tried to pan too hard, this function will provide the negative panning to stay in bounds.
function getExtraTranslationToStayInBounds( function getExtraTranslationToStayInBounds(
@@ -143,10 +144,7 @@ const ImageItem = ({
const pinch = Gesture.Pinch() const pinch = Gesture.Pinch()
.onStart(e => { .onStart(e => {
'worklet' 'worklet'
const screenSize = measure(safeAreaRef) const screenSize = measureSafeArea()
if (!screenSize) {
return
}
pinchOrigin.value = { pinchOrigin.value = {
x: e.focalX - screenSize.width / 2, x: e.focalX - screenSize.width / 2,
y: e.focalY - screenSize.height / 2, y: e.focalY - screenSize.height / 2,
@@ -154,8 +152,8 @@ const ImageItem = ({
}) })
.onChange(e => { .onChange(e => {
'worklet' 'worklet'
const screenSize = measure(safeAreaRef) const screenSize = measureSafeArea()
if (!imageDimensions || !screenSize) { if (!imageDimensions) {
return return
} }
// Don't let the picture zoom in so close that it gets blurry. // Don't let the picture zoom in so close that it gets blurry.
@@ -213,8 +211,8 @@ const ImageItem = ({
.minPointers(isScaled ? 1 : 2) .minPointers(isScaled ? 1 : 2)
.onChange(e => { .onChange(e => {
'worklet' 'worklet'
const screenSize = measure(safeAreaRef) const screenSize = measureSafeArea()
if (!imageDimensions || !screenSize) { if (!imageDimensions) {
return return
} }
@@ -257,8 +255,8 @@ const ImageItem = ({
.numberOfTaps(2) .numberOfTaps(2)
.onEnd(e => { .onEnd(e => {
'worklet' 'worklet'
const screenSize = measure(safeAreaRef) const screenSize = measureSafeArea()
if (!imageDimensions || !imageAspect || !screenSize) { if (!imageDimensions || !imageAspect) {
return return
} }
const [, , committedScale] = readTransform(committedTransform.value) const [, , committedScale] = readTransform(committedTransform.value)
@@ -302,11 +300,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,31 +310,99 @@ const ImageItem = ({
singleTap, singleTap,
) )
const containerStyle = useAnimatedStyle(() => {
const {scaleAndMoveTransform, isHidden} = transforms.value
// 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)
const manipulationTransform = [
{translateX},
{translateY: translateY},
{scale},
]
const screenSize = measureSafeArea()
return {
opacity: isHidden ? 0 : 1,
transform: scaleAndMoveTransform.concat(manipulationTransform),
width: screenSize.width,
maxHeight: screenSize.height,
aspectRatio: imageAspect,
alignSelf: 'center',
}
})
const imageCropStyle = useAnimatedStyle(() => {
const {cropFrameTransform} = transforms.value
return {
flex: 1,
overflow: 'hidden',
transform: cropFrameTransform,
}
})
const imageStyle = useAnimatedStyle(() => {
const {cropContentTransform} = transforms.value
return {
flex: 1,
transform: cropContentTransform,
}
})
const [showLoader, setShowLoader] = useState(false)
const [hasLoaded, setHasLoaded] = useState(false)
useAnimatedReaction(
() => {
return transforms.value.isResting && !hasLoaded
},
(show, prevShow) => {
if (show && !prevShow) {
runOnJS(setShowLoader)(false)
} else if (!prevShow && show) {
runOnJS(setShowLoader)(true)
}
},
)
const type = imageSrc.type const type = imageSrc.type
const borderRadius = const borderRadius =
type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0
return ( return (
<GestureDetector gesture={composedGesture}> <GestureDetector gesture={composedGesture}>
<Animated.View style={imageStyle} renderToHardwareTextureAndroid>
<Animated.View <Animated.View
ref={containerRef} ref={containerRef}
// Necessary to make opacity work for both children together. style={[styles.container]}
renderToHardwareTextureAndroid renderToHardwareTextureAndroid>
style={[styles.container, animatedStyle]}> <Animated.View style={containerStyle}>
<ActivityIndicator size="small" color="#FFF" style={styles.loading} /> {showLoader && (
<AnimatedImage <ActivityIndicator
contentFit="contain" size="small"
color="#FFF"
style={styles.loading}
/>
)}
<Animated.View style={imageCropStyle}>
<Animated.View style={imageStyle}>
<Image
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}]}
accessibilityLabel={imageSrc.alt} accessibilityLabel={imageSrc.alt}
onLoad={() => setHasLoaded(false)}
style={{flex: 1, borderRadius}}
accessibilityHint="" accessibilityHint=""
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
cachePolicy="memory" cachePolicy="memory"
/> />
</Animated.View> </Animated.View>
</Animated.View> </Animated.View>
</Animated.View>
</Animated.View>
</GestureDetector> </GestureDetector>
) )
} }
@@ -358,6 +419,7 @@ const styles = StyleSheet.create({
right: 0, right: 0,
top: 0, top: 0,
bottom: 0, bottom: 0,
justifyContent: 'center',
}, },
}) })
@@ -7,26 +7,28 @@
*/ */
import React, {useState} from 'react' import React, {useState} from 'react'
import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native' import {ActivityIndicator, StyleSheet} from 'react-native'
import { import {
Gesture, Gesture,
GestureDetector, GestureDetector,
PanGesture, PanGesture,
} from 'react-native-gesture-handler' } from 'react-native-gesture-handler'
import Animated, { import Animated, {
AnimatedRef,
measure,
runOnJS, runOnJS,
SharedValue,
useAnimatedReaction,
useAnimatedRef, useAnimatedRef,
useAnimatedStyle, 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} from 'expo-image'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {Dimensions as ImageDimensions, ImageSource} from '../../@types' import {
Dimensions as ImageDimensions,
const AnimatedImage = Animated.createAnimatedComponent(Image) ImageSource,
Transform,
} from '../../@types'
const MAX_ORIGINAL_IMAGE_ZOOM = 2 const MAX_ORIGINAL_IMAGE_ZOOM = 2
const MIN_SCREEN_ZOOM = 2 const MIN_SCREEN_ZOOM = 2
@@ -38,11 +40,24 @@ type Props = {
onZoom: (scaled: boolean) => void onZoom: (scaled: boolean) => void
isScrollViewBeingDragged: boolean isScrollViewBeingDragged: boolean
showControls: boolean showControls: boolean
safeAreaRef: AnimatedRef<View> measureSafeArea: () => {
x: number
y: number
width: number
height: number
}
imageAspect: number | undefined imageAspect: number | undefined
imageDimensions: ImageDimensions | undefined imageDimensions: ImageDimensions | undefined
imageStyle: StyleProp<ImageStyle>
dismissSwipePan: PanGesture dismissSwipePan: PanGesture
transforms: Readonly<
SharedValue<{
scaleAndMoveTransform: Transform
cropFrameTransform: Transform
cropContentTransform: Transform
isResting: boolean
isHidden: boolean
}>
>
} }
const ImageItem = ({ const ImageItem = ({
@@ -50,11 +65,11 @@ const ImageItem = ({
onTap, onTap,
onZoom, onZoom,
showControls, showControls,
safeAreaRef, measureSafeArea,
imageAspect, imageAspect,
imageDimensions, imageDimensions,
imageStyle,
dismissSwipePan, dismissSwipePan,
transforms,
}: Props) => { }: Props) => {
const scrollViewRef = useAnimatedRef<Animated.ScrollView>() const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
const [scaled, setScaled] = useState(false) const [scaled, setScaled] = useState(false)
@@ -67,16 +82,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
@@ -114,10 +119,7 @@ const ImageItem = ({
.numberOfTaps(2) .numberOfTaps(2)
.onEnd(e => { .onEnd(e => {
'worklet' 'worklet'
const screenSize = measure(safeAreaRef) const screenSize = measureSafeArea()
if (!screenSize) {
return
}
const {absoluteX, absoluteY} = e const {absoluteX, absoluteY} = e
let nextZoomRect = { let nextZoomRect = {
x: 0, x: 0,
@@ -143,9 +145,56 @@ const ImageItem = ({
singleTap, singleTap,
) )
const containerStyle = useAnimatedStyle(() => {
const {scaleAndMoveTransform, isHidden} = transforms.value
return {
flex: 1,
transform: scaleAndMoveTransform,
opacity: isHidden ? 0 : 1,
}
})
const imageCropStyle = useAnimatedStyle(() => {
const screenSize = measureSafeArea()
const {cropFrameTransform} = transforms.value
return {
overflow: 'hidden',
transform: cropFrameTransform,
width: screenSize.width,
maxHeight: screenSize.height,
aspectRatio: imageAspect,
alignSelf: 'center',
}
})
const imageStyle = useAnimatedStyle(() => {
const {cropContentTransform} = transforms.value
return {
transform: cropContentTransform,
width: '100%',
aspectRatio: imageAspect,
}
})
const [showLoader, setShowLoader] = useState(false)
const [hasLoaded, setHasLoaded] = useState(false)
useAnimatedReaction(
() => {
return transforms.value.isResting && !hasLoaded
},
(show, prevShow) => {
if (show && !prevShow) {
runOnJS(setShowLoader)(false)
} else if (!prevShow && show) {
runOnJS(setShowLoader)(true)
}
},
)
const type = imageSrc.type const type = imageSrc.type
const borderRadius = const borderRadius =
type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0
return ( return (
<GestureDetector gesture={composedGesture}> <GestureDetector gesture={composedGesture}>
<Animated.ScrollView <Animated.ScrollView
@@ -156,22 +205,29 @@ const ImageItem = ({
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
maximumZoomScale={maxZoomScale} maximumZoomScale={maxZoomScale}
onScroll={scrollHandler} onScroll={scrollHandler}
style={containerStyle}
bounces={scaled} bounces={scaled}
bouncesZoom={true} bouncesZoom={true}
style={imageStyle}
centerContent> centerContent>
{showLoader && (
<ActivityIndicator size="small" color="#FFF" style={styles.loading} /> <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
<AnimatedImage )}
<Animated.View style={imageCropStyle}>
<Animated.View style={imageStyle}>
<Image
contentFit="contain" contentFit="contain"
source={{uri: imageSrc.uri}} source={{uri: imageSrc.uri}}
placeholderContentFit="contain" placeholderContentFit="contain"
placeholder={{uri: imageSrc.thumbUri}} placeholder={{uri: imageSrc.thumbUri}}
style={[animatedStyle, {borderRadius}]} style={{flex: 1, borderRadius}}
accessibilityLabel={imageSrc.alt} accessibilityLabel={imageSrc.alt}
accessibilityHint="" accessibilityHint=""
enableLiveTextInteraction={showControls && !scaled} enableLiveTextInteraction={showControls && !scaled}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
onLoad={() => setHasLoaded(true)}
/> />
</Animated.View>
</Animated.View>
</Animated.ScrollView> </Animated.ScrollView>
</GestureDetector> </GestureDetector>
) )
@@ -1,11 +1,15 @@
// default implementation fallback for web // default implementation fallback for web
import React from 'react' import React from 'react'
import {ImageStyle, StyleProp, View} from 'react-native' import {View} from 'react-native'
import {PanGesture} from 'react-native-gesture-handler' import {PanGesture} from 'react-native-gesture-handler'
import {AnimatedRef} from 'react-native-reanimated' import {SharedValue} from 'react-native-reanimated'
import {Dimensions as ImageDimensions, ImageSource} from '../../@types' import {
Dimensions as ImageDimensions,
ImageSource,
Transform,
} from '../../@types'
type Props = { type Props = {
imageSrc: ImageSource imageSrc: ImageSource
@@ -14,11 +18,24 @@ type Props = {
onZoom: (scaled: boolean) => void onZoom: (scaled: boolean) => void
isScrollViewBeingDragged: boolean isScrollViewBeingDragged: boolean
showControls: boolean showControls: boolean
safeAreaRef: AnimatedRef<View> measureSafeArea: () => {
x: number
y: number
width: number
height: number
}
imageAspect: number | undefined imageAspect: number | undefined
imageDimensions: ImageDimensions | undefined imageDimensions: ImageDimensions | undefined
imageStyle: StyleProp<ImageStyle>
dismissSwipePan: PanGesture dismissSwipePan: PanGesture
transforms: Readonly<
SharedValue<{
scaleAndMoveTransform: Transform
cropFrameTransform: Transform
cropContentTransform: Transform
isResting: boolean
isHidden: boolean
}>
>
} }
const ImageItem = (_props: Props) => { const ImageItem = (_props: Props) => {
+250 -28
View File
@@ -9,23 +9,36 @@
// 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,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {Edge, SafeAreaView} from 'react-native-safe-area-context' import {
Edge,
SafeAreaView,
useSafeAreaFrame,
useSafeAreaInsets,
} from 'react-native-safe-area-context'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Trans} from '@lingui/macro' import {Trans} from '@lingui/macro'
@@ -36,17 +49,24 @@ 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 {ImageSource} from './@types' import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army'
import {ImageSource, Transform} from './@types'
import ImageDefaultHeader from './components/ImageDefaultHeader' import ImageDefaultHeader from './components/ImageDefaultHeader'
import ImageItem from './components/ImageItem/ImageItem' import ImageItem from './components/ImageItem/ImageItem'
type Rect = {x: number; y: number; width: number; height: number}
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
const SLOW_SPRING = {stiffness: 120}
const FAST_SPRING = {stiffness: 700}
export default function ImageViewRoot({ export default function ImageViewRoot({
lightbox, lightbox: nextLightbox,
onRequestClose, onRequestClose,
onPressSave, onPressSave,
onPressShare, onPressShare,
@@ -56,24 +76,70 @@ export default function ImageViewRoot({
onPressSave: (uri: string) => void onPressSave: (uri: string) => void
onPressShare: (uri: string) => void onPressShare: (uri: string) => void
}) { }) {
'use no memo'
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)
// https://github.com/software-mansion/react-native-reanimated/issues/6677
requestAnimationFrame(() => {
openProgress.value = canAnimate ? withClampedSpring(1, SLOW_SPRING) : 1
})
return () => {
// https://github.com/software-mansion/react-native-reanimated/issues/6677
requestAnimationFrame(() => {
openProgress.value = canAnimate ? withClampedSpring(0, SLOW_SPRING) : 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 +152,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,33 +174,41 @@ 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'}
}) })
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,
) )
opacity -= dragProgress opacity -= dragProgress
} }
const factor = isIOS ? 100 : 50
return { return {
opacity, opacity: Math.round(opacity * factor) / factor,
} }
}) })
const animatedHeaderStyle = useAnimatedStyle(() => { const animatedHeaderStyle = useAnimatedStyle(() => {
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,
FAST_SPRING,
),
transform: [ transform: [
{ {
translateY: withClampedSpring(show ? 0 : -30), translateY: withClampedSpring(show ? 0 : -30, FAST_SPRING),
}, },
], ],
} }
@@ -140,10 +218,13 @@ 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,
FAST_SPRING,
),
transform: [ transform: [
{ {
translateY: withClampedSpring(show ? 0 : 30), translateY: withClampedSpring(show ? 0 : 30, FAST_SPRING),
}, },
], ],
} }
@@ -172,7 +253,7 @@ function ImageView({
if (isOut && !wasOut) { if (isOut && !wasOut) {
// Stop the animation from blocking the screen forever. // Stop the animation from blocking the screen forever.
cancelAnimation(dismissSwipeTranslateY) cancelAnimation(dismissSwipeTranslateY)
runOnJS(onRequestClose)() onFlyAway()
} }
}, },
) )
@@ -209,6 +290,7 @@ function ImageView({
isFlyingAway={isFlyingAway} isFlyingAway={isFlyingAway}
isActive={i === imageIndex} isActive={i === imageIndex}
dismissSwipeTranslateY={dismissSwipeTranslateY} dismissSwipeTranslateY={dismissSwipeTranslateY}
openProgress={openProgress}
/> />
</View> </View>
))} ))}
@@ -247,6 +329,7 @@ function LightboxImage({
isActive, isActive,
showControls, showControls,
safeAreaRef, safeAreaRef,
openProgress,
dismissSwipeTranslateY, dismissSwipeTranslateY,
}: { }: {
imageSrc: ImageSource imageSrc: ImageSource
@@ -259,6 +342,7 @@ function LightboxImage({
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 +350,65 @@ function LightboxImage({
knownDimensions: imageSrc.dimensions, knownDimensions: imageSrc.dimensions,
}) })
const safeFrameDelayedForJSThreadOnly = useSafeAreaFrame()
const safeInsetsDelayedForJSThreadOnly = useSafeAreaInsets()
const measureSafeArea = React.useCallback(() => {
'worklet'
let safeArea: Rect | null = measure(safeAreaRef)
if (!safeArea) {
if (_WORKLET) {
console.error('Expected to always be able to measure safe area.')
}
const frame = safeFrameDelayedForJSThreadOnly
const insets = safeInsetsDelayedForJSThreadOnly
safeArea = {
x: frame.x + insets.left,
y: frame.y + insets.top,
width: frame.width - insets.left - insets.right,
height: frame.height - insets.top - insets.bottom,
}
}
return safeArea
}, [
safeFrameDelayedForJSThreadOnly,
safeInsetsDelayedForJSThreadOnly,
safeAreaRef,
])
const {thumbRect} = imageSrc
const transforms = useDerivedValue(() => {
'worklet'
const safeArea = measureSafeArea()
const dismissTranslateY =
isActive && openProgress.value === 1 ? dismissSwipeTranslateY.value : 0
if (openProgress.value === 0 && isFlyingAway.value) {
return {
isHidden: true,
isResting: false,
scaleAndMoveTransform: [],
cropFrameTransform: [],
cropContentTransform: [],
}
}
if (isActive && thumbRect && imageAspect && openProgress.value < 1) {
return interpolateTransform(
openProgress.value,
thumbRect,
safeArea,
imageAspect,
)
}
return {
isHidden: false,
isResting: dismissTranslateY === 0,
scaleAndMoveTransform: [{translateY: dismissTranslateY}],
cropFrameTransform: [],
cropContentTransform: [],
}
})
const dismissSwipePan = Gesture.Pan() const dismissSwipePan = Gesture.Pan()
.enabled(isActive && !isScaled) .enabled(isActive && !isScaled)
.activeOffsetY([-10, 10]) .activeOffsetY([-10, 10])
@@ -273,14 +416,14 @@ function LightboxImage({
.maxPointers(1) .maxPointers(1)
.onUpdate(e => { .onUpdate(e => {
'worklet' 'worklet'
if (isFlyingAway.value) { if (openProgress.value !== 1 || isFlyingAway.value) {
return return
} }
dismissSwipeTranslateY.value = e.translationY dismissSwipeTranslateY.value = e.translationY
}) })
.onEnd(e => { .onEnd(e => {
'worklet' 'worklet'
if (isFlyingAway.value) { if (openProgress.value !== 1 || isFlyingAway.value) {
return return
} }
if (Math.abs(e.velocityY) > 1000) { if (Math.abs(e.velocityY) > 1000) {
@@ -303,11 +446,6 @@ function LightboxImage({
} }
}) })
const imageStyle = useAnimatedStyle(() => {
return {
transform: [{translateY: dismissSwipeTranslateY.value}],
}
})
return ( return (
<ImageItem <ImageItem
imageSrc={imageSrc} imageSrc={imageSrc}
@@ -316,11 +454,11 @@ function LightboxImage({
onRequestClose={onRequestClose} onRequestClose={onRequestClose}
isScrollViewBeingDragged={isScrollViewBeingDragged} isScrollViewBeingDragged={isScrollViewBeingDragged}
showControls={showControls} showControls={showControls}
safeAreaRef={safeAreaRef} measureSafeArea={measureSafeArea}
imageAspect={imageAspect} imageAspect={imageAspect}
imageDimensions={imageDimensions} imageDimensions={imageDimensions}
imageStyle={imageStyle}
dismissSwipePan={dismissSwipePan} dismissSwipePan={dismissSwipePan}
transforms={transforms}
/> />
) )
} }
@@ -476,7 +614,91 @@ const styles = StyleSheet.create({
}, },
}) })
function withClampedSpring(value: any) { function interpolatePx(
px: number,
inputRange: readonly number[],
outputRange: readonly number[],
) {
'worklet' 'worklet'
return withSpring(value, {overshootClamping: true, stiffness: 300}) 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
},
safeArea: {width: number; height: number; x: number; y: number},
imageAspect: number,
): {
scaleAndMoveTransform: Transform
cropFrameTransform: Transform
cropContentTransform: Transform
isResting: boolean
isHidden: boolean
} {
'worklet'
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 safeAreaAspect = safeArea.width / safeArea.height
let finalWidth
let finalHeight
if (safeAreaAspect > imageAspect) {
finalWidth = safeArea.height * imageAspect
finalHeight = safeArea.height
} else {
finalWidth = safeArea.width
finalHeight = safeArea.width / imageAspect
}
const initialScale = Math.min(
uncroppedInitialWidth / finalWidth,
uncroppedInitialHeight / finalHeight,
)
const croppedFinalWidth = thumbnailDims.width / initialScale
const croppedFinalHeight = thumbnailDims.height / initialScale
const screenCenterX = safeArea.width / 2
const screenCenterY = safeArea.height / 2
const thumbnailSafeAreaX = thumbnailDims.pageX - safeArea.x
const thumbnailSafeAreaY = thumbnailDims.pageY - safeArea.y
const thumbnailCenterX = thumbnailSafeAreaX + thumbnailDims.width / 2
const thumbnailCenterY = thumbnailSafeAreaY + 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 cropScaleX = interpolate(
progress,
[0, 1],
[croppedFinalWidth / finalWidth, 1],
)
const cropScaleY = interpolate(
progress,
[0, 1],
[croppedFinalHeight / finalHeight, 1],
)
return {
isHidden: false,
isResting: progress === 1,
scaleAndMoveTransform: [{translateX}, {translateY}, {scale}],
cropFrameTransform: [{scaleX: cropScaleX}, {scaleY: cropScaleY}],
cropContentTransform: [{scaleX: 1 / cropScaleX}, {scaleY: 1 / cropScaleY}],
}
}
function withClampedSpring(value: any, {stiffness}: {stiffness: number}) {
'worklet'
return withSpring(value, {overshootClamping: true, stiffness})
} }
+29 -8
View File
@@ -1,5 +1,12 @@
import React from 'react' import React from 'react'
import {Pressable, StyleSheet, View} from 'react-native' import {Pressable, StyleSheet, View} from 'react-native'
import Animated, {
measure,
MeasuredDimensions,
runOnJS,
runOnUI,
useAnimatedRef,
} from 'react-native-reanimated'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -53,6 +60,7 @@ export function ProfileSubpageHeader({
const {openLightbox} = useLightboxControls() const {openLightbox} = useLightboxControls()
const pal = usePalette('default') const pal = usePalette('default')
const canGoBack = navigation.canGoBack() const canGoBack = navigation.canGoBack()
const aviRef = useAnimatedRef()
const onPressBack = React.useCallback(() => { const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) { if (navigation.canGoBack()) {
@@ -66,15 +74,14 @@ export function ProfileSubpageHeader({
setDrawerOpen(true) setDrawerOpen(true)
}, [setDrawerOpen]) }, [setDrawerOpen])
const onPressAvi = React.useCallback(() => { const _openLightbox = React.useCallback(
if ( (uri: string, thumbRect: MeasuredDimensions | null) => {
avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
) {
openLightbox({ openLightbox({
images: [ images: [
{ {
uri: avatar, uri,
thumbUri: avatar, thumbUri: uri,
thumbRect,
dimensions: { dimensions: {
// It's fine if it's actually smaller but we know it's 1:1. // It's fine if it's actually smaller but we know it's 1:1.
height: 1000, height: 1000,
@@ -84,10 +91,22 @@ export function ProfileSubpageHeader({
}, },
], ],
index: 0, index: 0,
thumbDims: null,
}) })
},
[openLightbox],
)
const onPressAvi = React.useCallback(() => {
if (
avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
) {
runOnUI(() => {
'worklet'
const rect = measure(aviRef)
runOnJS(_openLightbox)(avatar, rect)
})()
} }
}, [openLightbox, avatar]) }, [_openLightbox, avatar, aviRef])
return ( return (
<CenteredView style={pal.view}> <CenteredView style={pal.view}>
@@ -135,6 +154,7 @@ export function ProfileSubpageHeader({
paddingBottom: 6, paddingBottom: 6,
paddingHorizontal: isMobile ? 12 : 14, paddingHorizontal: isMobile ? 12 : 14,
}}> }}>
<Animated.View ref={aviRef} collapsable={false}>
<Pressable <Pressable
testID="headerAviButton" testID="headerAviButton"
onPress={onPressAvi} onPress={onPressAvi}
@@ -148,6 +168,7 @@ export function ProfileSubpageHeader({
<UserAvatar type={avatarType} size={58} avatar={avatar} /> <UserAvatar type={avatarType} size={58} avatar={avatar} />
)} )}
</Pressable> </Pressable>
</Animated.View>
<View style={{flex: 1}}> <View style={{flex: 1}}>
{isLoading ? ( {isLoading ? (
<LoadingPlaceholder <LoadingPlaceholder
+8 -5
View File
@@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import {DimensionValue, Pressable, View} from 'react-native' import {DimensionValue, Pressable, View} from 'react-native'
import Animated, {AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {AppBskyEmbedImages} from '@atproto/api' import {AppBskyEmbedImages} from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
@@ -92,7 +93,7 @@ export function AutoSizedImage({
image: AppBskyEmbedImages.ViewImage image: AppBskyEmbedImages.ViewImage
crop?: 'none' | 'square' | 'constrained' crop?: 'none' | 'square' | 'constrained'
hideBadge?: boolean hideBadge?: boolean
onPress?: () => void onPress?: (containerRef: AnimatedRef<React.Component<{}, {}, any>>) => void
onLongPress?: () => void onLongPress?: () => void
onPressIn?: () => void onPressIn?: () => void
}) { }) {
@@ -107,12 +108,14 @@ export function AutoSizedImage({
src: image.thumb, src: image.thumb,
knownDimensions: image.aspectRatio ?? null, knownDimensions: image.aspectRatio ?? null,
}) })
const containerRef = useAnimatedRef()
const cropDisabled = crop === 'none' const cropDisabled = crop === 'none'
const isCropped = rawIsCropped && !cropDisabled const isCropped = rawIsCropped && !cropDisabled
const hasAlt = !!image.alt const hasAlt = !!image.alt
const contents = ( const contents = (
<> <Animated.View ref={containerRef} collapsable={false}>
<Image <Image
style={[a.w_full, a.h_full]} style={[a.w_full, a.h_full]}
source={image.thumb} source={image.thumb}
@@ -185,13 +188,13 @@ export function AutoSizedImage({
)} )}
</View> </View>
) : null} ) : null}
</> </Animated.View>
) )
if (cropDisabled) { if (cropDisabled) {
return ( return (
<Pressable <Pressable
onPress={onPress} onPress={() => onPress?.(containerRef)}
onLongPress={onLongPress} onLongPress={onLongPress}
onPressIn={onPressIn} onPressIn={onPressIn}
// alt here is what screen readers actually use // alt here is what screen readers actually use
@@ -213,7 +216,7 @@ export function AutoSizedImage({
fullBleed={crop === 'square'} fullBleed={crop === 'square'}
aspectRatio={constrained ?? 1}> aspectRatio={constrained ?? 1}>
<Pressable <Pressable
onPress={onPress} onPress={() => onPress?.(containerRef)}
onLongPress={onLongPress} onLongPress={onLongPress}
onPressIn={onPressIn} onPressIn={onPressIn}
// alt here is what screen readers actually use // alt here is what screen readers actually use
+9 -5
View File
@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import {Pressable, StyleProp, View, ViewStyle} from 'react-native' import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
import Animated, {AnimatedRef, useAnimatedRef} from 'react-native-reanimated' import Animated, {AnimatedRef} from 'react-native-reanimated'
import {Image, ImageStyle} from 'expo-image' import {Image, ImageStyle} from 'expo-image'
import {AppBskyEmbedImages} from '@atproto/api' import {AppBskyEmbedImages} from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
@@ -19,13 +19,14 @@ interface Props {
index: number index: number
onPress?: ( onPress?: (
index: number, index: number,
containerRef: AnimatedRef<React.Component<{}, {}, any>>, containerRefs: AnimatedRef<React.Component<{}, {}, any>>[],
) => void ) => void
onLongPress?: EventFunction onLongPress?: EventFunction
onPressIn?: EventFunction onPressIn?: EventFunction
imageStyle?: StyleProp<ImageStyle> imageStyle?: StyleProp<ImageStyle>
viewContext?: PostEmbedViewContext viewContext?: PostEmbedViewContext
insetBorderStyle?: StyleProp<ViewStyle> insetBorderStyle?: StyleProp<ViewStyle>
containerRefs: AnimatedRef<React.Component<{}, {}, any>>[]
} }
export function GalleryItem({ export function GalleryItem({
@@ -37,6 +38,7 @@ export function GalleryItem({
onLongPress, onLongPress,
viewContext, viewContext,
insetBorderStyle, insetBorderStyle,
containerRefs,
}: Props) { }: Props) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
@@ -45,11 +47,13 @@ export function GalleryItem({
const hasAlt = !!image.alt const hasAlt = !!image.alt
const hideBadges = const hideBadges =
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const containerRef = useAnimatedRef()
return ( return (
<Animated.View style={a.flex_1} ref={containerRef}> <Animated.View
style={a.flex_1}
ref={containerRefs[index]}
collapsable={false}>
<Pressable <Pressable
onPress={onPress ? () => onPress(index, containerRef) : undefined} onPress={onPress ? () => onPress(index, containerRefs) : undefined}
onPressIn={onPressIn ? () => onPressIn(index) : undefined} onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onLongPress={onLongPress ? () => onLongPress(index) : undefined} onLongPress={onLongPress ? () => onLongPress(index) : undefined}
style={[ style={[
+31 -6
View File
@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {AnimatedRef} from 'react-native-reanimated' import {AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
import {AppBskyEmbedImages} from '@atproto/api' import {AppBskyEmbedImages} from '@atproto/api'
import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
@@ -11,7 +11,7 @@ interface ImageLayoutGridProps {
images: AppBskyEmbedImages.ViewImage[] images: AppBskyEmbedImages.ViewImage[]
onPress?: ( onPress?: (
index: number, index: number,
containerRef: AnimatedRef<React.Component<{}, {}, any>>, containerRefs: AnimatedRef<React.Component<{}, {}, any>>[],
) => void ) => void
onLongPress?: (index: number) => void onLongPress?: (index: number) => void
onPressIn?: (index: number) => void onPressIn?: (index: number) => void
@@ -41,7 +41,7 @@ interface ImageLayoutGridInnerProps {
images: AppBskyEmbedImages.ViewImage[] images: AppBskyEmbedImages.ViewImage[]
onPress?: ( onPress?: (
index: number, index: number,
containerRef: AnimatedRef<React.Component<{}, {}, any>>, containerRefs: AnimatedRef<React.Component<{}, {}, any>>[],
) => void ) => void
onLongPress?: (index: number) => void onLongPress?: (index: number) => void
onPressIn?: (index: number) => void onPressIn?: (index: number) => void
@@ -53,8 +53,14 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
const gap = props.gap const gap = props.gap
const count = props.images.length const count = props.images.length
const containerRef1 = useAnimatedRef()
const containerRef2 = useAnimatedRef()
const containerRef3 = useAnimatedRef()
const containerRef4 = useAnimatedRef()
switch (count) { switch (count) {
case 2: case 2: {
const containerRefs = [containerRef1, containerRef2]
return ( return (
<View style={[a.flex_1, a.flex_row, gap]}> <View style={[a.flex_1, a.flex_row, gap]}>
<View style={[a.flex_1, {aspectRatio: 1}]}> <View style={[a.flex_1, {aspectRatio: 1}]}>
@@ -62,6 +68,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
{...props} {...props}
index={0} index={0}
insetBorderStyle={noCorners(['topRight', 'bottomRight'])} insetBorderStyle={noCorners(['topRight', 'bottomRight'])}
containerRefs={containerRefs}
/> />
</View> </View>
<View style={[a.flex_1, {aspectRatio: 1}]}> <View style={[a.flex_1, {aspectRatio: 1}]}>
@@ -69,12 +76,15 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
{...props} {...props}
index={1} index={1}
insetBorderStyle={noCorners(['topLeft', 'bottomLeft'])} insetBorderStyle={noCorners(['topLeft', 'bottomLeft'])}
containerRefs={containerRefs}
/> />
</View> </View>
</View> </View>
) )
}
case 3: case 3: {
const containerRefs = [containerRef1, containerRef2, containerRef3]
return ( return (
<View style={[a.flex_1, a.flex_row, gap]}> <View style={[a.flex_1, a.flex_row, gap]}>
<View style={[a.flex_1, {aspectRatio: 1}]}> <View style={[a.flex_1, {aspectRatio: 1}]}>
@@ -82,6 +92,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
{...props} {...props}
index={0} index={0}
insetBorderStyle={noCorners(['topRight', 'bottomRight'])} insetBorderStyle={noCorners(['topRight', 'bottomRight'])}
containerRefs={containerRefs}
/> />
</View> </View>
<View style={[a.flex_1, {aspectRatio: 1}, gap]}> <View style={[a.flex_1, {aspectRatio: 1}, gap]}>
@@ -94,6 +105,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
'bottomLeft', 'bottomLeft',
'bottomRight', 'bottomRight',
])} ])}
containerRefs={containerRefs}
/> />
</View> </View>
<View style={[a.flex_1]}> <View style={[a.flex_1]}>
@@ -105,13 +117,21 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
'bottomLeft', 'bottomLeft',
'topRight', 'topRight',
])} ])}
containerRefs={containerRefs}
/> />
</View> </View>
</View> </View>
</View> </View>
) )
}
case 4: case 4: {
const containerRefs = [
containerRef1,
containerRef2,
containerRef3,
containerRef4,
]
return ( return (
<> <>
<View style={[a.flex_row, gap]}> <View style={[a.flex_row, gap]}>
@@ -124,6 +144,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
'topRight', 'topRight',
'bottomRight', 'bottomRight',
])} ])}
containerRefs={containerRefs}
/> />
</View> </View>
<View style={[a.flex_1, {aspectRatio: 1.5}]}> <View style={[a.flex_1, {aspectRatio: 1.5}]}>
@@ -135,6 +156,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
'bottomLeft', 'bottomLeft',
'bottomRight', 'bottomRight',
])} ])}
containerRefs={containerRefs}
/> />
</View> </View>
</View> </View>
@@ -148,6 +170,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
'topRight', 'topRight',
'bottomRight', 'bottomRight',
])} ])}
containerRefs={containerRefs}
/> />
</View> </View>
<View style={[a.flex_1, {aspectRatio: 1.5}]}> <View style={[a.flex_1, {aspectRatio: 1.5}]}>
@@ -159,11 +182,13 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
'bottomLeft', 'bottomLeft',
'topRight', 'topRight',
])} ])}
containerRefs={containerRefs}
/> />
</View> </View>
</View> </View>
</> </>
) )
}
default: default:
return null return null
+10 -12
View File
@@ -6,13 +6,12 @@ import {
View, View,
ViewStyle, ViewStyle,
} from 'react-native' } from 'react-native'
import Animated, { import {
AnimatedRef, AnimatedRef,
measure, measure,
MeasuredDimensions, MeasuredDimensions,
runOnJS, runOnJS,
runOnUI, runOnUI,
useAnimatedRef,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import { import {
@@ -69,7 +68,6 @@ export function PostEmbeds({
viewContext?: PostEmbedViewContext viewContext?: PostEmbedViewContext
}) { }) {
const {openLightbox} = useLightboxControls() const {openLightbox} = useLightboxControls()
const containerRef = useAnimatedRef()
// quote post with media // quote post with media
// = // =
@@ -149,25 +147,25 @@ export function PostEmbeds({
})) }))
const _openLightbox = ( const _openLightbox = (
index: number, index: number,
thumbDims: MeasuredDimensions | null, thumbRects: (MeasuredDimensions | null)[],
) => { ) => {
openLightbox({ openLightbox({
images: items.map(item => ({ images: items.map((item, i) => ({
...item, ...item,
thumbRect: thumbRects[i] ?? null,
type: 'image', type: 'image',
})), })),
index, index,
thumbDims,
}) })
} }
const onPress = ( const onPress = (
index: number, index: number,
ref: AnimatedRef<React.Component<{}, {}, any>>, refs: AnimatedRef<React.Component<{}, {}, any>>[],
) => { ) => {
runOnUI(() => { runOnUI(() => {
'worklet' 'worklet'
const dims = measure(ref) const rects = refs.map(ref => (ref ? measure(ref) : null))
runOnJS(_openLightbox)(index, dims) runOnJS(_openLightbox)(index, rects)
})() })()
} }
const onPressIn = (_: number) => { const onPressIn = (_: number) => {
@@ -180,7 +178,7 @@ export function PostEmbeds({
const image = images[0] const image = images[0]
return ( return (
<ContentHider modui={moderation?.ui('contentMedia')}> <ContentHider modui={moderation?.ui('contentMedia')}>
<Animated.View ref={containerRef} style={[a.mt_sm, style]}> <View style={[a.mt_sm, style]}>
<AutoSizedImage <AutoSizedImage
crop={ crop={
viewContext === PostEmbedViewContext.ThreadHighlighted viewContext === PostEmbedViewContext.ThreadHighlighted
@@ -191,13 +189,13 @@ export function PostEmbeds({
: 'constrained' : 'constrained'
} }
image={image} image={image}
onPress={() => onPress(0, containerRef)} onPress={containerRef => onPress(0, [containerRef])}
onPressIn={() => onPressIn(0)} onPressIn={() => onPressIn(0)}
hideBadge={ hideBadge={
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
} }
/> />
</Animated.View> </View>
</ContentHider> </ContentHider>
) )
} }