実践！ ComposeでLottieアニメーション内のパーツの色を動的に変更する。テーマにも対応する。

Lottie はデザイナーさんがオーサリングツールで作成したアニメーションを埋め込むことができるので非常に便利ですよね。

Android Developer 公式の Compose のアニメーションの選択のフローチャートでも、複数の要素で構成された複雑なイラストのアニメーションについては Lottie のようなフレームワークを使うことが提案されていて、実際のところこの手のライブラリとしては第一の選択肢として挙がるのではないでしょうか:

![Choose an animation API の Lottie の例](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/f1d3bae4-3ce1-4c4c-9ba8-5f000c49b50d.png)

https://developer.android.com/develop/ui/compose/animation/choose-api

ところで、ツールで作られたアニメーションについて、一部を動的に変更したくなることはないでしょうか。

たとえば、Theme にあわせて色を変更したいというのは真っ先におもいつくことかと思います。Light / Dark の切り替えもそうですし、デザインシステムに調和するように色をあわせたいことはよくあるユースケースかと思います。

今回はサンプルのアニメーションを使って、アニメーション内の色をあわせる方法を紹介します。

## サンプル

まず、サンプルのアニメーションを用意します。LottieFiles の Lottie Creator で適当なアニメーションを作成してみました:

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/e3f504c8-d680-4c4e-9c38-e46f31eb41bd.png)

これを素直に再生するとこうなります:

```kotlin
@Composable
fun MyAnimation(modifier: Modifier = Modifier) {
    val composition by rememberLottieComposition(
        spec = LottieCompositionSpec.RawRes(R.raw.my_animation),
    )
    LottieAnimation(
        composition = composition,
        modifier = modifier,
        iterations = LottieConstants.IterateForever,
    )
}
```

![Apr-01-2025 20-45-53.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/bfb39187-7cba-43a4-8b58-57a4b6570d0d.gif)


## 色をつける

このアニメーションでたとえばチェックマークの丸の背景色を動的に変更したいとします。
その場合は **Dynamic Property** を使ってこう指定します:

```diff_kotlin
     composition = composition,
     modifier = modifier,
     iterations = LottieConstants.IterateForever,
+    dynamicProperties = rememberLottieDynamicProperties(
+        rememberLottieDynamicProperty(
+            property = LottieProperty.COLOR,
+            value = Color.Red.toArgb(),
+            keyPath = arrayOf("Icon Asset", "Background", "Background", "Fill"),
+        ),
+    ),
 )
```

この変更により、丸の色が赤に変化しました:

![背景色を変更したときのイメージ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/8ad66fba-1b89-48b7-8799-fd0a0a203558.gif)

Dynamic Property については公式のドキュメントで解説されています:

https://airbnb.io/lottie/#/android-compose?id=dynamic-properties

この `dynamicProperties` で目的のオブジェクトまでを **KeyPath** で指定し、その値を変更することで値を動的に変更できます。今回でいうと `Icon Asset` > `Background` > `Background` > `Fill` という階層の KeyPath を指定しています。 (KeyPath の決め方は後述します)

同様に、円周を動くプログレスの色を変更するとします。これは種別としてはフィルではなくストロークなので、プロパティの種別を `LottieProperty.COLOR` ではなく `LottieProperty.STROKE_COLOR` を指定する必要があるでしょう:

```kotlin
rememberLottieDynamicProperty(
    property = LottieProperty.STROKE_COLOR,
    value = Color.Red.toArgb(),
    keyPath = arrayOf("Progress Track", "Stroke"),
),
```

![Apr-03-2025 14-05-24.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/9148037a-c819-4016-9c4b-5f6537d19b07.gif)

最後にまだ青いままのリップルを同様に指定して完成です:

```kotlin
dynamicProperties = rememberLottieDynamicProperties(
    rememberLottieDynamicProperty(
        property = LottieProperty.COLOR,
        value = Color.Red.toArgb(),
        keyPath = arrayOf("Icon Asset", "Background", "Background", "Fill"),
    ),
    rememberLottieDynamicProperty(
        property = LottieProperty.STROKE_COLOR,
        value = Color.Red.toArgb(),
        keyPath = arrayOf("Progress Track", "Stroke"),
    ),
    rememberLottieDynamicProperty(
        property = LottieProperty.COLOR,
        value = Color.Red.toArgb(),
        keyPath = arrayOf("Ripple", "Fill"),
    ),
),
```

