アニメーション

Jetpack Compose は、アプリの UI でさまざまなアニメーションを簡単に実装できる強力で拡張可能な API を提供します。このドキュメントでは、これらの API の使用方法と、アニメーションのシナリオに応じてどの API を使用すべきかについて説明します。

概要

最新のモバイルアプリでは、スムーズでわかりやすいユーザー エクスペリエンスを実現するためにアニメーションが不可欠です。多くの Jetpack Compose Animation API は、レイアウトやその他の UI 要素と同様にコンポーズ可能な関数として使用できます。また、これらの API は、Kotlin コルーチンの suspend 関数で作成された低レベル API によってサポートされています。このガイドでは、最初に多くの実践的なシナリオで役立つ高レベル API について説明し、その後でより細かい制御とカスタマイズが可能な低レベル API について解説します。

次のチャートは、アニメーションの実装に使用する API を決めるのに役立ちます。

  • レイアウト内のコンテンツの変更をアニメーション化する場合:
    • 表示と非表示をアニメーション化する場合:
      • AnimationVisibility を使用します。
    • 状態に基づいてコンテンツを入れ替える場合:
      • コンテンツをクロスフェードする場合:
        • Crossfade を使用します。
      • それ以外の場合は、AnimatedContent を使用します。
    • それ以外の場合は、Modifier.contentSize を使用します。
  • アニメーションが状態ベースである場合:
    • コンポジション中にアニメーションが発生する場合:
      • アニメーションが無限である場合:
        • rememberInfiniteTransition を使用します。
      • 複数の値を同時にアニメーション化する場合:
        • updateTransition を使用します。
      • それ以外の場合は、animate*AsState を使用します。
  • アニメーション時間をより細かく制御する場合:
    • Animation を使用します。
  • アニメーションが唯一の信頼できる情報源である場合:
    • Animatable を使用します。
  • それ以外の場合は、AnimationState または animate を使用します。

適切なアニメーション API を選択するための決定木を示すフローチャート

高レベル アニメーション API

Compose には、多くのアプリで使用される一般的なアニメーション パターン用の高レベル アニメーション API が用意されています。これらの API は、マテリアル デザイン モーションのベスト プラクティスに合わせてカスタマイズされています。

AnimatedVisibility(試験運用版)

AnimatedVisibility コンポーザブルは、コンテンツの表示と非表示をアニメーション化します。

var editable by remember { mutableStateOf(true) }
AnimatedVisibility(visible = editable) {
    Text(text = "Edit")
}

デフォルトでは、コンテンツの表示にはフェードインと拡大が使われ、非表示にはフェードアウトと縮小が使われます。この遷移は、EnterTransitionExitTransition を指定することでカスタマイズできます。

var visible by remember { mutableStateOf(true) }
val density = LocalDensity.current
AnimatedVisibility(
    visible = visible,
    enter = slideInVertically(
        // Slide in from 40 dp from the top.
        initialOffsetY = { with(density) { -40.dp.roundToPx() } }
    ) + expandVertically(
        // Expand from the top.
        expandFrom = Alignment.Top
    ) + fadeIn(
        // Fade in with the initial alpha of 0.3f.
        initialAlpha = 0.3f
    ),
    exit = slideOutVertically() + shrinkVertically() + fadeOut()
) {
    Text("Hello", Modifier.fillMaxWidth().height(200.dp))
}

上記の例に示すように、複数の EnterTransition オブジェクトまたは ExitTransition オブジェクトを + 演算子で結合できます。各オブジェクトは、動作をカスタマイズするためのオプション パラメータを受け入れます。詳細については、それぞれのリファレンスをご覧ください。

EnterTransition の例

fadeIn:

slideIn:

expandIn:

expandHorizontally:

expandVertically:

slideInHorizontally:

slideInVertically:

ExitTransition の例

fadeOut:

slideOut:

shrinkOut:

shrinkHorizontally:

shrinkVertically:

slideOutHorizontally:

slideOutVertically:

AnimatedVisibility には、MutableTransitionState を取る派生形も用意されています。これにより、AnimatedVisibility がコンポジション ツリーに追加された直後にアニメーションをトリガーできます。アニメーションの状態を監視する場合にも便利です。

// Create a MutableTransitionState<Boolean> for the AnimatedVisibility.
val state = remember {
    MutableTransitionState(false).apply {
        // Start the animation immediately.
        targetState = true
    }
}
Column {
    AnimatedVisibility(visibleState = state) {
        Text(text = "Hello, world!")
    }

    // Use the MutableTransitionState to know the current animation state
    // of the AnimatedVisibility.
    Text(
        text = when {
            state.isIdle && state.currentState -> "Visible"
            !state.isIdle && state.currentState -> "Disappearing"
            state.isIdle && !state.currentState -> "Invisible"
            else -> "Appearing"
        }
    )
}

子の開始と終了をアニメーション化する

