AGENT SKILL

genjutsu-compose-motion

Jetpack Compose animation foundations - animate*AsState, AnimatedVisibility, Crossfade, updateTransition, SharedTransitionLayout, gestures.

License 许可
MIT
最近更新
2026-11-14
Stars
★ 2
Forks
0

01 前言 Front Matter

name
genjutsu-compose-motion
description
Jetpack Compose animation foundations - animate*AsState, AnimatedVisibility, Crossfade, updateTransition, SharedTransitionLayout, gestures.

03 SKILL.md 正文

Compose Motion - Sub-skill

Jetpack Compose animation core. Loaded for Android Compose and Compose Multiplatform projects. Concise rules here. Deep-dive in references/. Baseline: Compose 1.7+ (April 2026 stable). Modern stable APIs only - no swipeable, no animateContentSize hacks where AnimatedContent is correct.


API Decision Tree

Need API
Single value over time animateFloatAsState, animateDpAsState, animateColorAsState, etc.
Visibility / mount-unmount AnimatedVisibility(visible) { ... }
Crossfade between states Crossfade(target) { state -> ... }
Multi-state coordinated updateTransition(target).animateFloat { ... }
Manual control / interruption Animatable(initialValue) + animateTo(...)
Looping / infinite rememberInfiniteTransition().animateFloat(...)
Shared elements SharedTransitionLayout + Modifier.sharedElement(...) (Compose 1.7+)
Layout swap with anim AnimatedContent(target) { ... }
Drag / swipe Modifier.draggable + Animatable.snapTo/animateTo, or Modifier.anchoredDraggable for snap-points

Rule: climb the ladder only when needed. animate*AsState covers 70% of cases. Reach for Animatable only when you need to interrupt, chain, or read velocity.


Spring API (opinionated defaults)

Use Spec
UI snap (modal, drawer, tab) spring(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioNoBouncy)
Tactile (button press release, toggle) spring(stiffness = Spring.StiffnessMedium, dampingRatio = 0.85f)
Bouncy reveal (toast, FAB, success) spring(stiffness = Spring.StiffnessLow, dampingRatio = Spring.DampingRatioMediumBouncy)
Drag follow (1:1 finger tracking) spring(stiffness = Spring.StiffnessHigh, dampingRatio = 1f)

Stiffness constants: VeryLow 200, Low 400, MediumLow 700, Medium 1500, High 10000. Higher = faster settle. Damping constants: HighBouncy 0.2, MediumBouncy 0.5, LowBouncy 0.75, NoBouncy 1.0. Below 1.0 overshoots. Springs ignore durationMillis; if you need a deterministic duration, use tween(...) instead.


animate*AsState - The Bread and Butter

val targetAlpha = if (visible) 1f else 0f
val alpha by animateFloatAsState(
    targetValue = targetAlpha,
    animationSpec = spring(stiffness = Spring.StiffnessMedium),
    label = "alpha",
)
Box(modifier = Modifier.alpha(alpha))

The label shows up in Layout Inspector / Animation Preview - always set it, future-you will thank present-you. Variants ship for Dp, Color, Offset, IntOffset, Size, Rect, Float, Int, and a generic animateValueAsState for custom types via TwoWayConverter.


AnimatedVisibility - Mount / Unmount with Anim

AnimatedVisibility(
    visible = expanded,
    enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
    exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(animationSpec = tween(150)),
) {
    Panel()
}

Combine multiple enter/exit transitions with +. Respect motion-principles: exit shorter and simpler than enter (here 150ms tween fade vs full slide+fade enter). The content composable only runs while visible OR animating - safe to mount expensive children inside.


AnimatedContent - State-Driven Layout Swap

AnimatedContent(
    targetState = currentTab,
    transitionSpec = {
        (slideInHorizontally { it } + fadeIn()) togetherWith
            (slideOutHorizontally { -it } + fadeOut())
    },
    label = "tabs",
) { tab ->
    TabContent(tab)
}

togetherWith runs enter and exit in parallel; using SizeTransform(clip = false) controls how the container resizes between contents. Keys matter: if targetState doesn't change identity, no transition fires.


Crossfade - Simple Fade Between States

Crossfade(targetState = isLoading, label = "loadState") { loading ->
    if (loading) Spinner() else Content()
}

Use when you only need a fade. For anything richer (slide, scale, layout-aware), reach for AnimatedContent. Crossfade does NOT animate size - the container takes the size of the new content immediately.


updateTransition - Multi-Property Coordinated

val transition = updateTransition(targetState = expanded, label = "expand")
val width by transition.animateDp(label = "width") { if (it) 300.dp else 100.dp }
val color by transition.animateColor(label = "color") { if (it) Color.Blue else Color.Gray }
val corner by transition.animateDp(label = "corner") { if (it) 24.dp else 8.dp }

