Androidのアニメーションオフ設定が地味だけど結構インパクトでかかった話（Composeでボイボ劇場も作ったよ）

# 導入

発覚など結構リアルに近いシナリオのボイボ劇場をComposeだけで作ってみましたのでもしよければご覧になってください！こちらの記事で同様のノリで書くと抵抗ある方もいると思ったため、こちらでは動画で触れていないコード部分について中心にいつも通り書いていきます。

<iframe width="560" height="315" src="https://www.youtube.com/embed/RIUkG3UxGKw?si=RMTyCscopapGH-oF" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>

## 意外と見落としがちな「ユーザー補助」

動画でも触れているのですが開発者オプションで昔からある、アニメーションスケールを一律オフにするかなり強めのオプションとして「アニメーションを無効化」する設定がトップから浅い位置に用意されており、こちらをオンにするとネイティブのアニメーション全般に影響を与えます。

## 何が問題なのか

大体のケースだと画面遷移や項目の移動など装飾的にアニメーションを付与することが多いと思うのですが、例えばカットインのような動きが重視されるようなビジュアルが一律カットされてしまうと例えば音声との同期が想定外の状態に陥ってしまったり、Composeのアニメーションの場合、coroutineを使って上から順番にアニメーションを記載すればその通りに実行されるため、例えば **ループ** を使って無限アニメーションを実現している場合、動画のようなシュールな不具合が発生してしまいます。

```kotlin:実際動画で示した影響受けるアニメーションのコード例
    var carOffsetX by remember { mutableStateOf(1f) }
    var carRotateZ by remember { mutableStateOf(0f) }
    LaunchedEffect(Unit) {
        // ずっと繰り返す
        while (true) {
            carRotateZ = -180f
            animate(-1f, 1f, animationSpec = tween(1000)) { v, _ ->
                carOffsetX = v
            }
            carRotateZ = 0f
            animate(0f, -1f, animationSpec = tween(1000)) { v, _ ->
                carOffsetX = v
            }
        }
    }

    Box(modifier = modifier.fillMaxSize()) {
        imageBitmaps.value["car.png"]?.value?.let {
            Image(
                painter = BitmapPainter(it),
                contentDescription = null,
                contentScale = ContentScale.Fit,
                modifier = Modifier
                    .align(Alignment.BottomCenter)
                    .graphicsLayer {
                        // translationX で横移動
                        translationX = carOffsetX * it.width
                        // 復路の向き調整
                        rotationY = carRotateZ
                    },
            )
        }
    }
```

## FlutterやWebView※は範囲外

こちらはプラットフォームやCSSの実装者(prefers-reduced-motionの対応)次第にはなるかと思いますが現状影響を受けないようです。さすがにWebページのアニメーション全てに干渉してしまうと影響が大きすぎる気がします。

## ネイティブだと回避が難しいのか

動画ではカットしてしまった部分になりますが、今回のComposeのアニメーションに関しては違う実装方法に変更したり正しいアプローチから対処することができます。

### durationScale が 0 になるということ

上記コードで使っている `animate` 関数の中を覗いてみることで直接的な原因と対処法を見出すことができます。ご興味があれば各自で調べてみて欲しいのですが 2023/12/01 現在の実装では animate 関数の中身は別の animate 関数を呼んだり `AnimationState` を作っていたりします、重要なのはこの AnimationState に対する animate 関数 (SuspendAnimation.kt) で、下記1行が重要です。

```kotlin
val durationScale = coroutineContext.durationScale
```

この durationScale こそがあの開発者オプションにあるオフ〜1x〜2xといった値を格納しており、 coroutineContext から取り出しています。ブレークポイントで止まるようにしながらOSの設定を変えると面白いです（反映にはアプリか少なくともActivityの再起動は必要そうでした、アニメーションの合間に反映されるケースも見たので要調査）。

### MotionDurationScale

