Skip to content

Commit

Permalink
Merge pull request vospennikov#1 from vospennikov/feature/swiftui_sup…
Browse files Browse the repository at this point in the history
…port

Add SwiftUI support
  • Loading branch information
vospennikov authored Sep 7, 2023
2 parents ee8a6ec + 432be70 commit 0ce2df3
Show file tree
Hide file tree
Showing 115 changed files with 3,203 additions and 1,832 deletions.
3 changes: 3 additions & 0 deletions .periphery.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
retain_public: true
targets:
- ClusterMap
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// CLLocationCoordinate2D.Random.swift
// CLLocationCoordinate2D+Random.swift
// DataSource
//
// Created by Mikhail Vospennikov on 08.02.2023.
Expand Down
19 changes: 19 additions & 0 deletions Example/DataSource/CoordinateRandomizer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// CoordinateRandomizer.swift
// DataSource
//
// Created by Mikhail Vospennikov on 08.02.2023.
//

import Foundation
import MapKit

public struct CoordinateRandomizer {
public init() { }

public func generateRandomCoordinates(count: Int, within region: MKCoordinateRegion) -> [CLLocationCoordinate2D] {
(0..<count).map { _ in
region.randomCoordinate()
}
}
}
22 changes: 0 additions & 22 deletions Example/DataSource/MKAnnotation.Random.swift

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// MKCoordinateRegion.Cities.swift
// MKCoordinateRegion+Cities.swift
// DataSource
//
// Created by Mikhail Vospennikov on 08.02.2023.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// MKCoordinateRegion.Random.swift
// MKCoordinateRegion+Random.swift
// DataSource
//
// Created by Mikhail Vospennikov on 08.02.2023.
Expand All @@ -15,7 +15,7 @@ extension MKCoordinateRegion {
}

