MKMapViewで最大ズームしてもクラスタリングされてしまう時の回避方法

MKMapViewでクラスタリングした時に、最大ズームしてもクラスタリングが解除されず、ピンが表示されない問題を自分なりに回避したので、その実装方法をメモとして残しておきます。
2020.11.30

概要

大阪オフィスの山田です。MKMapViewでクラスタリングした時に、最大ズームしてもクラスタリングが解除されず、ピンが表示されない問題を自分なりに回避したので、その実装方法をメモとして残しておきます。今回はSwiftUIではなく、UIKitを使っています。

開発環境

  • macOS: 10.15.4
  • Xcode: 12.0.1

準備編

まず準備します。MKMapViewの上にピンを配置し、クラスタリングできるように実装します。

ViewControllerにMKMapViewを配置する

MapKitをimportして、ViewController上にMKMapViewを配置します。コードでMKMapViewを生成していますが、storyboardを使って配置しても同じです。

class ViewController: UIViewController {
    private var mapView: MKMapView!

    override func viewDidLoad() {
        super.viewDidLoad()

        mapView = MKMapView()
        mapView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(mapView)
        mapView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        mapView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
        mapView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        mapView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
    }
}

地図にピンを配置する

地図にピンを配置するメソッドを作っておきます。

class ViewController: UIViewController {
    private var mapView: MKMapView!

    override func viewDidLoad() {
        // ...省略...

        addPoint(latitude: 34.7024, longitude: 135.4937) // 大阪駅
        addPoint(latitude: 34.7331, longitude: 135.5002) // 新大阪駅
    }
}

private extension ViewController {
    func addPoint(latitude: CLLocationDegrees, longitude: CLLocationDegrees) {
        let pin = MKPointAnnotation()
        pin.coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
        mapView.addAnnotation(pin)
    }
}

ピンの表示をカスタマイズする

MKMapViewDelegateを実装します。

class ViewController: UIViewController {
    private var mapView: MKMapView!

    override func viewDidLoad() {
        // ...省略...
        mapView.delegate = self
        mapView.register(CustomAnnotationView.self, forAnnotationViewWithReuseIdentifier: CustomAnnotationView.identifier)

    }
}

extension ViewController: MKMapViewDelegate {
    func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
        let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: CustomAnnotationView.identifier) as? CustomAnnotationView
        annotationView?.setup()
        return annotationView
}

/// ピンのAnnotationView
class CustomAnnotationView: MKAnnotationView {
    static let identifier = "CustomAnnotationView"

    override func prepareForDisplay() {
        super.prepareForDisplay()
        image = UIImage.pinImage
    }

    func setup() {
        clusteringIdentifier = "StationCluster"
    }
}

extension UIImage {
    static let pinImage: UIImage? = {
        let size: CGFloat = 16.0
        let contextSize = CGSize(width: size, height: size)
        UIGraphicsBeginImageContextWithOptions(contextSize, false, 0)
        defer {
            UIGraphicsEndImageContext()
        }
        let fillColor = UIColor.green
        let borderColor = UIColor.blue
        let circlePath = UIBezierPath(ovalIn: CGRect(x: 0.0, y: 0.0, width: size, height: size))
        fillColor.setFill()
        circlePath.fill()
        borderColor.setStroke()
        circlePath.stroke()
        return UIGraphicsGetImageFromCurrentImageContext()
    }()
}

CustomAnnotationViewを独自に定義しておきます。MKMapViewのdelegateメソッドmapView(viewFor:)で画面に表示するAnnotationViewを設定します。dequeueReusableAnnotationViewを使うことで、AnnotationViewを再利用することが可能です。UIImageを拡張して、ピン用の画像を生成できるようにしています。CustomAnnotationViewsetupメソッドを定義して、clusteringIdentifierを設定するようにします。これでクラスタリングされるようになります。

クラスタリングを使ってピンを配置する

クラスター化されたAnnotationViewをカスタムクラスとして定義します。

