[Android] 021. アニメーション

とりあえず試してみたアニメーションの覚書

高レベルアニメーションAPI / 低レベルアニメーションAPI があります
https://developer.android.com/jetpack/compose/animation?hl=ja
API

・高レベルアニメーションAPI
AnimationContent
AnimatedVisibility
animateContentSize

・低レベルアニメーションAPI
animate*AsState
updateTransition
Animatable
AnimeState
Animetion

複数のイメージを入れ替えるアニメーション

俗に言うぱたぱたアニメーション
pixelart town様から青色スライムと緑髪女の子の画像をお借りしました
https://pixelarttown.net/terms/

画像はassetsにimgフォルダーを作成してそこに置く
assetsからBitmap読込むのは以前紹介した以下

/**
 * Assetsにあるイメージファイルを読込みBitmapを返す
 */
fun Context.assetsToBitmap(fileName: String): Bitmap? {
    return try {
        with(assets.open(fileName)) {
            BitmapFactory.decodeStream(this)
        }
    } catch (e: IOException) { null }
}

キャラクター全体を管理するinterface Characterを作成してrememberSaveableに対応する
コンストラクタでloadImageすると失敗するので別に呼ぶ形にした
画像リネームなど面倒でしたくなかったので不要なファイルも読込んでいます

画像表示するImageとアニメーションを無限に繰り返すためinfiniteTransitionにkeyframesを設定

import androidx.compose.foundation.Image

interface Character {
    val name: String
    val count: Int
    val animation: List<Int>
    val images: MutableList<Bitmap>
    val interval: Int
    val beforeDelay: Int
    val afterDelay: Int

    open fun loadImage(context: Context) {
        if (images.isNotEmpty()) return

        repeat(count) { count ->
            val bitmap = context.assetsToBitmap("img/${name}/${name}_${count + 1}.png")
            bitmap?.let {
                images.add(it)
            }
        }
    }

    @OptIn(ExperimentalAnimationApi::class)
    @Composable open fun drawCharacter(modifier: Modifier = Modifier) {
        val infiniteTransition = rememberInfiniteTransition()
        val anime = infiniteTransition.animateValue(
            initialValue = 0,
            targetValue = animation.count() - 1,
            typeConverter = Int.VectorConverter,
            animationSpec = infiniteRepeatable(
                animation = keyframes {
                    delayMillis = beforeDelay
                    durationMillis = (animation.count() + 1) * interval + afterDelay
                    repeat(animation.count()) {
                        it at (it + 1) * interval with LinearEasing
                    }
                },
                repeatMode = RepeatMode.Restart
            )
        )

        AnimatedContent(
            targetState = anime,
        ) { targetCount ->
            val index: Int = targetCount.value
            val bitmap = images[animation[index]]
            Image(
                bitmap = bitmap.asImageBitmap(),
                contentDescription = null,
                modifier = modifier,
                contentScale = ContentScale.Fit,
            )
        }
    }

    companion object {
        inline fun <reified T: Character> Server() = listSaver<T, Any>(
            save = { listOf(it.images) },
            restore = {
                val instance = Class.forName(T::class.qualifiedName!!).newInstance() as T
                @Suppress("UNCHECKED_CAST")
                instance.images.addAll(it[0] as List<Bitmap>)
                return@listSaver instance
            }
        )

        @Composable inline fun <reified T: Character> remember(context: Context) = rememberSaveable(saver = Server<T>()) {
            val instance = Class.forName(T::class.qualifiedName!!).newInstance() as T
            instance.loadImage(context)
            return@rememberSaveable instance
        }
    }
}

今回は青色スライムと緑髪女の子を描画したいのでSlimeクラスとGirlクラスを実装して
画像ファイル情報とアニメーションしたい画像のindexをセット(indexはファイルの番号-1なので注意)

class Slime: Character {
    override val name = "slime03_blue01"
    override val count = 23
    override val animation = listOf(0, 10, 11, 4, 5, 20, 19, 18)
    override val images: MutableList<Bitmap> = mutableListOf()
    override val interval = 100
    override val beforeDelay = 1500
    override val afterDelay = 0
}

