はじめに
composeの1.0.0安定版が出て一年と少し経ちましたが、
まだ本格的にcomposeを触れていないという方も多いのではないかと思います。
そこで、本記事ではAndroid開発の経験がある方向けに、
composeの主要な機能の一つであるアニメーションについて解説していきます。
なお、本記事では jetpack compose version: 1.2.0
の情報を使用しています。
composeのアニメーションとは
Jetpackライブラリの一つであるJetpack Composeを構成するパッケージの一つです。
compose.animation
としてcomposeのuiなどとは別で提供されています。
パッケージ | 内容 |
---|---|
compose.animation | アニメーションAPIを提供 (本記事で扱うパッケージ) |
compose.material | マテリアルデザイン2に対応したコンポーネントを提供 |
compose.material3 | マテリアルデザイン3に対応したコンポーネントを提供(Material Youなど含む) |
compose.foundation | Scaffoldなどのui構築の基盤を提供 |
compose.ui | 描画、入力などのuiの基本的なコンポーネントを提供 |
compose.runtime | ComposeCompilerのコアランタイム |
compose.compiler |
@Composable 関数をkotlinコンパイラプラグインを使って変換する |
composeは上図のように7つのパッケージから構成されています。
そして、compose.animationはその中の一つです。
composeのパッケージはそれぞれ依存関係が存在しますが、compose.animationはその中でも上位レイヤに位置するものです。
構成
composeのアニメーションAPIは、
簡単にuiにアニメーションを付できる高レベルアニメーションAPI
と、
より柔軟にアニメーションをカスタム可能な低レベルアニメーションAPI
の2つから構成されています。
高レベルアニメーションAPI
はマテリアルデザインのアニメーションに合わせてカスタマイズされています。
また、低レベルアニメーションAPI
を基盤に構築されており、低レベルアニメーションAPI
のラッパーとも言えます。
特徴
開発元のgoogleはゼロから構築した新しいアニメーションAPIであると表現しています。
具体的に以下のような特徴があります
- コルーチンベースのAPIであるため中断可能である
- アニメーションを宣言的に記述できる
- APIの多くがcomposableな関数として提供される
- IDEのanimation previewでアニメーション実装をサポート
-
高レベルアニメーションAPI
を使用することで手軽にアニメーションを付与できる
特にコルーチンベースでアニメーションが作られていることが大きな特徴であり、とてもUIにアニメーションを組み込みやすくなっています。
具体的なユースケースとしては、ライフサイクルに合わせてアニメーションを自動的にキャンセルし、必要ならばアニメーションを自動的に再開するなどが挙げられます。
高レベルアニメーションAPI
ここからは、composeのアニメーションAPIからいくつかピックアップして、その使い方と特徴を紹介していきます。
まず、簡単にuiをアニメーションすることができる高レベルアニメーションAPI
について紹介します。
AnimatedVisibility
AnimatedVisibility
を使用することで、UIの表示、非表示切り替えを簡単にアニメーション化させることができます。
var isExtended by remember { mutableStateOf(false) }
FloatingActionButton(onClick = { isExtended = !isExtended }) {
Row {
// 表示非表示切り替えをアニメーション化する
AnimatedVisibility(isExtended) {
Text(text = "Add", Modifier.padding(start = 8.dp, end = 12.dp))
}
Icon(
Icons.Default.Add,
contentDescription = null,
Modifier.padding(start = 8.dp end = 12.dp)
)
}
}
表示非表示を切り替える場合はif分で分岐させることが多いですが、その条件文をAnimatedVisibility
に変更するだけで使用できます。
- if(isExtended) {
+ AnimatedVisibility(isExtended) {
Text(text = "Add", Modifier.padding(end = 12.dp, start = 8.dp))
...
デフォルトでは、アニメーションはフェードイン、フェードアウトですが、引数にTransitionを指定することでカスタムすることもできます。
AnimatedVisibility(isExtended, enter = scaleIn(), exit = scaleOut()) {
...
animateContentSize
animatedContentSize()
はサイズの変更をアニメーション化することができます。
Card(
modifier = Modifier
.width(200.dp)
.animateContentSize() // 子のサイズ変更に応じてサイズ変更をアニメーション化する
.clickable {
isExpanded = !isExpanded
}
) {
Column {
AsyncImage(
model = url,
contentDescription = null
)
Text("title")
if (isExpanded) {
Text("content here.")
}
}
}
animatedContentSize()
はpadding()
などと同じようにmodifier
の拡張関数として提供されています。
そのため非常に扱いやすいです。
よくあるcollapsingパネルなどを簡単にアニメーション化させることができます。
AnimatedContent
AnimatedContent
は、上記二つのanimatedVisibility
, animateContentSize
を合わせたようなアニメーションを作ることができます。
また、任意の状態の変化に応じてuiをアニメーション化させることができるため、他の高レベルアニメーションAPIよりも柔軟性が高いです。
AnimatedContent(
targetState = count, // countの変更に応じてアニメーション
transitionSpec = { scaleIn() with scaleOut() }
) { targetState ->
Box(
Modifier
.background(Color.White)
.size(100.dp)
.background(colors[targetState])
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = "count: $targetState"
)
}
}
アニメーションのターゲットとなる値をtargetState
として引数に入れて使用します。
このtargetState
はラムダの引数で更新された値を取得できるのでuiではそれを使用して描画します。
また、transitionSpec
の引数を使用することでアニメーションをカスタムすることも可能です。
拡張関数のwith
を使用して開始時と終了時のアニメーションを指定できます。
transitionSpec = { enterTransition(開始時) with exitTransition(終了時) }
transitionはoperator funで定義されている演算子+
を使用することで簡単に組み合わせることができます
transitionSpec = { scaleIn() + fadeIn() with scaleOut() + fadeOut() }
低レベルアニメーションAPI
次は低レベルアニメーションAPI
について紹介します。
上で紹介した高レベルアニメーションAPI
は、これから説明する低レベルアニメーションAPI
から構成されており、ただのラッパーになっているようなAPIも存在します。
animate*AsState
animate*AsState
は任意の単一の値の変化をアニメーション化させることができます。
*
の部分にはAPIで定義されている型が入ります。ここで紹介する例では、Float
になっています。
val scale: Float by animateFloatAsState(if (isExpanded) 2f else 1f)
Box(
Modifier
.scale(scale)
.background(Color.Magenta)
.padding(32.dp)
)
animateFloatAsState
の引数にアニメーションさせたい値の開始時と終了時の値を入れれることで直感的にアニメーションを記述できます。
この例では、Boxのscaleを1倍 -> 2倍へと変更するアニメーションを作成しています。
アニメーションする値として他に提供されているものは、Color
, Offset
, Dp
などがあります。
animate*AsState
で作成した値をmodifierなどでそのまま装飾するだけで、アニメーション化させることが出来るので非常に使い勝手がいいです。
updateTransition
updateTransition
は任意の複数の値の変化を同時にアニメーション化させることができます。
animate*AsState
では、単一の値の変化のアニメーションでしたが、updateTransition
は複数の値の変化をアニメーション化することができます。
val transition = updateTransition(targetState = isExpanded, label = "animation")
val scale: Float by transition.animateFloat(label = "scale") { isExpanded ->
if (isExpanded) 2f else 1f
}
val color by transition.animateColor(label = "color") { isExpanded ->
if (isExpanded) Color.Cyan else Color.Magenta
}
Box(
Modifier
.scale(scale)
.background(color)
.padding(32.dp)
)
実行中のアニメーションや状態を管理するTransition
を作成して、animate*
関数を呼び出してアニメーション化させます。
この例では、先ほどのanimate*AsState
でサイズの値のみのアニメーションだったものを、updateTransition
を使ってサイズとカラーの2つの値のアニメーション化させています。
おそらく大抵のアニメーションはこのupdateTransition
を使用すれば実現できると思います。
Animatable
Animatable
はこれまで紹介してきたAPIでは実現できない複雑な制御が必要とされるアニメーションを実装する場合に使用します。
Animatable
を使うことで、アニメーションの速度と値をリアルタイムにトラックし、実行中のアニメーションがキャンセルされた時、新しいアニメーションをキャンセル時の状態から開始することができます。
ここでのキャンセルとは実行中のアニメーションが存在する場合に、新しいアニメーションを開始しようとすると実行中のアニメーションがキャンセルされるという意味です。
読んでもピンとこないと思いますので、実際にupdateTransition
とAnimatable
のスケールとカラーをアニメーション化した時のキャンセル時の違いを紹介します。
val animatedScale = remember { Animatable(1f) }
val animatedColor = remember { Animatable(Color.Magenta) }
LaunchedEffect(isExpanded) {
// アニメーションの終了値
val targetScale = if (isExpanded) 2f else 1f
val targetColor = if (isExpanded) Color.Cyan else Color.Magenta
// スケールとカラーの2つのアニメーションを同時に実行するために、launch関数で別々にコルーチンを起動する
launch {
animatedScale.animateTo(targetScale, animationSpec = tween(2000)) // tweenでduration:2秒間
}
launch {
animatedColor.animateTo(targetColor, animationSpec = tween(2000))
}
}
Box(
Modifier
.scale(animatedScale.value)
.background(animatedColor.value)
.size(120.dp)
)
// updateTransitionの方のコードは上で紹介したものとほぼ変わらないので省略
Animatable
にアニメーション化する値を入れて、animateTo(ターゲットの値)
を呼ぶことでアニメーションを実行します。
animateTo(ターゲットの値)
関数はsuspend関数のためコルーチンスコープを用意する必要があります。
デフォルトで対応しているAnimatable
に入れるアニメーション化する値は、Float
, Color
だけですが、VectorConverter
を使用することで他の値も使用できます。
キャンセル時について
アニメーションを実行中の状態で、ボタンをクリックするとアニメーションがキャンセルされて新しいターゲット値に向かってアニメーションされていることがgifからわかると思います。ここで、updateTransition
とAnimatable
で動作に違いが存在します。
-
updateTransition
では、アニメーションのターゲット値が変更されると、実行中のアニメーションはキャンセルされずにそのまま変更されたターゲット値に向かうようにアニメーションが自動で調整されます。そのためtween(2000)
の動きを無視して新しいターゲット値に向かってアニメーションが自動で実行されています。 -
Animatable
では、実行中のアニメーションが存在する場合に新しくanimateTo
でアニメーションを開始すると、コルーチンのCancellationException
がスローされ実行中のアニメーションがキャンセルされます。そのキャンセル時のアニメーションの速度や状態をAnimatable
が保持しており、新しいアニメーションはその値を開始時のアニメーション状態として、新しいターゲット値に向かってアニメーションが実行されます。
このようにAnimatable
を使用することで、キャンセル時の制御を細かく行うことができ、アニメーションのターゲット値を柔軟に変更しuiをそれに追従させることができます。
このAPIは少し難しいので公式ドキュメントで紹介されていた使い方も載せておきます。
コード: https://developer.android.com/reference/kotlin/androidx/compose/animation/core/Animatable
タップした位置にoffsetでboxを移動させるアニメーションです。
移動中に新しいタップ入力が来たときにそれをキャンセルし、新しいタップ位置にアニメーションさせています
composeで実現できないこと
composeのアニメーションの魅力だけ伝えてきましたが、現状まだ実現できないアニメーションも存在しています。
具体的には以下のアニメーションなどが挙げられます
- MotionLayout
- Shared Element Transition
- Navigation Animation (accompanistに入っているが少し不安定なのと、compose.animationに統合され削除される予定で提供されている)
ただ、これらの未対応アニメーションはgoogleが今後対応していきたいと言っているので、対応されるのも時間の問題と思われます。
おまけ
最後にcomposeのアニメーションで遊びで作ったものを紹介します
-
rememberInfiniteTransition
+ グラデーション
簡単にshimmer loadingを作ってみました。
rememberInfiniteTransition
は使い方的にはupdateTransition
に似ている低レベルアニメーションAPIです。
無限に繰り返すアニメーションを実装することができます。
-
Animatable
+Canvas
Animatable
を使ってウマ娘のアニメーションを作ってみました(kawaii)
composeでもcanvasが使用できるので柔軟に作りたい形をドローイングすることができます。
素材: https://www.youtube.com/watch?v=DE0rfFfd8fY
参考
公式ドキュメント: https://developer.android.com/jetpack/compose/animation
コードラボ: https://developer.android.com/codelabs/jetpack-compose-animation