MKMapView で中心座標をちょっとずらした位置で表示させる

たまには、このような需要があるのではないでしょうか：地図をなるべく大きく表示したいのですが、画面の一部にバナーやボタンを置きたいので、地図の中心座標をその分ずらしたいですね。例えば下記のような感じで、全画面で地図を表示したいのですが、下の水色の部分に何かを置きたいです。そしてこの状態で東京駅を中心に移動したいです。ところがこのまま東京駅の座標を指定するとマップビューのど真ん中に東京駅が来るので、下の水色の部分とのバランスが悪いから、東京駅を真ん中ではなく少し上にずらして、水色がない部分の中心を東京駅にしたいですね。

![スクリーンショット-2022-12-24-11.49.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63998/66d4d8a4-816a-f2af-b152-0fff9cf24932.png)

残念ながら、`MKMapView` の純正 API では、このような動きを直接対応するものがありません。特定の座標を、画面の中央以外の特定の場所に移動する API はありません。一瞬 `setVisibleMapRect(_:edgePadding:animated:)` メソッドの `edgePadding` 引数に期待しましたが、残念ながらこの API はあくまで指定した `MapRect` 範囲をビューに収まるように調整するものですので、中央位置の座標だけでなく画面全体の範囲も必要になってくるから、非常に面倒です。

なのでここは別のアプローチを取ることにしました：東京駅ではなく、東京駅から下にある程度ずらした座標を中央に持ってくるようにします。これなら下の水色のビューの高さと、地図ビュー自体の高さ及び一番上と一番下の緯度の差分が分かれば計算が楽なのですぐにできますね。

```swift:MKMapView+.swift
extension MKMapView {
    
    private func latitudeBottomOffset(byPadding bottomPadding: CGFloat) -> CGFloat {
        guard bounds.height > 0 else { return 0 }
        let latitudeDelta = region.span.latitudeDelta
        let bottomOffset = (bottomPadding / 2) / bounds.height
        let latitudeOffset = bottomOffset * latitudeDelta
        return latitudeOffset
    }
    
}
```

上記の計算はまず画面上で表示されている緯度の差分、すなわちビューの一番下のところの緯度から一番上のところの緯度を引いた数値を `region.span.latitudeDelta` で求めます。次にどれほどずらしたいかを、画面の高さとの比率で求めます。ここで `bottomPadding` がしたの水色のビューの高さで、それを 2 で割ってさらに地図ビュー自身の高さで割れば求められます。最終的にこれらを掛ければ、どれくらいの緯度をずらせばいいかがわかります。ただしここで気をつけたいのは 0 で割っても数学的に意味がないし、そもそも地図ビューの高さが 0 なら実質何も表示されていないので、地図ビュー自身の高さが 0 以上でないとダメだから、万が一そうなったらここのオフセットを 0 にした方ばいいでしょう。

この緯度の計算ができたら、それを考慮した中心座標を `setCenter(_:animated:)` でセットすればいいですね：

```swift:MKMapView+.swift
extension MKMapView {
    
    func setCenter(_ center: CLLocationCoordinate2D, bottomPadding: CGFloat, animated: Bool = false) {
        let latitudeOffset = latitudeBottomOffset(byPadding: bottomPadding)
        let offsetCenter = CLLocationCoordinate2D(latitude: center.latitude - latitudeOffset, longitude: center.longitude)
        setCenter(offsetCenter, animated: animated)
    }
    
}
```

この方法の最大のメリットは、もし例えばタッチ操作で画面を移動しても、移動後の中心座標も、オフセットを考慮した座標が簡単に取れますし、また画面の表示範囲を考慮する必要が一切ないのと、ずらす差分自体を保存したりしないので、画面をズームしたり、何度も指定の座標に移動しても、縮尺が変わることはありません。

```swift:MKMapView+.swift
extension MKMapView {
    
    func center(offsetByBottomPadding bottomPadding: CGFloat) -> CLLocationCoordinate2D {
        let originalCenter = region.center
        let latitudeOffset = latitudeBottomOffset(byPadding: bottomPadding)
        let offsetCenter = CLLocationCoordinate2D(latitude: originalCenter.latitude + latitudeOffset, longitude: originalCenter.longitude)
        return offsetCenter
    }
    
}
```

あと気をつけないといけないのは、例えば初期表示時の地図範囲が決まってある場合、一回先に `setRegion(_:animated:)` で表示範囲をセットしてから、もう一回 `setCenter(_:bottomPadding:animated:)` を呼び出さないといけないです。なぜなら一回こうしておかないと、画面上の正確な `region.span.latitudeDelta` が決まらないです（頑張って画面サイズなどから計算して取れないことはないですが、まあ面倒ですね）。

最終的に VC 側はこのように実装します

```swift:MapVC.swift
final class MapVC: UIViewController {

    private lazy var mapView = {
        let view = MKMapView()
        view.delegate = self
        return view
    }()
    private lazy var overlayView = UIView()
    
    var initialCenter = CLLocationCoordinate2D(latitude: 35.681236, longitude: 139.767125) // 東京駅
    
    var bottomPadding: CGFloat {
        get {
            overlayHeightConstraint.constant
        }
        set {
            overlayHeightConstraint.constant = newValue
        }
    }
    
    var center: CLLocationCoordinate2D {
        mapView.center(offsetByBottomPadding: bottomPadding)
    }
    
    private lazy var overlayHeightConstraint = {
        let constraint = overlayView.heightAnchor.constraint(equalToConstant: 300)
        constraint.isActive = false
        return constraint
    }()
    
    private lazy var onceAfterLayout: Void = {
        let initialRegion = MKCoordinateRegion(center: initialCenter, latitudinalMeters: 2_500, longitudinalMeters: 2_500)
        mapView.setRegion(initialRegion, animated: false)
        mapView.setCenter(initialCenter, bottomPadding: bottomPadding)
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupMapView()
        setupOverlayView()
    }
    
    private func setupMapView() {
        let targetView = mapView
        targetView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(targetView)
        targetView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        targetView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        targetView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        targetView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
    }
    
    private func setupOverlayView() {
        let targetView = overlayView
        targetView.translatesAutoresizingMaskIntoConstraints = false
        targetView.backgroundColor = .systemMint
        targetView.alpha = 0.7
        view.addSubview(targetView)
        targetView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        targetView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        overlayHeightConstraint.isActive = true
        targetView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
        let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(backToInitialCenter(sender:)))
        targetView.addGestureRecognizer(tapRecognizer)
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        _ = onceAfterLayout
    }
    
    @objc
    func backToInitialCenter(sender: AnyObject? = nil) {
        let animated = sender is UITapGestureRecognizer
        mapView.setCenter(initialCenter, bottomPadding: bottomPadding, animated: animated)
    }

}

extension MapVC: MKMapView {
    
    func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
        // マップビュー移動時の処理
    }
    
}
```

これでマップビューをどんなに移動したりズームしても、水色の部分をタップするとまた東京駅のところに戻ります。

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63998/3e7a5041-c0e6-35cc-ee3d-79b0393eb474.gif)