AnimatedVisibility 内のコンテンツ(直接または間接の子)は、animateEnterExit 修飾子を使用して、それぞれに異なるアニメーションの動作を指定できます。子それぞれの視覚効果は、AnimatedVisibility コンポーザブルで指定されたアニメーションと、子自身の開始アニメーションと終了アニメーションの組み合わせです。

AnimatedVisibility(
    visible = visible,
    // Fade in/out the background and the foreground.
    enter = fadeIn(),
    exit = fadeOut()
) {
    Box(Modifier.fillMaxSize().background(Color.DarkGray)) {
        Box(
            Modifier
                .align(Alignment.Center)
                .animateEnterExit(
                    // Slide in/out the inner box.
                    enter = slideInVertically(),
                    exit = slideOutVertically()
                )
                .sizeIn(minWidth = 256.dp, minHeight = 64.dp)
                .background(Color.Red)
        ) {
            // Content of the notification…
        }
    }
}

場合によっては、AnimatedVisibility にアニメーションをまったく適用しないようにして、子の animateEnterExit でそれぞれ独自のアニメーションを持つようにしたい場合があります。これを実現するには、AnimatedVisibility コンポーザブルで EnterTransition.NoneExitTransition.None を指定します。

カスタム アニメーションを追加する

組み込みの開始アニメーションと終了アニメーション以外のカスタム アニメーション効果を追加する場合は、AnimatedVisibility のコンテンツ ラムダ内の transition プロパティを使用して、元となる Transition インスタンスにアクセスします。Transition インスタンスに追加されたアニメーションの状態は、AnimatedVisibility の開始アニメーションと終了アニメーションと同時に実行されます。AnimatedVisibility は、Transition 内のすべてのアニメーションが終了するまで待ってから、コンテンツを削除します。(animate*AsState を使用するなどして)Transition とは独立して作成された終了アニメーションについては、AnimatedVisibility では考慮できないため、終了前にコンテンツ コンポーザブルを削除する場合があります。

AnimatedVisibility(
    visible = visible,
    enter = fadeIn(),
    exit = fadeOut()
) { // this: AnimatedVisibilityScope
    // Use AnimatedVisibilityScope.transition() to add a custom animation
    // to the AnimatedVisibility.
    val background by transition.animateColor { state ->
        if (state == EnterExitState.Visible) Color.Blue else Color.Gray
    }
    Box(modifier = Modifier.size(128.dp).background(background))
}

Transition の詳細については、updateTransition をご覧ください。

AnimatedContent(試験運用版)

AnimatedContent コンポーザブルは、ターゲット状態に基づいてコンテンツが変化するのに応じて、コンテンツをアニメーション化します。

Row {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Add")
    }
    AnimatedContent(targetState = count) { targetCount ->
        // Make sure to use `targetCount`, not `count`.
        Text(text = "Count: $targetCount")
    }
}

必ずラムダ パラメータを使用し、それをコンテンツに反映する必要があります。API はこの値をキーとして使用して、現在表示されているコンテンツを識別します。

デフォルトでは、初期コンテンツがフェードアウトしてから、ターゲット コンテンツがフェードインします(この動作はフェードスルーと呼ばれています)。このアニメーションの動作をカスタマイズするには、transitionSpec パラメータに ContentTransform オブジェクトを指定します。ContentTransform を作成するには、with 中置関数を使用して EnterTransitionExitTransition を組み合わせます。SizeTransformContentTransform に適用するには、using 中置関数で接続します。

AnimatedContent(
    targetState = count,
    transitionSpec = {
        // Compare the incoming number with the previous number.
        if (targetState > initialState) {
            // If the target number is larger, it slides up and fades in
            // while the initial (smaller) number slides up and fades out.
            slideInVertically({ height -> height }) + fadeIn() with
                slideOutVertically({ height -> -height }) + fadeOut()
        } else {
            // If the target number is smaller, it slides down and fades in
            // while the initial number slides down and fades out.
            slideInVertically({ height -> -height }) + fadeIn() with
                slideOutVertically({ height -> height }) + fadeOut()
        }.using(
            // Disable clipping since the faded slide-in/out should
            // be displayed out of bounds.
            SizeTransform(clip = false)
        )
    }
) { targetCount ->
    Text(text = "$targetCount")
}

EnterTransition は、ターゲット コンテンツの表示方法を定義し、ExitTransition は、初期コンテンツの消失方法を定義します。AnimatedVisibility で使用可能なすべての EnterTransition 関数と ExitTransition 関数に加えて、AnimatedContent には slideIntoContainerslideOutOfContainer が用意されています。これらは slideInHorizontally/VerticallyslideOutHorizontally/Vertically に代わる便利な方法であり、AnimatedContent コンテンツの初期コンテンツとターゲット コンテンツのサイズに基づいてスライド距離を計算します。

SizeTransform は、初期コンテンツとターゲット コンテンツの間のサイズのアニメーション方法を定義します。アニメーションの作成時に、初期サイズとターゲット サイズの両方にアクセスできます。SizeTransform は、アニメーション中にコンテンツをコンポーネントのサイズに切り取るかどうかも制御します。

