[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:
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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={[
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user