extension MKCoordinateRegion {
func randomLocationWithinRegion() -> CLLocationCoordinate2D {
func randomCoordinate() -> CLLocationCoordinate2D {
.random(
minLatitude: minLatitude,
maxLatitude: maxLatitude,
Expand Down
File renamed without changes.
31 changes: 31 additions & 0 deletions Example/Example-AppKit/App/Map/Annotations/ClusterAnnotation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// ClusterAnnotation.swift
// Example-AppKit
//
// Created by Mikhail Vospennikov on 03.09.2023.
//

import ClusterMap
import MapKit

final class ClusterAnnotation: PointAnnotation {
var memberAnnotations = [MKAnnotation]()

override func isEqual(_ object: Any?) -> Bool {
guard let object = object as? ClusterAnnotation else { return false }

if self === object {
return true
}

if coordinate != object.coordinate {
return false
}

if memberAnnotations.count != object.memberAnnotations.count {
return false
}

return memberAnnotations.map(\.coordinate) == object.memberAnnotations.map(\.coordinate)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//
// ClusterAnnotationView.swift
// Example-AppKit
//
// Created by Mikhail Vospennikov on 03.09.2023.
//

import MapKit

final class ClusterAnnotationView: MKAnnotationView {
lazy var countLabel: NSTextField = {
let label = NSTextField()
label.isBezeled = false
label.isEditable = false
label.isSelectable = false
label.maximumNumberOfLines = 1
label.backgroundColor = .clear
label.font = .boldSystemFont(ofSize: 13)
label.textColor = .white
label.alignment = .center
addSubview(label)
return label
}()

override func prepareForDisplay() {
super.prepareForDisplay()

if let annotation = annotation as? ClusterAnnotation {
configure(annotation)
}
}

func configure(_ annotation: ClusterAnnotation) {
countLabel.stringValue = "\(annotation.memberAnnotations.count)"
updateLayout()
}

var backgroundColor: NSColor? {
get {
guard let layer, let backgroundColor = layer.backgroundColor else { return nil }
return NSColor(cgColor: backgroundColor)
}
set {
layer?.backgroundColor = newValue?.cgColor
}
}

private func updateLayout() {
updateFrameSize()
placeLabelToCenter()
drawBackground()
}

private func updateFrameSize() {
countLabel.sizeToFit()
let maxLength = max(countLabel.bounds.width, countLabel.bounds.height)
frame.size = CGSize(width: maxLength, height: maxLength)
}

private func placeLabelToCenter() {
countLabel.frame.origin.x = bounds.width / 2 - countLabel.bounds.width / 2
countLabel.frame.origin.y = bounds.height / 2 - countLabel.bounds.height / 2
}

private func drawBackground() {
layer?.cornerRadius = frame.width / 2
layer?.masksToBounds = true
layer?.borderColor = NSColor.white.cgColor
layer?.borderWidth = 1.5
}
}
19 changes: 19 additions & 0 deletions Example/Example-AppKit/App/Map/Annotations/PointAnnotation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// PointAnnotation.swift
// Example-AppKit
//
// Created by Mikhail Vospennikov on 03.09.2023.
//

import ClusterMap
import Foundation
import MapKit

class PointAnnotation: MKPointAnnotation, CoordinateIdentifiable, Identifiable {
var id: UUID = .init()

override func isEqual(_ object: Any?) -> Bool {
guard let object = object as? PointAnnotation else { return false }
return coordinate == object.coordinate && self === object
}
}
150 changes: 150 additions & 0 deletions Example/Example-AppKit/App/Map/MapViewController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//
// MapViewController.swift
// Example-AppKit
//
// Created by Mikhail Vospennikov on 10.02.2023.
//

import ClusterMap
import Cocoa
import DataSource
import MapKit

final class MapViewController: NSViewController {
private lazy var mapView = MKMapView(frame: .zero)
private lazy var clusterManager = ClusterManager<PointAnnotation>(
configuration: .init(maxZoomLevel: 17, minCountForClustering: 3, clusterPosition: .nearCenter)
)
private var mapViewDelegate: MapViewDelegate?
private lazy var dataSource = CoordinateRandomizer()
private var annotations: [PointAnnotation] = []

override func loadView() {
view = NSView(frame: NSRect(x: 0, y: 0, width: 640, height: 480))
}

override func viewDidLoad() {
super.viewDidLoad()
configureHierarchy()
}

private func configureHierarchy() {
configureMap()
configureMapControls()
}
}

// MARK: - Layout
private extension MapViewController {
func configureMap() {
let mapView = MKMapView(frame: view.bounds)
mapView.isZoomEnabled = true
mapView.isPitchEnabled = false
mapView.isRotateEnabled = false
mapView.isScrollEnabled = true
mapView.showsZoomControls = true
mapView.setRegion(.sanFrancisco, animated: false)
self.mapView = mapView
view.addSubview(mapView)
mapView.autoresizingMask = [.height, .width]

let delegate = MapViewDelegate(
regionDidChange: { newRegion in
Task { await self.reloadMap() }
}
)
mapView.delegate = delegate
mapViewDelegate = delegate
}

func configureMapControls() {
let controlsContainerView = NSStackView()
controlsContainerView.spacing = 16.0
controlsContainerView.orientation = .vertical
controlsContainerView.addArrangedSubview(buildAnnotationActionsControl())

view.addSubview(controlsContainerView)
controlsContainerView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
controlsContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
controlsContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
controlsContainerView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8.0),
])
}

func buildAnnotationActionsControl() -> NSView {
let containerView = NSStackView()
containerView.spacing = 8.0
containerView.orientation = .horizontal
containerView.distribution = .fillProportionally
containerView.addArrangedSubview(
NSButton(title: "Add annotations", target: self, action: #selector(addActionHandler))
)
containerView.addArrangedSubview(
NSButton(title: "Remove annotations", target: self, action: #selector(removeActionHandler))
)
return containerView
}
}

// MARK: - Target handlers
@objc private extension MapViewController {
func addActionHandler(_ sender: NSButton) {
Task {
let annotations = dataSource.generateRandomCoordinates(count: 10000, within: mapView.region)
await addAnnotations(annotations)
}
}

func removeActionHandler(_ sender: NSButton) {
clusterManager.removeAll()
Task { await reloadMap() }
}
}

extension MapViewController {
func addAnnotations(_ coordinates: [CLLocationCoordinate2D]) async {
clusterManager.add(coordinates.map {
let point = PointAnnotation()
point.coordinate = $0
return point
})
async let changes = clusterManager.reload(mapViewSize: mapView.bounds.size, coordinateRegion: mapView.region)
await applyChanges(changes)
}

func reloadMap() async {
async let changes = clusterManager.reload(mapViewSize: mapView.bounds.size, coordinateRegion: mapView.region)
await applyChanges(changes)
}

@MainActor
private func applyChanges(_ difference: ClusterManager<PointAnnotation>.Difference) {
for annotationType in difference.removals {
switch annotationType {
case .annotation(let annotation):
annotations.removeAll(where: { $0 == annotation })
mapView.removeAnnotation(annotation)
case .cluster(let clusterAnnotation):
if let result = annotations.enumerated().first(where: { $0.element.id == clusterAnnotation.id }) {
annotations.remove(at: result.offset)
mapView.removeAnnotation(result.element)
}
}
}
for annotationType in difference.insertions {
switch annotationType {
case .annotation(let annotation):
annotations.append(annotation)
mapView.addAnnotation(annotation)
case .cluster(let clusterAnnotation):
let cluster = ClusterAnnotation()
cluster.id = clusterAnnotation.id
cluster.coordinate = clusterAnnotation.coordinate
cluster.memberAnnotations = clusterAnnotation.memberAnnotations
annotations.append(cluster)
mapView.addAnnotation(cluster)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,18 @@ import Foundation
import MapKit

final class MapViewDelegate: NSObject, MKMapViewDelegate {
private let manager: ClusterManager
var regionDidChange: (MKCoordinateRegion) -> Void

init(manager: ClusterManager) {
self.manager = manager
init(regionDidChange: @escaping (MKCoordinateRegion) -> Void) {
self.regionDidChange = regionDidChange
}

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
switch annotation {
case is ClusterAnnotation:
let identifier = "Cluster"
let annotationView = mapView.annotationView(
of: ClusterView.self,
of: ClusterAnnotationView.self,
annotation: annotation,
reuseIdentifier: identifier
)
Expand All @@ -43,17 +43,15 @@ final class MapViewDelegate: NSObject, MKMapViewDelegate {
}

func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
manager.reload(mapView: mapView) { result in
print(#function, "cluster manager reload result: \(result)")
}
regionDidChange(mapView.region)
}

func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
guard let annotation = view.annotation else { return }

if let cluster = annotation as? ClusterAnnotation {
var zoomRect = MKMapRect.null
for annotation in cluster.annotations {
for annotation in cluster.memberAnnotations {
let annotationPoint = MKMapPoint(annotation.coordinate)
let pointRect = MKMapRect(x: annotationPoint.x, y: annotationPoint.y, width: 0, height: 0)
if zoomRect.isNull {
Expand Down
File renamed without changes.
Loading

0 comments on commit 0ce2df3

Please sign in to comment.