google_maps_flutterのMarker
(ピンみたいなやつ)を、Widgetで表示する方法を共有します。
個人開発の過程でここにだいぶ時間を溶かしていたので、この記事が似たような課題を抱えてる開発者の方々の助けになればと思います。
なぜWidgetで表示するのか
google_maps_flutterはMarker
のicon
を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に渡す」みたいな表現の方が実態と近いです。
大まかな構造
一旦、ここにマップの大まかな構造を示すクラスのコードを貼ります。
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
を返してるだけです。
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
が何をやっているのか、ということがメインになります。
return Stack(
children: [
if (widget.widgetMarkers.isNotEmpty)
/// これ
MarkerGenerator(
widgetMarkers: widget.widgetMarkers,
onMarkerGenerated: (_markers) {
setState(
() {
markers = _markers.toSet();
},
);
},
),
GoogleMap(
一旦MarkerGenerator
のソースコード全体をここに貼ります。
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を更新する
@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に変換する
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
というコールバックに渡します。
これが親ではどのように使われてるかというと、
if (widget.widgetMarkers.isNotEmpty)
MarkerGenerator(
widgetMarkers: widget.widgetMarkers,
onMarkerGenerated: (_markers) {
setState(
() {
markers = _markers.toSet();
},
);
},
),
こんな感じでシンプルに値を受け渡す形で使われてます。
使用例
WidgetMarkerGoogleMap
は、中身が複雑な代わりに外から使う分には限りなくシンプルに扱えるようになっています。
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として公開しているので、もしこのユースケースで問題ない方は使ってみてください。