From 5e9a0e123b65829814ee7f1294733344f0bbef91 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 5 May 2026 12:09:09 +0200 Subject: [PATCH 1/3] fix: include image loading indicator for video thumbnails as well --- .../src/components/FastImageAdapter.tsx | 48 ++++++++++ .../SampleAppComponentOverrides.tsx | 57 ++++++----- .../android/StreamShimmerFrameLayout.kt | 24 +---- package/src/components/Attachment/Gallery.tsx | 59 ++---------- .../components/Attachment/GalleryImage.tsx | 94 ++++++++++++++++++- .../Attachment/ImageLoadingIndicator.tsx | 4 +- .../components/Attachment/VideoThumbnail.tsx | 31 ++++-- .../__tests__/VideoThumbnail.test.tsx | 44 +++++++++ 8 files changed, 245 insertions(+), 116 deletions(-) create mode 100644 examples/SampleApp/src/components/FastImageAdapter.tsx create mode 100644 package/src/components/Attachment/__tests__/VideoThumbnail.test.tsx diff --git a/examples/SampleApp/src/components/FastImageAdapter.tsx b/examples/SampleApp/src/components/FastImageAdapter.tsx new file mode 100644 index 0000000000..076c06d1ec --- /dev/null +++ b/examples/SampleApp/src/components/FastImageAdapter.tsx @@ -0,0 +1,48 @@ +import React, { useMemo } from 'react'; +import { ImageProps } from 'react-native'; +import FastImage from '@d11/react-native-fast-image'; +import type { FastImageProps } from '@d11/react-native-fast-image'; +import { useChatContext } from 'stream-chat-react-native'; + +type FastImageAdapterProps = Omit & + Pick; + +export const FastImageAdapter = React.memo((props: ImageProps) => { + const { isOnline } = useChatContext(); + const { + source, + transition = FastImage.transition.fade, + ...rest + } = props as FastImageAdapterProps; + + const resolvedSource = useMemo(() => { + if ( + !source || + typeof source !== 'object' || + Array.isArray(source) || + !('uri' in source) || + typeof source.uri !== 'string' || + !/^https?:\/\//i.test(source.uri) + ) { + return source; + } + + return { + ...source, + cache: + source.cache ?? + (isOnline === false ? FastImage.cacheControl.cacheOnly : FastImage.cacheControl.immutable), + priority: source.priority ?? FastImage.priority.normal, + }; + }, [isOnline, source]); + + return ( + )} + source={resolvedSource} + // transition={transition} + /> + ); +}); + +FastImageAdapter.displayName = 'FastImageAdapter'; diff --git a/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx b/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx index 5bba6e1624..78037184c9 100644 --- a/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx +++ b/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx @@ -2,42 +2,39 @@ import React, { useMemo } from 'react'; import { Platform, StyleSheet, useColorScheme, View } from 'react-native'; import type { ComponentOverrides } from 'stream-chat-react-native'; import { BlurView } from '@react-native-community/blur'; -import FastImage from '@d11/react-native-fast-image'; -import { - useTheme, -} from 'stream-chat-react-native'; +import { useTheme } from 'stream-chat-react-native'; import { CustomAttachmentPickerContent } from './AttachmentPickerContent'; import { CustomChannelPreviewStatus } from './ChannelPreview'; +import { FastImageAdapter } from './FastImageAdapter'; import { MessageLocation } from './LocationSharing/MessageLocation'; import type { MessageOverlayBackdropConfigItem } from './SecretMenu'; -const MessageOverlayBlurBackground: NonNullable = - () => { - const { - theme: { semantics }, - } = useTheme(); - const scheme = useColorScheme(); - const isDark = scheme === 'dark'; - const isIOS = Platform.OS === 'ios'; +const MessageOverlayBlurBackground: NonNullable< + ComponentOverrides['MessageOverlayBackground'] +> = () => { + const { + theme: { semantics }, + } = useTheme(); + const scheme = useColorScheme(); + const isDark = scheme === 'dark'; + const isIOS = Platform.OS === 'ios'; - return ( - <> - - - - ); - }; + return ( + <> + + + + ); +}; const RenderNull = () => null; @@ -48,7 +45,7 @@ export const useSampleAppComponentOverrides = ( () => ({ AttachmentPickerContent: CustomAttachmentPickerContent, ChannelListHeaderNetworkDownIndicator: RenderNull, - ImageComponent: FastImage, + ImageComponent: FastImageAdapter, MessageLocation, NetworkDownIndicator: RenderNull, ChannelPreviewStatus: CustomChannelPreviewStatus, diff --git a/package/shared-native/android/StreamShimmerFrameLayout.kt b/package/shared-native/android/StreamShimmerFrameLayout.kt index da84863d77..63b63250b5 100644 --- a/package/shared-native/android/StreamShimmerFrameLayout.kt +++ b/package/shared-native/android/StreamShimmerFrameLayout.kt @@ -155,13 +155,10 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( return } - // Wide multi-stop strip creates a softer "glassy" sweep and avoids the hard thin-line look. + // Match iOS CAGradientLayer shimmer stops so both platforms have the same visual falloff. val shimmerWidth = (viewWidth * SHIMMER_STRIP_WIDTH_RATIO).coerceAtLeast(1f) val transparentHighlight = colorWithAlpha(gradientColor, 0f) - val edgeBase = colorWithAlpha(gradientColor, EDGE_HIGHLIGHT_ALPHA_FACTOR) val softBase = colorWithAlpha(gradientColor, SOFT_HIGHLIGHT_ALPHA_FACTOR) - val mediumBase = colorWithAlpha(gradientColor, MID_HIGHLIGHT_ALPHA_FACTOR) - val innerBase = colorWithAlpha(gradientColor, INNER_HIGHLIGHT_ALPHA_FACTOR) shimmerShader = LinearGradient( 0f, 0f, @@ -169,28 +166,16 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( 0f, intArrayOf( transparentHighlight, - edgeBase, softBase, - mediumBase, - innerBase, gradientColor, - innerBase, - mediumBase, softBase, - edgeBase, transparentHighlight, ), floatArrayOf( 0f, - 0.08f, - 0.2f, - 0.32f, - 0.4f, + 0.35f, 0.5f, - 0.6f, - 0.68f, - 0.8f, - 0.92f, + 0.65f, 1f, ), Shader.TileMode.CLAMP, @@ -252,9 +237,6 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( private const val DEFAULT_DURATION_MS = 1200L private const val DEFAULT_GRADIENT_COLOR = 0x59FFFFFF private const val SHIMMER_STRIP_WIDTH_RATIO = 1.25f - private const val EDGE_HIGHLIGHT_ALPHA_FACTOR = 0.1f private const val SOFT_HIGHLIGHT_ALPHA_FACTOR = 0.24f - private const val MID_HIGHLIGHT_ALPHA_FACTOR = 0.48f - private const val INNER_HIGHLIGHT_ALPHA_FACTOR = 0.72f } } diff --git a/package/src/components/Attachment/Gallery.tsx b/package/src/components/Attachment/Gallery.tsx index 716d2e325c..0b933819eb 100644 --- a/package/src/components/Attachment/Gallery.tsx +++ b/package/src/components/Attachment/Gallery.tsx @@ -1,9 +1,9 @@ import React, { useMemo } from 'react'; -import { ImageErrorEvent, Pressable, StyleSheet, Text, View } from 'react-native'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; import type { Attachment, LocalMessage } from 'stream-chat'; -import { GalleryImage } from './GalleryImage'; +import { LoadableGalleryImage } from './GalleryImage'; import { buildGallery } from './utils/buildGallery/buildGallery'; import type { Thumbnail } from './utils/buildGallery/types'; @@ -35,8 +35,6 @@ import { } from '../../contexts/overlayContext/OverlayContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { useLoadingImage } from '../../hooks/useLoadingImage'; -import { useStableCallback } from '../../hooks/useStableCallback'; import { isVideoPlayerAvailable } from '../../native'; import { primitives } from '../../theme'; import { FileTypes } from '../../types/types'; @@ -331,15 +329,6 @@ const GalleryImageThumbnail = ({ borderRadius, thumbnail, }: Pick) => { - const { AttachmentUploadIndicator, ImageLoadingFailedIndicator, ImageLoadingIndicator } = - useComponentsContext(); - const { - isLoadingImage, - isLoadingImageError, - onReloadImage, - setLoadingImage, - setLoadingImageError, - } = useLoadingImage(); const { theme: { messageItemView: { gallery }, @@ -347,44 +336,14 @@ const GalleryImageThumbnail = ({ } = useTheme(); const styles = useStyles(); - const onLoadStart = useStableCallback(() => { - setLoadingImageError(false); - setLoadingImage(true); - }); - const onLoad = useStableCallback(() => { - setTimeout(() => { - setLoadingImage(false); - setLoadingImageError(false); - }, 0); - }); - const onError = useStableCallback(({ nativeEvent: { error } }: ImageErrorEvent) => { - console.warn(error); - setLoadingImage(false); - setLoadingImageError(true); - }); return ( - - {isLoadingImageError ? ( - - ) : ( - <> - - {isLoadingImage ? : null} - - - )} - + ); }; const areEqual = (prevProps: GalleryPropsWithContext, nextProps: GalleryPropsWithContext) => { diff --git a/package/src/components/Attachment/GalleryImage.tsx b/package/src/components/Attachment/GalleryImage.tsx index 70a754d540..0b7d0b7221 100644 --- a/package/src/components/Attachment/GalleryImage.tsx +++ b/package/src/components/Attachment/GalleryImage.tsx @@ -1,7 +1,18 @@ import React from 'react'; -import { Image, ImageProps, StyleSheet } from 'react-native'; +import { + Image, + ImageErrorEvent, + ImageProps, + ImageStyle, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; +import { useLoadingImage } from '../../hooks/useLoadingImage'; +import { useStableCallback } from '../../hooks/useStableCallback'; import { getUrlWithoutParams, isLocalUrl, makeImageCompatibleUrl } from '../../utils/utils'; @@ -10,7 +21,13 @@ export type GalleryImageWithContextProps = GalleryImageProps & { }; export const GalleryImageWithContext = (props: GalleryImageWithContextProps) => { - const { ImageComponent = Image, uri, style, ...rest } = props; + const { + accessibilityLabel = 'Gallery Image', + ImageComponent = Image, + uri, + style, + ...rest + } = props; // Caching image components such as FastImage will not work with local images. // This for the case of local uris, we use the default Image component. @@ -18,7 +35,7 @@ export const GalleryImageWithContext = (props: GalleryImageWithContextProps) => return ( return ( { return ; }; +export type LoadableGalleryImageProps = Pick< + GalleryImageProps, + 'accessibilityLabel' | 'resizeMode' | 'uri' +> & { + children?: React.ReactNode; + containerStyle?: StyleProp; + imageStyle?: StyleProp; + localId?: string; +}; + +export const LoadableGalleryImage = ({ + accessibilityLabel = 'Gallery Image', + children, + containerStyle, + imageStyle, + localId, + resizeMode, + uri, +}: LoadableGalleryImageProps) => { + const { AttachmentUploadIndicator, ImageLoadingFailedIndicator, ImageLoadingIndicator } = + useComponentsContext(); + const { + isLoadingImage, + isLoadingImageError, + onReloadImage, + setLoadingImage, + setLoadingImageError, + } = useLoadingImage(); + + const onLoadStart = useStableCallback(() => { + setLoadingImageError(false); + setLoadingImage(true); + }); + const onLoad = useStableCallback(() => { + setTimeout(() => { + setLoadingImage(false); + setLoadingImageError(false); + }, 0); + }); + const onError = useStableCallback(({ nativeEvent: { error } }: ImageErrorEvent) => { + console.warn(error); + setLoadingImage(false); + setLoadingImageError(true); + }); + + return ( + + {isLoadingImageError ? ( + + ) : ( + <> + + {children} + {isLoadingImage ? : null} + + + )} + + ); +}; + const styles = StyleSheet.create({ image: { flex: 1, diff --git a/package/src/components/Attachment/ImageLoadingIndicator.tsx b/package/src/components/Attachment/ImageLoadingIndicator.tsx index 29897f22ba..876083152a 100644 --- a/package/src/components/Attachment/ImageLoadingIndicator.tsx +++ b/package/src/components/Attachment/ImageLoadingIndicator.tsx @@ -4,7 +4,7 @@ import { ActivityIndicator, StyleSheet, View } from 'react-native'; import { useTheme } from '../../contexts'; import { NativeShimmerView } from '../UIComponents/NativeShimmerView'; -export const ImageLoadingIndicator = () => { +export const ImageLoadingIndicator = React.memo(() => { const { theme: { semantics }, } = useTheme(); @@ -21,7 +21,7 @@ export const ImageLoadingIndicator = () => { ); -}; +}); const styles = StyleSheet.create({ centered: { diff --git a/package/src/components/Attachment/VideoThumbnail.tsx b/package/src/components/Attachment/VideoThumbnail.tsx index 8e30036bbb..118ab96e91 100644 --- a/package/src/components/Attachment/VideoThumbnail.tsx +++ b/package/src/components/Attachment/VideoThumbnail.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { ImageStyle, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; -import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; +import { LoadableGalleryImage } from './GalleryImage'; + import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { VideoPlayIndicator } from '../ui/VideoPlayIndicator'; @@ -12,6 +13,10 @@ const styles = StyleSheet.create({ flex: 1, overflow: 'hidden', }, + playIndicatorContainer: { + alignItems: 'center', + justifyContent: 'center', + }, }); export type VideoThumbnailProps = { @@ -32,18 +37,26 @@ export const VideoThumbnail = (props: VideoThumbnailProps) => { }, }, } = useTheme(); - const { AttachmentUploadIndicator, ImageComponent } = useComponentsContext(); const { imageStyle, localId, style, thumb_url } = props; return ( - - - + {thumb_url ? ( + + + + + + ) : null} ); }; diff --git a/package/src/components/Attachment/__tests__/VideoThumbnail.test.tsx b/package/src/components/Attachment/__tests__/VideoThumbnail.test.tsx new file mode 100644 index 0000000000..7aa79b5783 --- /dev/null +++ b/package/src/components/Attachment/__tests__/VideoThumbnail.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { ImageProps, View } from 'react-native'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext'; +import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { VideoThumbnail } from '../VideoThumbnail'; + +const CustomImageComponent = (props: ImageProps) => ( + +); + +const renderVideoThumbnail = (thumb_url: string) => + render( + + + + + , + ); + +describe('VideoThumbnail', () => { + it('uses the configured ImageComponent for remote thumbnail URLs', () => { + renderVideoThumbnail('https://example.com/video-thumbnail.jpg'); + + expect(screen.getByTestId('custom-image-component')).toBeTruthy(); + }); + + it('uses the default Image fallback for local thumbnail paths', () => { + renderVideoThumbnail('file:///tmp/video-thumbnail.jpg'); + + expect(screen.queryByTestId('custom-image-component')).toBeNull(); + expect(screen.getByLabelText('Video Thumbnail')).toBeTruthy(); + }); + + it('renders the image loading indicator while the thumbnail is loading', () => { + renderVideoThumbnail('file:///tmp/video-thumbnail.jpg'); + + fireEvent(screen.getByLabelText('Video Thumbnail'), 'loadStart'); + + expect(screen.getByLabelText('Image Loading Indicator')).toBeTruthy(); + }); +}); From f324b65d0bbf1b91b958e21c68f82cea58505ad6 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 5 May 2026 15:40:29 +0200 Subject: [PATCH 2/3] perf: shimmer performance android --- .../src/components/FastImageAdapter.tsx | 2 +- .../android/StreamShimmerFrameLayout.kt | 123 +++++++++++++----- .../Attachment/ImageLoadingIndicator.tsx | 16 +-- 3 files changed, 95 insertions(+), 46 deletions(-) diff --git a/examples/SampleApp/src/components/FastImageAdapter.tsx b/examples/SampleApp/src/components/FastImageAdapter.tsx index 076c06d1ec..2717160c8d 100644 --- a/examples/SampleApp/src/components/FastImageAdapter.tsx +++ b/examples/SampleApp/src/components/FastImageAdapter.tsx @@ -40,7 +40,7 @@ export const FastImageAdapter = React.memo((props: ImageProps) => { )} source={resolvedSource} - // transition={transition} + transition={transition} /> ); }); diff --git a/package/shared-native/android/StreamShimmerFrameLayout.kt b/package/shared-native/android/StreamShimmerFrameLayout.kt index 63b63250b5..c8a34b2715 100644 --- a/package/shared-native/android/StreamShimmerFrameLayout.kt +++ b/package/shared-native/android/StreamShimmerFrameLayout.kt @@ -1,6 +1,5 @@ package com.streamchatreactnative -import android.animation.ValueAnimator import android.content.Context import android.graphics.Canvas import android.graphics.Color @@ -9,8 +8,8 @@ import android.graphics.Matrix import android.graphics.Paint import android.graphics.Shader import android.util.AttributeSet +import android.view.Choreographer import android.view.View -import android.view.animation.LinearInterpolator import android.widget.FrameLayout import kotlin.math.roundToInt @@ -41,9 +40,8 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( private var shimmerShader: LinearGradient? = null private var shimmerTranslateX: Float = 0f - private var animatedDurationMs: Long = 0L - private var animatedViewWidth: Float = 0f - private var animator: ValueAnimator? = null + private var isRegisteredForShimmerFrames: Boolean = false + private var shimmerStartTimeNanos: Long = UNSET_FRAME_TIME_NANOS init { setWillNotDraw(false) @@ -68,6 +66,7 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( if (duration > 0) duration.toLong() else DEFAULT_DURATION_MS if (durationMs == normalizedDurationMs) return durationMs = normalizedDurationMs + shimmerStartTimeNanos = UNSET_FRAME_TIME_NANOS updateAnimatorState() } @@ -79,8 +78,8 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( } fun updateAnimatorState() { - // Centralized lifecycle gate for animation start/stop. This keeps shimmer off for detached or - // hidden views to avoid wasting UI-thread work in long lists. + // Centralized lifecycle gate for the shared frame clock. This keeps shimmer off for detached or + // hidden views and prevents every mounted shimmer from owning a separate ValueAnimator. if (shouldAnimateShimmer()) { startShimmer() } else { @@ -96,7 +95,7 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( } override fun onDetachedFromWindow() { - // Detached views are not drawable; stop and clear animator so a future attach starts cleanly. + // Detached views are not drawable; unregister so a future attach starts cleanly. stopShimmer() super.onDetachedFromWindow() } @@ -114,9 +113,7 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( override fun onVisibilityChanged(changedView: View, visibility: Int) { super.onVisibilityChanged(changedView, visibility) - if (changedView === this) { - updateAnimatorState() - } + updateAnimatorState() } override fun dispatchDraw(canvas: Canvas) { @@ -183,34 +180,54 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( } private fun startShimmer() { + if (isRegisteredForShimmerFrames) return + val viewWidth = width.toFloat() + shimmerStartTimeNanos = UNSET_FRAME_TIME_NANOS + if (viewWidth > 0f) { + shimmerTranslateX = -(viewWidth * SHIMMER_STRIP_WIDTH_RATIO).coerceAtLeast(1f) + } + isRegisteredForShimmerFrames = true + StreamShimmerFrameClock.register(this) + } + + private fun stopShimmer() { + if (isRegisteredForShimmerFrames) { + isRegisteredForShimmerFrames = false + StreamShimmerFrameClock.unregister(this) + } + resetShimmerFrameState() + } + + internal fun onSharedShimmerFrame(frameTimeNanos: Long) { val viewWidth = width.toFloat() if (viewWidth <= 0f) return - // Keep the existing animator only when size and duration still match the current request. - if (animator != null && animatedViewWidth == viewWidth && animatedDurationMs == durationMs) return - stopShimmer() + if (shimmerStartTimeNanos == UNSET_FRAME_TIME_NANOS) { + shimmerStartTimeNanos = frameTimeNanos + } // Animate from fully offscreen left to fully offscreen right so the strip enters/exits cleanly. val shimmerWidth = (viewWidth * SHIMMER_STRIP_WIDTH_RATIO).coerceAtLeast(1f) - animatedViewWidth = viewWidth - animatedDurationMs = durationMs - animator = ValueAnimator.ofFloat(-shimmerWidth, viewWidth).apply { - duration = durationMs - repeatCount = ValueAnimator.INFINITE - interpolator = LinearInterpolator() - addUpdateListener { - shimmerTranslateX = it.animatedValue as Float - invalidate() - } - start() - } + val durationNanos = (durationMs * NANOS_PER_MILLISECOND).coerceAtLeast(1L) + val elapsedNanos = (frameTimeNanos - shimmerStartTimeNanos).coerceAtLeast(0L) + val progress = (elapsedNanos % durationNanos).toFloat() / durationNanos.toFloat() + + shimmerTranslateX = -shimmerWidth + ((viewWidth + shimmerWidth) * progress) + invalidate() } - private fun stopShimmer() { - animator?.cancel() - animator = null - animatedDurationMs = 0L - animatedViewWidth = 0f + internal fun onRemovedFromSharedFrameClock() { + isRegisteredForShimmerFrames = false + resetShimmerFrameState() + } + + internal fun shouldRunSharedShimmerFrame(): Boolean { + return shouldAnimateShimmer() + } + + private fun resetShimmerFrameState() { + shimmerStartTimeNanos = UNSET_FRAME_TIME_NANOS + shimmerTranslateX = 0f } private fun shouldAnimateShimmer(): Boolean { @@ -236,7 +253,51 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( private const val DEFAULT_BASE_COLOR = 0x00FFFFFF private const val DEFAULT_DURATION_MS = 1200L private const val DEFAULT_GRADIENT_COLOR = 0x59FFFFFF + private const val NANOS_PER_MILLISECOND = 1_000_000L private const val SHIMMER_STRIP_WIDTH_RATIO = 1.25f private const val SOFT_HIGHLIGHT_ALPHA_FACTOR = 0.24f + private const val UNSET_FRAME_TIME_NANOS = -1L + } +} + +private object StreamShimmerFrameClock : Choreographer.FrameCallback { + private val activeViews = LinkedHashSet() + private var frameScheduled = false + + fun register(view: StreamShimmerFrameLayout) { + activeViews.add(view) + scheduleNextFrame() + } + + fun unregister(view: StreamShimmerFrameLayout) { + activeViews.remove(view) + if (activeViews.isEmpty() && frameScheduled) { + Choreographer.getInstance().removeFrameCallback(this) + frameScheduled = false + } + } + + override fun doFrame(frameTimeNanos: Long) { + frameScheduled = false + if (activeViews.isEmpty()) return + + val iterator = activeViews.iterator() + while (iterator.hasNext()) { + val view = iterator.next() + if (view.shouldRunSharedShimmerFrame()) { + view.onSharedShimmerFrame(frameTimeNanos) + } else { + iterator.remove() + view.onRemovedFromSharedFrameClock() + } + } + + scheduleNextFrame() + } + + private fun scheduleNextFrame() { + if (frameScheduled || activeViews.isEmpty()) return + Choreographer.getInstance().postFrameCallback(this) + frameScheduled = true } } diff --git a/package/src/components/Attachment/ImageLoadingIndicator.tsx b/package/src/components/Attachment/ImageLoadingIndicator.tsx index 876083152a..c5958f11b4 100644 --- a/package/src/components/Attachment/ImageLoadingIndicator.tsx +++ b/package/src/components/Attachment/ImageLoadingIndicator.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { ActivityIndicator, StyleSheet, View } from 'react-native'; +import { StyleSheet } from 'react-native'; import { useTheme } from '../../contexts'; import { NativeShimmerView } from '../UIComponents/NativeShimmerView'; @@ -15,18 +15,6 @@ export const ImageLoadingIndicator = React.memo(() => { enabled gradientColor={semantics.skeletonLoadingHighlight} style={StyleSheet.absoluteFill} - > - - - - + /> ); }); - -const styles = StyleSheet.create({ - centered: { - alignItems: 'center', - flex: 1, - justifyContent: 'center', - }, -}); From 73535a0961efdf6157550e9565f31b96a0cf519f Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 5 May 2026 15:51:12 +0200 Subject: [PATCH 3/3] perf: only actually animate onscreen shimmers --- .../shared-native/android/StreamShimmerFrameLayout.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/package/shared-native/android/StreamShimmerFrameLayout.kt b/package/shared-native/android/StreamShimmerFrameLayout.kt index c8a34b2715..b2f2a64492 100644 --- a/package/shared-native/android/StreamShimmerFrameLayout.kt +++ b/package/shared-native/android/StreamShimmerFrameLayout.kt @@ -6,6 +6,7 @@ import android.graphics.Color import android.graphics.LinearGradient import android.graphics.Matrix import android.graphics.Paint +import android.graphics.Rect import android.graphics.Shader import android.util.AttributeSet import android.view.Choreographer @@ -37,6 +38,7 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( isDither = true } private val shimmerMatrix = Matrix() + private val visibleViewportRect = Rect() private var shimmerShader: LinearGradient? = null private var shimmerTranslateX: Float = 0f @@ -200,7 +202,7 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( internal fun onSharedShimmerFrame(frameTimeNanos: Long) { val viewWidth = width.toFloat() - if (viewWidth <= 0f) return + if (viewWidth <= 0f || !hasVisibleViewport()) return if (shimmerStartTimeNanos == UNSET_FRAME_TIME_NANOS) { shimmerStartTimeNanos = frameTimeNanos @@ -225,6 +227,11 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( return shouldAnimateShimmer() } + private fun hasVisibleViewport(): Boolean { + visibleViewportRect.setEmpty() + return getGlobalVisibleRect(visibleViewportRect) && !visibleViewportRect.isEmpty + } + private fun resetShimmerFrameState() { shimmerStartTimeNanos = UNSET_FRAME_TIME_NANOS shimmerTranslateX = 0f