class CustomeClusterAnnotationView: MKAnnotationView {
    static let identifier = "CustomeClusterAnnotationView"
}

次に、MKMapViewに登録しておきます。

    override func viewDidLoad() {
        super.viewDidLoad()

        // ...省略...
        mapView.register(CustomeClusterAnnotationView.self, forAnnotationViewWithReuseIdentifier: CustomeClusterAnnotationView.identifier)
    }

mapView(viewFor:)でクラスター化されたAnnotationの場合、先ほど定義したCustomeClusterAnnotationViewを表示します。

    func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
        // ...省略...
        if annotation is MKClusterAnnotation {
            return mapView.dequeueReusableAnnotationView(withIdentifier: CustomeClusterAnnotationView.identifier)
        }
        // ...省略...
    }

CustomeClusterAnnotationViewを表示するタイミングで、クラスター用の画像を設定するようにします。

class CustomeClusterAnnotationView: MKAnnotationView {
    static let identifier = "CustomeClusterAnnotationView"

    override func prepareForDisplay() {
        super.prepareForDisplay()
        if annotation is MKClusterAnnotation {
            image = UIImage.clusterImage(count: clusterAnnotation.memberAnnotations.count)
        }
    }
}

extension UIImage {
    static func clusterImage(count: Int) -> UIImage? {
        let size: CGFloat = 33.0
        let contextSize = CGSize(width: size, height: size)
        UIGraphicsBeginImageContextWithOptions(contextSize, false, 0)
        defer {
            UIGraphicsEndImageContext()
        }
        let fillColor = UIColor.red
        let circlePath = UIBezierPath(ovalIn: CGRect(x: 0.0, y: 0.0, width: size, height: size))
        fillColor.setFill()
        circlePath.fill()

        let text = count.description
        let attributes: [NSAttributedString.Key: Any] = [
            .font: UIFont.systemFont(ofSize: 15.0, weight: UIFont.Weight.bold),
            .foregroundColor: UIColor.white,
        ]
        let textRect = CGRect(origin: CGPoint.zero, size: CGSize(width: size, height: size))
        let textBoundingRect = text.boundingRect(
            with: CGSize(width: textRect.width, height: textRect.height),
            options: .usesLineFragmentOrigin,
            attributes: attributes, context: nil)

        let finalRect = CGRect(
            x: textRect.midX - textBoundingRect.width / 2,
            y: textRect.midY - textBoundingRect.height / 2,
            width: textBoundingRect.width,
            height: textBoundingRect.height
        )
        text.draw(in: finalRect, withAttributes: attributes)

        return UIGraphicsGetImageFromCurrentImageContext()
    }
}

ここまでの実装で、地図をズームアウトした時にピンがクラスター化されます。これで準備編は完了です。準備編で用意したソースコードの全てを記載しておきます。

import UIKit
import MapKit

class ViewController: UIViewController {
    private var mapView: MKMapView!

    override func viewDidLoad() {
        super.viewDidLoad()

        // MKMapViewの生成と制約の追加
        mapView = MKMapView()
        mapView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(mapView)
        mapView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        mapView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
        mapView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        mapView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true

        // ピンを追加する
        addPoint(latitude: 34.7025, longitude: 135.4938)   // 大阪駅
        addPoint(latitude: 34.7331, longitude: 135.5002) // 新大阪駅

        // 地図の初期表示を大阪駅周辺にする
        mapView.setRegion(MKCoordinateRegion.osakaStationCoordinateRegion, animated: false)
        mapView.delegate = self
        mapView.register(CustomAnnotationView.self, forAnnotationViewWithReuseIdentifier: CustomAnnotationView.identifier)
        mapView.register(CustomClusterAnnotationView.self, forAnnotationViewWithReuseIdentifier: CustomClusterAnnotationView.identifier)
    }
}

