LoginSignup
11
8

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-12-18

google_maps_flutterMarker(ピンみたいなやつ)を、Widgetで表示する方法を共有します。
個人開発の過程でここにだいぶ時間を溶かしていたので、この記事が似たような課題を抱えてる開発者の方々の助けになればと思います。

なぜWidgetで表示するのか

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

  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);
  }

こちらはMarkerGeneratorinitStateと、そこで呼ばれている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に対するRenderObjectRenderRepaintBoundaryとして扱います。
これによって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を入れると、先ほど貼った完成図になります🎉

このコード自体はすでにpackageとして公開しているので、もしこのユースケースで問題ない方は使ってみてください。
santa112358/widget_marker_google_map - GitHub

参考

11
8
0

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
11
8