🦉

【Flutter】Mapで特定座標に任意のWidgetを表示する

2024/09/01に公開

はじめに

Flutter でマップアプリを開発する際、特定の位置に単純なマーカーを配置するだけでなく、任意のウィジェットを表示したいケースがあります。例えば:

  • ユーザーの現在地を動的なアニメーションで表示
  • 目的地に到着するまでの残り時間をリアルタイムで表示
  • 表示内容を動的に変化させて表示

本記事では、Google Maps 上の任意の座標にウィジェットを配置する方法を解説します。

https://pub.dev/packages/google_maps_flutter

実行例

1. マップの表示データ取得

GoogleMapウィジェットのonCameraMoveコールバックを使用して、マップの状態を取得します。

GoogleMap(
  mapType: MapType.normal,
  initialCameraPosition: CameraPosition(
    target: initialPosition,
    zoom: 16.0,
  ),
  onCameraMove: (CameraPosition position) {
    final mapCenter = position.target,
    final zoom = position.zoom,
    final angle = position.bearing
  },
)

2. スクリーン上の位置計算

マップの状態とターゲット座標から、スクリーン上の位置を計算します。

Offset mapToScreenCoordinates({
    required Size screenSize,
    required LatLng targetPosition,
    required LatLng mapCenter,
    required double zoom,
    required double angle,
  }) {
    /// ワールドの幅を計算
    final double worldWidth = 256 * pow(2, zoom).toDouble();

    /// 緯度をy座標に変換(緯度はメルカトル図法で非線形に変換する必要がある)
    double fromLatToY(double lat) {
      final double latRad = lat * pi / 180;
      return log(tan(pi / 4 + latRad / 2));
    }

    final double centerY = fromLatToY(mapCenter.latitude);
    final double targetY = fromLatToY(targetPosition.latitude);

    final double centerX = mapCenter.longitude * pi / 180;
    final double targetX = targetPosition.longitude * pi / 180;

    final double screenX =
        (targetX - centerX) * worldWidth / (2 * pi) + screenSize.width / 2;
    final double screenY =
        (centerY - targetY) * worldWidth / (2 * pi) + screenSize.height / 2;

    // 回転を適用
    final double rotatedX = screenSize.width / 2 +
        (screenX - screenSize.width / 2) * cos(-angle * pi / 180) -
        (screenY - screenSize.height / 2) * sin(-angle * pi / 180);
    final double rotatedY = screenSize.height / 2 +
        (screenX - screenSize.width / 2) * sin(-angle * pi / 180) +
        (screenY - screenSize.height / 2) * cos(-angle * pi / 180);

  return Offset(rotatedX, rotatedY);
}

https://ja.wikipedia.org/wiki/メルカトル図法

3. 任意のウィジェットを配置

計算した位置を使用して、StackOverlayでマップ上にカスタムウィジェットを配置します。

最後に

基本的にマーカーをカスタマイズしたい場合は、Marker クラスで bitmap を使用する方が良いでしょう。しかし、動的にマーカーを変化させたい場合は、本記事で紹介した方法が有効です。

https://pub.dev/documentation/google_maps_flutter/latest/google_maps_flutter/Marker-class.html

コード全文
class MapPage extends StatefulWidget {
  const MapPage({
    super.key,
  });

  
  State<MapPage> createState() => _MapPageState();
}

class _MapPageState extends State<MapPage> with SingleTickerProviderStateMixin {
  final LatLng _tokyoStation = const LatLng(35.681236, 139.767125);
  final double _circleSize = 50.0;

  GoogleMapController? _googleMapController;
  Offset _targetPosition = Offset.zero;

  /// ターゲット座標をスクリーン座標に変換する
  void _mapToScreenCoordinates({
    required LatLng targetPosition,
    required LatLng mapCenter,
    required Size screenSize,
    required double zoom,
    required double angle,
  }) {
    /// ワールドの幅を計算
    final double worldWidth = 256 * pow(2, zoom).toDouble();

    /// 緯度をy座標に変換(緯度はメルカトル図法で非線形に変換る必要がある)
    /// https://ja.wikipedia.org/wiki/%E3%83%A1%E3%83%AB%E3%82%AB%E3%83%88%E3%83%AB%E5%9B%B3%E6%B3%95
    double fromLatToY(double lat) {
      final double latRad = lat * pi / 180;
      return log(tan(pi / 4 + latRad / 2));
    }

    final double centerY = fromLatToY(mapCenter.latitude);
    final double targetY = fromLatToY(targetPosition.latitude);

    final double centerX = mapCenter.longitude * pi / 180;
    final double targetX = targetPosition.longitude * pi / 180;

    final double screenX =
        (targetX - centerX) * worldWidth / (2 * pi) + screenSize.width / 2;
    final double screenY =
        (centerY - targetY) * worldWidth / (2 * pi) + screenSize.height / 2;

    // 回転を適用
    final double rotatedX = screenSize.width / 2 +
        (screenX - screenSize.width / 2) * cos(-angle * pi / 180) -
        (screenY - screenSize.height / 2) * sin(-angle * pi / 180);
    final double rotatedY = screenSize.height / 2 +
        (screenX - screenSize.width / 2) * sin(-angle * pi / 180) +
        (screenY - screenSize.height / 2) * cos(-angle * pi / 180);

    setState(() {
      _targetPosition = Offset(rotatedX, rotatedY);
    });
  }

  
  void dispose() {
    _googleMapController?.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    final screenSize = MediaQuery.of(context).size;

    return Scaffold(
      body: Stack(
        children: [
          SizedBox(
            width: screenSize.width,
            height: screenSize.height,
            child: GoogleMap(
              mapType: MapType.normal,
              initialCameraPosition: CameraPosition(
                target: _tokyoStation,
                zoom: 16.0,
              ),
              onCameraMove: (CameraPosition position) {
                _mapToScreenCoordinates(
                  targetPosition: _tokyoStation,
                  mapCenter: position.target,
                  screenSize: screenSize,
                  zoom: position.zoom,
                  angle: position.bearing,
                );
              },
            ),
          ),

          /// 任意のWidget
          Positioned(
            top: _targetPosition.dy - _circleSize / 2,
            left: _targetPosition.dx - _circleSize / 2,
            child:Container(
              width: _circleSize,
              height: _circleSize,
              decoration: BoxDecoration(
                shape: BoxShape.circle,
                border: Border.all(color: Colors.red, width: 10),
              ),
            ),
          ),

          /// 任意のWidget
          Positioned(
            top: _targetPosition.dy - _circleSize / 2 - 80,
            left: _targetPosition.dx - _circleSize / 2 - 25,
            child: Lottie.asset(
              'assets/lottie/demo.json',
              width: 100,
              height: 100,
            ),
          ),
        ],
      ),
    );
  }
}

Discussion