🗺️

flutter_mapでMapbox Maps SDK Flutter Pluginを利用する

2024/01/03に公開

はじめに

flutter_mapはFlutterでマップアプリケーションを実装する上でお馴染みのツールで利用したことのある方も多いと思います。google_maps_flutterと同じくらい有名なのではないでしょうか。
flutter_mapがgoogle_maps_flutterよりも優れている点の一つに、タイルレイヤーを自由に設定できるという点があります。弊社のアプリケーションでもこの点でflutter_mapを採用し、タイルレイヤーにMapboxのStatic Tile APIを採用しで独自でデザインしたマップを表示するようにしていました。しかし、このStatic Tile APIがシード期のスタートアップには比較的高額でコストを圧迫するという問題に直面しました。

技術選定

この問題を回避する方法として、flutter_mapの代わりにモバイル用に提供されているSDKを利用する方法があります。flutterでこのSDKを利用できるパッケージは下記の二つです

古くからあるmapbox_glは最近メンテナンスがされていないため、今回の候補からは外しました。もう一つのmapbox_maps_flutterはMapboxが公式で提供しているパッケージのためこちらを利用するのが最も望ましいと考えられます。しかし、このパッケージは出たばかりでまだ全ての機能が利用できず、特にView Annotationsが利用できないのは要件を満たせないためそのまま採用することができませんでした。

解決策と実装

そこで色々調査した結果下記のようなdiscussionsを発見しました!
https://github.com/fleaflet/flutter_map/discussions/1158
この最後にあるgoogle_tile_layer_widget.dartがドンピシャでやりたいことを実現できるだけではなく、元々実装していたflutter_mapのコードをほとんど変えずにMapboxのSDKを利用することができました。(もう本当感謝です泣)
上記コードはGoogle Mapを利用するための実装のため、弊社で実際に利用しているMapbox用のコードを下記に示します。(Mapboxに変えると若干位置ずれが発生するためパラメータは少しいじってます。)

import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'
    show MapCamera, PointExtension, TileLayer;
import 'package:latlong2/latlong.dart';
import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';

const double kMaxZoom = 21;
const double kMinZoom = 3;

class MapboxTileLayer extends StatefulWidget {
  const MapboxTileLayer({
    super.key,
    this.padding = EdgeInsets.zero,
    this.trafficEnabled = false,
    this.buildingsEnabled = false,
    this.compassEnabled = false,
    this.myLocationEnabled = false,
    this.indoorViewEnabled = false,
    this.layoutDirection,
  });
  final EdgeInsets padding;
  final bool trafficEnabled;
  final bool buildingsEnabled;
  final bool compassEnabled;
  final bool myLocationEnabled;
  final bool indoorViewEnabled;
  final TextDirection? layoutDirection;

  @override
  State<MapboxTileLayer> createState() => _MapboxTileLayerState();
}

class _MapboxTileLayerState extends State<MapboxTileLayer> {
  MapboxMap? _controller;

  final String _mapboxAPIKey = const String.fromEnvironment('MAPBOX_API_KEY');

  Future<void> mapEvent(MapCamera mapCamera) async {
    if (_controller != null) {
      final center = convertLatLng(mapCamera);
      final zoom = mapCamera.zoom;
      final rotation = mapCamera.rotation;
      try {
        await _controller!.setCamera(
          CameraOptions(
            bearing: -rotation,
            center: Point(
              coordinates: Position(center.longitude, center.latitude),
            ).toJson(),
            zoom: zoom - 1,
          ),
        );
      } catch (e) {
        debugPrint(e.toString());
      }
    }
  }

  @override
  void initState() {
    super.initState();
  }

  @override
  void dispose() {
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // Mapbox APIキーが設定されてなかったらOpenStreetMapを利用
    if (_mapboxAPIKey == '') {
      return TileLayer(
        urlTemplate:
            'https://tile.openstreetmap.jp/styles/osm-bright-ja/{z}/{x}/{y}.png',
      );
    }
    final mapCamera = MapCamera.of(context);
    mapEvent(mapCamera);
    return Stack(
      alignment: Alignment.center,
      children: [
        IgnorePointer(
          child: MapWidget(
            styleUri: '<利用したいStyleUriを設定>',
            key: const ValueKey('mapWidget'),
            resourceOptions: ResourceOptions(accessToken: _mapboxAPIKey),
            cameraOptions: CameraOptions(
              center: Point(
                coordinates: Position(
                  convertLatLng(mapCamera).longitude,
                  convertLatLng(mapCamera).latitude,
                ),
              ).toJson(),
              zoom: 14,
            ),
            onMapCreated: (MapboxMap mapboxMap) {
              _controller = mapboxMap;
              WidgetsBinding.instance.addPostFrameCallback((_) {
                mapEvent(mapCamera);
                setState(() {});
              });
            },
          ),
        ),
        Container(
          color: Colors.transparent,
        ),
      ],
    );
  }
  LatLng convertLatLng(MapCamera mapCamera) {
    final center = mapCamera.center;
    final zoom = mapCamera.zoom;
    final padding = widget.padding;
    final top = padding.top;
    final right = padding.right;
    final bottom = padding.bottom;
    final left = padding.left;

    final mapCenterPoint = mapCamera.project(center, zoom);
    final adjustedCenterPoint = math.Point(
      mapCenterPoint.x + left / 2 - right / 2,
      mapCenterPoint.y + top / 2 - bottom / 2,
    );
    final rotatePoint =
        mapCamera.rotatePoint(mapCenterPoint, adjustedCenterPoint);

    final rounded = rotatePoint.round(); // optional
    final fixedLatLng = mapCamera.unproject(rounded, zoom);
    return LatLng(fixedLatLng.latitude, fixedLatLng.longitude);
  }
}

下記のように利用できます。

class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return FlutterMap(
      options: MapOptions(
        maxZoom: kMaxZoom,
        minZoom: kMinZoom,
        initialCenter: LatLng(15.5, 30),
        initialZoom: 5,
      ),
      children: [
        MapboxTileLayer(),
      ],
    );
  }
}

最後に

mapbox_maps_flutterは公式が提供しているパッケージのため今後本記事のような実装を利用しなくても実現したいことは叶うようになるかなとも思いますが、今すぐ実装で利用したいと思っている方もいるのではないでしょうか。
そういった方の助けに少しでもなれれば幸いです。

参考

Discussion