FlutterでGoogleマップを表示するのにgoogle_maps_flutterを使いつつ、googlemaps/v3-utility-libraryのMarkerClustererのような感じで、各マーカーをクラスタリングしてマーカー数を表示したかったので、alfonsocejudo/flusterを使ってみた。
日本語のドキュメントが少なそうだったので、やったことをメモしておく。

Github
pubspec.yaml
google_maps_flutter: ^0.5.21
location: ^2.3.5
fluster: ^1.1.2
ソースコード全体
マップ表示部分のソースコードはこんな感じ。以下、掻い摘んで説明する。
MapPage.dart
import 'dart:async';
import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:fluster/fluster.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:mapsample/MapMarker.dart';
class MapPage extends StatefulWidget {
const MapPage({Key key}) : super(key: key);
@override
_MapPageState createState() => _MapPageState();
}
class _MapPageState extends State {
Completer _controller = Completer();
Fluster _fluster;
List _points = List();
CameraPosition _currentPosition;
StreamController> _markers = StreamController();
static final CameraPosition _kInitialPosition = CameraPosition(
target: LatLng(35.6812405, 139.7649361),
zoom: 10,
);
double mapPixelWidth;
double mapPixelHeight;
@override
void initState() {
super.initState();
_createRandomPoints().then((value) async {
await _createCluster();
await _updateMarkers();
});
_currentPosition = _kInitialPosition;
}
@override
void dispose() {
super.dispose();
_markers.close();
}
@override
Widget build(BuildContext context) {
final Size size = MediaQuery.of(context).size;
final double ratio = MediaQuery.of(context).devicePixelRatio;
mapPixelWidth = size.width * ratio;
mapPixelHeight = size.height * ratio;
return Scaffold(
body: Container(
child: StreamBuilder>(
initialData: {},
stream: markers.stream,
builder: (, AsyncSnapshot> snapshot) {
return GoogleMap(
initialCameraPosition: _kInitialPosition,
mapType: MapType.normal,
myLocationEnabled: false,
myLocationButtonEnabled: false,
rotateGesturesEnabled: false,
onCameraMove: _onCameraMove,
onCameraIdle: _onCameraIdle,
onTap: _onTap,
onMapCreated: (GoogleMapController controller) {
_controller.complete(controller);
},
gestureRecognizers: >[
Factory(
() => EagerGestureRecognizer(),
),
].toSet(),
markers: Set.of(snapshot.data.values),
);
},
),
),
);
}
void _onCameraMove(CameraPosition cameraPosition) {
setState(() {
_currentPosition = cameraPosition;
});
}
void _onCameraIdle() {
_updateMarkers();
}
void _onTap(LatLng target) {
_controller.future.then((controller) {
controller.animateCamera(CameraUpdate.newLatLng(LatLng(target.latitude, target.longitude)));
});
}
Future _createCluster() async {
_fluster = Fluster(
minZoom: 0,
maxZoom: 21,
radius: 210,
extent: 512,
nodeSize: 64,
points: _points,
createCluster: (BaseCluster cluster, double longitude, double latitude) =>
MapMarker(
latitude: latitude,
longitude: longitude,
isCluster: true,
clusterId: cluster.id,
pointsSize: cluster.pointsSize,
markerId: cluster.id.toString(),
childMarkerId: cluster.childMarkerId
)
);
}
Future _updateMarkers() async {
if (_fluster == null || _points == null) {
return;
}
Map<MarkerId, Marker> markers = Map();
int zoom = _currentPosition.zoom.round();
markers = await _createMarkers(_currentPosition.target, zoom);
_markers.sink.add(markers);
}
Future> _createMarkers(LatLng location, int zoom) async {
LatLng northeast = _calculateLatLon(mapPixelWidth, 0);
LatLng southwest = _calculateLatLon(0, mapPixelHeight);
var bounds = [
southwest.longitude,
southwest.latitude,
northeast.longitude,
northeast.latitude
];
List clusters = _fluster.clusters(bounds, zoom);
Map markers = Map();
for (MapMarker feature in clusters) {
final Uint8List markerIcon = await _getBytesFromCanvas(feature);
BitmapDescriptor bitmapDescriptor = BitmapDescriptor.fromBytes(markerIcon);
Marker marker = Marker(
markerId: MarkerId(feature.markerId),
position: LatLng(feature.latitude, feature.longitude),
icon: bitmapDescriptor,
);
markers.putIfAbsent(MarkerId(feature.markerId), () => marker);
}
return markers;
}
LatLng _calculateLatLon(x, y) {
double parallelMultiplier = math.cos(_currentPosition.target.latitude * math.pi / 180);
double degreesPerPixelX = 360 / math.pow(2, _currentPosition.zoom + 8);
double degreesPerPixelY = 360 / math.pow(2, _currentPosition.zoom + 8) * parallelMultiplier;
var lat = _currentPosition.target.latitude - degreesPerPixelY * (y - mapPixelHeight / 2);
var lng = _currentPosition.target.longitude + degreesPerPixelX * (x - mapPixelWidth / 2);
LatLng latLng = LatLng(lat, lng);
return latLng;
}
Future _getBytesFromCanvas(MapMarker feature) async {
Color color = Colors.blue[300];
String text = "1";
int size = 80;
if (feature.pointsSize != null) {
text = feature.pointsSize.toString();
if (feature.pointsSize >= 100) {
color = Colors.red[400];
size = 110;
} else if (feature.pointsSize >= 10) {
color = Colors.yellow[600];
size = 90;
}
}
final ui.PictureRecorder pictureRecorder = ui.PictureRecorder();
final Canvas canvas = Canvas(pictureRecorder);
final Paint paint2 = Paint()..color = Colors.white;
final Paint paint1 = Paint()..color = color;
canvas.drawCircle(Offset(size / 2, size / 2), size / 3.0, paint2);
canvas.drawCircle(Offset(size / 2, size / 2), size / 3.3, paint1);
TextPainter painter = TextPainter(textDirection: TextDirection.ltr);
painter.text = TextSpan(
text: text,
style: TextStyle(
fontSize: size / 4, color: Colors.black, fontWeight: FontWeight.bold),
);
painter.layout();
painter.paint(
canvas,
Offset(size / 2 - painter.width / 2, size / 2 - painter.height / 2),
);
final img = await pictureRecorder.endRecording().toImage(size, size);
final data = await img.toByteData(format: ui.ImageByteFormat.png);
return data.buffer.asUint8List();
}
Future _createRandomPoints() async {
List data = [];
for (double i = 0; i < 300; i++) {
final String markerId = i.toString();
math.Random rand = math.Random();
int negative = rand.nextBool() ? -1 : 1;
double lat = _kInitialPosition.target.latitude + (rand.nextInt(10000) / 10000 * negative);
negative = rand.nextBool() ? -1 : 1;
double lng = _kInitialPosition.target.longitude + (rand.nextInt(10000) / 10000 * negative);
MapMarker point = MapMarker(
latitude: lat,
longitude: lng,
markerId: markerId
);
data.add(point);
}
_points = data;
}
}
</div></details>
### データクラス
```dart
import 'package:fluster/fluster.dart';
import 'package:meta/meta.dart';
class MapMarker extends Clusterable {
MapMarker({
@required latitude,
@required longitude,
isCluster = false,
clusterId,
pointsSize,
markerId,
childMarkerId,
}) : super(
latitude: latitude,
longitude: longitude,
isCluster: isCluster,
clusterId: clusterId,
pointsSize: pointsSize,
markerId: markerId,
childMarkerId: childMarkerId);
}
flusterでクラスタリングをするにあたって、まずはClusterableを継承したクラスを作る必要がある。
最低限の実装だと上記のような感じ。他に独自のフィールドを追加することもできる。
Flusterインスタンス
Future<void> _createCluster() async {
_fluster = Fluster<MapMarker>(
minZoom: 0,
maxZoom: 21,
radius: 210,
extent: 512,
nodeSize: 64,
points: _points,
createCluster: (BaseCluster cluster, double longitude, double latitude) =>
MapMarker(
latitude: latitude,
longitude: longitude,
isCluster: true,
clusterId: cluster.id,
pointsSize: cluster.pointsSize,
markerId: cluster.id.toString(),
childMarkerId: cluster.childMarkerId
)
);
}
次にクラスタリングを行ってくれるflusterインスタンスを作る。
パラメータ | 説明 |
---|---|
minZoom | マップの最小ズームに合わせる |
maxZoom | マップの最大ズームに合わせる |
radius | クラスタリングを行う範囲 |
extent | クラスタリング後のマーカー数に関係してそう。必ず2の指数で指定する |
nodeSize | あまりちゃんと理解してないけどパフォーマンスに影響してるっぽい |
points | クラスタリングさせたい緯度経度のリスト。List |
マップへの表示
Future<void> _updateMarkers() async {
if (_fluster == null || _points == null) {
return;
}
Map<MarkerId, Marker> markers = Map();
int zoom = _currentPosition.zoom.round();
markers = await _createMarkers(_currentPosition.target, zoom);
_markers.sink.add(markers);
}
Future<Map<MarkerId, Marker>> _createMarkers(LatLng location, int zoom) async {
LatLng northeast = _calculateLatLon(mapPixelWidth, 0);
LatLng southwest = _calculateLatLon(0, mapPixelHeight);
var bounds = [
southwest.longitude,
southwest.latitude,
northeast.longitude,
northeast.latitude
];
List<MapMarker> clusters = _fluster.clusters(bounds, zoom);
Map<MarkerId, Marker> markers = Map();
for (MapMarker feature in clusters) {
final Uint8List markerIcon = await _getBytesFromCanvas(feature);
BitmapDescriptor bitmapDescriptor = BitmapDescriptor.fromBytes(markerIcon);
Marker marker = Marker(
markerId: MarkerId(feature.markerId),
position: LatLng(feature.latitude, feature.longitude),
icon: bitmapDescriptor,
);
markers.putIfAbsent(MarkerId(feature.markerId), () => marker);
}
return markers;
}
_fluster.clusters()でクラスタリングされたMapMarkerのリストを受け取って、MapMarker毎にMarkerを作り、それを_markersのstreamに流している。
_fluster.clusters()で受け取るboundsはそれぞれ、
[南東の経度, 南東の緯度, 北西の経度, 北西の緯度]
でflusterのサンプルだと[-180, -85, 180, 85]
になっている。
このままだと画面上見えてないところにもマーカーが全部置かれることになる。少ないうちは大丈夫だが、マーカー数は100個を超えたあたりからマップの表示がもっさりし始めて、300以上になるとかなり重い。
なので、地図の表示サイズと現在位置から、_fluster.clusters()で作成するマーカーを見えてる範囲のだけに限定する。
...
double mapPixelWidth;
double mapPixelHeight;
@override
Widget build(BuildContext context) {
final Size size = MediaQuery.of(context).size;
final double ratio = MediaQuery.of(context).devicePixelRatio;
mapPixelWidth = size.width * ratio;
mapPixelHeight = size.height * ratio;
...
}
こんな感じで、devicePixelRatioを使ってピクセル換算できる。今回は画面全体に表示しているので、size.widthとsize.heightにそのまま掛けている。
LatLng _calculateLatLon(x, y) {
double parallelMultiplier = math.cos(_currentPosition.target.latitude * math.pi / 180);
double degreesPerPixelX = 360 / math.pow(2, _currentPosition.zoom + 8);
double degreesPerPixelY = 360 / math.pow(2, _currentPosition.zoom + 8) * parallelMultiplier;
var lat = _currentPosition.target.latitude - degreesPerPixelY * (y - mapPixelHeight / 2);
var lng = _currentPosition.target.longitude + degreesPerPixelX * (x - mapPixelWidth / 2);
LatLng latLng = LatLng(lat, lng);
return latLng;
}
こっちが計算式。
マーカー画像の生成
Future<Uint8List> _getBytesFromCanvas(MapMarker feature) async {
Color color = Colors.blue[300];
String text = "1";
int size = 80;
if (feature.pointsSize != null) {
text = feature.pointsSize.toString();
if (feature.pointsSize >= 100) {
color = Colors.red[400];
size = 110;
} else if (feature.pointsSize >= 10) {
color = Colors.yellow[600];
size = 90;
}
}
final ui.PictureRecorder pictureRecorder = ui.PictureRecorder();
final Canvas canvas = Canvas(pictureRecorder);
final Paint paint2 = Paint()..color = Colors.white;
final Paint paint1 = Paint()..color = color;
canvas.drawCircle(Offset(size / 2, size / 2), size / 3.0, paint2);
canvas.drawCircle(Offset(size / 2, size / 2), size / 3.3, paint1);
TextPainter painter = TextPainter(textDirection: TextDirection.ltr);
painter.text = TextSpan(
text: text,
style: TextStyle(
fontSize: size / 4, color: Colors.black, fontWeight: FontWeight.bold),
);
painter.layout();
painter.paint(
canvas,
Offset(size / 2 - painter.width / 2, size / 2 - painter.height / 2),
);
final img = await pictureRecorder.endRecording().toImage(size, size);
final data = await img.toByteData(format: ui.ImageByteFormat.png);
return data.buffer.asUint8List();
}
クラスタリング後に要素の数を表示したいので、マーカー用の画像を生成している。_fluster.cluster()で生成されたMapMarkerのpointSizeに要素数が入っているので、Canvasを使って数字を描写する。
おわり
ざっくりとこんな感じでマップ上にクラスタリングされたマーカーを置くことができる。GoogleのSDKとはロジックが違うため、全く同じようにはならないが、現状公式のgoogle_maps_flutterではクラスタリングがサポートされていないので、flusterを使うのが一番手っ取り早いかなと思ってる。
あとは、毎回クラスタを作るんじゃなくてzoomが変わってない場合はキャッシュさせておくとかすると良いかも。