var expanded by remember { mutableStateOf(false) }
Surface(
    color = MaterialTheme.colors.primary,
    onClick = { expanded = !expanded }
) {
    AnimatedContent(
        targetState = expanded,
        transitionSpec = {
            fadeIn(animationSpec = tween(150, 150)) with
                fadeOut(animationSpec = tween(150)) using
                SizeTransform { initialSize, targetSize ->
                    if (targetState) {
                        keyframes {
                            // Expand horizontally first.
                            IntSize(targetSize.width, initialSize.height) at 150
                            durationMillis = 300
                        }
                    } else {
                        keyframes {
                            // Shrink vertically first.
                            IntSize(initialSize.width, targetSize.height) at 150
                            durationMillis = 300
                        }
                    }
                }
        }
    ) { targetExpanded ->
        if (targetExpanded) {
            Expanded()
        } else {
            ContentIcon()
        }
    }
}

子の開始 / 終了をアニメーション化する

AnimatedVisibility と同様に、animateEnterExit 修飾子は AnimatedContent のコンテンツ ラムダ内で使用できます。これを使用して、EnterAnimationExitAnimation を直接または間接の子のそれぞれに個別に適用します。

カスタム アニメーションを追加する

AnimatedVisibility と同様に、transition フィールドは AnimatedContent のコンテンツ ラムダ内で使用できます。これを使用して、AnimatedContent 遷移と同時に実行されるカスタム アニメーション効果を作成します。詳細については、updateTransition をご覧ください。

animateContentSize

animateContentSize 修飾子は、サイズ変更をアニメーション化します。

var message by remember { mutableStateOf("Hello") }
Box(
    modifier = Modifier.background(Color.Blue).animateContentSize()
) {
    Text(text = message)
}

Crossfade

Crossfade は、クロスフェード アニメーションで 2 つのレイアウト間をアニメーション化します。current パラメータに渡される値を切り替えると、コンテンツがクロスフェード アニメーションで切り替わります。

var currentPage by remember { mutableStateOf("A") }
Crossfade(targetState = currentPage) { screen ->
    when (screen) {
        "A" -> Text("Page A")
        "B" -> Text("Page B")
    }
}

低レベル アニメーション API

前のセクションで説明したすべての高レベル アニメーション API は、低レベル アニメーション API の基盤の上に構築されています。

animate*AsState 関数は最もシンプルな API で、インスタント値の変更をアニメーション値としてレンダリングします。これらの関数は、単一の値をアニメーション化するコルーチン ベースの API である Animatable によってサポートされています。updateTransition は、複数の値のアニメーション化を管理し、状態変化に基づいて実行する遷移オブジェクトを作成します。rememberInfiniteTransition はこれと似ていますが、無限に実行される複数のアニメーションを管理できる無限遷移を作成します。Animatable を除き、これらの API はすべてコンポーザブルであるため、これらのアニメーションはコンポジションの外部で作成できます。

これらの API はすべて、さらに基本的な Animation API に基づいています。ほとんどのアプリは Animation と直接やり取りはしませんが、Animation のカスタマイズ機能の中には、高レベル API を通じて利用できるものもあります。AnimationVectorAnimationSpec の詳細については、アニメーションをカスタマイズするをご覧ください。

さまざまな低レベル アニメーション API の関係を示す図

animate*AsState

animate*AsState 関数は、Compose で単一の値をアニメーション化するための最もシンプルなアニメーション API です。終了値(またはターゲット値)を指定するだけで、API は現在の値から指定された値までのアニメーションを開始します。

この API を使用してアルファ値をアニメーション化する例を以下に示します。ターゲット値を animateFloatAsState でラップするだけで、アルファ値は、指定した値(この場合は 1f または 0.5f)の間のアニメーション値になります。

val alpha: Float by animateFloatAsState(if (enabled) 1f else 0.5f)
Box(
    Modifier.fillMaxSize()
        .graphicsLayer(alpha = alpha)
        .background(Color.Red)
)

アニメーション クラスのインスタンスを作成したり、割り込みを処理したりする必要はありません。内部では、アニメーション オブジェクト(Animatable インスタンス)がコールサイトで作成および記憶され、最初のターゲット値が初期値として指定されます。その後、このコンポーザブルに別のターゲット値を指定すると、その値までのアニメーションが自動的に開始されます。実行中のアニメーションがある場合、アニメーションは現在の値から同じ速度で開始し、ターゲット値までアニメーション化します。アニメーション中に、このコンポーザブルは再コンポーズされ、更新されたアニメーション値をフレームごとに返します。

Compose には、FloatColorDpSizeBoundsOffsetRectIntIntOffsetIntSize 用の animate*AsState 関数が、すぐに使える状態で用意されています。汎用型を受け入れる animateValueAsStateTwoWayConverter を指定することにより、他のデータ型のサポートを簡単に追加できます。