![Apr-04-2025 13-36-54.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/d1a294d2-5032-4cd1-a028-3d61a5880a74.gif)

## Theme との調和

アプリの Theme の色を指定することで、Light/Dark にも柔軟に対応できますし、調和をとることもできそうです:

| Light | Dark |
|-------|------|
| ![Apr-04-2025 13-43-54.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/9990d705-fe8a-4759-9b5f-3a9410e964f2.gif) | ![Apr-04-2025 13-43-14.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/55689146-1956-4b6d-89d5-04eadb12c53e.gif) |

```kotlin
dynamicProperties = rememberLottieDynamicProperties(
    rememberLottieDynamicProperty(
        property = LottieProperty.COLOR,
        value = MaterialTheme.colorScheme.primary.toArgb(),
        keyPath = arrayOf("Icon Asset", "Background", "Background", "Fill"),
    ),
    rememberLottieDynamicProperty(
        property = LottieProperty.COLOR,
        value = MaterialTheme.colorScheme.onPrimary.toArgb(),
        keyPath = arrayOf("Icon Asset", "Icon", "Icon", "Fill"),
    ),
    rememberLottieDynamicProperty(
        property = LottieProperty.STROKE_COLOR,
        value = MaterialTheme.colorScheme.primary.toArgb(),
        keyPath = arrayOf("Progress Track", "Stroke"),
    ),
    rememberLottieDynamicProperty(
        property = LottieProperty.COLOR,
        value = MaterialTheme.colorScheme.primary.toArgb(),
        keyPath = arrayOf("Ripple", "Fill"),
    ),
),
```

そういえば、LottieFiles にはテーマがあって、これを使うと JSON 内で `slot` に定義したトークンが使えそうで、これを変更できるようになるとよりやりやすそうなのですが……調べた範囲では方法が見つかりませんでした。

https://lottiefiles.com/blog/working-with-lottie-animations/how-to-add-themes-to-lottie-animations-in-lottie-creator

![Theme Manager](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/f8e43dc0-b590-4b49-b980-61b845f62ea3.png)


## 対象の KeyPath を知る方法

さて、色を変更する方法がわかったところで、肝心の KeyPath をどうやって見つけるかというところが明らかじゃないと思います。

これが結構大変でオーサリングツールのレイヤーの構造と一致しているかといわれるとそうでない場合もあるので、なかなか理解に苦しむかと思います。

実際、今回のアニメーションは LottieFiles で作成して構造は以下ですが、なかなかここからパッと想像はしがたいのかなと思います:

![レイヤー](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/cd70934a-adcd-4889-9786-783395f6cc1b.png)

今回のケースを例にして説明します。チェックマークの背景の丸のフィルの部分までの Key Path をみつけましょう。

JSON を開いて `nm` をキーを辿っていくと確実なのですが…とはいえ JSON の中身を追っていくのも大変です…

<details>
<summary>今回のアニメーションの JSON</summary>

