🎨

SwiftUIにて、MapKitでオーバレイをタップ可能にする話

2022/06/18に公開

MapKit上でポリゴン・線分や円を描画したいとき、MKOverlayを使います。2022/6/18現在、MKOverlayに対するタップイベント処理を登録するAPIはありません。UITapGestureRecognizerによるUIViewのタップ検知、およびOverlayのpathとの重なりチェックにより実現できたので、その方法を記載します。

動作プレビュー

動作例

1. Viewのタップイベントを登録する

SwiftUIのMapKit上でタップを検出するための処理フローは以下の通りです。

  1. UIViewRepresentableを継承したViewを作る
  2. このViewのCoodinatorクラスにて、マップをタップしたときの処理を定義する。MapView上でのピクセル座標(CGPoint)をgesture.locationで取得しておく。ピクセル座標をprocessTapEvent関数に渡し、ここでoverlayとの接触判定する
  3. 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に対して、以下の処理を実装します。

  1. MapView上のピクセル座標を緯度経度に変換する
  2. overlayごとに、overlayの種類に応じたRendererを作成する。なお、MKOverlayPathRendererはpathを生成しない(=nilになる)ので、MKPolylineRendererなどの具体的なRendererを使う必要がある
  3. 緯度経度を、overlayに対する相対座標に変換する
  4. 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.サンプルコード

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