AnimationSpec を指定することで、アニメーションの仕様をカスタマイズできます。詳細については、AnimationSpec をご覧ください。

Animatable

Animatable は、animateTo を介して変化する値をアニメーション化できる値ホルダーです。この API は、animate*AsState の実装をバックアップします。一貫した継続性と相互排他性が保証されるため、値の変化は常に連続的となり、進行中のアニメーションはキャンセルされます。

Animatable の多くの機能(animateTo を含む)は suspend 関数として提供されるため、適切なコルーチン スコープでラップする必要があります。たとえば、LaunchedEffect コンポーザブルを使用して、指定された Key-Value の持続時間だけスコープを作成できます。

// Start out gray and animate to green/red based on `ok`
val color = remember { Animatable(Color.Gray) }
LaunchedEffect(ok) {
    color.animateTo(if (ok) Color.Green else Color.Red)
}
Box(Modifier.fillMaxSize().background(color.value))

上記の例では、Animatable のインスタンスを、初期値を Color.Gray として作成および記憶しています。ブール値フラグ ok の値に応じて、Color.Green または Color.Red へのアニメーションが行われます。その後ブール値を変更すると、他の色へのアニメーションが開始されます。値が変更されたときに進行中のアニメーションが存在した場合、アニメーションはキャンセルされ、新しいアニメーションが、現在のスナップショットの値から、現在の速度で開始されます。

これは、前のセクションで説明した animate*AsState API をバックアップするアニメーションの実装です。animate*AsState と比較した場合、Animatable を直接使用することにより、いくつかの点でより細かい制御が可能になります。まず、Animatable には最初のターゲット値とは異なる初期値を設定できます。たとえば、上のサンプルコードでは、最初にグレーのボックスが表示された後、すぐに緑色または赤色へのアニメーションが開始されます。次に、Animatable は、コンテンツ値に対する他のオペレーション(snapToanimateDecay)を提供します。snapTo は、現在の値を直ちにターゲット値に設定します。これは、アニメーション自体が唯一の信頼できる情報源ではなく、タップイベントなどの他の状態と同期する必要がある場合に便利です。animateDecay は、指定された速度より遅くなるアニメーションを開始します。これは、フリング動作を実装する際に役立ちます。詳細については、操作とアニメーションをご覧ください。

Animatable ですぐに使用できるのは FloatColor のみですが、TwoWayConverter を指定することで、すべてのデータ型を使用できます。詳細については、AnimationVector をご覧ください。

AnimationSpec を指定することで、アニメーションの仕様をカスタマイズできます。詳細については、AnimationSpec をご覧ください。

updateTransition

Transition は、1 つ以上のアニメーションを子として管理し、複数の状態間で同時に実行します。

この状態はどのデータ型でもかまいません。多くの場合、次の例のようにカスタムの enum 型を使用することで型の安全性を確保できます。

private enum class BoxState {
    Collapsed,
    Expanded
}

updateTransitionTransition のインスタンスを作成して記憶し、その状態を更新します。

var currentState by remember { mutableStateOf(BoxState.Collapsed) }
val transition = updateTransition(currentState)

次に、animate* 拡張関数のいずれかを使用して、この遷移で子アニメーションを定義できます。各状態のターゲット値を指定します。これらの animate* 関数は、遷移状態が updateTransition で更新されると、アニメーション中にフレームごとに更新されるアニメーション値を返します。

val rect by transition.animateRect { state ->
    when (state) {
        BoxState.Collapsed -> Rect(0f, 0f, 100f, 100f)
        BoxState.Expanded -> Rect(100f, 100f, 300f, 300f)
    }
}
val borderWidth by transition.animateDp { state ->
    when (state) {
        BoxState.Collapsed -> 1.dp
        BoxState.Expanded -> 0.dp
    }
}

必要に応じて、transitionSpec パラメータを渡して、遷移状態の変更の各組み合わせごとに異なる AnimationSpec を指定できます。詳細については、AnimationSpec をご覧ください。

val color by transition.animateColor(
    transitionSpec = {
        when {
            BoxState.Expanded isTransitioningTo BoxState.Collapsed ->
                spring(stiffness = 50f)
            else ->
                tween(durationMillis = 500)
        }
    }
) { state ->
    when (state) {
        BoxState.Collapsed -> MaterialTheme.colors.primary
        BoxState.Expanded -> MaterialTheme.colors.background
    }
}

遷移がターゲット状態に到達すると、Transition.currentStateTransition.targetState と同じになります。これは、遷移が完了したかどうかのシグナルとして使用できます。

最初のターゲット状態とは異なる初期状態が必要になる場合があります。これを実現するには、updateTransitionMutableTransitionState とともに使用します。たとえば、コードがコンポジションに入り次第、アニメーションを開始できます。