Box(
    Modifier
        .width(width)
        .background(color, RoundedCornerShape(corner)),
)

Use when several properties animate together based on the same state. All children share the same transition timeline, so they finish in sync. Each animate* call accepts its own transitionSpec lambda for per-property tuning.


Animatable - Manual Control

val offsetX = remember { Animatable(0f) }
LaunchedEffect(triggerEvent) {
    offsetX.animateTo(100f, spring())
    offsetX.animateTo(0f, spring(dampingRatio = Spring.DampingRatioMediumBouncy))
}
Box(Modifier.offset { IntOffset(offsetX.value.roundToInt(), 0) })

Reach for Animatable when you need to interrupt (stop()), chain (animateTo returns when finished), read live velocity, or kick off decay (animateDecay). It is the imperative escape hatch for drag-then-fling, snap-back, and any flow animate*AsState cannot express.


SharedTransitionLayout (Compose 1.7+) - Hero Animations

SharedTransitionLayout {
    AnimatedContent(targetState = currentScreen, label = "nav") { screen ->
        when (screen) {
            Screen.List -> ListScreen(
                sharedTransitionScope = this@SharedTransitionLayout,
                animatedVisibilityScope = this@AnimatedContent,
            )
            is Screen.Detail -> DetailScreen(
                item = screen.item,
                sharedTransitionScope = this@SharedTransitionLayout,
                animatedVisibilityScope = this@AnimatedContent,
            )
        }
    }
}

// Inside ListScreen, on the card image:
with(sharedTransitionScope) {
    Image(
        painter = painter,
        contentDescription = null,
        modifier = Modifier.sharedElement(
            state = rememberSharedContentState(key = "hero-${item.id}"),
            animatedVisibilityScope = animatedVisibilityScope,
        ),
    )
}

Two scopes plumbed down: SharedTransitionScope (where Modifier.sharedElement extension lives) and AnimatedVisibilityScope (the visibility context that drives the transition). rememberSharedContentState(key) keys MUST match across screens or no animation fires. Deep-dive in references/shared-transitions.md.


InfiniteTransition - Looping

val infinite = rememberInfiniteTransition(label = "loader")
val rotation by infinite.animateFloat(
    initialValue = 0f,
    targetValue = 360f,
    animationSpec = infiniteRepeatable(
        animation = tween(1000, easing = LinearEasing),
        repeatMode = RepeatMode.Restart,
    ),
    label = "rotation",
)
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.rotate(rotation))

RepeatMode.Restart jumps back to start each cycle; RepeatMode.Reverse ping-pongs. Always provide a pause / off path for accessibility (see Reduced Motion section below). For one-shot decay (fling), use Animatable.animateDecay instead.


Gestures (quick map)

Need Modifier
Tap Modifier.clickable(onClick = ...)
Tap + long-press Modifier.combinedClickable(onClick = ..., onLongClick = ...)
Drag (single axis) Modifier.draggable(state, orientation)
Multi-touch / custom Modifier.pointerInput { detectDragGestures { ... } }
Scroll offset reading LazyListState or ScrollState.value
Swipe-to-dismiss / snap points Modifier.anchoredDraggable(state) (Compose 1.6+, replaces swipeable)
Pinch + pan + rotate Modifier.transformable(state)

Deep-dive in references/gestures-compose.md (NestedScrollConnection, conflict resolution, fling decay).


Anti-Patterns (BAD / GOOD)

1. Animating layout size instead of transform

// BAD - animateDpAsState on width forces a layout pass every frame
val w by animateDpAsState(if (expanded) 300.dp else 100.dp)
Box(Modifier.width(w).height(60.dp))
// GOOD - animate scale via graphicsLayer (composite-only, no layout)
val scale by animateFloatAsState(if (expanded) 3f else 1f, label = "scale")
Box(
    Modifier
        .width(100.dp)
        .height(60.dp)
        .graphicsLayer {
            scaleX = scale
            transformOrigin = TransformOrigin(0f, 0.5f)
        },
)

If the actual layout size MUST change (parent depends on it), use Modifier.animateContentSize() so neighbors animate too, or wrap the swap in AnimatedContent.

2. LaunchedEffect(true) / LaunchedEffect(Unit) with hidden inputs

// BAD - re-runs only once, BUT also fires on every recomposition surprise
//        when callers swap the composable instance. Worse: anything captured
//        in the lambda is stale.
LaunchedEffect(true) {
    offsetX.animateTo(target)
}
// GOOD - explicit key tied to the trigger
LaunchedEffect(triggerKey) {
    offsetX.animateTo(target)
}

If you genuinely want "run once", use LaunchedEffect(Unit) on purpose AND ensure you don't capture varying state - or hoist the captured values out. When in doubt, key on the values you read.

