【Flutter】GoogleMapで、ピンのタップ判定を自作した話

# 背景

Flutterで、GoogleMapのパッケージ"google_maps_flutter"を使ったんですが、
ピン(Marker)のタップ判定(onTap:)の範囲が、期待と異なりました。

### 期待するタップ判定の範囲

ピンの画像。

### 実際のタップ判定の範囲

ピンの経緯度を中心とした円。
なので、ピン近くの何も無い所をタップしても反応してしまう。

<img width="250" alt="UnexpectedPinTapArea.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/421601/fcf90482-8f5d-f488-5bc8-0221bd489d9b.gif">

### 実装の一部

```dart:GoogleMap
GoogleMap(
  mapType: MapType.normal,
  initialCameraPosition: sydney,
  onMapCreated: (GoogleMapController controller) {
    _controller.complete(controller);
  },
  // マーカーを生成して設定
  markers: _createMarkers(),
)
```

```dart:ピンの設定および生成
Set<Marker> _createMarkers() {
  return {
    Marker(
      markerId: MarkerId("marker_1"),
      position: sydney,
      // マーカーがタップされた時のコールバックを登録
      onTap: _onTapMarker
    ),
  };
}
```

### Androidネイティブでは、どうか？

FlutterのPlatformViewを使用して、AndroidネイティブでのGoogleMapを試してみました。
しかし、結果は変わらず。

# 本題

自前で作ってやろうと決意し、2つの方法を考えました。

(1) GoogleMapのonTap:を使用し、地図上のどこをタップされたか？を検知する事で、ピンがタップされたか判定する方法
(2) GoogleMapのmarkers:の代わりに、FlutterのButtonでピンを表現する方法

### (1)の方法

やってみた結果、ダメでした。
ピンをタップした時に、GoogleMapのonTap:がコールされなかったです。
おそらく、GoogleMapの内部的には、地図ウィジェットの前面にピンウィジェットを配置しているから、だと思われます。

### (2)の方法

できなくは無いですが、別の大きな問題が発生します。
それは、地図をズームイン/アウトした場合、ピンの位置が追従しない事です。
というのも、"google_maps_flutter"には、ズームイン/アウトされた際にコールバックを呼び出す機能が備わっていないからです。

### 諦めかけたその時

子ウィジェット上のタップされた位置を検知できる"XGestureDetector"というのを発見しました。

https://pub.dev/packages/gesture_x_detector

"XGestureDetector"のchildにGoogleMapを設定する事で、GoogleMapウィジェットのタップされた位置を検出できます。
(1)で問題となったピンであろうとなんであろうと、タップされた位置を検出できます。

### 次の問題(1) 座標系が合っていない

GoogleMapウィジェットにおける、タップされた位置を検出する事は出来ました。
では、その位置情報を元に、どうやってピンがタップされたか判定すればいいのでしょうか？

タップされた位置の情報は、GoogleMapウィジェット上の座標です。
ピンの位置の情報は、経緯度です。
座標系が合っていないため、そのまま比較できず、タップ判定できません。

#### (1)の解決

どの座標系を使うべきでしょうか？

そもそも、今回やりたい事は、ピンのタップ判定範囲をピン画像のみにする事です。
という事は、「ピン画像の幅・高さ」を扱いやすい座標系にすべきです。
なので、GoogleMapウィジェット上の座標系を基準とする事にしました。
また、座標系の単位は、「ピン画像の幅・高さ」の単位であるpixelに決めました。

### 次の問題(2) 地図の視点によって、ピンの位置が変化する
使用する座標系は決めました。
次に考えたのは、そのピンが、画面に表示されている地図上のどこに位置するか？です。
地図の視点を、左に移動させればピンは右へ移動しますし、上に移動させればピンは下へ移動します。

#### (2)の解決

地図中央の経緯度が分かっていれば、ピンが地図上のどこに位置するか？が分かりそうです。

まず、GoogleMapウィジェットにおける、地図中央の座標を取得しましょう。

```dart:地図中央の座標を取得
  final originX = MediaQuery.of(context).size.width / 2;
  final originY = MediaQuery.of(context).size.height / 2;
```

これらを、GoogleMapControllerのgetLatLng()の引数に渡す事で、戻り値で地図中央の経緯度が得られます。
としたい所なんですが、その引数はpixel単位の座標系で渡さなければいけません。
そこで、先程の地図中央の座標をpixel単位に変換します。

```dart:地図中央の座標をpixel単位に変換
  final originPixelX = originX * MediaQuery.of(context).devicePixelRatio;
  final originPixelY = priginY * MediaQuery.of(context).devicePixelRatio;
```

ようやく地図中央の経緯度が得られます。