// Start in collapsed state and immediately animate to expanded
var currentState = remember { MutableTransitionState(BoxState.Collapsed) }
currentState.targetState = BoxState.Expanded
val transition = updateTransition(currentState)
// ...

複数のコンポーズ可能な関数が関係する複雑な遷移の場合は、createChildTransition を使用して子遷移を作成できます。この手法を使うと、複雑なコンポーザブルで複数のサブコンポーネントに関心を分離できます。親遷移は、子遷移のすべてのアニメーション値を認識します。

enum class DialerState { DialerMinimized, NumberPad }

@Composable
fun DialerButton(isVisibleTransition: Transition<Boolean>) {
    // `isVisibleTransition` spares the need for the content to know
    // about other DialerStates. Instead, the content can focus on
    // animating the state change between visible and not visible.
}

@Composable
fun NumberPad(isVisibleTransition: Transition<Boolean>) {
    // `isVisibleTransition` spares the need for the content to know
    // about other DialerStates. Instead, the content can focus on
    // animating the state change between visible and not visible.
}

@Composable
fun Dialer(dialerState: DialerState) {
    val transition = updateTransition(dialerState)
    Box {
        // Creates separate child transitions of Boolean type for NumberPad
        // and DialerButton for any content animation between visible and
        // not visible
        NumberPad(
            transition.createChildTransition {
                it == DialerState.NumberPad
            }
        )
        DialerButton(
            transition.createChildTransition {
                it == DialerState.DialerMinimized
            }
        )
    }
}

AnimatedVisibility と AnimatedContent で遷移を使う

AnimatedVisibilityAnimatedContent は、Transition の拡張関数として使用できます。Transition.AnimatedVisibilityTransition.AnimatedContenttargetState は、Transition から派生し、TransitiontargetState が変更されたときに、必要に応じて enter / exit 遷移をトリガーします。これらの拡張関数により、AnimatedVisibility / AnimatedContent の内部に入っていたであろう enter / exit / sizeTransform アニメーションが、すべて Transition に取り込まれます。これらの拡張関数を使用すると、AnimatedVisibility / AnimatedContent の状態変化を外部から監視できます。このバージョンの AnimatedVisibility は、ブール値の visible パラメータではなく、親遷移のターゲット状態をブール値に変換するラムダを取ります。

詳細については、AnimatedVisibilityAnimatedContent をご覧ください。

var selected by remember { mutableStateOf(false) }
// Animates changes when `selected` is changed.
val transition = updateTransition(selected)
val borderColor by transition.animateColor { isSelected ->
    if (isSelected) Color.Magenta else Color.White
}
val elevation by transition.animateDp { isSelected ->
    if (isSelected) 10.dp else 2.dp
}
Surface(
    onClick = { selected = !selected },
    shape = RoundedCornerShape(8.dp),
    border = BorderStroke(2.dp, borderColor),
    elevation = elevation
) {
    Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
        Text(text = "Hello, world!")
        // AnimatedVisibility as a part of the transition.
        transition.AnimatedVisibility(
            visible = { targetSelected -> targetSelected },
            enter = expandVertically(),
            exit = shrinkVertically()
        ) {
            Text(text = "It is fine today.")
        }
        // AnimatedContent as a part of the transition.
        transition.AnimatedContent { targetState ->
            if (targetState) {
                Text(text = "Selected")
            } else {
                Icon(imageVector = Icons.Default.Phone, contentDescription = "Phone")
            }
        }
    }
}

遷移をカプセル化して再利用可能にする

シンプルなユースケースでは、UI と同じコンポーザブルで遷移アニメーションを定義することは、完全に有効な選択肢です。ただし、多数のアニメーション値を持つ複雑なコンポーネントを作成している場合は、アニメーション実装をコンポーザブル UI から分離することをおすすめします。

そのためには、すべてのアニメーション値を保持するクラスと、そのクラスのインスタンスを返す「update」関数を作成します。遷移の実装は、新しい別の関数に抽出できます。このパターンは、アニメーション ロジックを一元管理する必要がある場合や、複雑なアニメーションを再利用可能にする必要がある場合に役立ちます。

enum class BoxState { Collapsed, Expanded }

@Composable
fun AnimatingBox(boxState: BoxState) {
    val transitionData = updateTransitionData(boxState)
    // UI tree
    Box(
        modifier = Modifier
            .background(transitionData.color)
            .size(transitionData.size)
    )
}

// Holds the animation values.
private class TransitionData(
    color: State<Color>,
    size: State<Dp>
) {
    val color by color
    val size by size
}

// Create a Transition and return its animation values.
@Composable
private fun updateTransitionData(boxState: BoxState): TransitionData {
    val transition = updateTransition(boxState)
    val color = transition.animateColor { state ->
        when (state) {
            BoxState.Collapsed -> Color.Gray
            BoxState.Expanded -> Color.Red
        }
    }
    val size = transition.animateDp { state ->
        when (state) {
            BoxState.Collapsed -> 64.dp
            BoxState.Expanded -> 128.dp
        }
    }
    return remember(transition) { TransitionData(color, size) }
}

