🗺️

【Flutter】GoogleMap for Flutter あれこれ

2022/07/10に公開
2

GoogleMap for Flutter

google_maps_flutter | Flutter Package

FlutterでGoogleMapを表示する様なアプリを作る際の初期設定や、現在地表示、カスタムmarkerなどを試した記事になります。

開発環境

macOS Monterey バージョン12.1 Apple M1
iOS15.0 iPhone 13 Pro simulator
Android API 32 simulator
Flutter SDK version: 2.10.3
Dart SDK version: 2.16.1

環境構築

google_maps_flutter: ^2.1.3pubspec.yaml に追加します。

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  ...
  google_maps_flutter: ^2.1.3

事前に こちら でGoogle Map用のAPIキーを取得しておきます。

Android

android/app/src/main/AndroidManifest.xml に以下のようにAPIキーを設定します。

<manifest ...
  <application ...
    <meta-data android:name="com.google.android.geo.API_KEY"
               android:value="YOUR KEY HERE"/>

また、android/app/build.gradleminSdkVersion には 20 以上を設定しておく必要があります。

android {
    defaultConfig {
        minSdkVersion 20
    }
}

iOS

ios/Runner/AppDelegate.swift に以下を追加します。

import UIKit
import Flutter
import GoogleMaps // 追加

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    GMSServices.provideAPIKey("YOUR KEY HERE") // 追加
    // gmscore::renderer::GLState::GenBuffers のエラーが表示されている場合は↓を追記
    // GMSServices.setMetalRendererEnabled(true) // 詳細はバッドノウハウ参照
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

実装

まずは公式のサンプルを試してみる

こちらのサンプルをそのまま実装して試しに動かしてみたいと思います。

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';

class MapSample extends StatefulWidget {
  const MapSample({Key? key}) : super(key: key);

  
  State<MapSample> createState() => MapSampleState();
}

class MapSampleState extends State<MapSample> {
  final Completer<GoogleMapController> _controller = Completer();

  static const CameraPosition _kGooglePlex = CameraPosition(
    target: LatLng(37.42796133580664, -122.085749655962),
    zoom: 14.4746,
  );

  static const CameraPosition _kLake = CameraPosition(
      bearing: 192.8334901395799,
      target: LatLng(37.43296265331129, -122.08832357078792),
      tilt: 59.440717697143555,
      zoom: 19.151926040649414);

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: GoogleMap(
        mapType: MapType.hybrid,
        initialCameraPosition: _kGooglePlex,
        onMapCreated: (GoogleMapController controller) {
          _controller.complete(controller);
        },
      ),
      floatingActionButton: FloatingActionButton.extended(
        onPressed: _goToTheLake,
        label: const Text('To the lake!'),
        icon: const Icon(Icons.directions_boat),
      ),
    );
  }

  Future<void> _goToTheLake() async {
    final GoogleMapController controller = await _controller.future;
    controller.animateCamera(CameraUpdate.newCameraPosition(_kLake));
  }
}

以下の様な画面が表示されていればOKです。

現在地を表示する

次に現在地を取得し、GoogleMapを表示する際の初期位置を現在地にしてみたいと思います。

必要なパッケージ

現在位置を取得するパッケージですが、複数ある中で今回は
geolocator | Flutter Package
を使用してみたいと思います。
※ またダイアログ表示の為 adaptive_dialog | Flutter Package も使用しています

pubspec.yaml
dependencies:
    google_maps_flutter: ^2.1.3
    geolocator: ^8.2.1
    adaptive_dialog: ^1.6.3

必要な設定追加

Android

AndroidManifest.xml に以下パーミッションを追加します。

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

※ Backgroundで動かしたい場合は別途設定が必要

iOS

Info.plist に以下を追加します。

<key>NSLocationWhenInUseUsageDescription</key>
<string>This app needs access to location when open.</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>This app needs access to location when in the background.</string>

※ Backgroundで動かしたい場合は別途設定が必要

パーミッション確認

enum LocationSettingResult {
  serviceDisabled,
  permissionDenied,
  permissionDeniedForever,
  enabled,
}

