# 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` controlled by a `LaunchedEffect`: ```kotlin 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()` of already-animated item keys in `FeedScreen`: ```kotlin val animatedKeys = remember { mutableStateSetOf() } // 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: `animateFloatAsState` with `Bouncy` spring 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.pointerInput` detecting press → `animateFloatAsState` scale 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: `AnimatedVisibility` per item with `slideInVertically` + `fadeIn` using `Gentle` spring - Delay: Stagger pattern (see shared section). Capped at first ~8 visible items - Already-animated tracking via `animatedKeys` set (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)` with `fadeIn + expandVertically` / `fadeOut + shrinkVertically` as 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: `AnimatedVisibility` with `fadeIn + 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: `rememberInfiniteTransition` for pulse; `animateColorAsState` + scale bounce on status change - Shake on FAILED: `Animatable` with `animateTo` offset -4dp → 4dp → 0dp (3 oscillations) - Color transition: `Quick` tween - 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: `AnimatedVisibility` with `slideInVertically(initialOffsetY = { it })` + `Snappy` spring - Exit: `fadeOut` with `Quick` tween ## 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: `AnimatedVisibility` with `scaleIn` using `Bouncy` spring - 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: `AnimatedVisibility` with `slideInVertically + fadeIn` using `Gentle` spring - Loading state: Replace current `LinearProgressIndicator` with a pulsing placeholder card using `rememberInfiniteTransition` on 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: `AnimatedVisibility` with `scaleIn` using `Bouncy` ### 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 `animateFloatAsState` with `BouncyQuick`; `rememberInfiniteTransition` for pulse; `AnimatedContent` for icon swap with `scaleIn` ### 12. Character counter color (FADE/Quick) Smooth color crossfade when exceeding 280 characters — neutral → red. - Compose API: `animateColorAsState` with `Quick` tween - 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: `AnimatedVisibility` with `slideInHorizontally + fadeIn` using `Snappy` spring ## 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 `AnimatedVisibility` per section using `fadeIn + 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: `animateFloatAsState` scale with `Bouncy` spring ### 16. Delete confirmation dialog (SCALE/Snappy) Dialog scales from center (0.8→1.0) with spring + backdrop fades in. - Compose API: Custom `AnimatedDialog` wrapper with `scaleIn(initialScale = 0.8f)` + `fadeIn` backdrop - 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: `AnimatedVisibility` with `scaleIn` using `Bouncy` spring; `LaunchedEffect` → `delay(2000)` → hide - Exit: `fadeOut` with `Quick` tween - 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 `AnimatedDialog` wrapper as #16 - Red pulse: `rememberInfiniteTransition` on 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.kt` - `ui/components/AnimatedDialog.kt` - `ui/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