🎨
SwiftUIにて、MapKitでオーバレイをタップ可能にする話
MapKit上でポリゴン・線分や円を描画したいとき、MKOverlayを使います。2022/6/18現在、MKOverlayに対するタップイベント処理を登録するAPIはありません。UITapGestureRecognizerによるUIViewのタップ検知、およびOverlayのpathとの重なりチェックにより実現できたので、その方法を記載します。
動作プレビュー
1. Viewのタップイベントを登録する
SwiftUIのMapKit上でタップを検出するための処理フローは以下の通りです。
- UIViewRepresentableを継承したViewを作る
- このViewのCoodinatorクラスにて、マップをタップしたときの処理を定義する。MapView上でのピクセル座標(CGPoint)を
gesture.location
で取得しておく。ピクセル座標をprocessTapEvent関数に渡し、ここでoverlayとの接触判定する - 2.の処理をUITapGestureRecognizerのセレクタとして登録する
import SwiftUI
import MapKit
/* 1. MKMapViewをSwiftUI上で扱うための定義 */
struct TappableMapView: UIViewRepresentable {
let initialRegion: MKCoordinateRegion
let onOverlayTapped: ((MKOverlay) -> Void)?
let mapView = MKMapView()
func makeUIView(context: Context) -> MKMapView {
mapView.delegate = context.coordinator
mapView.region = initialRegion
/* 3. 本Viewをタップしたときのイベント処理を定義・登録する */
let gesture = UITapGestureRecognizer(
target: context.coordinator,
action: #selector(Coordinator.tapped)
)
mapView.addGestureRecognizer(gesture)
return mapView
}
func updateUIView(_ uiView: MKMapView, context: Context) {
context.coordinator.update(mapView, from: self, context: context)
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func processTapEvent(
for mapView: MKMapView,
on touchLocation: CGPoint
) {
/* マップ部分のViewをタップしたときの処理(後述) */
}
}
class Coordinator: NSObject, MKMapViewDelegate {
private var prevMap: TappableMapView?
/* 2. OverlayMapViewをタップしたときのイベント処理 */
@objc func tapped(gesture: UITapGestureRecognizer) {
guard let mapView = gesture.view as? MKMapView else { return }
let point = gesture.location(in: mapView)
prevMap?.processTapEvent(for: mapView, on: point)
}
}
2. Viewのタップ位置から、どのoverlayをタップしたか判定する
先ほど定義したprocessTapEventに対して、以下の処理を実装します。
- MapView上のピクセル座標を緯度経度に変換する
- overlayごとに、overlayの種類に応じたRendererを作成する。なお、MKOverlayPathRendererはpathを生成しない(=nilになる)ので、MKPolylineRendererなどの具体的なRendererを使う必要がある
- 緯度経度を、overlayに対する相対座標に変換する
- 4.2で得た座標がrenderer.pathに含まれるか判定する
func processTapEvent(
for mapView: MKMapView,
on touchLocation: CGPoint
) {
let locationCoordinate = mapView.convert(
touchLocation, toCoordinateFrom: mapView.self)
// ★ polylineがあった場合に備え、マップのズーム率に応じた倍率を計算する
// 参考: https://dev.classmethod.jp/articles/avoid_clustering_when_max_zoom/
let zoomLevel = log2(360.0 * ((Double(mapView.frame.size.width) / 256.0) / mapView.region.span.longitudeDelta)) + 1.0
let scale = pow(2, 20 - zoomLevel)
let tappedItems = mapView.overlays.filter { overlay in
guard let renderer: MKOverlayPathRenderer = mapView.renderer(for: overlay) as? MKOverlayPathRenderer else { return false }
// タップした緯度経度を、overlayに対する相対座標に変換する
let currentMapPoint: MKMapPoint = MKMapPoint(locationCoordinate)
let viewPoint: CGPoint = renderer.point(for: currentMapPoint)
var targetPath = renderer.path
// polylineの場合、pathは幅を持たないので幅を追加する必要がある
// pathの幅は、マップのズーム率に応じて調整が必要
if overlay is MKPolyline || overlay is MKMultiPolyline {
targetPath = targetPath?.copy(
strokingWithWidth: renderer.lineWidth * scale * UIScreen.main.scale,
lineCap: renderer.lineCap,
lineJoin: renderer.lineJoin,
miterLimit: renderer.miterLimit
)
}
guard let targetPath = targetPath else { return false }
return targetPath.contains(viewPoint)
}
// 後から追加されたoverlayほど上位に表示されるので、最後の要素のみタップ判定する
if let tappedItem = tappedItems.last {
onOverlayTapped?(tappedItem)
}
}
なお、MKPolylineやMKMultiPolylineの場合、pathに幅を持たせる必要があります。もし幅を持たせない場合、線分が囲っている領域に含まれているかどうかで判定され、意図しない挙動となります。
pathに追加する幅ですが、単にrenderer.lineWidthを追加してもNGです。追加する幅は、マップエリアの大きさに比例して増やす必要があります。1回のズームアウト(ダブルタップしたときのズームアウト)でマップのエリア幅は2倍になります。なので、上記コードの★の部分の通り計算しています。
3.サンプルコード
- ライブラリ部分(pauljohanneskraft/Mapライブラリをforkし、上記タップ処理に対応したもの): https://github.com/Niccari/Map (ブランチ名:
feature/tappable_overlay
) - サンプルコード例: https://github.com/Niccari/swiftui-mapkit-overlay-tap-sample
Appendix.1: pauljohanneskraft/Mapライブラリについて
SwiftUI上でMapKitを扱うためライブラリです。overlayやannotationを内部的に差分更新しながら、表示を更新できるようになっています。以下例のように、宣言的にoverlayやannotationの追加・削除をできるようになっています。
Map(
coordinateRegion: $initialRegion, // マップの初期座標
annotationItems: annotationItems,
annotationContent: {_ in
/*
annotationItems一つ一つを、ライブラリで定義しているMapAnnotationクラスにマッピングする
*/
},
overlayItems: overlayItems,
overlayContent: { styled in
/*
overlayItems一つ一つを、ライブラリで定義しているMapOverlayを実装したクラスにマッピングする。
以下は一例。styledは
- points: [CLLocationCoordinate2D]
- strokeColor: UIColor
- lineWidth: CGFloat
を持つものとする
*/
MapPolyline(
polyline: MKPolyline(
coordinates: styled.points,
count: styled.points.count
),
level: nil,
strokeColor: styled.strokeColor,
lineWidth: styled.lineWidth
)
}
)
使い方での注意点は以下です。
- annotationItemsおよびoverlayItemsはともにIdentifiableプロトコルを満たす必要があります。そのため、idをプロパティに持つ必要があります。
- overlay, annotationのパラメータが変わった場合もidを変えないといけません。内部的には各データのidでのみ差分判定をしているので、idを変えないとoverlayないしannotationが更新されません。
Appendix.2: MacOS向けに対応するには?
MacOSではUIKitの代わりにAppKitを利用します。その上で以下の通り置き換えます。
- UITapGestureRecognizer -> NSClickGestureRecognizer
- UIScreen.main.scale -> NSScreen.main?.backingScaleFactor ?? defaultValue // (1.0 or 2.0)
Discussion