Utility apps are trust products. The UI must feel: stable, immediate, precise, reversible, non-destructive.
| Situation | Best default |
|---|---|
| First load, known result card layout | skeleton |
| Small inline refresh of one section | keep content + small inline indicator |
| Whole-screen blocking startup with no known structure | spinner, but rare |
| Recalculating quote while old result exists | keep old result + "updating" affordance |
| Empty but idle state | empty-state hint, not spinner |
Never wipe content during refresh. Never cause height jumps, flicker, or lost context.
Default behavior:
Disabled is fine only when:
Bad disabled state: button disabled with no visible reason, form cleared during loading, entire screen grayed out for a small refresh.
Non-negotiable rules:
For dense forms:
Good pattern:
For form-heavy screens:
@Composable
fun QuoteSection(quote: QuoteUi?, isLoading: Boolean) {
if (isLoading) {
CircularProgressIndicator()
} else if (quote != null) {
QuoteContent(quote = quote, refreshing = false)
}
}
@Composable
fun QuoteSection(quote: QuoteUi?, isLoading: Boolean) {
ResultCardSlot {
when {
quote != null -> QuoteContent(quote = quote, refreshing = isLoading)
isLoading -> QuoteCardSkeleton()
else -> QuoteEmptyState()
}
}
}
@Composable
fun ResultCardSlot(content: @Composable BoxScope.() -> Unit) {
Box(modifier = Modifier.fillMaxWidth().heightIn(min = 180.dp)) { content() }
}
@Composable
fun QuoteCardSkeleton(modifier: Modifier = Modifier) {
val alpha by rememberInfiniteTransition(label = "skeleton").animateFloat(
initialValue = 0.35f,
targetValue = 0.60f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 800),
repeatMode = RepeatMode.Reverse,
),
label = "alpha",
)
Column(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 180.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = alpha))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(Modifier.fillMaxWidth(0.4f).height(20.dp).clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)))
Box(Modifier.fillMaxWidth().height(36.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)))
Box(Modifier.fillMaxWidth(0.7f).height(20.dp).clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)))
}
}