🐬

【Flutter】Riverpod / Google Mapsでマップをなぞる

2022/08/22に公開

はじめに

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のプロパティとしてonPanUpdateonPanEndを定義しています。どちらも『なぞる』状態ではたらく関数を定義していて、中身は後述します。

onPanUpdate => ドラッグ操作で位置が変化したときにコール
onPanEnd => ドラッグ操作が終了したときにコール

GoogleMapのプロパティにはpolylinespolygonsがあり、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_config

geolocator

GitHubで編集を提案

Discussion