```dart:地図中央の座標(pixel単位)を経緯度に変換
  // GoogleMapControllerを保持
  final controller = await _controller.future;
  // 地図中央の経緯度を取得
  final originLatLng = await controller.getLatLng(
    ScreenCoordinate(
      x: originPixelX.toInt(),
      y: originPixelY.toInt(),
    ),
  );
```

これで、地図中央の経緯度を原点とした座標系における、ピンの経緯度が算出できます。
すなわち、画面に表示されている地図上のピンの位置が分かります。

```dart:地図中央の経緯度を原点とした座標系における、ピンの経緯度
  final pinLatLngWithOrigin = LatLng(
    latitude: pinLatitude - originLatLng.latitude,
    longitude: pinLongitude - originLatLng.longitude,
  );
```

また、以上より、今後の座標系の原点は、地図中央で考えます。

### 次の問題(3) 経緯度を、地図中央を原点としたGoogleMap上の座標系(pixel単位)に変換する

このために、1[pixel]あたりの経緯度が知りたいです。
しかし、もちろんですが、公式docにはそんな事書いてません。

#### (3)の解決

簡単です。
検証しましょう。

まず、GoogleMapのonTap:を使って、タップされた位置の経緯度を取ります。
次に、GoogleMapControllerのgetScreenCoord()を使って、GoogleMap上の座標系(pixel単位)に変換します。
それぞれの値をログ出力して、メモります。

```dart:1[pixel]あたりの経緯度を知るための検証用コード(GoogleMapのonTapに設定してください)
  // 引数"latLng"は、タップされた位置の経緯度を表す
  void _onTapGoogleMap(latLng) async {
    // GoogleMapControllerを保持
    final controller = await _controller.future;
    // GoogleMap上の座標系(pixel単位)における、タップされた位置を取得
    final coord = await controller.getScreenCoordinate(latLng);
    // タップされた位置における、経緯度とGoogleMap上の座標系(pixel単位)の座標を、ログ出力
    log("LatLng(${latLng.latitude}, ${latLng.longitude}), ScreenCoordinate(${coord.x}, ${coord.y})");
  }
```

以上を2回行い、その差分を取る事で、1[pixel]あたりの経緯度が分かります。

```math
以下のように定義する.('は2回目の計測値を表す.) \\
緯度 : La, La' \\
経度 : Lo, Lo' \\
GoogleMap上の座標系(pixel単位)のx座標 : X, X' \\
GoogleMap上の座標系(pixel単位)のy座標 : Y, Y' \\
1[pixel]あたりの緯度 : La_{pixel} \\
1[pixel]あたりの経度 : Lo_{pixel} \\
```

```math
La_{pixel} = (La - La') / (Y - Y') \\
Lo_{pixel} = (Lo - Lo') / (X - X')
```

したがって、経緯度を、GoogleMap上の座標系(pixel単位)に変換する式は、以下のようになります。

```math
x座標 = 経度 / Lo_{pixel} ・・・(1) \\
y座標 = 緯度 / La_{pixel} ・・・(2) \\
```

### 次の問題(4) ズームの程度によって、1[pixel]あたりの経緯度が変化する

「経緯度を、GoogleMap上の座標系(pixel単位)に変換する」事が出来た！
と思いたいのですが、掲題の問題があります。

これを解決するために、GoogleMapのzoomLevel:による、1[pixel]あたりの経緯度の変化量を知りたいです。
これについては、公式docにヒントがあります。

#### (4)の解決

公式docによると、以下のようです。
> ズームレベルを 1 上げるごとに、画面の世界の幅が 2 倍になります。 

https://developers.google.com/maps/documentation/android-sdk/views?hl=ja#zoom

という事は、ズームレベルが1における、1[pixel]あたりの経緯度を知っておけば、
任意のzoomLevelにおける、1[pixel]あたりの経緯度が、以下の式で求まりそうです。

```math
以下のように定義する. \\
任意のzoomLevel : Z(Z \geqq 1) \\
Z=1における1[pixel]あたりの緯度 : La_{pixel_{z}} \\
Z=1における1[pixel]あたりの経度 : Lo_{pixel_{z}} \\

```

```math
Zにおける1[pixel]あたりの緯度 = \frac{La_{pixel_{z}}}{2^{Z - 1}} ・・・(3) \\
Zにおける1[pixel]あたりの経度 = \frac{Lo_{pixel_{z}}}{2^{Z - 1}} ・・・(4) \\
```

