Show almost-instant preview when opening lightbox (#6000)

* Plumb thumbUri down to the lightbox

* Remove onLoad tracking from lightbox

* Hook up placeholder URI to the image

* Fix NaN causing crash on double tap while offline

* Protect against NaNs in the future
This commit is contained in:
dan
2024-10-29 21:00:28 +00:00
committed by GitHub
parent 339f45ccbb
commit ab492cd77a
9 changed files with 56 additions and 78 deletions
+1
View File
@@ -10,6 +10,7 @@ type ProfileImageLightbox = {
type ImagesLightboxItem = { type ImagesLightboxItem = {
uri: string uri: string
thumbUri: string
alt?: string alt?: string
} }
@@ -16,4 +16,4 @@ export type Position = {
y: number y: number
} }
export type ImageSource = {uri: string; alt?: string} export type ImageSource = {uri: string; thumbUri: string; alt?: string}
@@ -34,7 +34,6 @@ const SCREEN = {
const MIN_DOUBLE_TAP_SCALE = 2 const MIN_DOUBLE_TAP_SCALE = 2
const MAX_ORIGINAL_IMAGE_ZOOM = 2 const MAX_ORIGINAL_IMAGE_ZOOM = 2
const AnimatedImage = Animated.createAnimatedComponent(Image)
const initialTransform = createTransform() const initialTransform = createTransform()
type Props = { type Props = {
@@ -53,7 +52,6 @@ const ImageItem = ({
isScrollViewBeingDragged, isScrollViewBeingDragged,
}: Props) => { }: Props) => {
const [isScaled, setIsScaled] = useState(false) const [isScaled, setIsScaled] = useState(false)
const [isLoaded, setIsLoaded] = useState(false)
const imageDimensions = useImageDimensions(imageSrc) const imageDimensions = useImageDimensions(imageSrc)
const committedTransform = useSharedValue(initialTransform) const committedTransform = useSharedValue(initialTransform)
const panTranslation = useSharedValue({x: 0, y: 0}) const panTranslation = useSharedValue({x: 0, y: 0})
@@ -313,20 +311,23 @@ const ImageItem = ({
singleTap, singleTap,
) )
const isLoading = !isLoaded || !imageDimensions
return ( return (
<Animated.View ref={containerRef} style={styles.container}> <Animated.View
{isLoading && ( ref={containerRef}
<ActivityIndicator size="small" color="#FFF" style={styles.loading} /> // Necessary to make opacity work for both children together.
)} renderToHardwareTextureAndroid
style={[styles.container, animatedStyle]}>
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
<GestureDetector gesture={composedGesture}> <GestureDetector gesture={composedGesture}>
<AnimatedImage <Image
contentFit="contain" contentFit="contain"
source={{uri: imageSrc.uri}} source={{uri: imageSrc.uri}}
style={[styles.image, animatedStyle]} placeholderContentFit="contain"
placeholder={{uri: imageSrc.thumbUri}}
style={styles.image}
accessibilityLabel={imageSrc.alt} accessibilityLabel={imageSrc.alt}
accessibilityHint="" accessibilityHint=""
onLoad={() => setIsLoaded(true)} accessibilityIgnoresInvertColors
cachePolicy="memory" cachePolicy="memory"
/> />
</GestureDetector> </GestureDetector>
@@ -7,9 +7,8 @@
*/ */
import React, {useState} from 'react' import React, {useState} from 'react'
import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
import {Dimensions, StyleSheet} from 'react-native' import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import {Image} from 'expo-image'
import Animated, { import Animated, {
interpolate, interpolate,
runOnJS, runOnJS,
@@ -17,14 +16,12 @@ import Animated, {
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {Image} from 'expo-image'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {Gesture, GestureDetector} from 'react-native-gesture-handler' import {Dimensions as ImageDimensions, ImageSource} from '../../@types'
import useImageDimensions from '../../hooks/useImageDimensions' import useImageDimensions from '../../hooks/useImageDimensions'
import {ImageSource, Dimensions as ImageDimensions} from '../../@types'
import {ImageLoading} from './ImageLoading'
const SWIPE_CLOSE_OFFSET = 75 const SWIPE_CLOSE_OFFSET = 75
const SWIPE_CLOSE_VELOCITY = 1 const SWIPE_CLOSE_VELOCITY = 1
const SCREEN = Dimensions.get('screen') const SCREEN = Dimensions.get('screen')
@@ -40,8 +37,6 @@ type Props = {
showControls: boolean showControls: boolean
} }
const AnimatedImage = Animated.createAnimatedComponent(Image)
const ImageItem = ({ const ImageItem = ({
imageSrc, imageSrc,
onTap, onTap,
@@ -51,7 +46,6 @@ const ImageItem = ({
}: Props) => { }: Props) => {
const scrollViewRef = useAnimatedRef<Animated.ScrollView>() const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
const translationY = useSharedValue(0) const translationY = useSharedValue(0)
const [loaded, setLoaded] = useState(false)
const [scaled, setScaled] = useState(false) const [scaled, setScaled] = useState(false)
const imageDimensions = useImageDimensions(imageSrc) const imageDimensions = useImageDimensions(imageSrc)
const maxZoomScale = imageDimensions const maxZoomScale = imageDimensions
@@ -141,18 +135,21 @@ const ImageItem = ({
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
maximumZoomScale={maxZoomScale} maximumZoomScale={maxZoomScale}
contentContainerStyle={styles.imageScrollContainer}
onScroll={scrollHandler}> onScroll={scrollHandler}>
{(!loaded || !imageDimensions) && <ImageLoading />} <Animated.View style={[styles.imageScrollContainer, animatedStyle]}>
<AnimatedImage <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
contentFit="contain" <Image
source={{uri: imageSrc.uri}} contentFit="contain"
style={[styles.image, animatedStyle]} source={{uri: imageSrc.uri}}
accessibilityLabel={imageSrc.alt} placeholderContentFit="contain"
accessibilityHint="" placeholder={{uri: imageSrc.thumbUri}}
onLoad={() => setLoaded(true)} style={styles.image}
enableLiveTextInteraction={showControls && !scaled} accessibilityLabel={imageSrc.alt}
/> accessibilityHint=""
enableLiveTextInteraction={showControls && !scaled}
accessibilityIgnoresInvertColors
/>
</Animated.View>
</Animated.ScrollView> </Animated.ScrollView>
</GestureDetector> </GestureDetector>
) )
@@ -170,6 +167,13 @@ const styles = StyleSheet.create({
width: SCREEN.width, width: SCREEN.width,
height: SCREEN.height, height: SCREEN.height,
}, },
loading: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
}) })
const getZoomRectAfterDoubleTap = ( const getZoomRectAfterDoubleTap = (
@@ -1,37 +0,0 @@
/**
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import React from 'react'
import {ActivityIndicator, Dimensions, StyleSheet, View} from 'react-native'
const SCREEN = Dimensions.get('screen')
const SCREEN_WIDTH = SCREEN.width
const SCREEN_HEIGHT = SCREEN.height
export const ImageLoading = () => (
<View style={styles.loading}>
<ActivityIndicator size="small" color="#FFF" />
</View>
)
const styles = StyleSheet.create({
listItem: {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
},
loading: {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
alignItems: 'center',
justifyContent: 'center',
},
imageScrollContainer: {
height: SCREEN_HEIGHT,
},
})
@@ -8,6 +8,7 @@
import {useEffect, useState} from 'react' import {useEffect, useState} from 'react'
import {Image, ImageURISource} from 'react-native' import {Image, ImageURISource} from 'react-native'
import {Dimensions, ImageSource} from '../@types' import {Dimensions, ImageSource} from '../@types'
const CACHE_SIZE = 50 const CACHE_SIZE = 50
@@ -36,8 +37,9 @@ const imageDimensionsCache = createCache(CACHE_SIZE)
const useImageDimensions = (image: ImageSource): Dimensions | null => { const useImageDimensions = (image: ImageSource): Dimensions | null => {
const [dimensions, setDimensions] = useState<Dimensions | null>(null) const [dimensions, setDimensions] = useState<Dimensions | null>(null)
// eslint-disable-next-line @typescript-eslint/no-shadow const getImageDimensions = (
const getImageDimensions = (image: ImageSource): Promise<Dimensions> => { image: ImageSource,
): Promise<Dimensions | null> => {
return new Promise(resolve => { return new Promise(resolve => {
if (image.uri) { if (image.uri) {
const source = image as ImageURISource const source = image as ImageURISource
@@ -51,16 +53,20 @@ const useImageDimensions = (image: ImageSource): Dimensions | null => {
source.uri, source.uri,
source.headers, source.headers,
(width: number, height: number) => { (width: number, height: number) => {
imageDimensionsCache.set(cacheKey, {width, height}) if (width > 0 && height > 0) {
resolve({width, height}) imageDimensionsCache.set(cacheKey, {width, height})
resolve({width, height})
} else {
resolve(null)
}
}, },
() => { () => {
resolve({width: 0, height: 0}) resolve(null)
}, },
) )
} }
} else { } else {
resolve({width: 0, height: 0}) resolve(null)
} }
}) })
} }
+3 -1
View File
@@ -31,7 +31,9 @@ export function Lightbox() {
const opts = activeLightbox const opts = activeLightbox
return ( return (
<ImageView <ImageView
images={[{uri: opts.profile.avatar || ''}]} images={[
{uri: opts.profile.avatar || '', thumbUri: opts.profile.avatar || ''},
]}
initialImageIndex={0} initialImageIndex={0}
visible visible
onRequestClose={onClose} onRequestClose={onClose}
@@ -72,7 +72,7 @@ export function ProfileSubpageHeader({
) { ) {
openLightbox({ openLightbox({
type: 'images', type: 'images',
images: [{uri: avatar}], images: [{uri: avatar, thumbUri: avatar}],
index: 0, index: 0,
}) })
} }
+1
View File
@@ -134,6 +134,7 @@ export function PostEmbeds({
if (images.length > 0) { if (images.length > 0) {
const items = embed.images.map(img => ({ const items = embed.images.map(img => ({
uri: img.fullsize, uri: img.fullsize,
thumbUri: img.thumb,
alt: img.alt, alt: img.alt,
aspectRatio: img.aspectRatio, aspectRatio: img.aspectRatio,
})) }))