diff --git a/package/expo-package/src/native/StreamShimmerViewNativeComponent.ts b/package/expo-package/src/native/StreamShimmerViewNativeComponent.ts index 8ba18ab123..b0bbb6c553 100644 --- a/package/expo-package/src/native/StreamShimmerViewNativeComponent.ts +++ b/package/expo-package/src/native/StreamShimmerViewNativeComponent.ts @@ -1,10 +1,11 @@ import type { ColorValue, HostComponent, ViewProps } from 'react-native'; -import type { WithDefault } from 'react-native/Libraries/Types/CodegenTypes'; +import type { Int32, WithDefault } from 'react-native/Libraries/Types/CodegenTypes'; import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; export interface NativeProps extends ViewProps { baseColor?: ColorValue; + duration?: WithDefault; enabled?: WithDefault; gradientColor?: ColorValue; } diff --git a/package/native-package/src/native/StreamShimmerViewNativeComponent.ts b/package/native-package/src/native/StreamShimmerViewNativeComponent.ts index 8ba18ab123..b0bbb6c553 100644 --- a/package/native-package/src/native/StreamShimmerViewNativeComponent.ts +++ b/package/native-package/src/native/StreamShimmerViewNativeComponent.ts @@ -1,10 +1,11 @@ import type { ColorValue, HostComponent, ViewProps } from 'react-native'; -import type { WithDefault } from 'react-native/Libraries/Types/CodegenTypes'; +import type { Int32, WithDefault } from 'react-native/Libraries/Types/CodegenTypes'; import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; export interface NativeProps extends ViewProps { baseColor?: ColorValue; + duration?: WithDefault; enabled?: WithDefault; gradientColor?: ColorValue; } diff --git a/package/shared-native/android/StreamShimmerFrameLayout.kt b/package/shared-native/android/StreamShimmerFrameLayout.kt index a82dcafbbb..da84863d77 100644 --- a/package/shared-native/android/StreamShimmerFrameLayout.kt +++ b/package/shared-native/android/StreamShimmerFrameLayout.kt @@ -28,6 +28,7 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( attrs: AttributeSet? = null, ) : FrameLayout(context, attrs) { private var baseColor: Int = DEFAULT_BASE_COLOR + private var durationMs: Long = DEFAULT_DURATION_MS private var gradientColor: Int = DEFAULT_GRADIENT_COLOR private var enabled: Boolean = true @@ -40,6 +41,7 @@ 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 @@ -61,6 +63,14 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( invalidate() } + fun setDuration(duration: Int) { + val normalizedDurationMs = + if (duration > 0) duration.toLong() else DEFAULT_DURATION_MS + if (durationMs == normalizedDurationMs) return + durationMs = normalizedDurationMs + updateAnimatorState() + } + fun setShimmerEnabled(enabled: Boolean) { if (this.enabled == enabled) return this.enabled = enabled @@ -190,16 +200,17 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( private fun startShimmer() { val viewWidth = width.toFloat() if (viewWidth <= 0f) return - // Keep the existing animator if the same-sized shimmer is already active. - if (animator != null && animatedViewWidth == viewWidth) return + // Keep the existing animator only when size and duration still match the current request. + if (animator != null && animatedViewWidth == viewWidth && animatedDurationMs == durationMs) return stopShimmer() // 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 = SHIMMER_DURATION_MS + duration = durationMs repeatCount = ValueAnimator.INFINITE interpolator = LinearInterpolator() addUpdateListener { @@ -213,6 +224,7 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( private fun stopShimmer() { animator?.cancel() animator = null + animatedDurationMs = 0L animatedViewWidth = 0f } @@ -237,8 +249,8 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( companion object { private const val DEFAULT_BASE_COLOR = 0x00FFFFFF + private const val DEFAULT_DURATION_MS = 1200L private const val DEFAULT_GRADIENT_COLOR = 0x59FFFFFF - private const val SHIMMER_DURATION_MS = 1200L 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 diff --git a/package/shared-native/android/StreamShimmerViewManager.kt b/package/shared-native/android/StreamShimmerViewManager.kt index 3110055853..4e49b39179 100644 --- a/package/shared-native/android/StreamShimmerViewManager.kt +++ b/package/shared-native/android/StreamShimmerViewManager.kt @@ -63,6 +63,10 @@ class StreamShimmerViewManager : ViewGroupManager(), view.setBaseColor(color ?: DEFAULT_BASE_COLOR) } + override fun setDuration(view: StreamShimmerFrameLayout, duration: Int) { + view.setDuration(duration) + } + override fun setGradientColor(view: StreamShimmerFrameLayout, color: Int?) { view.setGradientColor(color ?: DEFAULT_GRADIENT_COLOR) } diff --git a/package/shared-native/ios/StreamShimmerView.swift b/package/shared-native/ios/StreamShimmerView.swift index f77946d9a9..cea996385c 100644 --- a/package/shared-native/ios/StreamShimmerView.swift +++ b/package/shared-native/ios/StreamShimmerView.swift @@ -13,8 +13,8 @@ public final class StreamShimmerView: UIView { private static let midHighlightAlpha: CGFloat = 0.48 private static let innerHighlightAlpha: CGFloat = 0.72 private static let defaultHighlightAlpha: CGFloat = 0.35 + private static let defaultShimmerDuration: CFTimeInterval = 1.2 private static let shimmerStripWidthRatio: CGFloat = 1.25 - private static let shimmerDuration: CFTimeInterval = 1.2 private static let shimmerAnimationKey = "stream_shimmer_translate_x" private let baseLayer = CALayer() @@ -23,6 +23,8 @@ public final class StreamShimmerView: UIView { private var baseColor: UIColor = UIColor(white: 1, alpha: 0) private var gradientColor: UIColor = UIColor(white: 1, alpha: defaultHighlightAlpha) private var enabled = false + private var shimmerDuration: CFTimeInterval = defaultShimmerDuration + private var lastAnimatedDuration: CFTimeInterval = 0 private var lastAnimatedSize: CGSize = .zero private var isAppActive = true @@ -74,16 +76,19 @@ public final class StreamShimmerView: UIView { public func apply( baseColor: UIColor, gradientColor: UIColor, + durationMilliseconds: Double, enabled: Bool ) { self.baseColor = baseColor self.gradientColor = gradientColor + shimmerDuration = Self.normalizedDuration(milliseconds: durationMilliseconds) self.enabled = enabled updateLayersForCurrentState() } public func stopAnimation() { shimmerLayer.removeAnimation(forKey: Self.shimmerAnimationKey) + lastAnimatedDuration = 0 lastAnimatedSize = .zero } @@ -172,7 +177,10 @@ public final class StreamShimmerView: UIView { } // If an animation already exists for the same size, keep it running instead of restarting. - if shimmerLayer.animation(forKey: Self.shimmerAnimationKey) != nil, lastAnimatedSize == bounds.size { + if shimmerLayer.animation(forKey: Self.shimmerAnimationKey) != nil, + lastAnimatedSize == bounds.size, + lastAnimatedDuration == shimmerDuration + { return } @@ -183,14 +191,20 @@ public final class StreamShimmerView: UIView { let animation = CABasicAnimation(keyPath: "transform.translation.x") animation.fromValue = 0 animation.toValue = bounds.width + shimmerWidth - animation.duration = Self.shimmerDuration + animation.duration = shimmerDuration animation.repeatCount = .infinity animation.timingFunction = CAMediaTimingFunction(name: .linear) animation.isRemovedOnCompletion = true shimmerLayer.add(animation, forKey: Self.shimmerAnimationKey) + lastAnimatedDuration = shimmerDuration lastAnimatedSize = bounds.size } + private static func normalizedDuration(milliseconds: Double) -> CFTimeInterval { + guard milliseconds > 0 else { return defaultShimmerDuration } + return milliseconds / 1000 + } + private func color(_ color: UIColor, alphaFactor: CGFloat) -> UIColor { // Preserve the resolved color channels and shape only alpha for smooth highlight falloff. let resolvedColor = color.resolvedColor(with: traitCollection) diff --git a/package/shared-native/ios/StreamShimmerViewComponentView.mm b/package/shared-native/ios/StreamShimmerViewComponentView.mm index c6161d18a8..9cad9af460 100644 --- a/package/shared-native/ios/StreamShimmerViewComponentView.mm +++ b/package/shared-native/ios/StreamShimmerViewComponentView.mm @@ -85,6 +85,7 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & [_shimmerView applyWithBaseColor:baseColor gradientColor:gradientColor + durationMilliseconds:newProps.duration enabled:newProps.enabled]; [super updateProps:props oldProps:oldProps]; diff --git a/package/src/components/ChannelList/Skeleton.tsx b/package/src/components/ChannelList/Skeleton.tsx index 67a107d622..f85a3239a9 100644 --- a/package/src/components/ChannelList/Skeleton.tsx +++ b/package/src/components/ChannelList/Skeleton.tsx @@ -1,154 +1,63 @@ -import React, { useEffect, useMemo } from 'react'; -import { StyleSheet, useWindowDimensions, View } from 'react-native'; -import Animated, { - Easing, - useAnimatedStyle, - useSharedValue, - withRepeat, - withTiming, -} from 'react-native-reanimated'; -import Svg, { Path, Rect, Defs, LinearGradient, Stop, ClipPath, G, Mask } from 'react-native-svg'; +import React, { useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../theme'; +import { NativeShimmerView } from '../UIComponents/NativeShimmerView'; -export const Skeleton = () => { - const width = useWindowDimensions().width; - const startOffset = useSharedValue(-width); - const styles = useStyles(); - +const SkeletonBlock = ({ style }: { style: React.ComponentProps['style'] }) => { const { theme: { - channelListSkeleton: { animationTime = 1500, container, height = 80 }, + channelListSkeleton: { animationTime }, semantics, }, } = useTheme(); - useEffect(() => { - startOffset.value = withRepeat( - withTiming(width, { - duration: animationTime, - easing: Easing.linear, - }), - -1, - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const animatedStyle = useAnimatedStyle( - () => ({ - transform: [{ translateX: startOffset.value }], - }), - [], - ); - return ( - - - - {/* Mask */} - - - - - - {/* Gradients */} - - - - - - - - - - - - - - - - - - - - - {/* ClipPaths */} - - - + + + + ); +}; - - - +const SkeletonAvatar = () => { + const styles = useStyles(); + return ; +}; - - - - +const SkeletonTimestamp = () => { + const styles = useStyles(); + return ; +}; - {/* Avatar */} - - - - +const SkeletonContent = () => { + const styles = useStyles(); + return ( + + + + + - {/* Title */} - - - - + + + ); +}; - {/* Badge */} - - - - +export const Skeleton = () => { + const styles = useStyles(); - {/* Subtitle */} - - - - + return ( + + + + + ); }; @@ -157,16 +66,66 @@ Skeleton.displayName = 'Skeleton{channelListSkeleton}'; const useStyles = () => { const { - theme: { semantics }, + theme: { channelListSkeleton, semantics }, } = useTheme(); return useMemo(() => { return StyleSheet.create({ + avatar: { + borderRadius: primitives.radiusMax, + height: 48, + overflow: 'hidden', + width: 48, + ...channelListSkeleton.avatar, + }, + badge: { + borderRadius: primitives.radiusMax, + height: 16, + minWidth: 0, + overflow: 'hidden', + width: 48, + ...channelListSkeleton.badge, + }, container: { + borderBottomColor: semantics.borderCoreSubtle, borderBottomWidth: 1, flexDirection: 'row', - borderBottomColor: semantics.borderCoreDefault, + ...channelListSkeleton.container, + }, + content: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingMd, + padding: primitives.spacingMd, + width: '100%', + ...channelListSkeleton.content, + }, + headerRow: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingMd, + width: '100%', + ...channelListSkeleton.headerRow, + }, + subtitle: { + borderRadius: primitives.radiusMax, + height: primitives.spacingMd, + overflow: 'hidden', + width: '65%', + ...channelListSkeleton.subtitle, + }, + textContainer: { + flex: 1, + gap: primitives.spacingXs, + ...channelListSkeleton.textContainer, + }, + title: { + borderRadius: primitives.radiusMax, + flex: 1, + height: 16, + overflow: 'hidden', + ...channelListSkeleton.title, }, }); - }, [semantics]); + }, [channelListSkeleton, semantics.borderCoreSubtle]); }; diff --git a/package/src/components/ThreadList/ThreadListItemSkeleton.tsx b/package/src/components/ThreadList/ThreadListItemSkeleton.tsx index e3454c02dc..16664f3473 100644 --- a/package/src/components/ThreadList/ThreadListItemSkeleton.tsx +++ b/package/src/components/ThreadList/ThreadListItemSkeleton.tsx @@ -1,281 +1,160 @@ -import React, { useEffect, useMemo } from 'react'; -import { StyleSheet, useWindowDimensions, View } from 'react-native'; -import Animated, { - Easing, - useAnimatedStyle, - useSharedValue, - withRepeat, - withTiming, -} from 'react-native-reanimated'; -import Svg, { Path, Rect, Defs, LinearGradient, Stop, ClipPath, G, Mask } from 'react-native-svg'; +import React, { useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../theme'; +import { NativeShimmerView } from '../UIComponents/NativeShimmerView'; -export const ThreadListItemSkeleton = () => { - const width = useWindowDimensions().width; - const startOffset = useSharedValue(-width); - const styles = useStyles(); - +const SkeletonBlock = ({ style }: { style: React.ComponentProps['style'] }) => { const { theme: { - channelListSkeleton: { animationTime = 1500, container, height = 112 }, semantics, + threadListSkeleton: { animationTime }, }, } = useTheme(); - useEffect(() => { - startOffset.value = withRepeat( - withTiming(width, { - duration: animationTime, - easing: Easing.linear, - }), - -1, - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + return ( + + + + ); +}; - const animatedStyle = useAnimatedStyle( - () => ({ - transform: [{ translateX: startOffset.value }], - }), - [], +const SkeletonAvatar = () => { + const styles = useStyles(); + return ; +}; + +const SkeletonTimestamp = () => { + const styles = useStyles(); + return ; +}; + +const SkeletonFooter = () => { + const styles = useStyles(); + + return ( + + + + + ); +}; + +const SkeletonContent = () => { + const styles = useStyles(); return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + ); }; +export const ThreadListItemSkeleton = () => { + const styles = useStyles(); + + return ( + + + + + + + + ); +}; + +ThreadListItemSkeleton.displayName = 'ThreadListItemSkeleton{threadListSkeleton}'; + const useStyles = () => { const { - theme: { semantics }, + theme: { semantics, threadListSkeleton }, } = useTheme(); return useMemo(() => { return StyleSheet.create({ + avatar: { + borderRadius: primitives.radiusMax, + height: 48, + overflow: 'hidden', + width: 48, + ...threadListSkeleton.avatar, + }, + body: { + borderRadius: primitives.radiusMax, + height: 20, + overflow: 'hidden', + ...threadListSkeleton.body, + }, container: { + borderBottomColor: semantics.borderCoreSubtle, borderBottomWidth: 1, flexDirection: 'row', - borderBottomColor: semantics.borderCoreDefault, + ...threadListSkeleton.container, + }, + content: { + alignItems: 'flex-start', + flexDirection: 'row', + gap: primitives.spacingSm, + padding: primitives.spacingMd, + width: '100%', + ...threadListSkeleton.content, + }, + contentContainer: { + gap: primitives.spacingXs, + paddingVertical: primitives.spacingXxs, + ...threadListSkeleton.contentContainer, + }, + footerIcon: { + borderRadius: primitives.radiusMax, + height: 24, + overflow: 'hidden', + width: 24, + ...threadListSkeleton.footerIcon, + }, + footerPill: { + borderRadius: primitives.radiusMax, + height: 12, + overflow: 'hidden', + width: 64, + ...threadListSkeleton.footerPill, + }, + footerRow: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingXs, + ...threadListSkeleton.footerRow, + }, + headerLabel: { + borderRadius: primitives.radiusMax, + height: 12, + overflow: 'hidden', + width: '40%', + ...threadListSkeleton.headerLabel, + }, + textContainer: { + flex: 1, + gap: primitives.spacingXs, + ...threadListSkeleton.textContainer, + }, + timestamp: { + borderRadius: primitives.radiusMax, + height: 16, + overflow: 'hidden', + width: 48, + ...threadListSkeleton.timestamp, }, }); - }, [semantics]); + }, [semantics.borderCoreSubtle, threadListSkeleton]); }; diff --git a/package/src/components/UIComponents/NativeShimmerView.tsx b/package/src/components/UIComponents/NativeShimmerView.tsx index ea3d56431e..10251d2a8d 100644 --- a/package/src/components/UIComponents/NativeShimmerView.tsx +++ b/package/src/components/UIComponents/NativeShimmerView.tsx @@ -5,6 +5,7 @@ import { NativeHandlers } from '../../native'; export type NativeShimmerViewProps = ViewProps & { baseColor?: ColorValue; + duration?: number; enabled?: boolean; gradientColor?: ColorValue; }; diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 171694ef11..c0a04d6ca2 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -183,8 +183,28 @@ export type Theme = { }; channelListSkeleton: { animationTime: number; + avatar: ViewStyle; + badge: ViewStyle; + content: ViewStyle; container: ViewStyle; - height: number; + headerRow: ViewStyle; + subtitle: ViewStyle; + textContainer: ViewStyle; + title: ViewStyle; + }; + threadListSkeleton: { + animationTime: number; + avatar: ViewStyle; + body: ViewStyle; + content: ViewStyle; + contentContainer: ViewStyle; + container: ViewStyle; + footerIcon: ViewStyle; + footerPill: ViewStyle; + footerRow: ViewStyle; + headerLabel: ViewStyle; + textContainer: ViewStyle; + timestamp: ViewStyle; }; colors: typeof Colors; channelPreview: { @@ -1087,9 +1107,29 @@ export const defaultTheme: Theme = { }, }, channelListSkeleton: { - animationTime: 1500, // in milliseconds + animationTime: 1000, // in milliseconds + avatar: {}, + badge: {}, + content: {}, + container: {}, + headerRow: {}, + subtitle: {}, + textContainer: {}, + title: {}, + }, + threadListSkeleton: { + animationTime: 1000, // in milliseconds + avatar: {}, + body: {}, + content: {}, + contentContainer: {}, container: {}, - height: 80, + footerIcon: {}, + footerPill: {}, + footerRow: {}, + headerLabel: {}, + textContainer: {}, + timestamp: {}, }, channelPreview: { container: {}, diff --git a/package/src/native.ts b/package/src/native.ts index ec60c149ef..f151487703 100644 --- a/package/src/native.ts +++ b/package/src/native.ts @@ -295,6 +295,7 @@ export type VideoType = { export type NativeShimmerViewProps = ViewProps & { baseColor?: ColorValue; + duration?: number; enabled?: boolean; gradientColor?: ColorValue; };