[iOS] MKMapViewで2本指のみのgestureを許可する

## はじめに
iOSアプリ開発において、ScrollView内にViewを設置した場合、そのViewにgestureが奪われてしまい、ScrollViewがスクロールできなくなる現象はあるあるかな思います。
その対象のViewによって回避方法は異なると思いますが、MapKitの`MKMapView`の場合どのように対応すれば良いでしょうか。
いくつか解決策はありそうですが、今回は`GoogleMap`を参考に __2本指で行われたgestureのみを検知する方法__ でその問題を回避してみようと思いました。

## 動作環境
Xcode 15.3
Swift 5
iOS 14+

## 発生事例
まずは例として下記のようなViewがあるとします。

```swift: MultipleTouchMapViewController.swift
import UIKit
import MapKit

final class MultipleTouchMapViewController: UIViewController {
    private let mapView: MKMapView = {
        let mapView = MKMapView()
        mapView.translatesAutoresizingMaskIntoConstraints = false
        return mapView
    }()

    private let scrollView: UIScrollView = {
        let scrollView = UIScrollView()
        scrollView.backgroundColor = .gray
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        scrollView.alwaysBounceHorizontal = false
        return scrollView
    }()

    private let contentView: UIView = {
        let view = UIView()
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupScrollView()
    }
}

private extension MultipleTouchMapViewController {
    func setupScrollView() {
        view.addSubview(scrollView)
        scrollView.addSubview(contentView)
        contentView.addSubview(mapView)

        NSLayoutConstraint.activate([
            scrollView.topAnchor.constraint(equalTo: view.topAnchor),
            scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),

            contentView.topAnchor.constraint(equalTo: scrollView.topAnchor),
            contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
            contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
            contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
            contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
            contentView.heightAnchor.constraint(equalToConstant: 1000),

            mapView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
            mapView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            mapView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            mapView.heightAnchor.constraint(equalToConstant: 400),
            mapView.widthAnchor.constraint(equalTo: contentView.widthAnchor)
        ])
    }
}
```

上記は、高さの大きいScrollViewの中心に`MKMapView`があるUIを作成しています。
しかし、このコードでMapViewを起点としてスクロールをしようとすると、MapViewにgestureが吸収されてしまい、スクロールが出来ずに地図が動いてしまいます。

![画面収録 2024-11-01 17.38.29.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1745924/fc4aebc8-6072-a67c-ca29-8ca117440996.gif)

