Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Share knowledge on maintaining SwiftUI frames #40

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 73 additions & 5 deletions Sources/Annotations/MKMapAnnotationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,75 @@
import MapKit
import SwiftUI

class MKMapAnnotationView<Content: View>: MKAnnotationView {
class MKMapAnnotationView<Content: View>: MKAnnotationView, UpdatableAnnotationView {

// MARK: Stored Properties

private var controller: NativeHostingController<Content>?
private var mapAnnotation: ViewMapAnnotation<Content>?
private let deferAddingContentForPreviews: Bool = false
private let colorizeFramesForDebugging: Bool = false

// MARK: Methods

func update(with mapAnnotation: MapAnnotation) {
guard let mapAnnotation = mapAnnotation as? ViewMapAnnotation<Content> else {
assertionFailure("Attempting to update an MKMapAnnotationView with an incompatible type")
return
}
self.mapAnnotation = mapAnnotation

controller?.rootView = mapAnnotation.content
if #available(iOS 16.0, *) {
anchorPoint = mapAnnotation.anchorPoint
} else {
centerOffset = convertToCenterOffset(mapAnnotation.anchorPoint, in: bounds)
}
setNeedsLayout()
}

func setup(for mapAnnotation: ViewMapAnnotation<Content>) {
annotation = mapAnnotation.annotation
clusteringIdentifier = mapAnnotation.clusteringIdentifier
self.mapAnnotation = mapAnnotation

if !deferAddingContentForPreviews {
addContentIfNeeded()
}
}

private func addContentIfNeeded() {
guard let mapAnnotation else { return }
guard controller == nil else { return }

let controller = NativeHostingController(rootView: mapAnnotation.content)
addSubview(controller.view)
bounds.size = controller.preferredContentSize
self.controller = controller

if colorizeFramesForDebugging {
backgroundColor = .red
controller.view.backgroundColor = .green
} else {
controller.view.backgroundColor = .clear
}
frame.size = controller.view.intrinsicContentSize
addSubview(controller.view)
controller.view.frame = bounds

if #available(iOS 16.0, *) {
anchorPoint = mapAnnotation.anchorPoint
} else {
centerOffset = convertToCenterOffset(mapAnnotation.anchorPoint, in: bounds)
}
}

func convertToCenterOffset(_ anchorPoint: CGPoint, in rect: CGRect) -> CGPoint {
assert((0.0...1.0).contains(anchorPoint.x), "Valid anchor point range is 0.0 to 1.0, received x value: \(anchorPoint.x)")
assert((0.0...1.0).contains(anchorPoint.y), "Valid anchor point range is 0.0 to 1.0, received y value: \(anchorPoint.y)")

return .init(
x: (0.5 - anchorPoint.x) * rect.width,
y: (0.5 - anchorPoint.y) * rect.height
)
}

// MARK: Overrides
Expand All @@ -35,9 +88,22 @@ class MKMapAnnotationView<Content: View>: MKAnnotationView {
override func layoutSubviews() {
super.layoutSubviews()

if let controller = controller {
bounds.size = controller.preferredContentSize
// In previews, the height of the annotation view ends up being too big, I have no idea why there's a difference, but
// when run on device it's not noticeable
if deferAddingContentForPreviews && frame.origin != .zero {
addContentIfNeeded()
}

guard let controller = controller else { return }

bounds.size = controller.view.intrinsicContentSize
// Setting the frame to zero than immediately back triggers
// The SwiftUI frame to correctly follow the hosting view's frame in
// The map's coordinate space. I think SwiftUI cannot correctly hook into the parent
// view's coordinate updates because MKMapView moves the MKAnnotationView's around in
// non-standard ways.
controller.view.frame = .zero
controller.view.frame = bounds
}

#endif
Expand All @@ -51,6 +117,8 @@ class MKMapAnnotationView<Content: View>: MKAnnotationView {
controller?.view.removeFromSuperview()
controller?.removeFromParent()
controller = nil
mapAnnotation = nil
annotation = nil
}

}
Expand Down
4 changes: 4 additions & 0 deletions Sources/Annotations/MapAnnotation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ public protocol MapAnnotation {

}

protocol UpdatableAnnotationView {
func update(with associatedAnnotation: MapAnnotation)
}

extension MapAnnotation {

static var reuseIdentifier: String {
Expand Down
5 changes: 5 additions & 0 deletions Sources/Annotations/ViewMapAnnotation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,27 +43,32 @@ public struct ViewMapAnnotation<Content: View>: MapAnnotation {
public let annotation: MKAnnotation
let clusteringIdentifier: String?
let content: Content
let anchorPoint: CGPoint

// MARK: Initialization

public init(
coordinate: CLLocationCoordinate2D,
anchorPoint: CGPoint = CGPoint(x: 0.5, y: 0.5),
title: String? = nil,
subtitle: String? = nil,
clusteringIdentifier: String? = nil,
@ViewBuilder content: () -> Content
) {
self.annotation = Annotation(coordinate: coordinate, title: title, subtitle: subtitle)
self.anchorPoint = anchorPoint
self.clusteringIdentifier = clusteringIdentifier
self.content = content()
}

public init(
annotation: MKAnnotation,
anchorPoint: CGPoint = CGPoint(x: 0.5, y: 0.5),
clusteringIdentifier: String? = nil,
@ViewBuilder content: () -> Content
) {
self.annotation = annotation
self.anchorPoint = anchorPoint
self.clusteringIdentifier = clusteringIdentifier
self.content = content()
}
Expand Down
14 changes: 14 additions & 0 deletions Sources/Map/Map+Coordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,10 @@ extension Map {

private func updateAnnotations(on mapView: MKMapView, from previousView: Map?, to newView: Map) {
let changes: CollectionDifference<AnnotationItems.Element>
var updates: [AnnotationItems.Element] = []
if let previousView = previousView {
changes = newView.annotationItems.difference(from: previousView.annotationItems) { $0.id == $1.id }
updates = newView.annotationItems.filter { newItem in previousView.annotationItems.contains { newItem.id == $0.id } }
} else {
changes = newView.annotationItems.difference(from: []) { $0.id == $1.id }
}
Expand Down Expand Up @@ -111,6 +113,18 @@ extension Map {
annotationContentByID.removeValue(forKey: item.id)
}
}

// For SwiftUI, we need to forward the updated content to annotations so that values captured
// in the view builder can be updated. This is necessary for views that take inputs to update
for updatedItem in updates {
guard let oldAnnotationContent = annotationContentByID[updatedItem.id] else { continue }

// This isn't necessarily the best architecture long term, but it gets the job done
guard let currentView = mapView.view(for: oldAnnotationContent.annotation) as? UpdatableAnnotationView else {
continue
}
currentView.update(with: newView.annotationContent(updatedItem))
}
}

private func updateCamera(on mapView: MKMapView, context: Context, animated: Bool) {
Expand Down