Address all review findings: fix Bouncy spring damping (0.55→0.65), add BouncyQuick for press feedback, document stagger pattern and already-animated tracking, add reduced motion support, fix nav transitions with popEnter/popExit, add disconnect confirmation dialog as new behavior, and add PulsingPlaceholder component.
12 KiB
Micro-Animations Design — Swoosh
Date: 2026-03-19 Status: Approved Style: Expressive & playful — bouncy springs, overshoot, lively feedback
Overview
Add 19 micro-animations + 5 navigation transitions across all screens to make the app feel alive. Create a shared SwooshMotion object with predefined animation specs ensuring consistent character throughout the app.
Shared Animation Specs — SwooshMotion
Central object in a new file ui/animation/SwooshMotion.kt providing reusable animation specifications:
| Name | Type | Parameters | Use case |
|---|---|---|---|
Bouncy |
Spring | dampingRatio=0.65, stiffness=400 | FAB entrance, chips, badges |
BouncyQuick |
Spring | dampingRatio=0.7, stiffness=1000 | Press feedback (settles in ~150ms) |
Snappy |
Spring | dampingRatio=0.7, stiffness=800 | Expand/collapse, dialogs |
Gentle |
Spring | dampingRatio=0.8, stiffness=300 | Cards, content reveal |
Quick |
Tween | 200ms, FastOutSlowInEasing | Fade, color transitions |
StaggerDelay |
Offset | 50ms per item | List item entrances |
Reduced Motion Support
SwooshMotion exposes val isReducedMotion: Boolean that reads the system accessibility setting (Settings.Global.ANIMATOR_DURATION_SCALE). When true, all spring specs fall back to snap() (instant) and all tweens use 0ms duration. Every animation in this spec must route through SwooshMotion so the reduced-motion check is centralized.
Stagger Pattern
For staggered entrance animations (#3, #13, #14), each item uses a local MutableState<Boolean> controlled by a LaunchedEffect:
val visible = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(SwooshMotion.StaggerDelay * index)
visible.value = true
}
AnimatedVisibility(visible = visible.value, ...) { ... }
AnimatedVisibility has no built-in delay parameter — this pattern is the canonical approach.
Already-Animated Tracking (LazyColumn)
For staggered items inside LazyColumn (#3), items re-enter composition on scroll. To prevent replaying the entrance animation, hoist a mutableStateSetOf<String>() of already-animated item keys in FeedScreen:
val animatedKeys = remember { mutableStateSetOf<String>() }
// Inside each item:
val shouldAnimate = post.id !in animatedKeys
LaunchedEffect(Unit) {
if (shouldAnimate) {
delay(SwooshMotion.StaggerDelay * index)
animatedKeys.add(post.id)
}
visible.value = true // instant if already animated
}
Only animate the first batch visible on initial load (cap at ~8 items). Items appended via infinite scroll appear without stagger.
Feed Screen — 7 animations
1. FAB entrance (SPRING/Bouncy)
When the feed screen opens, the FAB scales from 0 to 1 with Bouncy spring. One visible overshoot — the FAB "pops" onto screen.
- Compose API:
animateFloatAsStatewithBouncyspring on scale modifier - Trigger:
LaunchedEffect(Unit)sets target to 1f
2. FAB press (SPRING/BouncyQuick)
On tap, FAB shrinks to 85% and snaps back to 100%. Must settle in ~150ms so it completes before navigation fires.
- Compose API:
Modifier.pointerInputdetecting press →animateFloatAsStatescale 0.85f → 1f - Spring spec:
BouncyQuick(dampingRatio=0.7, stiffness=1000) - Navigation fires immediately on tap — the spring animation is interrupted by the screen transition, which is fine
3. Post cards staggered entrance (SLIDE/Gentle)
Cards slide in from bottom with cascading delay — 50ms per item. "Waterfall" effect.
- Compose API:
AnimatedVisibilityper item withslideInVertically+fadeInusingGentlespring - Delay: Stagger pattern (see shared section). Capped at first ~8 visible items
- Already-animated tracking via
animatedKeysset (see shared section) - Items appended via infinite scroll appear without stagger (instant
visible = true)
4. "Show more" expand (SPRING/Snappy)
Card content transitions between truncated and full text with animated height change.
- Compose API:
AnimatedContent(targetState = expanded)withfadeIn + expandVertically/fadeOut + shrinkVerticallyas content transform - Height spring:
Snappy - This replaces the current discrete text swap with a smooth animated transition
5. Empty state (FADE/Quick + scale)
"No posts yet" icon and text fade in with subtle scale from 0.9 to 1.0.
- Compose API:
AnimatedVisibilitywithfadeIn + scaleIn(initialScale = 0.9f)
6. Queue status chip (BOUNCE/Bouncy)
During upload: chip pulses (infinite alpha animation 0.6→1.0). On status change (success/fail): bounce scale + color crossfade. On FAILED: shake animation (horizontal offset oscillation) to draw attention.
- Compose API:
rememberInfiniteTransitionfor pulse;animateColorAsState+ scale bounce on status change - Shake on FAILED:
AnimatablewithanimateTooffset -4dp → 4dp → 0dp (3 oscillations) - Color transition:
Quicktween - Note: Each chip in UPLOADING state creates its own
rememberInfiniteTransition. If many posts queue simultaneously, this is fine for ~5 items but could cause jank beyond that (unlikely in normal use)
7. Snackbar error (SLIDE/Snappy)
Slides in from bottom with slight overshoot. Fades out on timeout.
- Compose API:
AnimatedVisibilitywithslideInVertically(initialOffsetY = { it })+Snappyspring - Exit:
fadeOutwithQuicktween
Composer Screen — 7 animations
8. Image preview (SCALE/Bouncy)
After picking an image, the preview scales from 0 with bouncy spring. Close button fades in with scale.
- Compose API:
AnimatedVisibilitywithscaleInusingBouncyspring - Close button:
fadeIn + scaleIn(initialScale = 0.5f)— no rotation (an "X" icon should feel stable and immediately tappable)
9. Link preview card (SLIDE/Gentle)
After link loads, card slides up from below + fades in. While loading: pulsing placeholder card.
- Compose API:
AnimatedVisibilitywithslideInVertically + fadeInusingGentlespring - Loading state: Replace current
LinearProgressIndicatorwith a pulsing placeholder card usingrememberInfiniteTransitionon alpha (0.3→0.7). Simpler than a full shimmer gradient — just a fading placeholder shape
10. Schedule chip (SPRING/Bouncy)
After picking date, chip pops in with bouncy spring.
- Compose API:
AnimatedVisibilitywithscaleInusingBouncy
11. Publish button (BOUNCE/BouncyQuick)
Subtle bounce on activation. During publishing: loading pulse. On success: checkmark icon scales in.
- Compose API: Scale bounce on click via
animateFloatAsStatewithBouncyQuick;rememberInfiniteTransitionfor pulse;AnimatedContentfor icon swap withscaleIn
12. Character counter color (FADE/Quick)
Smooth color crossfade when exceeding 280 characters — neutral → red.
- Compose API:
animateColorAsStatewithQuicktween - Trigger:
text.length > 280
13. Action buttons staggered entrance (SCALE/Gentle)
Draft, Schedule, Publish buttons — cascading scale-in with 50ms delay each.
- Compose API: Stagger pattern (see shared section) with
AnimatedVisibility+scaleIn + fadeIn
NEW: 13b. Error text (SLIDE/Snappy)
Composer error text slides in from left + fades in, consistent with snackbar style.
- Compose API:
AnimatedVisibilitywithslideInHorizontally + fadeInusingSnappyspring
Detail Screen — 4 animations
14. Content reveal (FADE/Gentle)
Elements appear sequentially: status badge → text → image → metadata. 80ms delay each.
- Compose API: Stagger pattern (see shared section) with
AnimatedVisibilityper section usingfadeIn + slideInVertically(initialOffsetY = { 20 }) - Delay: 80ms per element (4 elements total = 320ms cascade)
15. Status badge entrance (SPRING/Bouncy)
Badge scales from 0 with bounce — first visible element, draws attention. Part of the content reveal sequence (index 0, no delay).
- Compose API:
animateFloatAsStatescale withBouncyspring
16. Delete confirmation dialog (SCALE/Snappy)
Dialog scales from center (0.8→1.0) with spring + backdrop fades in.
- Compose API: Custom
AnimatedDialogwrapper withscaleIn(initialScale = 0.8f)+fadeInbackdrop - Spring spec:
Snappy
17. Metadata section (SLIDE/Gentle)
Bottom metadata slides up — last in the content reveal sequence (index 3, 240ms delay).
- Compose API: Part of stagger pattern in #14
Settings Screen — 3 animations
18. "Settings saved" feedback (SPRING/Bouncy)
Existing "Settings saved" text (currently static conditional) gets animated: pops in with bounce (scale 0→1). After 2 seconds, fades out.
- Compose API:
AnimatedVisibilitywithscaleInusingBouncyspring;LaunchedEffect→delay(2000)→ hide - Exit:
fadeOutwithQuicktween - Text and color remain as-is: "Settings saved" with
MaterialTheme.colorScheme.primary
19. Disconnect confirmation dialog (NEW behavior + FADE+SCALE/Snappy)
New behavior: Add a confirmation dialog before disconnect (currently the button directly clears credentials). The dialog uses the same AnimatedDialog wrapper as #16. "Disconnect" button has subtle red pulse.
- Compose API: Same
AnimatedDialogwrapper as #16 - Red pulse:
rememberInfiniteTransitionon alpha of error color
NEW: 19b. Disconnect dialog behavior
The OutlinedButton currently calls credentials.clear() + onLogout() directly. After this change, it shows a confirmation dialog first. Confirm action triggers the existing clear + logout flow.
Navigation Transitions — 5
Each composable() call in NavGraph.kt receives enterTransition, exitTransition, popEnterTransition, and popExitTransition lambdas. Back navigation uses popEnterTransition/popExitTransition (the reverse of the enter animation) — these are distinct parameters, not automatic.
Feed → Composer
Slide up from bottom + fade.
enterTransition = slideInVertically(initialOffsetY = { it }) + fadeIn()exitTransition = fadeOut()popEnterTransition = fadeIn()popExitTransition = slideOutVertically(targetOffsetY = { it }) + fadeOut()
Feed → Detail
Slide in from right + fade.
enterTransition = slideInHorizontally(initialOffsetX = { it }) + fadeIn()exitTransition = fadeOut()popEnterTransition = fadeIn()popExitTransition = slideOutHorizontally(targetOffsetX = { it }) + fadeOut()
Detail → Composer (via Edit)
Same as Feed → Composer (slide up from bottom). The Composer always enters with the same transition regardless of source.
Feed → Settings
Standard slide from right.
enterTransition = slideInHorizontally(initialOffsetX = { it })exitTransition = fadeOut()popEnterTransition = fadeIn()popExitTransition = slideOutHorizontally(targetOffsetX = { it })
Setup → Feed
Crossfade — smooth transition from animated setup background to feed.
enterTransition = fadeIn(tween(500))exitTransition = fadeOut(tween(500))
File Structure
ui/
├── animation/
│ └── SwooshMotion.kt # Shared animation specs object + reduced motion check
├── components/
│ ├── AnimatedDialog.kt # Reusable animated dialog wrapper (used by #16, #19)
│ └── PulsingPlaceholder.kt # Pulsing loading placeholder (used by #9)
├── feed/
│ └── FeedScreen.kt # Modified: #1-#7
├── composer/
│ └── ComposerScreen.kt # Modified: #8-#13, #13b
├── detail/
│ └── DetailScreen.kt # Modified: #14-#17
├── settings/
│ └── SettingsScreen.kt # Modified: #18-#19, #19b (new disconnect dialog)
└── navigation/
└── NavGraph.kt # Modified: navigation transitions with enter/exit/popEnter/popExit
New files: 3
ui/animation/SwooshMotion.ktui/components/AnimatedDialog.ktui/components/PulsingPlaceholder.kt
Modified files: 5
FeedScreen.kt,ComposerScreen.kt,DetailScreen.kt,SettingsScreen.kt,NavGraph.kt
Testing Strategy
- Existing unit tests should pass unchanged (animations don't affect business logic)
- Manual verification: each animation visually correct on emulator
- Compose Preview for individual animated components where feasible
- Test with system "Remove animations" setting enabled to verify reduced-motion fallback