class Girl: Character {
    override val name = "girl07_longhair02"
    override val count = 23
    override val animation = listOf(0, 6, 7, 8, 9, 10, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 15, 14, 13, 12, 11, 18, 19, 20)
    override val images: MutableList<Bitmap> = mutableListOf()
    override val interval = 100
    override val beforeDelay = 1500
    override val afterDelay = 1500
}

描画座標やサイズ変更をして描画

@Composable fun AppContent() {
    val context = LocalContext.current
    val slime = Character.remember<Slime>(context)
    val girl = Character.remember<Girl>(context)

    MaterialTheme {
        Surface(modifier = Modifier.fillMaxSize())  {
            BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
                with(LocalDensity.current) {
                    // 320 * 320
                    slime.drawCharacter(modifier = Modifier.offset(10f.toDp(), 10f.toDp()))
           slime.drawCharacter(modifier = Modifier.offset(340f.toDp(), 10f.toDp()))
                    girl.drawCharacter(modifier = Modifier.offset(170f.toDp(), 330f.toDp()).size(420f.toDp()))
                }
            }
        }
    }
}

移動と画像の反転を追加
graphicsLayerで色々できます
以下の動画が参考になりました
https://www.youtube.com/watch?v=u9CX9BqyOKU

AnimatedContentをネストしてアニメーションの追加もできます

interface Character {
    var reverse: Boolean
    ...

    @Composable open fun drawCharacter(modifier: Modifier = Modifier) {
        ...
        AnimatedContent(
            targetState = anime,
        ) { targetCount ->
            val index: Int = targetCount.value
            val bitmap = images[animation[index]]
            Image(
                bitmap = bitmap.asImageBitmap(),
                contentDescription = null,
                modifier = modifier.graphicsLayer {
                    translationX = (targetCount.value * 10).toFloat()
                    if (reverse) rotationY = 180f
                },
                contentScale = ContentScale.Fit,
            )
        } 
    }
}

ボタンでアニメーション開始

ボタンを押すと星が一回転(左右反転)するアニメーション
(アニメーション開始タイミングを任意にする)

Star自身をrememberするのでクラス内の処理はrememberつけないで実装(問題ないはず)
アニメーションを元に戻すためduration 0msで実行
finishedListenerでanimatingを元に戻す
(animatingはStateなので戻したタイミングでアニメーションが再度実行されます)

アイコンの種類は以下で確認
https://developer.android.com/jetpack/compose/graphics/images/material?hl=ja
https://fonts.google.com/icons?hl=ja

標準に無いアイコンを使う場合
リリース時はリソースを減らすためsvgを使う分だけダウンロードした方が良いそうです
androidx.compose.material:material-icons-extended

class Star {
    private var animating by mutableStateOf(false)

    fun animation() {
        animating = true
    }

    @Composable fun animateRotation(): Float {
        return animateFloatAsState(
            targetValue = if (animating) 180f else 0f,
            animationSpec = tween(durationMillis = if (animating) 500 else 0),
            finishedListener = { animating = false }
        ).value
    }

    @Composable fun draw() {
        val rotation = animateRotation()
        Icon(
            imageVector = Icons.Default.StarBorder,
            contentDescription = null,
            modifier = Modifier.size(100.dp).graphicsLayer {
                rotationY = rotation
            }
        )
    }
}

@Composable fun AppContent() {
    MaterialTheme {
        val star by remember { mutableStateOf(Star()) }

        Surface(modifier = Modifier.fillMaxSize()) {
            Column {
                Button(onClick = { star.animation() }) {
                    Text("Click!")
                }
                star.draw()
            }
        }
    }
}

*** メモ ***

・SVGをリソース(drawable)に追加する方法
Vector Asset Studioでファイルを取り込んでxmlに変換
https://developer.android.com/studio/write/vector-asset-studio?hl=ja

painterResourceで読み込む

・ImageのContentScale
https://developer.android.com/jetpack/compose/graphics/images/customize?hl=a


Android Studio Dolphin 2021.3.1 Patch 1 built on September 30, 2022