6
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-04-14

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

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

Choose an animation API の Lottie の例

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

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

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

サンプル

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

image.png

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

@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

色をつける

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

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

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

背景色を変更したときのイメージ

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

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

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

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

Apr-03-2025 14-05-24.gif

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

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

Theme との調和

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

Light Dark
Apr-04-2025 13-43-54.gif Apr-04-2025 13-43-14.gif
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 に定義したトークンが使えそうで、これを変更できるようになるとよりやりやすそうなのですが……調べた範囲では方法が見つかりませんでした。

Theme Manager

対象の KeyPath を知る方法

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

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

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

レイヤー

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

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

今回のアニメーションの 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
        }
      ]
    }
  ]
}

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

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

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

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

楽な KeyPath を見つける手段

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

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

Copilot に nm の値をツリーにしてもらった図

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

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

楽な KeyPath を見つける手段 2

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

Script (CLI)

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

See the Pen Lottie KeyPath Tree Generator by マンガーノ・伊藤 (@mangano_ito) on CodePen.

Web Demo

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

以上です!

6
6
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?