Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions examples/SampleApp/src/components/FastImageAdapter.tsx
Original file line number Diff line number Diff line change
@@ -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<ImageProps, 'source'> &
Pick<FastImageProps, 'source' | 'transition'>;

export const FastImageAdapter = React.memo((props: ImageProps) => {
const { isOnline } = useChatContext();
const {
source,
transition = FastImage.transition.fade,
...rest
} = props as FastImageAdapterProps;

const resolvedSource = useMemo<FastImageProps['source']>(() => {
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 (
<FastImage
{...(rest as Omit<FastImageProps, 'source' | 'transition'>)}
source={resolvedSource}
transition={transition}
/>
);
});

FastImageAdapter.displayName = 'FastImageAdapter';
57 changes: 27 additions & 30 deletions examples/SampleApp/src/components/SampleAppComponentOverrides.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComponentOverrides['MessageOverlayBackground']> =
() => {
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 (
<>
<BlurView
blurAmount={isIOS ? 10 : 6}
blurRadius={isIOS ? undefined : 6}
blurType={isDark ? 'dark' : 'light'}
downsampleFactor={isIOS ? undefined : 12}
pointerEvents='none'
reducedTransparencyFallbackColor='rgba(0, 0, 0, 0.8)'
style={StyleSheet.absoluteFill}
/>
<View
style={[StyleSheet.absoluteFill, { backgroundColor: semantics.backgroundCoreScrim }]}
/>
</>
);
};
return (
<>
<BlurView
blurAmount={isIOS ? 10 : 6}
blurRadius={isIOS ? undefined : 6}
blurType={isDark ? 'dark' : 'light'}
downsampleFactor={isIOS ? undefined : 12}
pointerEvents='none'
reducedTransparencyFallbackColor='rgba(0, 0, 0, 0.8)'
style={StyleSheet.absoluteFill}
/>
<View style={[StyleSheet.absoluteFill, { backgroundColor: semantics.backgroundCoreScrim }]} />
</>
);
};

const RenderNull = () => null;

Expand All @@ -48,7 +45,7 @@ export const useSampleAppComponentOverrides = (
() => ({
AttachmentPickerContent: CustomAttachmentPickerContent,
ChannelListHeaderNetworkDownIndicator: RenderNull,
ImageComponent: FastImage,
ImageComponent: FastImageAdapter,
MessageLocation,
NetworkDownIndicator: RenderNull,
ChannelPreviewStatus: CustomChannelPreviewStatus,
Expand Down
2 changes: 1 addition & 1 deletion package/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
"path": "0.12.7",
"react-native-markdown-package": "1.8.2",
"react-native-url-polyfill": "^2.0.0",
"stream-chat": "^9.43.0",
"stream-chat": "^9.43.1",
"use-sync-external-store": "^1.5.0"
},
"peerDependencies": {
Expand Down
156 changes: 103 additions & 53 deletions package/shared-native/android/StreamShimmerFrameLayout.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
package com.streamchatreactnative

import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
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
import android.view.View
import android.view.animation.LinearInterpolator
import android.widget.FrameLayout
import kotlin.math.roundToInt

Expand Down Expand Up @@ -38,12 +38,12 @@ class StreamShimmerFrameLayout @JvmOverloads constructor(
isDither = true
}
private val shimmerMatrix = Matrix()
private val visibleViewportRect = Rect()

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)
Expand All @@ -68,6 +68,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()
}

Expand All @@ -79,8 +80,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 {
Expand All @@ -96,7 +97,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()
}
Expand All @@ -114,9 +115,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) {
Expand Down Expand Up @@ -155,77 +154,87 @@ 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,
shimmerWidth,
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,
)
}

private fun startShimmer() {
if (isRegisteredForShimmerFrames) return
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
shimmerStartTimeNanos = UNSET_FRAME_TIME_NANOS
if (viewWidth > 0f) {
shimmerTranslateX = -(viewWidth * SHIMMER_STRIP_WIDTH_RATIO).coerceAtLeast(1f)
}
isRegisteredForShimmerFrames = true
StreamShimmerFrameClock.register(this)
}

stopShimmer()
private fun stopShimmer() {
if (isRegisteredForShimmerFrames) {
isRegisteredForShimmerFrames = false
StreamShimmerFrameClock.unregister(this)
}
resetShimmerFrameState()
}

internal fun onSharedShimmerFrame(frameTimeNanos: Long) {
val viewWidth = width.toFloat()
if (viewWidth <= 0f || !hasVisibleViewport()) return

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 hasVisibleViewport(): Boolean {
visibleViewportRect.setEmpty()
return getGlobalVisibleRect(visibleViewportRect) && !visibleViewportRect.isEmpty
}

private fun resetShimmerFrameState() {
shimmerStartTimeNanos = UNSET_FRAME_TIME_NANOS
shimmerTranslateX = 0f
}

private fun shouldAnimateShimmer(): Boolean {
Expand All @@ -251,10 +260,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 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
private const val UNSET_FRAME_TIME_NANOS = -1L
}
}

private object StreamShimmerFrameClock : Choreographer.FrameCallback {
private val activeViews = LinkedHashSet<StreamShimmerFrameLayout>()
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
}
}
Loading
Loading