【Flutter】Google MapのMarkerをWidgetで表示する

[google_maps_flutter](https://pub.dev/packages/google_maps_flutter)の`Marker`(ピンみたいなやつ)を、Widgetで表示する方法を共有します。
[個人開発](https://twitter.com/3ta0212/status/1461206843624099841?s=20)の過程でここにだいぶ時間を溶かしていたので、この記事が似たような課題を抱えてる開発者の方々の助けになればと思います。

# なぜWidgetで表示するのか

google_maps_flutterは`Marker`の`icon`をBitmap画像として受け取ります。そのため、デフォルトの画像、もしくは予め用意した画像を使う他なく、そのデータに応じて柔軟に表示すること（例えば場所の評価をマーカーで表示する）が残念ながらできない仕様になっています。

```dart
  Marker(
    position: position,
    markerId: markerId,
    ///  これのせいで応用が効かない
    icon: BitmapDescriptor.fromBytes(uint8ListByteData)
  ),
```
仮にMarkerの表示をWidgetで指定できれば、簡単なデザインであればわざわざカスタムの画像を作る必要もありませんし、それぞれのMarkerのデータに応じて表示を柔軟に変えることができます。

# 進め方

今回はgoogle_maps_flutterの内側のコードはいじらずに、markerのデータの渡し方を工夫します。
具体的に言えば、**WidgetをUint8Listに変換したものを、Google CalendarのMarkerのBitmapDescriptorに渡します。**
なのでタイトルの「Widgetで表示する」よりは、「Widgetを画像に変換してスムーズにGoogleMapに渡す」みたいな表現の方が実態と近いです。

# 大まかな構造

一旦、ここにマップの大まかな構造を示すクラスのコードを貼ります。

```widget_marker_google_map.dart
class WidgetMarkerGoogleMap extends StatefulWidget {
  const WidgetMarkerGoogleMap({
    Key? key,
    required this.initialCameraPosition,
    this.onMapCreated,
    this.gestureRecognizers = const <Factory<OneSequenceGestureRecognizer>>{},
    this.compassEnabled = true,
    this.mapToolbarEnabled = true,
    this.cameraTargetBounds = CameraTargetBounds.unbounded,
    this.mapType = MapType.normal,
    this.minMaxZoomPreference = MinMaxZoomPreference.unbounded,
    this.rotateGesturesEnabled = true,
    this.scrollGesturesEnabled = true,
    this.zoomControlsEnabled = true,
    this.zoomGesturesEnabled = true,
    this.liteModeEnabled = false,
    this.tiltGesturesEnabled = true,
    this.myLocationEnabled = false,
    this.myLocationButtonEnabled = true,
    this.layoutDirection,
    this.padding = const EdgeInsets.all(0),
    this.indoorViewEnabled = false,
    this.trafficEnabled = false,
    this.buildingsEnabled = true,
    this.widgetMarkers = const <WidgetMarker>[],
    this.polygons = const <Polygon>{},
    this.polylines = const <Polyline>{},
    this.circles = const <Circle>{},
    this.onCameraMoveStarted,
    this.tileOverlays = const <TileOverlay>{},
    this.onCameraMove,
    this.onCameraIdle,
    this.onTap,
    this.onLongPress,
  }) : super(key: key);

  /// MarkerGeneratorに渡す
  ///
  /// WidgetMarkerのリスト
  final List<WidgetMarker> widgetMarkers;

  /// Markerを除く、純正Google Mapの引数たち
  final MapCreatedCallback? onMapCreated;
  final CameraPosition initialCameraPosition;
  final bool compassEnabled;
  final bool mapToolbarEnabled;
  final CameraTargetBounds cameraTargetBounds;
  final MapType mapType;
  final TextDirection? layoutDirection;
  final MinMaxZoomPreference minMaxZoomPreference;
  final bool rotateGesturesEnabled;
  final bool scrollGesturesEnabled;
  final bool zoomControlsEnabled;
  final bool zoomGesturesEnabled;
  final bool liteModeEnabled;
  final bool tiltGesturesEnabled;
  final EdgeInsets padding;
  final Set<Polygon> polygons;
  final Set<Polyline> polylines;
  final Set<Circle> circles;
  final Set<TileOverlay> tileOverlays;
  final VoidCallback? onCameraMoveStarted;
  final CameraPositionCallback? onCameraMove;
  final VoidCallback? onCameraIdle;
  final ArgumentCallback<LatLng>? onTap;
  final ArgumentCallback<LatLng>? onLongPress;
  final bool myLocationEnabled;
  final bool myLocationButtonEnabled;
  final bool indoorViewEnabled;
  final bool trafficEnabled;
  final bool buildingsEnabled;
  final Set<Factory<OneSequenceGestureRecognizer>> gestureRecognizers;

  @override
  State<WidgetMarkerGoogleMap> createState() => _WidgetMarkerGoogleMapState();
}

class _WidgetMarkerGoogleMapState extends State<WidgetMarkerGoogleMap> {
  Set<Marker> markers = {};

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        if (widget.widgetMarkers.isNotEmpty)
          MarkerGenerator(
            widgetMarkers: widget.widgetMarkers,
            onMarkerGenerated: (_markers) {
              setState(
                () {
                  markers = _markers.toSet();
                },
              );
            },
          ),
        GoogleMap(
          initialCameraPosition: widget.initialCameraPosition,
          onMapCreated: widget.onMapCreated,
          gestureRecognizers: widget.gestureRecognizers,
          compassEnabled: widget.compassEnabled,
          mapToolbarEnabled: widget.mapToolbarEnabled,
          cameraTargetBounds: widget.cameraTargetBounds,
          mapType: widget.mapType,
          minMaxZoomPreference: widget.minMaxZoomPreference,
          rotateGesturesEnabled: widget.rotateGesturesEnabled,
          scrollGesturesEnabled: widget.scrollGesturesEnabled,
          zoomControlsEnabled: widget.zoomControlsEnabled,
          zoomGesturesEnabled: widget.zoomGesturesEnabled,
          liteModeEnabled: widget.liteModeEnabled,
          tiltGesturesEnabled: widget.tiltGesturesEnabled,
          myLocationEnabled: widget.myLocationEnabled,
          myLocationButtonEnabled: widget.myLocationButtonEnabled,
          layoutDirection: widget.layoutDirection,
          padding: widget.padding,
          indoorViewEnabled: widget.indoorViewEnabled,
          trafficEnabled: widget.trafficEnabled,
          buildingsEnabled: widget.buildingsEnabled,
          markers: markers,
          polygons: widget.polygons,
          polylines: widget.polylines,
          circles: widget.circles,
          onCameraMoveStarted: widget.onCameraMoveStarted,
          tileOverlays: widget.tileOverlays,
          onCameraMove: widget.onCameraMove,
          onCameraIdle: widget.onCameraIdle,
          onTap: widget.onTap,
          onLongPress: widget.onLongPress,
        ),
      ],
    );
  }
}
```
このクラスのやっていることは

- `GoogleMap`の後ろに`MarkerGenerator`というものが`Stack`で隠されていること。

- `markers`の代わりに`widgetMarkers`が引数として渡されていて、`MarkerGenerator`とやらがそれを`markers`に変換して、`GoogleMap`に渡されていること。

以外は、ただ純正の`GoogleMap`を返してるだけです。

```widget_marker.dart
class WidgetMarker {
  WidgetMarker({
    required this.position,
    required this.markerId,
    required this.widget,
    this.onTap,
  }) : assert(markerId.isNotEmpty);

  final LatLng position;
  final String markerId;

  /// widgetのジェスチャーは無効化されるので、
  /// タップ時のコールバックが必要であればここに書く。
  final VoidCallback? onTap;

  /// ここが画像に変換される
  final Widget widget;
}
```
便宜上、`WidgetMarker`という`Marker`のデータをWidgetで受け渡すためのクラスを作っています。
この`WidgetMarker`のリストを、`MarkerGenerator`はそれぞれ`Marker`に変換してくれます。


# MarkerGeneratorは何をやっているのか

結局はこのGoogleMapの下に隠れてる`MarkerGenerator`が何をやっているのか、ということがメインになります。

```widget_marker_google_map.dart
    return Stack(
      children: [
        if (widget.widgetMarkers.isNotEmpty)
          /// これ
          MarkerGenerator(
            widgetMarkers: widget.widgetMarkers,
            onMarkerGenerated: (_markers) {
              setState(
                () {
                  markers = _markers.toSet();
                },
              );
            },
          ),
        GoogleMap(
```

一旦`MarkerGenerator`のソースコード全体をここに貼ります。

```marker_generator.dart
class MarkerGenerator extends StatefulWidget {
  const MarkerGenerator({
    Key? key,
    required this.widgetMarkers,
    required this.onMarkerGenerated,
  }) : super(key: key);
  final List<WidgetMarker> widgetMarkers;
  final ValueChanged<List<Marker>> onMarkerGenerated;

  @override
  _MarkerGeneratorState createState() => _MarkerGeneratorState();
}

class _MarkerGeneratorState extends State<MarkerGenerator> {
  List<GlobalKey> _globalKeys = [];
  List<WidgetMarker> _lastMarkers = [];

  Future<Marker> _convertToMarker(GlobalKey key) async {
    RenderRepaintBoundary boundary =
        key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
    final image = await boundary.toImage();
    final byteData =
        await image.toByteData(format: ImageByteFormat.png) ?? ByteData(0);
    final uint8List = byteData.buffer.asUint8List();
    final widgetMarker = widget.widgetMarkers[_globalKeys.indexOf(key)];
    return Marker(
      onTap: widgetMarker.onTap,
      markerId: MarkerId(widgetMarker.markerId),
      position: widgetMarker.position,
      icon: BitmapDescriptor.fromBytes(uint8List),
    );
  }

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance
        ?.addPersistentFrameCallback((_) => _onBuildCompleted());
  }

  Future<void> _onBuildCompleted() async {
    /// 無駄な更新を避けるため、
    /// 前回ビルド時とmarkerが同じだった場合はmarkerの生成をスキップします。
    if (_lastMarkers == widget.widgetMarkers) {
      return;
    }
    _lastMarkers = widget.widgetMarkers;
    final markers =
        await Future.wait(_globalKeys.map((key) => _convertToMarker(key)));
    widget.onMarkerGenerated.call(markers);
  }

  @override
  Widget build(BuildContext context) {
    _globalKeys = [];
    return Transform.translate(
      /// GoogleMapよりも先に描画されてしまった場合を考慮して、
      /// 生成したマーカーを画面の外に追いやります。
      offset: Offset(
        0,
        -MediaQuery.of(context).size.height,
      ),
      child: Stack(
        children: widget.widgetMarkers.map(
          (widgetMarker) {
            final key = GlobalKey();
            _globalKeys.add(key);
            return RepaintBoundary(
              key: key,
              child: widgetMarker.widget,
            );
          },
        ).toList(),
      ),
    );
  }
}

```
ご覧の通り、`MarkerGenerator`は引数で受け渡されたwidgetを見えないところでビルドして、ビルドが終わったらそれらをまとめて画像に変換するという、正直に言えばまあまあ外道なことをしています。（こうする他なかったんです。。。）

要所要所複雑な箇所があるので、それらについて順を追って説明していきます。

## 毎度build後にMarkerを更新する

```marker_generator.dart
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance
        ?.addPersistentFrameCallback((_) => _onBuildCompleted());
  }

  Future<void> _onBuildCompleted() async {
    /// 無駄な更新を避けるため、
    /// 前回ビルド時とmarkerが同じだった場合はmarkerの生成をスキップします。
    if (_lastMarkers == widget.widgetMarkers) {
      return;
    }
    _lastMarkers = widget.widgetMarkers;
    final markers =
        await Future.wait(_globalKeys.map((key) => _convertToMarker(key)));
    widget.onMarkerGenerated.call(markers);
  }
```
こちらは`MarkerGenerator`の`initState`と、そこで呼ばれている`addPersistentFrameCallback`の中身ですが、このコールバックはbuildメソッドが叩かれる都度呼ばれます。`widgetMarkers`は頻繁に変わることが想定されるので、buildのたびに変更を監視する仕組みにしました。

buildが終わると`_onBuildCompleted`が呼ばれて、`RepaintBoundary`を指定する`key`たちが入った`_globalKeys`をそれぞれ`_convertToMarker`という、画像を生成して純正の`Marker`に変換する関数に渡していきます。

## WidgetをUint8Listに変換する

```widget_marker.dart
  Future<Marker> _convertToMarker(GlobalKey key) async {
    RenderRepaintBoundary boundary =
        key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
    final image = await boundary.toImage();
    final byteData =
        await image.toByteData(format: ImageByteFormat.png) ?? ByteData(0);
    final uint8List = byteData.buffer.asUint8List();
    final widgetMarker = widget.widgetMarkers[_globalKeys.indexOf(key)];
    return Marker(
      onTap: widgetMarker.onTap,
      markerId: MarkerId(widgetMarker.markerId),
      position: widgetMarker.position,
      icon: BitmapDescriptor.fromBytes(uint8List),
    );
  }
```
ここでは受け取った`key`に対する`RenderObject`を`RenderRepaintBoundary`として扱います。
これによって`toImage()`が使えるようになり、晴れてWidgetをImageへと変換することができます。
`toImage()`が済んだら、それを`Uint8List`に変換して、`WidgetMarker`に入っていたパラメータと一緒に返り値である`Marker`に渡します。

それぞれ画像の生成が完了すると、それらを`onMarkerGenerated`というコールバックに渡します。
これが親ではどのように使われてるかというと、

```widget_marker_google_map.dart
   if (widget.widgetMarkers.isNotEmpty)
     MarkerGenerator(
       widgetMarkers: widget.widgetMarkers,
       onMarkerGenerated: (_markers) {
         setState(
           () {
             markers = _markers.toSet();
           },
         );
       },
     ),
```

こんな感じでシンプルに値を受け渡す形で使われてます。


# 使用例

`WidgetMarkerGoogleMap`は、中身が複雑な代わりに外から使う分には限りなくシンプルに扱えるようになっています。

```my_home_page.dart
class MyHomePage extends StatelessWidget {
  const MyHomePage({Key? key}) : super(key: key);

  static const shibuya = CameraPosition(
    bearing: 192.8334901395799,
    target: LatLng(35.6598003, 139.7023894),
    zoom: 15.151926040649414,
  );

  static const cafePosition = LatLng(35.659172, 139.7023894);
  static const clothesShopPosition = LatLng(35.659528, 139.698723);
  static const hamburgerShopPosition = LatLng(35.6614027, 139.6983333);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: WidgetMarkerGoogleMap(
        initialCameraPosition: shibuya,
        mapType: MapType.normal,
        widgetMarkers: [
          WidgetMarker(
            position: cafePosition,
            markerId: 'cafe',
            widget: Container(
              color: Colors.brown,
              padding: const EdgeInsets.all(2),
              child: const Icon(
                Icons.coffee,
                color: Colors.white,
                size: 64,
              ),
            ),
          ),
          WidgetMarker(
            position: clothesShopPosition,
            markerId: 'clothes',
            widget: Container(
              color: Colors.green,
              padding: const EdgeInsets.all(4),
              child: const Text(
                'shop',
                style: TextStyle(
                  color: Colors.white,
                  fontWeight: FontWeight.bold,
                  fontSize: 32,
                ),
              ),
            ),
          ),
          WidgetMarker(
            position: hamburgerShopPosition,
            markerId: 'hamburger',
            widget: Container(
              color: Colors.red,
              padding: const EdgeInsets.all(2),
              child: const Icon(
                Icons.fastfood,
                color: Colors.yellow,
                size: 64,
              ),
            ),
          ),
        ],
      ),
    );
  }
}
```
このように`widgetMarkers`に所定の`position`と一意な`markerId`、適当な`widget`を入れると、先ほど貼った完成図になります🎉

<img src="https://user-images.githubusercontent.com/43510799/142984717-853a7686-503e-4e2c-b090-9767e25fb8d3.png" width=330>

このコード自体はすでにpackageとして公開しているので、もしこのユースケースで問題ない方は使ってみてください。
[![santa112358/widget_marker_google_map - GitHub](https://gh-card.dev/repos/santa112358/widget_marker_google_map.svg)](https://github.com/santa112358/widget_marker_google_map)

# 参考

- [Export your widget to image with flutter](https://medium.com/flutter-community/export-your-widget-to-image-with-flutter-dc7ecfa6bafb)
- [Using Widgets as Custom Google Map Markers in Flutter](https://medium.com/swlh/using-material-icons-as-custom-google-map-markers-in-flutter-3e854de22e2)
- [Creating Custom Markers on Google Maps in Flutter Apps](https://infinum.com/the-capsized-eight/creating-custom-markers-on-google-maps-in-flutter-apps)