はじめに
iOSアプリ開発において、ScrollView内にViewを設置した場合、そのViewにgestureが奪われてしまい、ScrollViewがスクロールできなくなる現象はあるあるかな思います。
その対象のViewによって回避方法は異なると思いますが、MapKitのMKMapView
の場合どのように対応すれば良いでしょうか。
いくつか解決策はありそうですが、今回はGoogleMap
を参考に 2本指で行われたgestureのみを検知する方法 でその問題を回避してみようと思いました。
動作環境
Xcode 15.3
Swift 5
iOS 14+
発生事例
まずは例として下記のようなViewがあるとします。
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が吸収されてしまい、スクロールが出来ずに地図が動いてしまいます。
これを解決するために、UIPanGestureRecognizerを使い、MapViewに対しては二本指のみのScrollを許可するように改修していきます。
ワークアラウンド
まずは、Mapの.isScrollEnabled
をfalse
にし、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は二本指でのみスクロール可能 |
---|---|
コード全体
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
で表示するのが一番楽かなと思いました。
(良さそうな方法をご存知な方いれば教えて頂きたいです🙏)