この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
概要
大阪オフィスの山田です。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を拡張して、ピン用の画像を生成できるようにしています。CustomAnnotationView
のsetup
メソッドを定義して、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件あったりするので、わりとあるある問題だと思ってます。 人はなぜ都会に集まってしまうのか。