なので、ズームレベルが1における、1[pixel]あたりの経緯度を求めたいです。
これは、[(3)の解決](#-3の解決)を、ズームレベル1で再度行えば求まります。

最終的に、ズームレベル1における、1[pixel]あたりの経緯度は、以下の通りです。

```
ZoomLevel=1における1[pixel]あたりの緯度 = -0.2150696911 
ZoomLevel=1における1[pixel]あたりの経度 =  0.2526855329 
```

### 次の問題(5) ピンの経緯度を、地図中央を原点としたGoogleMap上の座標系(pixel単位)に変換する

再度、変換式を考えます。

#### (5)の解決

まず、特定の経緯度から、GoogleMap上の座標系(pixel単位)の座標を算出する式を求めます。
[(3)の解決](#-3の解決), [(4)の解決](#-4の解決) を組み合わせます。
特定の経度のx座標は、式(1),(4)より、

```math
x = \frac{経度}{\frac{Lo_{pixel_{z}}}{2^{Z - 1}}} ・・・(5) \\
```

特定の緯度のy座標は、式(2),(3)より、

```math
y = \frac{緯度}{\frac{La_{pixel_{z}}}{2^{Z - 1}}} ・・・(6) \\
```

次に、ピンの経緯度から、 **地図中央を原点とした** GoogleMap上の座標系(pixel単位)の座標を算出する式を求めます。
[(2)の解決](#-2の解決), 式(5)(6)より、

```math
x = \frac{ピンの経度 - 地図中央の経度}{\frac{Lo_{pixel_{z}}}{2^{Z - 1}}} ・・・(7) \\
y = \frac{ピンの緯度 - 地図中央の緯度}{\frac{La_{pixel_{z}}}{2^{Z - 1}}} ・・・(8) \\
```

これで、欲しい式は求まりました！
あとはコード化するだけです。

```dart
import 'dart:math' as math;

  // ズームレベル1における、1[pixel]あたりの経緯度
  const latPer1PixelInZoom1 = -0.2150696911 
  const lngPer1PixelInZoom1 =  0.2526855329 

  // ピンの経緯度を、地図中央を原点としたGoogleMap上の座標系(pixel単位)に変換する式
  final pinX = (pinLongitude - originLongitude) / (lngPer1PixelInZoom1 / (math.pow(2, zoomLevel-1))); // 式(7)
  final pinY = (pinLatitude  - originLatitude)  / (latPer1PixelInZoom1 / (math.pow(2, zoomLevel-1))); // 式(8)
```

以上で、地図中央を原点としたGoogleMap上の座標系(pixel単位)における、ピンの座標が分かりました！

### 次の問題(6) タップされた位置を、地図中央を原点としたGoogleMap上の座標系(pixel単位)に変換する

タップされた位置は、既にGoogleMap上の座標系です。
ですので、やるべき事は2つだけです。

　(a) 地図中央を原点とする事
　(b) pixel単位とする事

#### (6)の解決

(a)(b)をコード化します。

```dart:タップされた位置を、地図中央を原点としたGoogleMap上の座標系(pixel単位)に変換する
  // 引数"pos"は、タップされた位置を表す
  void _onTapOnXGestureDetector(pos) {
    // 地図中央の座標を取得
    final originX = MediaQuery.of(context).size.width / 2;
    final originY = MediaQuery.of(context).size.height / 2;
    // (a)　タップされた位置を、地図中央を原点とした座標系に変換
    final tapXWithOrigin = pos.localPos.dx - originX;
    final tapYWithOrigin = pos.localPos.dy - originY;
    // (b) pixel単位に変換
    final tapXWithOriginPixel = tapXWithOrigin * MediaQuery.of(context).devicePixelRatio;
    final tapYWithOriginPixel = tapYWithOrigin * MediaQuery.of(context).devicePixelRatio;
  }
```

これで、地図中央を原点としたGoogleMap上の座標系(pixel単位)における、タップされた位置の座標が分かりました！

そして、[(5)の解決](#-5の解決) では、地図中央を原点としたGoogleMap上の座標系(pixel単位)における、ピンの座標が分かりました。
したがって、両者の比較が可能となります。
すなわち、ピンの位置がタップされたか？の判定が可能となりました！
(但し、現時点では、ピンの位置をピクセル単位で寸分たがわずタップしないと、タップされた判定になりません。)

### 次の問題(7) ピンのタップ判定の範囲を広げる

ここは簡単なので省きます。
おそらく、ここまで読み解く事が出来たあなたなら、コードが頭に浮かんでいる事でしょう。

# 最後に

やってみた感想です。

いやー、やってる最中は無我夢中でしたし、コード量も100行程度だったので、
文書化するのもそこまで難しくないだろうって思ってたんですけどね。
こうして書いてみると、想定より長くなったし、数式が面倒臭いですね。。

あと、ピンのタップ判定範囲がおかしいよねって事で、GoogleにIssue投げてます。

https://issuetracker.google.com/issues/210119725

そのうち修正入るかも？です。

# 追記(2021/12/30)

iOSだと正常に動作しない事が発覚しました。
具体的には、ピンをタップしても反応しません。
そちらは、以下の記事にまとめています。

https://qiita.com/taku_kw/items/14352cfda8b08c4f5067