これを解決するために、[UIPanGestureRecognizer](https://developer.apple.com/documentation/uikit/uipangesturerecognizer)を使い、MapViewに対しては二本指のみのScrollを許可するように改修していきます。

## ワークアラウンド
まずは、Mapの`.isScrollEnabled`を`false`にし、MapViewのスクロールを不可にします。
```diff_swift:
private let mapView: MKMapView = {
  let mapView = MKMapView()
  mapView.translatesAutoresizingMaskIntoConstraints = false
+ mapView.isScrollEnabled = false
  return mapView
}()
```

そして、UIPanGestureRecognizerの定義を行います。
細かい説明はコード内に記述しています。

```diff_swift
override func viewDidLoad() {
    super.viewDidLoad()
    setupScrollView()
+   adjustMapViewPanGesture()
}
```
```swift
func adjustMapViewPanGesture() {
   let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handleTwoFingerPan(_:)))
   // 最小で2本の指でGestureを行う
   panGesture.minimumNumberOfTouches = 2
   // 2本のみに限定したいので、最大数にも2を設定
   panGesture.maximumNumberOfTouches = 2
   mapView.addGestureRecognizer(panGesture)
}

@objc func handleTwoFingerPan(_ gestureRecognizer: UIPanGestureRecognizer) {
   //ユーザーの指が対象のView上（今回はmapView)でどれだけ移動したかを示すベクトル
   let translation = gestureRecognizer.translation(in: mapView)
   // 次回の移動量が累積されないようにリセット
   gestureRecognizer.setTranslation(.zero, in: mapView)
   var region = mapView.region

   // マップビューの幅と高さをピクセル単位で取得
   let mapWidthInPixels = mapView.bounds.size.width
   let mapHeightInPixels = mapView.bounds.size.height
   // 経度の変化量を計算
   let lonDelta = -Double(translation.x / mapWidthInPixels) * region.span.longitudeDelta
   // 緯度の変化量を計算
   let latDelta = Double(translation.y / mapHeightInPixels) * region.span.latitudeDelta
   // 新しい中心座標を計算
   let newCenter = CLLocationCoordinate2D(
       latitude: region.center.latitude + latDelta,
       longitude: region.center.longitude + lonDelta
    )
    // マップの中心座標を更新
    region.center = newCenter
    // マップビューに新しい表示領域を適用（アニメーションなしで即時反映）
    mapView.setRegion(region, animated: false)
}
```
この`handleTwoFingerPan`を通して、2本指で行われたGestureから移動量を計算し、Mapを都度更新することで、ScrollView内でもGestureが干渉することなく操作できるようになります。

| ScrollViewのgestureを奪わない | Mapは二本指でのみスクロール可能 |
| --- | --- |
| ![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1745924/8fc21b21-845b-02cc-7f87-1ce8fb61f903.gif) |  ![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1745924/a48de473-faca-9f65-cf3e-ac139b1fc7f0.gif) |

## コード全体
```swift: MultipleTouchMapViewController.swift
import UIKit
import MapKit

final class MultipleTouchMapViewController: UIViewController {
    private let mapView: MKMapView = {
        let mapView = MKMapView()
        mapView.translatesAutoresizingMaskIntoConstraints = false
        mapView.isScrollEnabled = false
        return mapView
    }()

    private let scrollView: UIScrollView = {
        let scrollView = UIScrollView()
        scrollView.backgroundColor = .gray
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        scrollView.alwaysBounceHorizontal = false
        return scrollView
    }()

    private let contentView: UIView = {
        let view = UIView()
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupScrollView()
        adjustMapViewPanGesture()
    }
}

private extension MultipleTouchMapViewController {
    func setupScrollView() {
        view.addSubview(scrollView)
        scrollView.addSubview(contentView)
        contentView.addSubview(mapView)

        NSLayoutConstraint.activate([
            scrollView.topAnchor.constraint(equalTo: view.topAnchor),
            scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),

            contentView.topAnchor.constraint(equalTo: scrollView.topAnchor),
            contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
            contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
            contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
            contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
            contentView.heightAnchor.constraint(equalToConstant: 1000),

            mapView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
            mapView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            mapView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            mapView.heightAnchor.constraint(equalToConstant: 400),
            mapView.widthAnchor.constraint(equalTo: contentView.widthAnchor)
        ])
    }

    func adjustMapViewPanGesture() {
        let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handleTwoFingerPan(_:)))
        panGesture.minimumNumberOfTouches = 2
        panGesture.maximumNumberOfTouches = 2
        mapView.addGestureRecognizer(panGesture)
    }

    @objc func handleTwoFingerPan(_ gestureRecognizer: UIPanGestureRecognizer) {
        let translation = gestureRecognizer.translation(in: mapView)
        gestureRecognizer.setTranslation(.zero, in: mapView)
        var region = mapView.region

        let mapWidthInPixels = mapView.bounds.size.width
        let mapHeightInPixels = mapView.bounds.size.height
        let lonDelta = -Double(translation.x / mapWidthInPixels) * region.span.longitudeDelta
        let latDelta = Double(translation.y / mapHeightInPixels) * region.span.latitudeDelta
        let newCenter = CLLocationCoordinate2D(
            latitude: region.center.latitude + latDelta,
            longitude: region.center.longitude + lonDelta
        )

        region.center = newCenter
        mapView.setRegion(region, animated: false)
    }
}
```

 ## まとめ
 `WKMapView`に対して`UIPanGestureRecognizer`を使用し二本指で行われたgestureのみを監視・計算することで、ScrollGestureに干渉せず動作させることが可能になりました。
 
 SwiftUIでGesture等を拡張してうまくワークアラウンドできないかなと模索していたのですが、おそらく上記VCを`UIViewControllerRepresentable`で表示するのが一番楽かなと思いました。
 （良さそうな方法をご存知な方いれば教えて頂きたいです🙏）