ツールのサポート

Android Studio は、Compose プレビューでの Transition の検査をサポートしています。

  • 遷移のフレーム単位のプレビュー
  • 遷移内の全アニメーションの値の検査
  • 初期状態とターゲット状態の間の遷移のプレビュー

アニメーション インスペクタを起動すると、インタラクティブ プレビューの下に [Animations] パネルが表示され、プレビューで遷移を実行して確認できます。遷移と各アニメーション値には、デフォルト名でラベルが付けられます。ラベルをカスタマイズするには、updateTransition 関数と animate* 関数で label パラメータを指定します。Compose のプレビューの詳細については、レイアウト プレビューをご覧ください。

rememberInfiniteTransition

InfiniteTransition は 1 つ以上の子アニメーション(Transition など)を保持します。ただし、それらのアニメーションはコンポジションに入るとすぐに開始し、削除されない限り停止しません。InfiniteTransition のインスタンスは rememberInfiniteTransition で作成できます。子アニメーションは、animateColoranimatedFloat、または animatedValue を使用して追加できます。また、アニメーションの仕様を指定するには、infiniteRepeatable を指定する必要があります。

val infiniteTransition = rememberInfiniteTransition()
val color by infiniteTransition.animateColor(
    initialValue = Color.Red,
    targetValue = Color.Green,
    animationSpec = infiniteRepeatable(
        animation = tween(1000, easing = LinearEasing),
        repeatMode = RepeatMode.Reverse
    )
)

Box(Modifier.fillMaxSize().background(color))

TargetBasedAnimation

TargetBasedAnimation は、ここまで説明してきた中で最も低いレベルの Animation API です。他の API はほとんどのユースケースに対応していますが、TargetBasedAnimation を直接使用すると、アニメーションの再生時間を自分で制御できます。以下の例では、TargetAnimation の再生時間は、withFrameMillis で提供されるフレーム時間に基づいて手動で制御されます。

val anim = remember {
    TargetBasedAnimation(
        animationSpec = tween(200),
        typeConverter = Float.VectorConverter,
        initialValue = 200f,
        targetValue = 1000f
    )
}
var playTime by remember { mutableStateOf(0L) }

LaunchedEffect(anim) {
    val startTime = withFrameNanos { it }

    do {
        playTime = withFrameNanos { it } - startTime
        val animationValue = anim.getValueFromNanos(playTime)
    } while (someCustomCondition())
}

アニメーションをカスタマイズする

アニメーション API の多くは、動作をカスタマイズするためのパラメータを受け入れます。

AnimationSpec

ほとんどのアニメーション API では、オプションの AnimationSpec パラメータでアニメーションの仕様をカスタマイズできます。

val alpha: Float by animateFloatAsState(
    targetValue = if (enabled) 1f else 0.5f,
    // Configure the animation duration and easing.
    animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
)

各種アニメーションの作成に対応した、さまざまなタイプの AnimationSpec があります。

spring

spring は、開始値と終了値の間で物理学ベースのアニメーションを作成します。dampingRatiostiffness の 2 つのパラメータを受け取ります。

dampingRatio は、ばねの弾性を定義します。デフォルト値は Spring.DampingRatioNoBouncy です。

異なる減衰率の動作を示すアニメーション画像

stiffness は、終了値までのばねの移動速度を定義します。デフォルト値は Spring.StiffnessMedium です。

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = spring(
        dampingRatio = Spring.DampingRatioHighBouncy,
        stiffness = Spring.StiffnessMedium
    )
)

継続時間ベースの AnimationSpec 型と比べて、spring は中断をより適切に処理できます。これは、spring の場合、アニメーション中にターゲット値が変更されたときに速度の継続性が保証されるためです。spring は、animate*AsStateupdateTransition など、多くのアニメーション API によってデフォルトの AnimationSpec として使用されます。

tween

tween は、イージング カーブを使用して、指定された durationMillis の開始値と終了値の間をアニメーション化します。詳細については、イージングをご覧ください。delayMillis を指定して、アニメーションの開始を延期することもできます。

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = tween(
        durationMillis = 300,
        delayMillis = 50,
        easing = LinearOutSlowInEasing
    )
)

keyframes

keyframes は、アニメーションの持続時間内の異なるタイムスタンプで指定されたスナップショット値に基づいてアニメーション化します。アニメーション値は、常に 2 つのキーフレーム値の間で補間されます。これらのキーフレームごとに、イージングを指定して補間曲線を設定できます。

0 ms と持続時間の値の指定はオプションです。これらの値を指定しない場合は、デフォルトでアニメーションの開始値と終了値が設定されます。

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = keyframes {
        durationMillis = 375
        0.0f at 0 with LinearOutSlowInEasing // for 0-15 ms
        0.2f at 15 with FastOutLinearInEasing // for 15-75 ms
        0.4f at 75 // ms
        0.4f at 225 // ms
    }
)