```json:my_animation.json
{
  "nm": "Main Scene",
  "ddd": 0,
  "h": 500,
  "w": 500,
  "meta": { "g": "@lottiefiles/creator 1.45.0" },
  "layers": [
    {
      "ty": 0,
      "nm": "Icon Asset",
      "sr": 1,
      "st": 48,
      "op": 90,
      "ip": 48,
      "hd": false,
      "ddd": 0,
      "bm": 0,
      "hasMask": false,
      "ao": 0,
      "ks": {
        "a": { "a": 0, "k": [250, 250] },
        "s": {
          "a": 1,
          "k": [
            {
              "o": { "x": 0.65, "y": 0 },
              "i": { "x": 0.36, "y": 1 },
              "s": [74.422, 74.422],
              "t": 48
            },
            {
              "o": { "x": 0.65, "y": 0 },
              "i": { "x": 0.36, "y": 1 },
              "s": [200.309, 200.309],
              "t": 56
            },
            {
              "o": { "x": 0.65, "y": 0 },
              "i": { "x": 0.36, "y": 1 },
              "s": [143.0236, 143.0236],
              "t": 63
            },
            { "s": [148.7659, 148.7659], "t": 68 }
          ]
        },
        "sk": { "a": 0, "k": 0 },
        "p": { "a": 0, "k": [250, 250] },
        "r": { "a": 0, "k": 0 },
        "sa": { "a": 0, "k": 0 },
        "o": {
          "a": 1,
          "k": [
            {
              "o": { "x": 0.65, "y": 0 },
              "i": { "x": 0.36, "y": 1 },
              "s": [0],
              "t": 48
            },
            { "s": [100], "t": 56 }
          ]
        }
      },
      "w": 500,
      "h": 500,
      "refId": "precomp_newScene_d3dd3211-476a-4493-aaa1-08613e963887_fd99ed59-2b14-42b6-894e-407e605fe6cc",
      "ind": 1
    },
    {
      "ty": 4,
      "nm": "Ripple",
      "sr": 1,
      "st": 48,
      "op": 90,
      "ip": 48,
      "hd": false,
      "ddd": 0,
      "bm": 0,
      "hasMask": false,
      "ao": 0,
      "ks": {
        "a": { "a": 0, "k": [0, 0] },
        "s": {
          "a": 1,
          "k": [
            {
              "o": { "x": 0.65, "y": 0 },
              "i": { "x": 0.36, "y": 1 },
              "s": [102.1023, 102.1023],
              "t": 63
            },
            { "s": [165.2173, 165.2173], "t": 90 }
          ]
        },
        "sk": { "a": 0, "k": 0 },
        "p": { "a": 0, "k": [250, 250] },
        "r": { "a": 0, "k": -360 },
        "sa": { "a": 0, "k": 0 },
        "o": {
          "a": 1,
          "k": [
            {
              "o": { "x": 0.65, "y": 0 },
              "i": { "x": 0.36, "y": 1 },
              "s": [0],
              "t": 56
            },
            {
              "o": { "x": 0.65, "y": 0 },
              "i": { "x": 0.36, "y": 1 },
              "s": [80],
              "t": 63
            },
            { "s": [1], "t": 90 }
          ]
        }
      },
      "shapes": [
        {
          "ty": "el",
          "bm": 0,
          "hd": false,
          "nm": "Ellipse Path 1",
          "d": 1,
          "p": { "a": 0, "k": [0, 0] },
          "s": { "a": 0, "k": [296, 296] }
        },
        {
          "ty": "fl",
          "bm": 0,
          "hd": false,
          "nm": "Fill",
          "c": { "a": 0, "k": [0.2275, 0.5255, 1] },
          "r": 2,
          "o": { "a": 0, "k": 100 }
        }
      ],
      "ind": 2
    },
    {
      "ty": 4,
      "nm": "Progress Track",
      "sr": 1,
      "st": 0,
      "op": 150,
      "ip": 0,
      "hd": false,
      "ddd": 0,
      "bm": 0,
      "hasMask": false,
      "ao": 0,
      "ks": {
        "a": { "a": 0, "k": [0, 0] },
        "s": {
          "a": 1,
          "k": [
            {
              "o": { "x": 0.65, "y": 0 },
              "i": { "x": 0.36, "y": 1 },
              "s": [100, 100],
              "t": 48
            },
            {
              "o": { "x": 0.65, "y": 0 },
              "i": { "x": 0.36, "y": 1 },
              "s": [104.911, 104.911],
              "t": 50
            },
            { "s": [90.3795, 90.3795], "t": 61 }
          ]
        },
        "sk": { "a": 0, "k": 0 },
        "p": { "a": 0, "k": [250, 250] },
        "r": { "a": 0, "k": 0 },
        "sa": { "a": 0, "k": 0 },
        "o": { "a": 0, "k": 100 }
      },
      "shapes": [
        {
          "ty": "sh",
          "bm": 0,
          "hd": false,
          "nm": "Ellipse Path 1",
          "d": 1,
          "ks": {
            "a": 0,
            "k": {
              "c": true,
              "i": [
                [-86.92424999999999, 0],
                [0, -86.92424999999999],
                [86.92424999999999, 0],
                [0, 86.92424999999999]
              ],
              "o": [
                [86.92424999999999, 0],
                [0, 86.92424999999999],
                [-86.92424999999999, 0],
                [0, -86.92424999999999]
              ],
              "v": [
                [0, -157.5],
                [157.5, 0],
                [0, 157.5],
                [-157.5, 0]
              ]
            }
          }
        },
        {
          "ty": "st",
          "bm": 0,
          "hd": false,
          "nm": "Stroke",
          "lc": 2,
          "lj": 2,
          "ml": 1,
          "o": { "a": 0, "k": 100 },
          "w": { "a": 0, "k": 10 },
          "c": {
            "a": 1,
            "k": [
              {
                "o": { "x": 0.65, "y": 0 },
                "i": { "x": 0.36, "y": 1 },
                "s": [0, 0.7765, 1],
                "t": 48
              },
              { "s": [0.2275, 0.5255, 1], "t": 61 }
            ]
          }
        },
        {
          "ty": "tm",
          "bm": 0,
          "hd": false,
          "nm": "Trim Path",
          "e": {
            "a": 1,
            "k": [
              {
                "o": { "x": 0.65, "y": 0 },
                "i": { "x": 0.36, "y": 1 },
                "s": [0],
                "t": 0
              },
              { "s": [100], "t": 48 }
            ]
          },
          "o": { "a": 0, "k": 0 },
          "s": { "a": 0, "k": 0 },
          "m": 1
        }
      ],
      "ind": 3
    }
  ],
  "v": "5.7.0",
  "fr": 30,
  "op": 90,
  "ip": 0,
  "assets": [
    {
      "nm": "Icon Asset",
      "id": "precomp_newScene_d3dd3211-476a-4493-aaa1-08613e963887_fd99ed59-2b14-42b6-894e-407e605fe6cc",
      "layers": [
        {
          "ty": 4,
          "nm": "Icon",
          "sr": 1,
          "st": 0,
          "op": 90,
          "ip": 0,
          "hd": false,
          "ddd": 0,
          "bm": 0,
          "hasMask": false,
          "ao": 0,
          "ks": {
            "a": { "a": 0, "k": [83.00074768066405, 86.04505157470705] },
            "s": { "a": 0, "k": [124.2456, 124.2456] },
            "sk": { "a": 0, "k": 0 },
            "p": { "a": 0, "k": [250.00092896032712, 253.7833425993042] },
            "r": { "a": 0, "k": 0 },
            "sa": { "a": 0, "k": 0 },
            "o": { "a": 0, "k": 100 }
          },
          "shapes": [
            {
              "ty": "gr",
              "bm": 0,
              "hd": false,
              "nm": "Icon",
              "it": [
                {
                  "ty": "sh",
                  "bm": 0,
                  "hd": false,
                  "nm": "Path 1",
                  "d": 1,
                  "ks": {
                    "a": 0,
                    "k": {
                      "c": true,
                      "i": [
                        [0, 0],
                        [0, 0],
                        [0, 0],
                        [0, 0],
                        [0, 0],
                        [0, 0],
                        [0, 0]
                      ],
                      "o": [
                        [0, 0],
                        [0, 0],
                        [0, 0],
                        [0, 0],
                        [0, 0],
                        [0, 0],
                        [0, 0]
                      ],
                      "v": [
                        [31.2285, 89.0905],
                        [67.7751, 125.635],
                        [134.773, 58.6362],
                        [122.592, 46.4551],
                        [67.7751, 101.273],
                        [43.4096, 76.9094],
                        [31.2285, 89.0905]
                      ]
                    }
                  }
                },
                {
                  "ty": "fl",
                  "bm": 0,
                  "hd": false,
                  "nm": "Fill",
                  "c": { "a": 0, "k": [1, 1, 1] },
                  "r": 1,
                  "o": { "a": 0, "k": 100 }
                },
                {
                  "ty": "tr",
                  "a": { "a": 0, "k": [82.99999999999999, 86.21942990335272] },
                  "s": { "a": 0, "k": [100, 100] },
                  "sk": { "a": 0, "k": 0 },
                  "p": { "a": 0, "k": [82.99999999999999, 86.21942990335272] },
                  "r": { "a": 0, "k": 0 },
                  "sa": { "a": 0, "k": 0 },
                  "o": { "a": 0, "k": 100 }
                }
              ]
            }
          ],
          "ind": 1
        },
        {
          "ty": 4,
          "nm": "Background",
          "sr": 1,
          "st": 0,
          "op": 90,
          "ip": 0,
          "hd": false,
          "ddd": 0,
          "bm": 0,
          "hasMask": false,
          "ao": 0,
          "ks": {
            "a": { "a": 0, "k": [82.99999999999999, 82.99999999999999] },
            "s": { "a": 0, "k": [124.2456, 124.2456] },
            "sk": { "a": 0, "k": 0 },
            "p": { "a": 0, "k": [249.99999999999997, 249.99999999999997] },
            "r": { "a": 0, "k": 0 },
            "sa": { "a": 0, "k": 0 },
            "o": { "a": 0, "k": 100 }
          },
          "shapes": [
            {
              "ty": "gr",
              "bm": 0,
              "hd": false,
              "nm": "Background",
              "it": [
                {
                  "ty": "sh",
                  "bm": 0,
                  "hd": false,
                  "nm": "Path 2",
                  "d": 1,
                  "ks": {
                    "a": 0,
                    "k": {
                      "c": true,
                      "i": [
                        [0, 0],
                        [-45.8376, 0],
                        [0, -45.8392],
                        [45.84299999999999, 0],
                        [0, 45.839]
                      ],
                      "o": [
                        [0, -45.8392],
                        [45.84100000000001, 0],
                        [0, 45.839],
                        [-45.8376, 0],
                        [0, 0]
                      ],
                      "v": [
                        [0, 83],
                        [83, 0],
                        [166, 83],
                        [83, 166],
                        [0, 83]
                      ]
                    }
                  }
                },
                {
                  "ty": "fl",
                  "bm": 0,
                  "hd": false,
                  "nm": "Fill",
                  "c": { "a": 0, "k": [0.2275, 0.5255, 1] },
                  "r": 1,
                  "o": { "a": 0, "k": 100 }
                },
                {
                  "ty": "tr",
                  "a": { "a": 0, "k": [82.99999999999999, 82.99999999999999] },
                  "s": { "a": 0, "k": [100, 100] },
                  "sk": { "a": 0, "k": 0 },
                  "p": { "a": 0, "k": [82.99999999999999, 82.99999999999999] },
                  "r": { "a": 0, "k": 0 },
                  "sa": { "a": 0, "k": 0 },
                  "o": { "a": 0, "k": 100 }
                }
              ]
            }
          ],
          "ind": 2
        }
      ]
    }
  ]
}

```
</details>