3. Forgetting key in a LazyColumn with item animations

// BAD - on insert/delete/reorder, items animate to wrong slots
LazyColumn {
    items(list) { item ->
        AnimatedVisibility(visible = item.expanded) { ItemRow(item) }
    }
}
// GOOD - stable key by id, plus Modifier.animateItem for reorder anim
LazyColumn {
    items(list, key = { it.id }) { item ->
        Row(modifier = Modifier.animateItem()) {
            AnimatedVisibility(visible = item.expanded) { ItemRow(item) }
        }
    }
}

Modifier.animateItem() (Compose 1.7+, replaces animateItemPlacement) handles insert/remove/move automatically when key is stable.

4. Nesting AnimatedContent inside scrolling list items

// BAD - every item runs its own transition graph; scroll = jank
LazyColumn {
    items(list, key = { it.id }) { item ->
        AnimatedContent(targetState = item.state) { state -> Row(state) }
    }
}
// GOOD - lift state, animate only the changing prop on the row
LazyColumn {
    items(list, key = { it.id }) { item ->
        val color by animateColorAsState(if (item.selected) selBg else bg, label = "rowBg")
        Row(Modifier.background(color)) { Content(item) }
    }
}

Rule: heavy animation containers (AnimatedContent, SharedTransitionLayout) belong at screen scope, not per row.


Reduced Motion - Respect It

See ../motion-principles/SKILL.md for the cross-platform doctrine.

@Composable
fun rememberReduceMotion(): Boolean {
    val context = LocalContext.current
    return remember {
        Settings.Global.getFloat(
            context.contentResolver,
            Settings.Global.ANIMATOR_DURATION_SCALE,
            1f,
        ) == 0f
    }
}

val reduce = rememberReduceMotion()
val alpha by animateFloatAsState(
    targetValue = if (visible) 1f else 0f,
    animationSpec = if (reduce) snap() else spring(),
    label = "alpha",
)

ANIMATOR_DURATION_SCALE covers the developer-options "Animations off" toggle. For the user-facing accessibility setting on Android 14+ ("Remove animations" in Settings -> Accessibility), use AccessibilityManager.areTransitionsEnabled() (API 34+). Deep-dive in ../mobile-principles/references/accessibility-mobile.md.


Quick Reference

Need Load
Shared transitions deep-dive references/shared-transitions.md
Gestures + nestedScroll patterns references/gestures-compose.md
Recomposition / jank / Layout Inspector references/recomposition-and-anim.md
Advanced (M3 Expressive, AGSL, Canvas) ../compose-graphics/SKILL.md
CMP / KMP patterns ../compose-multiplatform/SKILL.md
Mobile UX context ../mobile-principles/SKILL.md
Foundation (timing, easing, a11y) ../motion-principles/SKILL.md

Sources

04 同仓库更多 Skills

nexuslinkproductions
nexuslinkproductions/yuri-os

zoom-out

Tell the agent to zoom out and give broader context or a higher-level perspective. Use when you're unfamiliar with a section of code or need to understand how it fits into the bigger picture.

★ 2
nexuslinkproductions
nexuslinkproductions/yuri-os

yuri-shura

6-perspective adversarial review for high-stakes turns (architecture decisions, refactor planning, deployment review) — fans out architect, adversary (7-vector attack), maintainer, ops, product, and security perspectives in parallel, then consolidates. Use when user says 'review this decision', '/shura', 'adversarial review', 'what could go wrong', 'architecture review', or 'deployment review'.

★ 2
nexuslinkproductions
nexuslinkproductions/yuri-os

yuri-sales-intelligence

Unified sales and outreach skill generating cold outreach openers, social selling sequences, objection rebuttals, and ad creative for any paid platform — multi-persona aware, adapting tone and offer frame to buyer type. Use when user says 'write a cold email', 'outreach sequence', 'objection handling', 'ad copy', or 'sales pitch'.

邮件与沟通
★ 2
nexuslinkproductions
nexuslinkproductions/yuri-os

yuri-code-intelligence

Unified code quality intelligence layer that diagnoses code smells (Long Method, Feature Envy, design pattern issues), prescribes targeted Fowler refactoring techniques, and reviews code for security, performance, and clarity. Use when user says 'review this code', 'refactor this', 'what's wrong with this method', 'code smells', or 'clean this up'.

开发工程师代码审查
★ 2
nexuslinkproductions
nexuslinkproductions/yuri-os

yuri-adw

Invoke when user asks to run the ADW loop / agentic engineering loop / software factory workflow.

★ 2
nexuslinkproductions
nexuslinkproductions/yuri-os

writing-skills

Use when creating new skills, editing existing skills, or verifying skills work before deployment

内容创作者测试与质量写作与文案
★ 2