Skip to content

Commit 632ff3c

Browse files
authored
fix: video thumbnail loading (#3584)
## 🎯 Goal This PR fixes video thumbnail loading logic so that it is consistent across video thumbnails and image attachments. Additionally, it: - Improves performance on Android by reducing the number of moving parts in the shimmering layer (same as we did for iOS) - Introduces an orchestrator so that each shimmering view doesn't create its own `ValueAnimator` unnecessarily - Makes sure offscreen views are not animating (they still stay registered though unless they actually unmount/detach) - Fixes offline images with the new `FastImage` library in the sample app ## 🛠 Implementation details <!-- Provide a description of the implementation --> ## 🎨 UI Changes <!-- Add relevant screenshots --> <details> <summary>iOS</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> <details> <summary>Android</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> ## 🧪 Testing <!-- Explain how this change can be tested (or why it can't be tested) --> ## ☑️ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android
1 parent b5ed71a commit 632ff3c

8 files changed

Lines changed: 346 additions & 161 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import React, { useMemo } from 'react';
2+
import { ImageProps } from 'react-native';
3+
import FastImage from '@d11/react-native-fast-image';
4+
import type { FastImageProps } from '@d11/react-native-fast-image';
5+
import { useChatContext } from 'stream-chat-react-native';
6+
7+
type FastImageAdapterProps = Omit<ImageProps, 'source'> &
8+
Pick<FastImageProps, 'source' | 'transition'>;
9+
10+
export const FastImageAdapter = React.memo((props: ImageProps) => {
11+
const { isOnline } = useChatContext();
12+
const {
13+
source,
14+
transition = FastImage.transition.fade,
15+
...rest
16+
} = props as FastImageAdapterProps;
17+
18+
const resolvedSource = useMemo<FastImageProps['source']>(() => {
19+
if (
20+
!source ||
21+
typeof source !== 'object' ||
22+
Array.isArray(source) ||
23+
!('uri' in source) ||
24+
typeof source.uri !== 'string' ||
25+
!/^https?:\/\//i.test(source.uri)
26+
) {
27+
return source;
28+
}
29+
30+
return {
31+
...source,
32+
cache:
33+
source.cache ??
34+
(isOnline === false ? FastImage.cacheControl.cacheOnly : FastImage.cacheControl.immutable),
35+
priority: source.priority ?? FastImage.priority.normal,
36+
};
37+
}, [isOnline, source]);
38+
39+
return (
40+
<FastImage
41+
{...(rest as Omit<FastImageProps, 'source' | 'transition'>)}
42+
source={resolvedSource}
43+
transition={transition}
44+
/>
45+
);
46+
});
47+
48+
FastImageAdapter.displayName = 'FastImageAdapter';

examples/SampleApp/src/components/SampleAppComponentOverrides.tsx

Lines changed: 27 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,42 +2,39 @@ import React, { useMemo } from 'react';
22
import { Platform, StyleSheet, useColorScheme, View } from 'react-native';
33
import type { ComponentOverrides } from 'stream-chat-react-native';
44
import { BlurView } from '@react-native-community/blur';
5-
import FastImage from '@d11/react-native-fast-image';
6-
import {
7-
useTheme,
8-
} from 'stream-chat-react-native';
5+
import { useTheme } from 'stream-chat-react-native';
96

107
import { CustomAttachmentPickerContent } from './AttachmentPickerContent';
118
import { CustomChannelPreviewStatus } from './ChannelPreview';
9+
import { FastImageAdapter } from './FastImageAdapter';
1210
import { MessageLocation } from './LocationSharing/MessageLocation';
1311
import type { MessageOverlayBackdropConfigItem } from './SecretMenu';
1412

15-
const MessageOverlayBlurBackground: NonNullable<ComponentOverrides['MessageOverlayBackground']> =
16-
() => {
17-
const {
18-
theme: { semantics },
19-
} = useTheme();
20-
const scheme = useColorScheme();
21-
const isDark = scheme === 'dark';
22-
const isIOS = Platform.OS === 'ios';
13+
const MessageOverlayBlurBackground: NonNullable<
14+
ComponentOverrides['MessageOverlayBackground']
15+
> = () => {
16+
const {
17+
theme: { semantics },
18+
} = useTheme();
19+
const scheme = useColorScheme();
20+
const isDark = scheme === 'dark';
21+
const isIOS = Platform.OS === 'ios';
2322

24-
return (
25-
<>
26-
<BlurView
27-
blurAmount={isIOS ? 10 : 6}
28-
blurRadius={isIOS ? undefined : 6}
29-
blurType={isDark ? 'dark' : 'light'}
30-
downsampleFactor={isIOS ? undefined : 12}
31-
pointerEvents='none'
32-
reducedTransparencyFallbackColor='rgba(0, 0, 0, 0.8)'
33-
style={StyleSheet.absoluteFill}
34-
/>
35-
<View
36-
style={[StyleSheet.absoluteFill, { backgroundColor: semantics.backgroundCoreScrim }]}
37-
/>
38-
</>
39-
);
40-
};
23+
return (
24+
<>
25+
<BlurView
26+
blurAmount={isIOS ? 10 : 6}
27+
blurRadius={isIOS ? undefined : 6}
28+
blurType={isDark ? 'dark' : 'light'}
29+
downsampleFactor={isIOS ? undefined : 12}
30+
pointerEvents='none'
31+
reducedTransparencyFallbackColor='rgba(0, 0, 0, 0.8)'
32+
style={StyleSheet.absoluteFill}
33+
/>
34+
<View style={[StyleSheet.absoluteFill, { backgroundColor: semantics.backgroundCoreScrim }]} />
35+
</>
36+
);
37+
};
4138

4239
const RenderNull = () => null;
4340

@@ -48,7 +45,7 @@ export const useSampleAppComponentOverrides = (
4845
() => ({
4946
AttachmentPickerContent: CustomAttachmentPickerContent,
5047
ChannelListHeaderNetworkDownIndicator: RenderNull,
51-
ImageComponent: FastImage,
48+
ImageComponent: FastImageAdapter,
5249
MessageLocation,
5350
NetworkDownIndicator: RenderNull,
5451
ChannelPreviewStatus: CustomChannelPreviewStatus,

package/shared-native/android/StreamShimmerFrameLayout.kt

Lines changed: 103 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
package com.streamchatreactnative
22

3-
import android.animation.ValueAnimator
43
import android.content.Context
54
import android.graphics.Canvas
65
import android.graphics.Color
76
import android.graphics.LinearGradient
87
import android.graphics.Matrix
98
import android.graphics.Paint
9+
import android.graphics.Rect
1010
import android.graphics.Shader
1111
import android.util.AttributeSet
12+
import android.view.Choreographer
1213
import android.view.View
13-
import android.view.animation.LinearInterpolator
1414
import android.widget.FrameLayout
1515
import kotlin.math.roundToInt
1616

@@ -38,12 +38,12 @@ class StreamShimmerFrameLayout @JvmOverloads constructor(
3838
isDither = true
3939
}
4040
private val shimmerMatrix = Matrix()
41+
private val visibleViewportRect = Rect()
4142

4243
private var shimmerShader: LinearGradient? = null
4344
private var shimmerTranslateX: Float = 0f
44-
private var animatedDurationMs: Long = 0L
45-
private var animatedViewWidth: Float = 0f
46-
private var animator: ValueAnimator? = null
45+
private var isRegisteredForShimmerFrames: Boolean = false
46+
private var shimmerStartTimeNanos: Long = UNSET_FRAME_TIME_NANOS
4747

4848
init {
4949
setWillNotDraw(false)
@@ -68,6 +68,7 @@ class StreamShimmerFrameLayout @JvmOverloads constructor(
6868
if (duration > 0) duration.toLong() else DEFAULT_DURATION_MS
6969
if (durationMs == normalizedDurationMs) return
7070
durationMs = normalizedDurationMs
71+
shimmerStartTimeNanos = UNSET_FRAME_TIME_NANOS
7172
updateAnimatorState()
7273
}
7374

@@ -79,8 +80,8 @@ class StreamShimmerFrameLayout @JvmOverloads constructor(
7980
}
8081

8182
fun updateAnimatorState() {
82-
// Centralized lifecycle gate for animation start/stop. This keeps shimmer off for detached or
83-
// hidden views to avoid wasting UI-thread work in long lists.
83+
// Centralized lifecycle gate for the shared frame clock. This keeps shimmer off for detached or
84+
// hidden views and prevents every mounted shimmer from owning a separate ValueAnimator.
8485
if (shouldAnimateShimmer()) {
8586
startShimmer()
8687
} else {
@@ -96,7 +97,7 @@ class StreamShimmerFrameLayout @JvmOverloads constructor(
9697
}
9798

9899
override fun onDetachedFromWindow() {
99-
// Detached views are not drawable; stop and clear animator so a future attach starts cleanly.
100+
// Detached views are not drawable; unregister so a future attach starts cleanly.
100101
stopShimmer()
101102
super.onDetachedFromWindow()
102103
}
@@ -114,9 +115,7 @@ class StreamShimmerFrameLayout @JvmOverloads constructor(
114115

115116
override fun onVisibilityChanged(changedView: View, visibility: Int) {
116117
super.onVisibilityChanged(changedView, visibility)
117-
if (changedView === this) {
118-
updateAnimatorState()
119-
}
118+
updateAnimatorState()
120119
}
121120

122121
override fun dispatchDraw(canvas: Canvas) {
@@ -155,77 +154,87 @@ class StreamShimmerFrameLayout @JvmOverloads constructor(
155154
return
156155
}
157156

158-
// Wide multi-stop strip creates a softer "glassy" sweep and avoids the hard thin-line look.
157+
// Match iOS CAGradientLayer shimmer stops so both platforms have the same visual falloff.
159158
val shimmerWidth = (viewWidth * SHIMMER_STRIP_WIDTH_RATIO).coerceAtLeast(1f)
160159
val transparentHighlight = colorWithAlpha(gradientColor, 0f)
161-
val edgeBase = colorWithAlpha(gradientColor, EDGE_HIGHLIGHT_ALPHA_FACTOR)
162160
val softBase = colorWithAlpha(gradientColor, SOFT_HIGHLIGHT_ALPHA_FACTOR)
163-
val mediumBase = colorWithAlpha(gradientColor, MID_HIGHLIGHT_ALPHA_FACTOR)
164-
val innerBase = colorWithAlpha(gradientColor, INNER_HIGHLIGHT_ALPHA_FACTOR)
165161
shimmerShader = LinearGradient(
166162
0f,
167163
0f,
168164
shimmerWidth,
169165
0f,
170166
intArrayOf(
171167
transparentHighlight,
172-
edgeBase,
173168
softBase,
174-
mediumBase,
175-
innerBase,
176169
gradientColor,
177-
innerBase,
178-
mediumBase,
179170
softBase,
180-
edgeBase,
181171
transparentHighlight,
182172
),
183173
floatArrayOf(
184174
0f,
185-
0.08f,
186-
0.2f,
187-
0.32f,
188-
0.4f,
175+
0.35f,
189176
0.5f,
190-
0.6f,
191-
0.68f,
192-
0.8f,
193-
0.92f,
177+
0.65f,
194178
1f,
195179
),
196180
Shader.TileMode.CLAMP,
197181
)
198182
}
199183

200184
private fun startShimmer() {
185+
if (isRegisteredForShimmerFrames) return
201186
val viewWidth = width.toFloat()
202-
if (viewWidth <= 0f) return
203-
// Keep the existing animator only when size and duration still match the current request.
204-
if (animator != null && animatedViewWidth == viewWidth && animatedDurationMs == durationMs) return
187+
shimmerStartTimeNanos = UNSET_FRAME_TIME_NANOS
188+
if (viewWidth > 0f) {
189+
shimmerTranslateX = -(viewWidth * SHIMMER_STRIP_WIDTH_RATIO).coerceAtLeast(1f)
190+
}
191+
isRegisteredForShimmerFrames = true
192+
StreamShimmerFrameClock.register(this)
193+
}
205194

206-
stopShimmer()
195+
private fun stopShimmer() {
196+
if (isRegisteredForShimmerFrames) {
197+
isRegisteredForShimmerFrames = false
198+
StreamShimmerFrameClock.unregister(this)
199+
}
200+
resetShimmerFrameState()
201+
}
202+
203+
internal fun onSharedShimmerFrame(frameTimeNanos: Long) {
204+
val viewWidth = width.toFloat()
205+
if (viewWidth <= 0f || !hasVisibleViewport()) return
206+
207+
if (shimmerStartTimeNanos == UNSET_FRAME_TIME_NANOS) {
208+
shimmerStartTimeNanos = frameTimeNanos
209+
}
207210

208211
// Animate from fully offscreen left to fully offscreen right so the strip enters/exits cleanly.
209212
val shimmerWidth = (viewWidth * SHIMMER_STRIP_WIDTH_RATIO).coerceAtLeast(1f)
210-
animatedViewWidth = viewWidth
211-
animatedDurationMs = durationMs
212-
animator = ValueAnimator.ofFloat(-shimmerWidth, viewWidth).apply {
213-
duration = durationMs
214-
repeatCount = ValueAnimator.INFINITE
215-
interpolator = LinearInterpolator()
216-
addUpdateListener {
217-
shimmerTranslateX = it.animatedValue as Float
218-
invalidate()
219-
}
220-
start()
221-
}
213+
val durationNanos = (durationMs * NANOS_PER_MILLISECOND).coerceAtLeast(1L)
214+
val elapsedNanos = (frameTimeNanos - shimmerStartTimeNanos).coerceAtLeast(0L)
215+
val progress = (elapsedNanos % durationNanos).toFloat() / durationNanos.toFloat()
216+
217+
shimmerTranslateX = -shimmerWidth + ((viewWidth + shimmerWidth) * progress)
218+
invalidate()
222219
}
223220

224-
private fun stopShimmer() {
225-
animator?.cancel()
226-
animator = null
227-
animatedDurationMs = 0L
228-
animatedViewWidth = 0f
221+
internal fun onRemovedFromSharedFrameClock() {
222+
isRegisteredForShimmerFrames = false
223+
resetShimmerFrameState()
224+
}
225+
226+
internal fun shouldRunSharedShimmerFrame(): Boolean {
227+
return shouldAnimateShimmer()
228+
}
229+
230+
private fun hasVisibleViewport(): Boolean {
231+
visibleViewportRect.setEmpty()
232+
return getGlobalVisibleRect(visibleViewportRect) && !visibleViewportRect.isEmpty
233+
}
234+
235+
private fun resetShimmerFrameState() {
236+
shimmerStartTimeNanos = UNSET_FRAME_TIME_NANOS
237+
shimmerTranslateX = 0f
229238
}
230239

231240
private fun shouldAnimateShimmer(): Boolean {
@@ -251,10 +260,51 @@ class StreamShimmerFrameLayout @JvmOverloads constructor(
251260
private const val DEFAULT_BASE_COLOR = 0x00FFFFFF
252261
private const val DEFAULT_DURATION_MS = 1200L
253262
private const val DEFAULT_GRADIENT_COLOR = 0x59FFFFFF
263+
private const val NANOS_PER_MILLISECOND = 1_000_000L
254264
private const val SHIMMER_STRIP_WIDTH_RATIO = 1.25f
255-
private const val EDGE_HIGHLIGHT_ALPHA_FACTOR = 0.1f
256265
private const val SOFT_HIGHLIGHT_ALPHA_FACTOR = 0.24f
257-
private const val MID_HIGHLIGHT_ALPHA_FACTOR = 0.48f
258-
private const val INNER_HIGHLIGHT_ALPHA_FACTOR = 0.72f
266+
private const val UNSET_FRAME_TIME_NANOS = -1L
267+
}
268+
}
269+
270+
private object StreamShimmerFrameClock : Choreographer.FrameCallback {
271+
private val activeViews = LinkedHashSet<StreamShimmerFrameLayout>()
272+
private var frameScheduled = false
273+
274+
fun register(view: StreamShimmerFrameLayout) {
275+
activeViews.add(view)
276+
scheduleNextFrame()
277+
}
278+
279+
fun unregister(view: StreamShimmerFrameLayout) {
280+
activeViews.remove(view)
281+
if (activeViews.isEmpty() && frameScheduled) {
282+
Choreographer.getInstance().removeFrameCallback(this)
283+
frameScheduled = false
284+
}
285+
}
286+
287+
override fun doFrame(frameTimeNanos: Long) {
288+
frameScheduled = false
289+
if (activeViews.isEmpty()) return
290+
291+
val iterator = activeViews.iterator()
292+
while (iterator.hasNext()) {
293+
val view = iterator.next()
294+
if (view.shouldRunSharedShimmerFrame()) {
295+
view.onSharedShimmerFrame(frameTimeNanos)
296+
} else {
297+
iterator.remove()
298+
view.onRemovedFromSharedFrameClock()
299+
}
300+
}
301+
302+
scheduleNextFrame()
303+
}
304+
305+
private fun scheduleNextFrame() {
306+
if (frameScheduled || activeViews.isEmpty()) return
307+
Choreographer.getInstance().postFrameCallback(this)
308+
frameScheduled = true
259309
}
260310
}

0 commit comments

Comments
 (0)