`jq` を使うと多少リストアップができますが、階層構造を示しながらダンプするのは困難です:

```sh
jq '.. | .nm? | strings' my_animation.json
```

したがって、KeyPath はワイルドカードを指定できるので、まずあたりをつけて大まかに指定が適用されるか確認して近づけていくのがよいでしょう:

```kotlin
rememberLottieDynamicProperty(
    property = LottieProperty.COLOR,
    value = Color.Red.toArgb(),
    keyPath = arrayOf("Icon Asset", "**", "Fill"),
)
```

## 楽な KeyPath を見つける手段

とはいえ、いい手段がないかというといくつか方法が思い付きます。

まず現代では AI (GitHub Copilot) にやってもらうのはお手軽手段としてオススメです:

![Copilot に nm の値をツリーにしてもらった図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/372e85c6-d17a-420e-a7f3-feea434f3720.png)


正しい結果を出すのか微妙なところがあり、やや信頼性にかける印象はありますが…… (実際この例でも Icon Asset > Background > Background が解釈できていないわけで)

とはいえこれはシステムに組み込むようなものではなくて、エンジニアの手作業をサポートするのに使っていて、誤りがあっても人間が容易に気づいて修正できて取り返しがつくので気軽に使ってもいいと思えます。

## 楽な KeyPath を見つける手段 2

あとはスクリプトで用意しておくことでしょう。今回、仕様に合っている実装か不明ですが、雰囲気で手書きしてスクリプトを書いてみました:

![Script (CLI)](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/fb7ab0b0-c4c0-4534-b379-c2bad85f85e9.png)     

https://github.com/mangano-ito/lottie-keypath-finder

Web 版も用意したので JSON ファイルをぶちこめばダンプできます:

<p class="codepen" data-height="300" data-default-tab="result" data-slug-hash="MYWNNbd" data-pen-title="Lottie KeyPath Tree Generator" data-user="mangano_ito" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/mangano_ito/pen/MYWNNbd">
  Lottie KeyPath Tree Generator</a> by マンガーノ・伊藤 (<a href="https://codepen.io/mangano_ito">@mangano_ito</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://public.codepenassets.com/embed/index.js"></script>

![Web Demo](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/07fa745b-3d94-4e43-abf3-26292d4f8151.png)

こっちを書くほうがだんぜん時間がかかりました。一度用意すれば再利用できるのでめっちゃ使うならアリかもしれませんが。

以上です！