// 位置情報に関するパーミションを確認
Future<LocationSettingResult> checkLocationSetting() async {
  final serviceEnabled = await Geolocator.isLocationServiceEnabled();
  if (!serviceEnabled) {
    logger.w('Location services are disabled.');
    return Future.value(LocationSettingResult.serviceDisabled);
  }
  var permission = await Geolocator.checkPermission();
  if (permission == LocationPermission.denied) {
    permission = await Geolocator.requestPermission();
    if (permission == LocationPermission.denied) {
      logger.w('Location permissions are denied.');
      return Future.value(LocationSettingResult.permissionDenied);
    }
  }

  if (permission == LocationPermission.deniedForever) {
    logger.w('Location permissions are permanently denied.');
    return Future.value(LocationSettingResult.permissionDeniedForever);
  }
  return Future.value(LocationSettingResult.enabled);
}

Future<void> recoverLocationSettings(
    BuildContext context, LocationSettingResult locationResult) async {
  if (locationResult == LocationSettingResult.enabled) {
    return;
  }
  final result = await showOkCancelAlertDialog(
    context: context,
    okLabel: 'OK',
    cancelLabel: 'キャンセル',
    title: 'xxxxxxx',
    message: 'xxxxxxxxxxxx',
  );
  if (result == OkCancelResult.cancel) {
    logger.w('Cancel recover location settings.');
  } else {
    locationResult == LocationSettingResult.serviceDisabled
        ? await Geolocator.openLocationSettings()
        : await Geolocator.openAppSettings();
  }
}

↑では checkLocationSetting で位置情報に関するパーミションを確認して結果を返します。
結果は LocationSettingResult として別途enumで定義しました。
recoverLocationSettings では結果を受けてユーザーにメッセージを表示し、設定画面に移動して再度設定を行うように促しています。

iOSの場合

  • 「一度だけ許可」を選択した場合
    • permission deniedにはならないが、次回起動時に許可ダイアログが再度開く
  • 「App の使用中は許可」を選択した場合
    • permission deniedにはならなず、次回以降も何も聞かれない
  • 「許可しない」を選択した場合
    • permission == LocationPermission.deniedForever

Androidの場合

  • 「アプリの使用時のみ」を選択した場合
    • permission deniedにはならなず、次回以降も何も聞かれない
  • 「今回のみ」を選択した場合
    • permission deniedにはならないが、次回起動時に許可ダイアログが再度開く
  • 「許可しない」を選択した場合
    • permission == LocationPermission.denied となる

また、設定画面で位置情報を使用を「OFF」にしている場合

Geolocator.isLocationServiceEnabled()false となります。
この場合だけ Geolocator.openLocationSettings() で設定画面を表示

現在位置を表示するサンプル

事前のチェック処理が済んだら、現在位置を取得し現在位置でGoogleMapを表示させます。

Future<LatLng> getCurrentLocation() async {
  final position = await Geolocator.getCurrentPosition();
  return LatLng(position.latitude, position.longitude);
}

// 先ほどのサンプルを修正
class MapSampleState extends State<MapSample> {
  // ....
  
  Widget build(BuildContext context) {
    return FutureBuilder<LatLng>(
      future: getCurrentLocation(),
      builder: (BuildContext context, AsyncSnapshot<LatLng> snapshot) {
        if (!snapshot.hasData) {
          return const Center(child: CircularProgressIndicator());
        }
        return GoogleMap(
          mapType: MapType.normal,
          initialCameraPosition: CameraPosition(
              target: snapshot.data ?? defaultLocation, zoom: 17.0),
          myLocationEnabled: true,
          onMapCreated: (GoogleMapController controller) {
            _controller.complete(controller);
          },
        );
      },
    );
  }
}

Markerにカスタム画像を表示する

Add Custom Marker Images to your Google Maps in Flutter

pubspec.yamlassets の設定を行ってない場合、設定します。

  assets:
    - assets/
    - assets/icons/

今回は assets/icons/ic_maker.png を作成して使う前提で進めていきます。

↓先ほどのサンプルを以下の様に修正します。(細かいエラー処理などは省略してます)

class MapSampleState extends State<MapSample> {
  // ....
  BitmapDescriptor? _markerIcon;

  Future<LatLng> _initAsync(BuildContext context) async {
    await _loadPinAsset();

    final result = await checkLocationSetting();
    if (result != LocationSettingResult.enabled) {
      await recoverLocationSettings(context, result);
    }
    return await getCurrentLocation();
  }

  Future<void> _loadPinAsset() async {
    _markerIcon = await BitmapDescriptor.fromAssetImage(
        const ImageConfiguration(size: Size(48, 48)),
        'assets/icons/ic_marker.png');
  }