extension ViewController: MKMapViewDelegate {
    func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
        if annotation is MKClusterAnnotation {
            return mapView.dequeueReusableAnnotationView(withIdentifier: CustomClusterAnnotationView.identifier)
        }
        let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: CustomAnnotationView.identifier) as? CustomAnnotationView
        annotationView?.setup()
        return annotationView
    }
}

private extension ViewController {
    /// ピンを追加する
    /// - Parameters:
    ///   - latitude: 緯度
    ///   - longitude: 経度
    func addPoint(latitude: CLLocationDegrees, longitude: CLLocationDegrees) {
        let pin = MKPointAnnotation()
        pin.coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
        mapView.addAnnotation(pin)
    }
}

/// ピンのAnnotationView
class CustomAnnotationView: MKAnnotationView {
    static let identifier = "CustomAnnotationView"

    override func prepareForDisplay() {
        super.prepareForDisplay()
        image = UIImage.pinImage
    }

    func setup() {
        clusteringIdentifier = "StationCluster"
    }
}

/// クラスター化されたピンのAnnotationView
class CustomClusterAnnotationView: MKAnnotationView {
    static let identifier = "CustomClusterAnnotationView"

    override func prepareForDisplay() {
        super.prepareForDisplay()
        if let clusterAnnotation = annotation as? MKClusterAnnotation {
            image = UIImage.clusterImage(count: clusterAnnotation.memberAnnotations.count)
        }
    }
}

extension UIImage {
    /// ピンの画像
    static let pinImage: UIImage? = {
        let size: CGFloat = 16.0
        let contextSize = CGSize(width: size, height: size)
        UIGraphicsBeginImageContextWithOptions(contextSize, false, 0)
        defer {
            UIGraphicsEndImageContext()
        }
        let fillColor = UIColor.green
        let borderColor = UIColor.blue
        let circlePath = UIBezierPath(ovalIn: CGRect(x: 0.0, y: 0.0, width: size, height: size))
        fillColor.setFill()
        circlePath.fill()
        borderColor.setStroke()
        circlePath.stroke()
        return UIGraphicsGetImageFromCurrentImageContext()
    }()

    /// クラスター化されたピンの画像を生成する
    /// - Parameters:
    ///   - count: 中央に表示する数字
    /// - Returns: クラスター化されたピンの画像
    static func clusterImage(count: Int) -> UIImage? {
        let size: CGFloat = 33.0
        let contextSize = CGSize(width: size, height: size)
        UIGraphicsBeginImageContextWithOptions(contextSize, false, 0)
        defer {
            UIGraphicsEndImageContext()
        }
        let fillColor = UIColor.red
        let circlePath = UIBezierPath(ovalIn: CGRect(x: 0.0, y: 0.0, width: size, height: size))
        fillColor.setFill()
        circlePath.fill()

        let text = count.description
        let attributes: [NSAttributedString.Key: Any] = [
            .font: UIFont.systemFont(ofSize: 15.0, weight: UIFont.Weight.bold),
            .foregroundColor: UIColor.white,
        ]
        let textRect = CGRect(origin: CGPoint.zero, size: CGSize(width: size, height: size))
        let textBoundingRect = text.boundingRect(
            with: CGSize(width: textRect.width, height: textRect.height),
            options: .usesLineFragmentOrigin,
            attributes: attributes, context: nil)

        let finalRect = CGRect(
            x: textRect.midX - textBoundingRect.width / 2,
            y: textRect.midY - textBoundingRect.height / 2,
            width: textBoundingRect.width,
            height: textBoundingRect.height
        )
        text.draw(in: finalRect, withAttributes: attributes)

        return UIGraphicsGetImageFromCurrentImageContext()
    }
}

extension MKCoordinateRegion {
    /// 大阪駅周辺のRegion
    static let osakaStationCoordinateRegion: MKCoordinateRegion = {
        let center = CLLocationCoordinate2D(latitude: 34.7024, longitude: 135.4937)
        return MKCoordinateRegion(center: center, latitudinalMeters: 2000, longitudinalMeters: 2000)
    }()
}

