-
Notifications
You must be signed in to change notification settings - Fork 0
Shimmer implemented on Authenticator screen #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| package com.auth0.universalcomponents.presentation.ui.components.skeleton | ||
|
|
||
| import android.provider.Settings | ||
| import androidx.compose.animation.core.LinearEasing | ||
| import androidx.compose.animation.core.RepeatMode | ||
| import androidx.compose.animation.core.animateFloat | ||
| import androidx.compose.animation.core.infiniteRepeatable | ||
| import androidx.compose.animation.core.rememberInfiniteTransition | ||
| import androidx.compose.animation.core.tween | ||
| import androidx.compose.runtime.Composable | ||
| import androidx.compose.runtime.getValue | ||
| import androidx.compose.runtime.remember | ||
| import androidx.compose.ui.Modifier | ||
| import androidx.compose.ui.draw.drawWithContent | ||
| import androidx.compose.ui.geometry.Offset | ||
| import androidx.compose.ui.graphics.BlendMode | ||
| import androidx.compose.ui.graphics.Brush | ||
| import androidx.compose.ui.graphics.Color | ||
| import androidx.compose.ui.graphics.CompositingStrategy | ||
| import androidx.compose.ui.graphics.graphicsLayer | ||
| import androidx.compose.ui.graphics.luminance | ||
| import androidx.compose.ui.platform.LocalContext | ||
| import com.auth0.universalcomponents.theme.Auth0Theme | ||
|
|
||
| private const val SHIMMER_DURATION_MS = 1300 | ||
| private const val BAND_WIDTH_MULTIPLIER = 2f | ||
| private const val BAND_TRAVEL_MULTIPLIER = 3f | ||
| private const val LUMINANCE_DARK_THRESHOLD = 0.5f | ||
|
|
||
| // Purpose-built skeleton greys. Declared as properties (not inline literals) so the colour | ||
| // hex values are exempt from detekt's MagicNumber rule, mirroring how the theme tokens are | ||
| // handled. | ||
| private val DarkSkeletonBase = Color(0xFF383838) | ||
| private val DarkSkeletonHighlight = Color(0xFF575757) | ||
| private val LightSkeletonBase = Color(0xFFE0E0E0) | ||
| private val LightSkeletonHighlight = Color(0xFFF7F7F7) | ||
|
|
||
| private val DarkSkeletonPalette = SkeletonPalette(base = DarkSkeletonBase, highlight = DarkSkeletonHighlight) | ||
| private val LightSkeletonPalette = SkeletonPalette(base = LightSkeletonBase, highlight = LightSkeletonHighlight) | ||
|
|
||
| /** | ||
| * Light/dark greys for skeletons. The SDK's layer tokens are near-white-on-white in | ||
| * light mode (an invisible sweep), so we use purpose-built greys that adapt to the | ||
| * colour scheme. | ||
| */ | ||
| internal data class SkeletonPalette(val base: Color, val highlight: Color) { | ||
| companion object { | ||
| fun forScheme(dark: Boolean): SkeletonPalette = | ||
| if (dark) DarkSkeletonPalette else LightSkeletonPalette | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Resolves the active skeleton palette from the current [Auth0Theme]. | ||
| * | ||
| * Uses the resolved theme background's luminance rather than [androidx.compose.foundation.isSystemInDarkTheme] | ||
| * so that a consumer who forces `Auth0Theme(darkTheme = …)` (independent of the system | ||
| * setting) still gets greys that match the rendered surface. | ||
| */ | ||
| @Composable | ||
| internal fun rememberSkeletonPalette(): SkeletonPalette { | ||
| val dark = Auth0Theme.colors.backgroundLayerBase.luminance() < LUMINANCE_DARK_THRESHOLD | ||
| return remember(dark) { SkeletonPalette.forScheme(dark) } | ||
| } | ||
|
|
||
| /** | ||
| * Sweeps an animated shimmer highlight across this composable to indicate loading. | ||
| * Apply to ONE container (a list/Column) so every child animates in a single | ||
| * synchronized sweep — the highlight is clipped to the content that is actually drawn. | ||
| * | ||
| * When [active] is false, or the system "remove animations" setting is on, this is a | ||
| * no-op and the placeholders render as static base-grey blocks (the reduce-motion | ||
| * fallback). | ||
| */ | ||
| @Composable | ||
| internal fun Modifier.shimmer(active: Boolean = true): Modifier { | ||
| if (!active || !animationsEnabled()) return this | ||
|
|
||
| val palette = rememberSkeletonPalette() | ||
| val transition = rememberInfiniteTransition(label = "shimmer") | ||
| val phase by transition.animateFloat( | ||
| initialValue = 0f, | ||
| targetValue = 1f, | ||
| animationSpec = infiniteRepeatable( | ||
| animation = tween(durationMillis = SHIMMER_DURATION_MS, easing = LinearEasing), | ||
| repeatMode = RepeatMode.Restart, // sweep travels in one direction only | ||
| ), | ||
| label = "shimmer-phase", | ||
| ) | ||
|
|
||
| // SrcAtop masks to the content only when that content is drawn into an isolated | ||
| // buffer; without an offscreen layer the band would paint across the whole bounding | ||
| // rect (including the gaps between cards), instead of just the drawn placeholders. | ||
| return this | ||
| .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } | ||
| // `phase` is read inside the draw lambda → only the draw phase re-runs per frame, | ||
| // not recomposition. | ||
| .drawWithContent { | ||
| drawContent() // 1) draw the real placeholders first | ||
| val w = size.width | ||
| // base → highlight → base band, 2× width, translated by (phase*3 - 2)*w. | ||
| val startX = (phase * BAND_TRAVEL_MULTIPLIER - BAND_WIDTH_MULTIPLIER) * w | ||
| val brush = Brush.linearGradient( | ||
| colors = listOf(palette.base, palette.highlight, palette.base), | ||
| start = Offset(startX, 0f), | ||
| end = Offset(startX + w * BAND_WIDTH_MULTIPLIER, 0f), | ||
| ) | ||
| // 2) paint the band ONLY over already-drawn pixels → clips the sweep to the content. | ||
| drawRect(brush = brush, blendMode = BlendMode.SrcAtop) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Compose has no direct `accessibilityReduceMotion`. Honour the system "remove animations" | ||
| * setting instead: when animations are scaled to 0, skip the sweep (static placeholders). | ||
| */ | ||
| @Composable | ||
| internal fun animationsEnabled(): Boolean { | ||
| val context = LocalContext.current | ||
| val scale = Settings.Global.getFloat( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can we avoid syncronous call to Setting.Global.getFloat this can induce Jank. Need to check on this :
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @pacific-ring The call is in-memory, not heavy like DB-call or disk write and the composition read that runs once before the animation begins. It will never happen on the per-frame draw path. So there should not be a meaningful jank.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Sudhanshu96 good point . checking above got to know its not on the per-frame draw path. So good to go buddy 👍 |
||
| context.contentResolver, | ||
| Settings.Global.ANIMATOR_DURATION_SCALE, | ||
| 1f, | ||
| ) | ||
| return scale != 0f | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| package com.auth0.universalcomponents.presentation.ui.components.skeleton | ||
|
|
||
| import androidx.compose.foundation.border | ||
| import androidx.compose.foundation.layout.Arrangement | ||
| import androidx.compose.foundation.layout.Column | ||
| import androidx.compose.foundation.layout.Row | ||
| import androidx.compose.foundation.layout.RowScope | ||
| import androidx.compose.foundation.layout.Spacer | ||
| import androidx.compose.foundation.layout.fillMaxWidth | ||
| import androidx.compose.foundation.layout.height | ||
| import androidx.compose.foundation.layout.padding | ||
| import androidx.compose.foundation.layout.size | ||
| import androidx.compose.foundation.layout.width | ||
| import androidx.compose.runtime.Composable | ||
| import androidx.compose.ui.Alignment | ||
| import androidx.compose.ui.Modifier | ||
| import androidx.compose.ui.unit.Dp | ||
| import androidx.compose.ui.unit.dp | ||
| import com.auth0.universalcomponents.theme.Auth0Theme | ||
|
|
||
| private val AuthMethodCardHeight = 70.dp | ||
| private val AuthMethodTitleWidth = 140.dp | ||
|
|
||
| /** | ||
| * Repeats a skeleton [row] [count] times. Intentionally does NOT shimmer itself — apply | ||
| * Modifier.shimmer() ONCE on the screen's loading container (see the screens) so the header | ||
| * line and every card animate in a single synchronized sweep. | ||
| */ | ||
| @Composable | ||
| internal fun SkeletonList( | ||
| count: Int = 5, | ||
| modifier: Modifier = Modifier, | ||
| spacing: Dp = Auth0Theme.dimensions.spacingMd, | ||
| row: @Composable () -> Unit, | ||
| ) { | ||
| Column( | ||
| modifier = modifier, | ||
| verticalArrangement = Arrangement.spacedBy(spacing), | ||
| ) { | ||
| repeat(count) { row() } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Border-only card shell matching the real cards' size, border, and radius (NO fill — | ||
| * a grey sweep over the near-white `backgroundLayerMedium` fill would read as a dark band | ||
| * crossing a white card). Size, border, and radius still match the real card so there's no | ||
| * layout shift when data arrives. Parametrised because the two real cards differ in | ||
| * height/padding. | ||
| */ | ||
| @Composable | ||
| internal fun SkeletonCard( | ||
| height: Dp, | ||
| contentPadding: Dp, | ||
| verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, | ||
| content: @Composable RowScope.() -> Unit, | ||
| ) { | ||
| Row( | ||
| modifier = Modifier | ||
| .fillMaxWidth() | ||
| .height(height) | ||
| .border(1.dp, Auth0Theme.colors.borderDefault, Auth0Theme.shapes.large) | ||
| .padding(contentPadding), | ||
| verticalAlignment = verticalAlignment, | ||
| content = content, | ||
| ) | ||
| } | ||
|
|
||
| /** Mirrors AuthenticatorItem: leading icon, title line, trailing chevron. */ | ||
| @Composable | ||
| internal fun AuthMethodCardSkeleton() = SkeletonCard( | ||
| height = AuthMethodCardHeight, | ||
| contentPadding = Auth0Theme.sizes.padding, // 16.dp | ||
| ) { | ||
| SkeletonBox(Modifier.size(Auth0Theme.sizes.iconMedium), shape = Auth0Theme.shapes.small) | ||
| Spacer(Modifier.width(Auth0Theme.dimensions.spacingMd)) | ||
| SkeletonLine(width = AuthMethodTitleWidth) | ||
| Spacer(Modifier.weight(1f)) | ||
| SkeletonBox(Modifier.size(Auth0Theme.sizes.iconMedium), shape = Auth0Theme.shapes.small) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package com.auth0.universalcomponents.presentation.ui.components.skeleton | ||
|
|
||
| import androidx.compose.foundation.background | ||
| import androidx.compose.foundation.layout.Box | ||
| import androidx.compose.foundation.layout.height | ||
| import androidx.compose.foundation.layout.width | ||
| import androidx.compose.foundation.shape.RoundedCornerShape | ||
| import androidx.compose.runtime.Composable | ||
| import androidx.compose.ui.Modifier | ||
| import androidx.compose.ui.graphics.Shape | ||
| import androidx.compose.ui.unit.Dp | ||
| import androidx.compose.ui.unit.dp | ||
|
|
||
| /** Default height of a single text-line placeholder. */ | ||
| private val SkeletonLineHeight = 14.dp | ||
|
|
||
| /** | ||
| * A single themed placeholder block — the atom of the skeleton system. Compose several | ||
| * inside Rows/Columns to mirror a real layout, then wrap the parent in Modifier.shimmer(). | ||
| * Renders the resting base grey; the sweep is layered on by the parent's shimmer(). | ||
| */ | ||
| @Composable | ||
| internal fun SkeletonBox( | ||
| modifier: Modifier = Modifier, | ||
| shape: Shape = RoundedCornerShape(8.dp), | ||
| ) { | ||
| val base = rememberSkeletonPalette().base | ||
| Box(modifier.background(color = base, shape = shape)) | ||
| } | ||
|
|
||
| /** Convenience: a single text-line placeholder (default 14.dp tall). */ | ||
| @Composable | ||
| internal fun SkeletonLine(width: Dp, modifier: Modifier = Modifier) { | ||
| SkeletonBox(modifier.width(width).height(SkeletonLineHeight)) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not required. Shimmer effect is more of a cosmetic change