  Marker _createMarker() {
    return Marker(
      markerId: const MarkerId('marker'),
      position: const LatLng(xxxxx, xxxx),
      icon: _markerIcon ?? BitmapDescriptor.defaultMarker,
      infoWindow: const InfoWindow(title: 'title'),
    );
  }

  
  Widget build(BuildContext context) {
    return FutureBuilder<LatLng>(
      future: _initAsync(context),
      builder: (BuildContext context, AsyncSnapshot<LatLng> snapshot) {
        if (!snapshot.hasData) {
          return const Center(child: CircularProgressIndicator());
        }
        return GoogleMap(
          mapType: MapType.normal,
          initialCameraPosition: CameraPosition(
              target: snapshot.data ?? defaultLocation, zoom: 17.0),
          myLocationEnabled: true,
          onMapCreated: (GoogleMapController controller) {
            _controller.complete(controller);
          },
          markers: Set<Marker>.of(<Marker>{_createMarker()}),
        );
      },
    );
  }
}

Markerにカスタム画像(SVG)を表示する

今度は SVG 画像をMakerとして表示させてみたいと思います。 assets/icons/ic_maker.svg を作成して使う前提で進めていきます。
まずは flutter_svgpubspec.yaml に追加します。

pubspec.yaml
flutter_svg: ^1.0.3

まずはsvg画像をassetsからBitmapDescriptorとして読むこむ処理を実装します。

import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';

Future<BitmapDescriptor?> bitmapDescriptorFromSvgAsset(
    BuildContext context, String assetName, {int w = 32, int h = 32}) async {

  String svgString = await DefaultAssetBundle.of(context).loadString(assetName);
  DrawableRoot svgDrawableRoot = await svg.fromSvgString(svgString, assetName);

  MediaQueryData queryData = MediaQuery.of(context);
  double devicePixelRatio = queryData.devicePixelRatio;
  double width = w * devicePixelRatio;
  double height = h * devicePixelRatio;

  ui.Picture picture = svgDrawableRoot.toPicture(size: Size(width, height));

  ui.Image image = await picture.toImage(width.toInt(), height.toInt());
  ByteData? bytes = await image.toByteData(format: ui.ImageByteFormat.png);
  return bytes != null
      ? BitmapDescriptor.fromBytes(bytes.buffer.asUint8List())
      : null;
}

先ほどpng画像を読み込んでいた箇所を以下に変更してやればOKです。

  Future<void> _loadPinAsset() async {
    _markerIcon = await bitmapDescriptorFromSvgAsset(
        context, 'assets/icons/ic_marker.svg');
  }

バッドノウハウ

  • Unhandled Exceptionが発生する!
E/flutter ( 5711): [ERROR:flutter/lib/ui/ui_dart_state.cc(157)] Unhandled Exception: setState() called after dispose(): _GoogleMapsState#b9489(lifecycle state: defunct, not mounted)
E/flutter ( 5711): This error happens if you call setState() on a State object for a widget that no longer appears in the widget tree (e.g., whose parent widget no longer includes the widget in its build). This error can occur when code calls setState() from a timer or an animation callback.
E/flutter ( 5711): The preferred solution is to cancel the timer or stop listening to the animation in the dispose() callback. Another solution is to check the "mounted" property of this object before calling setState() to ensure the object is still in the tree.
E/flutter ( 5711): This error might indicate a memory leak if setState() is being called because another object is retaining a reference to this State object after it has been removed from the tree. To avoid memory leaks, consider breaking the reference to this object during dispose().

エラーメッセージ通り、一回disposeを呼ぶか、setState時にmountedフラグを確認する if (mounted) { setState() }

  • たまにEXC_BAD_ACCESSでiOSシュミレータが落ちる

エラーログを見て gmscore::renderer::GLState::GenBuffers 関連のログがある場合は、

AppDelegate.swift
GMSServices.setMetalRendererEnabled(true)

を使いすると改善する場合があります。
iOS 14 crash in gmscore::renderer::GLState::GenBuffers [225014752] - Visible to Public - Issue Tracker

参考URL

Discussion

めろんぺんめろんぺん

GMSServices.setMetalRendererEnabled(true)
このコードがあってアプリがクラッシュしていましたので、エラーが出ない場合は書かなくてもいいって書いて欲しいです。。
2時間溶かしました笑

slowhandslowhand

コメントありがとうございます。助かります!
記事修正しました。