動作はこちらになります。

最大ズームした時にクラスタリングされてしまう問題の解決

地理的に近いピンを立ててみる

実際に近いピンを立ててみます。

    override func viewDidLoad() {
        // ...省略...
        // ピンを追加する
        addPoint(latitude: 34.7025, longitude: 135.4938)   // 大阪駅
        addPoint(latitude: 34.73311, longitude: 135.50021) // 新大阪駅 その1
        addPoint(latitude: 34.73312, longitude: 135.50022) // 新大阪駅 その2
        // ...省略...
    }

新大阪駅にとても近いピンを立ててみます。すると以下のように最大ズームしても、クラスター化されたままで、それぞれのピンが個別に表示されなくなりました。

最大ズームしてもクラスター化が解除されない問題を解決する

まずズームしているレベルを取得できるようにします。

fileprivate extension MKMapView {
    var zoomLevel: Double {
        return log2(360.0 * ((Double(self.frame.size.width) / 256.0) / self.region.span.longitudeDelta)) + 1.0
    }
}

世界地図を縦横256のパネル何枚(2^xのx部分)で表示できるか、をズームレベルとします。frameを256で割ることで、端末のサイズに左右されずにズームレベルを算出できるようにしています。この考え方は以下の記事を参考にしています。

それでは以下のように実装していきます。

  • クラスタリングするかしないかのフラグを持つ
  • 初期ではtrueに設定する
  • 一定以上ズームした場合は、クラスタリングするフラグを折る
  • 一定以上ズームアウトした場合は、クラスタリングするフラグを立てる
  • フラグに変更があった場合は、Annotationをセットし直して、ピンを再描画させる
class ViewController: UIViewController {
    // ...省略...
    /// クラスタリングを解除するズームレベルの閾値
    private let clusteringZoomLevelThreshold = 19.0
    /// クラスタリングを行うかどうかのフラグ
    private var clusteringSwitch: Bool = true {
        didSet {
            // Annotationの再描画をするために、annotationをMapに再セットする
            let annotations = mapView.annotations
            mapView.removeAnnotations(mapView.annotations)
            mapView.addAnnotations(annotations)
        }
    }
    // ...省略...
}

extension ViewController: MKMapViewDelegate {
    func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
        if annotation is MKClusterAnnotation {
            return mapView.dequeueReusableAnnotationView(withIdentifier: CustomClusterAnnotationView.identifier)
        }
        let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: CustomAnnotationView.identifier) as? CustomAnnotationView
        annotationView?.setup(isEnableClustering: clusteringSwitch)
        return annotationView
    }

    func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
        // ズーム、ズームアウトするタイミングでズームレベルが閾値を超えているか判断する
        // 閾値を超えた場合は、ON/OFFを切り替える
        if clusteringSwitch {
            if mapView.zoomLevel > clusteringZoomLevelThreshold { clusteringSwitch = false }
        } else {
            if mapView.zoomLevel <= clusteringZoomLevelThreshold { clusteringSwitch = true }
        }
    }
}

/// ピンのAnnotationView
class CustomAnnotationView: MKAnnotationView {
    // ...省略...
    func setup(isEnableClustering: Bool) {
        // クラスタリングしない場合はnilをセットする
        clusteringIdentifier = isEnableClustering ? "StationCluster" : nil
    }
}

fileprivate extension MKMapView {
    var zoomLevel: Double {
        return log2(360.0 * ((Double(self.frame.size.width) / 256.0) / self.region.span.longitudeDelta)) + 1.0
    }
}

以上の実装を入れることで、ある一定以上ズームした場合はクラスタリングが解除され、ズームアウトしたら再度クラスタリングされるようになりました。

最後に

お店にピンを打つといった場合、東京だと同一建物内に2件あったりするので、わりとあるある問題だと思ってます。 人はなぜ都会に集まってしまうのか。