2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-11-01

はじめに

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

動作環境

Xcode 15.3
Swift 5
iOS 14+

発生事例

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

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

これを解決するために、UIPanGestureRecognizerを使い、MapViewに対しては二本指のみのScrollを許可するように改修していきます。

ワークアラウンド

まずは、Mapの.isScrollEnabledfalseにし、MapViewのスクロールを不可にします。

private let mapView: MKMapView = {
  let mapView = MKMapView()
  mapView.translatesAutoresizingMaskIntoConstraints = false
+ mapView.isScrollEnabled = false
  return mapView
}()

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

override func viewDidLoad() {
    super.viewDidLoad()
    setupScrollView()
+   adjustMapViewPanGesture()
}
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は二本指でのみスクロール可能

コード全体

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で表示するのが一番楽かなと思いました。
(良さそうな方法をご存知な方いれば教えて頂きたいです🙏)

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?