repeatable

repeatable は、指定された繰り返し回数に達するまで、持続時間ベースのアニメーション(tweenkeyframes など)を繰り返します。repeatMode パラメータを渡すと、アニメーションを繰り返す際に、最初から(RepeatMode.Restart)開始するか、最後から(RepeatMode.Reverse)開始するかを指定できます。

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = repeatable(
        iterations = 3,
        animation = tween(durationMillis = 300),
        repeatMode = RepeatMode.Reverse
    )
)

infiniteRepeatable

infiniteRepeatablerepeatable に似ていますが、繰り返し回数が無限である点が異なります。

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = tween(durationMillis = 300),
        repeatMode = RepeatMode.Reverse
    )
)

ComposeTestRule を使用したテストでは、infiniteRepeatable を使用したアニメーションは実行されません。各アニメーション値の初期値を使用してコンポーネントがレンダリングされます。

snap

snap は、値をすぐに終了値に切り替える特別な AnimationSpec です。delayMillis を指定して、アニメーションの開始を遅らせることができます。

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = snap(delayMillis = 50)
)

Easing

持続時間ベースの AnimationSpec オペレーション(tweenkeyframes など)では、Easing を使用してアニメーションの割合を調整します。これにより、アニメーション化する値の速度を変えながら動かすことができます。割合は、0(開始)から 1.0(終了)までの値で、アニメーションの現在の位置を示します。

Easing とは、実際には 0~1.0 の小数値を受け取り、浮動小数点数を返す関数です。戻り値は、オーバーシュートまたはアンダーシュートを表すために境界外となる場合もあります。カスタム Easing は、次のようなコードで作成できます。

val CustomEasing = Easing { fraction -> fraction * fraction }

@Composable
fun EasingUsage() {
    val value by animateFloatAsState(
        targetValue = 1f,
        animationSpec = tween(
            durationMillis = 300,
            easing = CustomEasing
        )
    )
    // … …
}

Compose には、ほとんどのユースケースに対応できる組み込みの Easing 関数がいくつか用意されています。シナリオに応じた Easing の使用については、速度 - マテリアル デザインをご覧ください。

  • FastOutSlowInEasing
  • LinearOutSlowInEasing
  • FastOutLinearEasing
  • LinearEasing
  • CubicBezierEasing

AnimationVector

ほとんどの Compose アニメーション API は、すぐに使えるアニメーション値として FloatColorDp などの基本的なデータ型をサポートしています。ただし、カスタムのデータ型など、他のデータ型のアニメーション化が必要な場合もあります。アニメーション中、アニメーション化する値は AnimationVector として表されます。値は、対応する TwoWayConverter によって AnimationVector に変換されます(逆も同様です)。これにより、コア アニメーション システムが値を均一に処理できるようになります。たとえば、Int は、単一の浮動小数点値を保持する AnimationVector1D として表されます。IntTwoWayConverter は次のようになります。

val IntToVector: TwoWayConverter<Int, AnimationVector1D> =
    TwoWayConverter({ AnimationVector1D(it.toFloat()) }, { it.value.toInt() })

Color は基本的に 4 つの値(赤、緑、青、アルファ)のセットであるため、Color は 4 つの浮動小数点値を保持する AnimationVector4D に変換されます。このように、アニメーションで使用されるデータ型はすべて、次元数に応じて AnimationVector1DAnimationVector2DAnimationVector3DAnimationVector4D のいずれかに変換されます。これにより、オブジェクトの異なるコンポーネントを別々にアニメーション化し、それぞれの速度をトラッキングすることができます。基本的なデータ型の組み込みコンバータには、Color.VectorConverterDp.VectorConverter などを使用してアクセスできます。

新しいデータ型のサポートをアニメーション化する値として追加する場合は、独自の TwoWayConverter を作成して API に指定できます。たとえば、次のように animateValueAsState を使用してカスタムのデータ型をアニメーション化できます。

data class MySize(val width: Dp, val height: Dp)

@Composable
fun MyAnimation(targetSize: MySize) {
    val animSize: MySize by animateValueAsState<MySize, AnimationVector2D>(
        targetSize,
        TwoWayConverter(
            convertToVector = { size: MySize ->
                // Extract a float value from each of the `Dp` fields.
                AnimationVector2D(size.width.value, size.height.value)
            },
            convertFromVector = { vector: AnimationVector2D ->
                MySize(vector.v1.dp, vector.v2.dp)
            }
        )
    )
}

操作とアニメーション(上級者向け)

タップイベントやアニメーションを使用する場合は、アニメーションだけを使用する場合と比べて、考慮すべき点がいくつかあります。まず、ユーザー操作を最優先する必要があるため、場合によっては、タップイベントの開始時に実行中のアニメーションを中断する必要があります。

