FlutterでSVGの色を動的に変更したい



# FlutterでSVGの色を動的に変更する実装方法

## はじめに

SVGファイルは拡大縮小しても品質が劣化しない優れたベクター画像形式です。アプリ内で色を動的に変更したい場合、単純な画像置き換えでは対応できません。

今回は、SVGファイル内の特定の要素の色を自由に変更できるウィジェットの実装方法をご紹介します。

## SVG の色を動的に変更するとできること
百聞は一見に如かず。という事で下記をご覧ください。

![change_svg_color.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3525158/e4a84429-c0e0-2b1b-a937-b31cbc8a3882.gif)

私が現在開発中のアプリではテーマカラー選択ができるのですが、
ワンちゃんの画像のスカーフの部分の色だけが
テーマカラーに合わせて変わっていると思います。
こんなふうに、動的に色を変えられるようになるのがメリットです。

:::note info
[アプリ KnocQnow は AppStore で公開中です](https://apps.apple.com/jp/app/knocqnow/id6499095123)
:::

## SVG ファイルの準備
まず、動的に色を変更させるsvgファイルを用意します。
svg形式で取得可能なフリー素材を用いればOKですが、迷ったらこのサイトから探してみてください。
#### **[unDraw](https://undraw.co/illustrations)**
色を変更したい要素に `id=` で適当な文字列を渡します。 
例では `id="change_color_target"` を追加しています。

```xml
<svg xmlns="http://www.w3.org/2000/svg" ...>
  <!-- 色を変更したい要素にIDを追加 -->
  <path id="change_color_target" 
        d="..." 
        fill="#000000"/>
  <!-- 他の要素 -->
  <path d="..." fill="#3f3d56"/>
</svg>
```

:::note alert
上記例では視認性向上のために改行しているが、
実際のSVGファイルでは以下のように1行で記述すること：
:::
```xml
<svg xmlns="http://www.w3.org/2000/svg" ...><path id="change_color_target" d="..." fill="#000000"/><path d="..." fill="#3f3d56"/></svg>
```

### 改行を避けるべき理由
XMLパーサーの挙動
FlutterのSVGパーサーは、XMLの構文解析を厳密に行います
改行やインデントが入ることで、予期せぬ場所で要素が分割される可能性があります
これにより `XmlParserException: ">" expected at XX:XX` のようなエラーが発生することがあります

## pubspec.yaml の準備

まず、必要なパッケージを`pubspec.yaml`に追加します：

```yaml
dependencies:
  flutter_svg: ^2.0.14
```

次に、SVGファイルをアセットとして登録します：

```yaml
flutter:
  assets:
    - assets/images/example.svg
```

:::note info
SVGファイルは`assets/images`ディレクトリに配置することを推奨します。異なるディレクトリに配置する場合は、パスを適切に変更してください。
:::

パッケージのインストールとアセットの登録が完了したら、以下のコマンドを実行します：

```bash
flutter pub get
```

## 実装

`ColorChangeSvg`クラスを作成します：

:::note warn
このファイルは特に変更不要
:::

```dart
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';

/// SVGファイル内の特定の要素の色を変更するウィジェット
class ColorChangeSvg extends StatelessWidget {
  final String assetName;
  final Map<String, Color> colorMapping;
  final double width;
  final double height;

  const ColorChangeSvg({
    required this.assetName,
    required this.colorMapping,
    required this.width,
    required this.height,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<String>(
      future: DefaultAssetBundle.of(context).loadString(assetName),
      builder: (context, snapshot) {
        if (!snapshot.hasData) {
          return SizedBox(width: width, height: height);
        }

        String svgContent = snapshot.data!;

        // 各ターゲットに対して色を変更
        colorMapping.forEach((targetId, color) {
          final hexColor = '#${color.value.toRadixString(16).padLeft(8, '0').substring(2)}';
          final pattern = RegExp('(id="$targetId"[^>]*?)(fill="[^"]*")', multiLine: true);
          
          svgContent = svgContent.replaceAllMapped(pattern, (match) {
            final prefix = match.group(1) ?? '';
            return '$prefix fill="$hexColor"';
          });
        });

        return SvgPicture.string(
          svgContent,
          width: width,
          height: height,
          fit: BoxFit.contain,
        );
      },
    );
  }
}
```

## 使用方法

このウィジェットは以下のように使用できます：

```dart
// 単一の色を変更する場合
ColorChangeSvg(
  assetName: 'assets/images/example.svg',
  colorMapping: {'change_color_target': Colors.blue},
  width: 150,
  height: 150,
)

// 複数の色を変更する場合
ColorChangeSvg(
  assetName: 'assets/images/example.svg',
  colorMapping: {
    'target_1': Colors.yellow,
    'target_2': Colors.red,
    'target_3': Colors.green,
  },
  width: 150,
  height: 150,
)
```

:::note info
### パラメータの説明
- `assetName`: SVGファイルのアセットパス
  - 例: `assets/images/example.svg`
  - `pubspec.yaml`に登録したパスと一致している必要があります

- `colorMapping`: 変更したい要素のIDと色のマップ
  - キー: SVGファイル内の要素のID
  - 値: 変更後の色（`Colors`クラスの色）
  - 単一の色変更の場合も、複数の色変更の場合も同じ形式で指定できます

- `width`と`height`: SVGの表示サイズ
  - 数値で指定（例: `150`）
  - 単位はデバイスの論理ピクセルです
  - アスペクト比は`BoxFit.contain`で自動調整されます
:::


## デモページの実装例

下記のsvgファイルを使用します。

:::note info
練習用にそのまま使用してOK
:::

```example.svg
<svg xmlns="http://www.w3.org/2000/svg" width="888" height="483.61099" viewBox="0 0 888 483.61099" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="m1,421.53481h887v-2H1c-.55228,0-1,.44772-1,1h0c0,.55228.44771,1,1,1Z" fill="#3f3d56"/><path d="m406,438.53481c5,24,33.5-5.5,33.5-5.5l34-4s21.5.5,35.5,11.5,23.5-3.5,23.5-3.5c15.77283,5.2576,35.09692,5.23627,51.83264,3.55252,24.11597-2.42627,43.44604-21.04041,46.76044-45.05038,18.69678-135.44147-84.09314-202.00211-84.09314-202.00211l-30-81s9.5-23.5,12.5-44.5-19-41-46-61c-11.8125-8.75-25.34766-8.12109-36.83716-4.64502-12.88892,3.89946-24.13214,11.9183-32.4397,22.51616-6.88251,8.77995-18.68735,21.21468-31.66315,23.36885-4.48999.73999-8.91998,2.52002-13.06,5.76001-4.28003,3.34998-6.16998,8.94-6.22998,15.89001-.32001,30.35999,24.75,66.90998,46.72998,72.60999,27,7,12.5,73.5,12.5,73.5-44,62-13,131-13,131l-8,56s-.5,11.5,4.5,35.5l.00006-.00003Z" fill="#3f3d56"/><path d="m553,309.53481l-11,137s15,38-36,36-21-80-21-80l16-102" fill="#3f3d56"/><path d="m509.79492,483.61099c-1.24707,0-2.52344-.02539-3.83398-.07715-11.29004-.44238-19.60352-4.60059-24.71094-12.3584-14.60254-22.18164,1.63867-65.90625,2.77832-68.89941l15.9834-101.89648c.08594-.5459.59375-.91309,1.14355-.83301.54492.08594.91797.59766.83301,1.14355l-16,102c-.01172.06934-.0293.1377-.05469.2041-.1748.45215-17.25195,45.55566-3.01172,67.18359,4.73242,7.18652,12.50977,11.04199,23.11719,11.45801,16.65234.64941,27.85254-2.90332,33.29785-10.56836,7.12402-10.02832,1.78711-23.92676,1.73242-24.06641-.05566-.1416-.07812-.29492-.06641-.44629l11-137c.04395-.5498.5166-.95117,1.07715-.91699.55078.04492.96094.52637.91699,1.07715l-10.98242,136.77539c.72656,1.96094,5.28223,15.4043-2.03906,25.72363-5.41699,7.63574-15.89844,11.49707-31.18066,11.49707Z" fill="#2f2e41"/><path d="m462,337.53481s3,127-60,124-6-43-6-43l5.5-15.5" fill="#3f3d56"/><path d="m404.16211,462.58657c-.73145,0-1.4668-.01758-2.20996-.05273-17.98828-.85645-28.15186-4.88672-30.20801-11.97852-3.7041-12.7793,20.43945-30.52148,23.41895-32.65332l5.39453-15.20117c.18457-.52148.75586-.79297,1.27637-.6084s.79297.75586.6084,1.27637l-5.5,15.5c-.06934.19629-.19824.36523-.36816.48438-.26367.18555-26.37744,18.68457-22.90869,30.64551,1.79248,6.18066,11.34131,9.72559,28.38232,10.53711,11.34863.5459,21.29688-3.27051,29.55078-11.3291,30.95215-30.21582,29.4209-110.83789,29.40137-111.64844-.0127-.55176.42383-1.00977.97656-1.02344.52246.00098,1.00977.4248,1.02344.97656.0791,3.3457,1.57227,82.29785-30.00293,113.125-8.12793,7.93555-17.82031,11.9502-28.83496,11.9502Z" fill="#2f2e41"/><path d="m363.27002,69.92484c7.17999.85999,18.41998.81995,27.72998-5.39001,9.96997-6.65002-.38-12.85004-8.44-16.26001-4.48999.73999-8.91998,2.52002-13.06,5.76001-4.28003,3.34998-6.16998,8.94-6.22998,15.89001Z" fill="#2f2e41"/><path id="target_2" d="m479.2984,51.94388s-17.44968-44.81639,7.54245-45.4435,44.73257,38.20994,44.73257,38.20994c0,0,62.72797,114.34248-23.21378,108.17637-85.94171-6.16615-44.87375-51.81067-44.87375-51.81067,0,0,19.60165-24.09928,15.81253-49.13211h-.00003v-.00002h0s0-.00002,0-.00002Z" fill="#2f2e41"/><path id="target_3" d="m575.03857,386.85064c11.06787,4.92365,22.63171,9.948,34.73578,9.46738s24.84155-8.27557,26.72772-20.24146c.97363-6.17697-.95746-12.80396,1.40759-18.59268,3.18213-7.78867,13.44672-10.79233,21.42578-8.12323s13.84076,9.55188,18.04358,16.84058c7.86328,13.63672,11.0141,31.53461,2.58307,44.82779-7.30878,11.52368-21.17084,16.73105-34.06519,21.19748-17.17535,5.94928-36.35114,11.854-52.99396,4.54617-16.7381-7.34964-25.85565-28.58435-19.65753-45.7821" fill="#3f3d56"/><path id="target_1" d="m538.1142,170.92143s22,11,11,23-79,46-104,41-31-20-31-24,1.88583-52.38661,9.88583-50.38661,58.11417,47.38661,114.11417,10.38661Z" fill="#00bfa6"/></svg>
```

実際の使用例はこちら：

```dart
import 'package:flutter/material.dart';
import 'package:knocqnow/presentation/components/common/color_change_svg.dart';

/// SVGの色変更機能のデモページ
class SvgMockPage extends StatelessWidget {
  const SvgMockPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('SVG Color Change Demo'),
      ),
      body: const Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('Single Color Change'),
            SizedBox(height: 16),
            ColorChangeSvg(
              assetName: 'assets/images/example.svg',
              colorMapping: {'target_1': Colors.blue},
              width: 200,
              height: 200,
            ),
            SizedBox(height: 32),
            Text('Multi Color Change'),
            SizedBox(height: 16),
            ColorChangeSvg(
              assetName: 'assets/images/example.svg',
              colorMapping: {
                'target_1': Colors.yellow,
                'target_2': Colors.red,
                'target_3': Colors.green,
              },
              width: 200,
              height: 200,
            ),
          ],
        ),
      ),
    );
  }
}
```

:::note warn
SVGファイルで色を変更したい要素には、必ず対応する`id`属性を指定する必要があります。
例えば、`colorMapping`で`target_1`を指定する場合、SVGファイル内に`id="target_1"`の要素が存在している必要があります。
:::

## 完成イメージはこちら
![Screenshot 2024-11-28 at 10.24.03 PM.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3525158/aec3578e-7be8-c14b-b3ee-e1467b4f4807.png)

:::note info
あとは `colorMapping: {'target_1': Colors.blue},` の色の部分を
現在使用中のアプリのテーマカラー用の変数に変更してあげれば、
テーマカラーに合わせてSVGファイルの色を動的に変更可能！
:::

## トラブルシューティング

:::note alert
色変更が正しく反映されない場合は、以下の点を確認してください：
:::
1. SVGファイルが`pubspec.yaml`の`assets`に正しく登録されているか
2. SVGファイル内の要素に適切な`id`属性が設定されているか
3. `colorMapping`で指定したIDがSVGファイル内のIDと完全に一致しているか

問題が解決しない場合は、以下のコマンドを実行してキャッシュをクリアしてください。
一度でうまくいかない場合は何回か試してみてください：

```bash
flutter clean
flutter pub get
```

## まとめ

このウィジェットを使用することで、SVGファイルの色を動的に変更できるようになります。
是非お試しください。

## 参考資料
- [flutter_svg パッケージ](https://pub.dev/packages/flutter_svg)
- [SVG仕様](https://developer.mozilla.org/ja/docs/Web/SVG)
- [Dart正規表現](https://api.dart.dev/stable/dart-core/RegExp-class.html)