上記 durationScale の型を調べるとこちらの MotionDurationScale にたどり着くことができます。自分はこれにたどり着くまで知らなかったのですが、 CoroutineContext には Map (辞書型) のように任意のキーと値を保持しておく機構が用意されていて( `CoroutineContext.Element` )、どうやら Compose
 側から用意される coroutineContext にはデフォルトでOSから取ってきた値の `MotionDurationScale` がセットされるようです（durationScaleのscaleFactorをFind Usage...してValue writeを見ると実際に渡ってくるコードが見れそうです :smile: ）。

### CoroutineContext の Element は新たに作るときに取り外したり追加ができる

こちらは割と知られていそうな情報なので軽くしか書かないのですが、対処法はとても！簡単で

```kotlin
coroutineContext.minusKey(MotionDurationScale)
```

とすれば既存の0の MotionDurationScale を取っ払うことができ、

```kotlin
// MotionDurationScaleの実装から拝借
private class MotionDurationScaleImpl : MotionDurationScale {
    override var scaleFactor by mutableStateOf(1f)
}

withContext(coroutineContext.minusKey(MotionDurationScale) + MotionDurationScaleImpl())  {
    // ぬるぬる動くぞ！
}
```

と組み合わせれば自作 context 内ではOSの設定に関係なくしっかりと動かすことができます！

## そもそも animate*AsState の方が良くね？

ボイボ劇場を作っている最中、不具合再現箇所と場面切り替えの Crossfade 以外は全部 animate\*AsState で実現できているはずです。理由としてはキャラの移動や回転など個別に長さや補完の関数を指定できるので使い勝手が良かったというのもありますが、今回の coroutine の実行時間が0になって凄まじい動きをしてしまうという問題も、delayを挟む animate*AsState ならカクついて見えますが発生しません。

# 最後に、なんとなく作ってしまったCompose版ボイボ劇場の応用・発展に期待

Qiitaの記事で「ずんだもん：『…』」みたいな文体で書くのはちょっとなぁと思って、思いつきで動画も作ろうとしていろんなことに手をつけた結果かなり遅刻気味になってしまったわけですが、最初からJetpack Composeのapk専用で作るつもりなら8時間程度×3日＋αあれば今回ぐらいの規模の動画は誰でも作れると思います！そして作ったことにより、動画ではなくアプリとして例えばクイズにしてみたり、AIと喋らせてみたりなどなど一方的な動画では実現できないインタラクティブなコンテンツの作成も実現できそうです。Google Playに溢れかえる時も近いのかも。

# おまけ: 今回実現したかったけどできなかったこと

- サーバーをローカル立ててボイボ連動して立ち絵やセリフの編集UIを提供、ZIPファイルで固めたらQRコードを生成してアプリで読み取ったらそのコンテンツを再生できる
  - Next.js である程度の管理画面は作れた
  - ZIPで固めてQR生成もできた
  - アプリ側も12/1の未明ぐらいにはJSONファイルを読み取って喋らせるところまではできた
  - が、まだまだセリフの設定やVoicevox APIも碌に叩けておらずまだまだ完成には時間を要すると判断（遅い）
  - せっかく時間ができたので、より汎用的な仕組みを作って生産性の高いアプリが作れるようにできたら最高
- SwiftUI版、Web版、Windows版なども作ってみたい！
- Voicevox以外も使ってみる

せっかく作ったので一旦貼っておきますw プレーヤーも中途半端なんで公開しても全然実用的ではないのも中途半端で残念…

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282588/818b0d02-24b1-bd29-c59a-5f028189ebdd.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282588/b626d8fa-6b28-9c60-596e-cf21ce2db764.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282588/a3036011-6f49-a117-963b-960bf02d2787.png)

:arrow_up: 実際にメディアファイルをダウンロードできる、未経験の Next 13 のサーバーサイドコンポーネントと fs の組み合わせが一番しんどかったかもw