以下の例では、Animatable を使用して円コンポーネントのオフセット位置を表しています。タップイベントは、pointerInput 修飾子で処理されます。新しいタップイベントを検出すると、animateTo を呼び出して、タップ位置までのオフセット値をアニメーション化します。アニメーション中にタップイベントが発生する場合もあります。その場合、animateTo は実行中のアニメーションを中断し、中断されたアニメーションの速度を維持しながら、新しいターゲット位置までのアニメーションを開始します。

@Composable
fun Gesture() {
    val offset = remember { Animatable(Offset(0f, 0f), Offset.VectorConverter) }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                coroutineScope {
                    while (true) {
                        // Detect a tap event and obtain its position.
                        val position = awaitPointerEventScope {
                            awaitFirstDown().position
                        }
                        launch {
                            // Animate to the tap position.
                            offset.animateTo(position)
                        }
                    }
                }
            }
    ) {
        Circle(modifier = Modifier.offset { offset.value.toIntOffset() })
    }
}

private fun Offset.toIntOffset() = IntOffset(x.roundToInt(), y.roundToInt())

もう 1 つのよくあるパターンでは、アニメーション値を、ドラッグなどのタップイベントから取得した値と同期させる必要があります。以下の例では、SwipeToDismiss コンポーザブルを使用するのではなく、Modifier として「スワイプで閉じる」を実装しています。要素の水平オフセットは、Animatable として表されます。この API には操作アニメーションで役立つ特性があります。この値は、タップイベントとアニメーションによって変更できます。タップイベントを受け取ると、進行中のすべてのアニメーションがインターセプトされるように、stop メソッドにより Animatable を停止します。

ドラッグ イベントでは、snapTo を使用して、タップイベントから計算された値で Animatable の値を更新します。フリングについては、Compose に用意されている VelocityTracker を使うと、ドラッグ イベントを記録して速度を計算できます。速度は animateDecay に直接送られ、フリング アニメーションに使用されます。オフセット値を元の位置に戻すには、animateTo メソッドでターゲット オフセット値を 0f に指定します。

fun Modifier.swipeToDismiss(
    onDismissed: () -> Unit
): Modifier = composed {
    val offsetX = remember { Animatable(0f) }
    pointerInput(Unit) {
        // Used to calculate fling decay.
        val decay = splineBasedDecay<Float>(this)
        // Use suspend functions for touch events and the Animatable.
        coroutineScope {
            while (true) {
                // Detect a touch down event.
                val pointerId = awaitPointerEventScope { awaitFirstDown().id }
                val velocityTracker = VelocityTracker()
                // Stop any ongoing animation.
                offsetX.stop()
                awaitPointerEventScope {
                    horizontalDrag(pointerId) { change ->
                        // Update the animation value with touch events.
                        launch {
                            offsetX.snapTo(
                                offsetX.value + change.positionChange().x
                            )
                        }
                        velocityTracker.addPosition(
                            change.uptimeMillis,
                            change.position
                        )
                    }
                }
                // No longer receiving touch events. Prepare the animation.
                val velocity = velocityTracker.calculateVelocity().x
                val targetOffsetX = decay.calculateTargetValue(
                    offsetX.value,
                    velocity
                )
                // The animation stops when it reaches the bounds.
                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat()
                )
                launch {
                    if (targetOffsetX.absoluteValue <= size.width) {
                        // Not enough velocity; Slide back.
                        offsetX.animateTo(
                            targetValue = 0f,
                            initialVelocity = velocity
                        )
                    } else {
                        // The element was swiped away.
                        offsetX.animateDecay(velocity, decay)
                        onDismissed()
                    }
                }
            }
        }
    }
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
}

テスト

Compose の ComposeTestRule を使用すると、決定論的な方法で、テストクロックを完全に制御しながらアニメーションのテストを作成できます。これにより、アニメーションの中間値を検証できます。また、テストでは、実際の持続時間よりも短い時間でアニメーションを実行できます。

ComposeTestRule は、テストクロックを mainClock として公開します。テストコードのクロックを制御するには、autoAdvance プロパティを false に設定します。テストするアニメーションを開始したら、クロックを advanceTimeBy で進めることができます。

advanceTimeBy は、指定された持続時間分だけ正確にクロックを進めるのではなく、フレーム間隔の倍数に最も近い持続時間に切り上げることに注意してください。

@get:Rule
val rule = createComposeRule()

@Test
fun testAnimationWithClock() {
    // Pause animations
    rule.mainClock.autoAdvance = false
    var enabled by mutableStateOf(false)
    rule.setContent {
        val color by animateColorAsState(
            targetValue = if (enabled) Color.Red else Color.Green,
            animationSpec = tween(durationMillis = 250)
        )
        Box(Modifier.size(64.dp).background(color))
    }

    // Initiate the animation.
    enabled = true

    // Let the animation proceed.
    rule.mainClock.advanceTimeBy(50L)

    // Compare the result with the image showing the expected result.
    rule.onRoot().captureToImage().assertAgainstGolden()
}

詳細

詳細については、Jetpack Compose アニメーション Codelab をご覧ください。