【Flutter】Riverpod / Google Mapsでマップをなぞる
はじめに
flutter 上で Google マップを表示させ、なぞった範囲内を塗りつぶす機能を実装してみました。
実装方法はこちらの記事を参考にさせていただきました。Dart
のバージョンが古くNull Safety
に対応しておらず、また状態管理にライブラリを用いていなかったため、そのあたりを本記事でアレンジしております。
バージョン
❯ fvm flutter --version
Flutter 3.0.1 • channel stable • https://github.com/flutter/flutter.git
Framework • revision fb57da5f94 (3 months ago) • 2022-05-19 15:50:29 -0700
Engine • revision caaafc5604
Tools • Dart 2.17.1 • DevTools 2.12.2
ライブラリ
hooks_riverpod: ^1.0.4
flutter_hooks: ^0.18.4
google_maps_flutter: ^2.1.10 // mapを表示する
flutter_config: ^2.0.0 // 環境変数を設定する
geolocator: ^9.0.1 // 現在地情報を取得する
用語
実装当初、知らなくて戸惑った単語たち…
用語 | 説明 | 本記事における扱い |
---|---|---|
LatLng | 緯度経度のこと | Polyline と Polygon を構成する要素で、この座標の集まりで描画を表現する |
Polyline | マップ上に線を描くためのもの | なぞっている間、線が動的に指についてくる動作を描画するのに用いる |
Polygon | マップ上に座標で囲まれた領域を多角形で表すもの | なぞり終えて、指が離れた際に多角形を描画するのに用いる |
事前準備
google_maps_flutterなどを参考に、Google Maps API と表示させたいプラットフォームとを連携します。これをしないとマップが表示されません。
また本記事では解説しませんが、API キーを直接書かないようにflutter_config
を用いました。
環境変数を Dart だけでなく iOS や Android のコードに公開できる便利なライブラリです。
今回は iOS と Android で実装したかったので、このあたりのファイルを編集しました。
/ios/Runner/Info.plist
/ios/Runner/AppDelegate.swift
/android/app/src/main/AndroidManifest.xml
/android/app/build.gradle
参考: AndroidManifest.xml、AppDelegate.swift から env にアクセス
実装
コード全体はまとめに記載しています。
build
ビルド部分の要点を抜き出したコードは次のとおりです。
Widget build(BuildContext context) {
return Scaffold(
body: ...
GestureDetector(
onPanUpdate: (drawPolygonEnabled) ? _onPanUpdate : null,
onPanEnd: (drawPolygonEnabled) ? _onPanEnd : null,
child: GoogleMap(
...
polylines: _polylineSet,
polygons: _polygonSet,
...
),
),
floatingActionButton: FloatingActionButton(
onPressed: _toggleDrawing,
...
),
...
GestureDetecter
(child に対してドラッグ操作やピンチ操作などを付与する)の child にGoogleMap
という Widget を指定しています。
GoogleMap
ウィジェットのみだと、普段私たちが使っている Google Maps アプリのような拡大縮小やマップ移動の操作しかできません。これをなぞれるようにするため、FloatingActionButton
に『マップ操作』と『なぞる操作』を切り替える機能を実装しています。
GestureDetector
のプロパティとしてonPanUpdate
とonPanEnd
を定義しています。どちらも『なぞる』状態ではたらく関数を定義していて、中身は後述します。
onPanUpdate
=> ドラッグ操作で位置が変化したときにコール
onPanEnd
=> ドラッグ操作が終了したときにコール
GoogleMap
のプロパティにはpolylines
とpolygons
があり、Polyline / Polygon
のインスタンスをvalue
とするハッシュセットを指定することでマップ上に線分や図形を表示できます。
_onPanUpdate
つづいて_onPanUpdate
関数の中身です。
コメントにそれぞれの解説が書いてあるとおりですが、こちらの関数の役割は”触った部分の座標をもとに Polyline インスタンスを生成し、_polylineSet に追加する”です。
_onPanUpdate(DragUpdateDetails details) async {
// 常に新しいポリゴンを描画する
if (ref.watch(clearDrawingProvider.state).state) {
ref.read(clearDrawingProvider.state).state = false;
_clearPolygons();
}
double? x, y;
// Androidの場合、描画距離が明らかに短い
// 恐らくGoogleMapsのバグ
if (Platform.isAndroid) {
x = details.localPosition.dx * 3;
y = details.localPosition.dy * 3;
} else if (Platform.isIOS) {
x = details.localPosition.dx;
y = details.localPosition.dy;
}
if (x != null && y != null) {
int xCoordinate = x.round();
int yCoordinate = y.round();
// 2本指描画を防止するため
if (_lastXCoordinate != null && _lastYCoordinate != null) {
var distance = math.sqrt(math.pow(xCoordinate - _lastXCoordinate!, 2) +
math.pow(yCoordinate - _lastYCoordinate!, 2));
// 点と点の距離が大きいかどうかをチェック
if (distance > 80.0) return;
}
// 座標をキャッシュする
_lastXCoordinate = xCoordinate;
_lastYCoordinate = yCoordinate;
// GoogleMapにおける座標に変換
ScreenCoordinate screenCoordinate =
ScreenCoordinate(x: xCoordinate, y: yCoordinate);
final GoogleMapController controller = await _controller.future;
LatLng latLng = await controller.getLatLng(screenCoordinate);
try {
// 新しいポイントをリストに追加する
_latLngList.add(latLng);
// ポリラインのセットを初期化
_polylineSet.removeWhere(
(polyline) => polyline.polylineId.value == 'user_polyline');
// 新しいポリラインを追加
_polylineSet.add(
Polyline(
polylineId: const PolylineId('user_polyline'),
points: _latLngList,
width: 2,
color: Colors.blue,
),
);
} catch (e) {
if (kDebugMode) {
print(" error painting $e");
}
}
// なぞっている間ポリラインが描画され続ける
ref.read(polygonSetProvider.state).state = {..._polygonSet};
}
}
_onPanEnd
つづいて_onPanEnd
関数の中身です。
こちらの関数の役割は”なぞり終えた際に Polygon インスタンスを生成し、_polygonSet に追加する。”です。
onPanEnd(DragEndDetails details) async {
// 最後にキャッシュされた座標をリセット
_lastXCoordinate = null;
_lastYCoordinate = null;
// ポリゴンのセットを初期化
_polygonSet
.removeWhere((polygon) => polygon.polygonId.value == 'user_polygon');
// 新しいポリゴンの追加
_polygonSet.add(
Polygon(
polygonId: const PolygonId('user_polygon'),
points: _latLngList,
strokeWidth: 2,
strokeColor: Colors.blue,
fillColor: Colors.blue.withOpacity(0.4),
),
);
// 描画状態を終了
ref.read(drawPolygonEnabledProvider.state).update((state) => !state);
}
_toggleDrawing / _clearPolygons
_toggleDrawing
と_clearPolygons
です。
/// 「なぞる」の切り替えを行う関数
_toggleDrawing() {
_clearPolygons();
ref.read(drawPolygonEnabledProvider.state).update((state) => !state);
}
/// PolylineとPolygonとLatLngをクリアする関数
_clearPolygons() {
_latLngList.clear();
_polylineSet.clear();
_polygonSet.clear();
}
_determinePosition
デバイスの位置情報周りの確認を行い、現在地を返す関数です。
『なぞる』が目的であればこちらはオプションになります。
/// デバイスの現在位置を決定する
/// 位置情報サービスが有効でない場合 or 許可されていない場合エラー
Future<Position> _determinePosition() async {
bool serviceEnabled;
LocationPermission permission;
serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
return Future.error('位置情報サービスが無効です。');
}
permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
return Future.error('位置情報を取得する権限がありません。');
}
}
if (permission == LocationPermission.deniedForever) {
return Future.error('位置情報サービスの権限が永久に拒否されています。権限を要求することができません。');
}
Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high);
_loading = false;
ref.read(getUserLocationProvider.state).state =
LatLng(position.latitude, position.longitude);
return position;
}
これにより位置情報サービスを許可するか確認した上で、現在地を取得できるようになります。
GoogleMap
ウィジェットにおいてmyLocationButtonEnabled: true
(デフォルトで true)とすることでマップにボタンが表示され、押下することで現在地まで移動してくれます。
まとめ
コードの全体像です。
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'dart:io';
import 'dart:async';
import 'dart:collection';
import 'dart:math' as math;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:geolocator/geolocator.dart';
final drawPolygonEnabledProvider = StateProvider<bool>((ref) => false);
final clearDrawingProvider = StateProvider<bool>((ref) => false);
final polygonSetProvider = StateProvider<Set<Polygon>>((ref) => {});
final getUserLocationProvider =
StateProvider<LatLng>((ref) => const LatLng(0, 0));
class MyApp extends StatefulHookConsumerWidget {
const MyApp({Key? key}) : super(key: key);
MyAppState createState() => MyAppState();
}
class MyAppState extends ConsumerState<MyApp> {
static final Completer<GoogleMapController> _controller = Completer();
bool _loading = true;
void initState() {
super.initState();
_loading = true;
_determinePosition();
}
int? _lastXCoordinate;
int? _lastYCoordinate;
get _polygonSet => ref.watch(polygonSetProvider);
final Set<Polyline> _polylineSet = HashSet<Polyline>();
final List<LatLng> _latLngList = [];
Widget build(BuildContext context) {
final drawPolygonEnabled = ref.watch(drawPolygonEnabledProvider);
final currentPosition = ref.watch(getUserLocationProvider);
return Scaffold(
body: _loading
? const CircularProgressIndicator()
: GestureDetector(
onPanUpdate: (drawPolygonEnabled) ? _onPanUpdate : null,
onPanEnd: (drawPolygonEnabled) ? _onPanEnd : null,
child: GoogleMap(
mapType: MapType.normal,
initialCameraPosition: CameraPosition(
target: currentPosition,
zoom: 14.4746,
),
polygons: _polygonSet,
polylines: _polylineSet,
myLocationEnabled: true,
myLocationButtonEnabled: false,
onMapCreated: (GoogleMapController controller) {
_controller.complete(controller);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: _toggleDrawing,
backgroundColor: Theme.of(context).colorScheme.background,
child: Icon(drawPolygonEnabled ? Icons.cancel : Icons.edit),
),
);
}
_onPanUpdate(DragUpdateDetails details) async {
if (ref.watch(clearDrawingProvider.state).state) {
ref.read(clearDrawingProvider.state).state = false;
_clearPolygons();
}
double? x, y;
if (Platform.isAndroid) {
x = details.localPosition.dx * 3;
y = details.localPosition.dy * 3;
} else if (Platform.isIOS) {
x = details.localPosition.dx;
y = details.localPosition.dy;
}
if (x != null && y != null) {
int xCoordinate = x.round();
int yCoordinate = y.round();
if (_lastXCoordinate != null && _lastYCoordinate != null) {
var distance = math.sqrt(math.pow(xCoordinate - _lastXCoordinate!, 2) +
math.pow(yCoordinate - _lastYCoordinate!, 2));
if (distance > 80.0) return;
}
_lastXCoordinate = xCoordinate;
_lastYCoordinate = yCoordinate;
ScreenCoordinate screenCoordinate =
ScreenCoordinate(x: xCoordinate, y: yCoordinate);
final GoogleMapController controller = await _controller.future;
LatLng latLng = await controller.getLatLng(screenCoordinate);
try {
_latLngList.add(latLng);
_polylineSet.removeWhere(
(polyline) => polyline.polylineId.value == 'user_polyline');
_polylineSet.add(
Polyline(
polylineId: const PolylineId('user_polyline'),
points: _latLngList,
width: 2,
color: Colors.blue,
),
);
} catch (e) {
if (kDebugMode) {
print(" error painting $e");
}
}
ref.read(polygonSetProvider.state).state = {..._polygonSet};
}
}
_onPanEnd(DragEndDetails details) async {
_lastXCoordinate = null;
_lastYCoordinate = null;
_polygonSet
.removeWhere((polygon) => polygon.polygonId.value == 'user_polygon');
_polygonSet.add(
Polygon(
polygonId: const PolygonId('user_polygon'),
points: _latLngList,
strokeWidth: 2,
strokeColor: Colors.blue,
fillColor: Colors.blue.withOpacity(0.4),
),
);
ref.read(drawPolygonEnabledProvider.state).update((state) => !state);
}
_toggleDrawing() {
_clearPolygons();
ref.read(drawPolygonEnabledProvider.state).update((state) => !state);
}
_clearPolygons() {
_latLngList.clear();
_polylineSet.clear();
_polygonSet.clear();
}
Future<Position> _determinePosition() async {
bool serviceEnabled;
LocationPermission permission;
serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
return Future.error('位置情報サービスが無効です。');
}
permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
return Future.error('位置情報を取得する権限がありません。');
}
}
if (permission == LocationPermission.deniedForever) {
return Future.error('位置情報サービスの権限が永久に拒否されています。権限を要求することができません。');
}
Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high);
_loading = false;
ref.read(getUserLocationProvider.state).state =
LatLng(position.latitude, position.longitude);
return position;
}
}
現在Riverpod
を勉強中でして、急ごしらえでStateProvider
で実装したものになります。
もっといい方法がありましたらコメントいただけますと幸いです 🙇♂️
参考
なぞる
- Flutter —Free Hand Polygon Drawing on Google Maps(GitHub)
- Google Maps Flutter: Marker, circle and polygon.(GitHub)
- 【Flutter】GestureDetector を使って、スワイプしたときの位置を取得する
flutter_config
geolocator
Discussion