Reputation: 68
I have custom map annotations that will display a label when they are positioned near the center of the screen. I am not sure how to calculate the physical position of the map annotations on the map. Doing this with a scroll view and geometry reader is quite simple, but how can this be done with a Map View?
I tried to use this SO solution but it didn't work as I would need to use a MKMapView view controller. And I was not able to find a way to use custom annotations with a MKMapView view controller. Additionally I would like to calculate the zoom or scale of the map to modify the size of the annotations. Any pointers would be greatly appreciated.
Copy Paste-able code
import SwiftUI
import MapKit
struct LocationsView: View {
@EnvironmentObject private var vm: LocationsViewModel
@State var scale: CGFloat = 0.0
var body: some View {
ZStack {
mapLayer.ignoresSafeArea()
}
}
}
// --- WARNINGS HERE --
extension LocationsView {
private var mapLayer: some View {
Map(coordinateRegion: $vm.mapRegion,
annotationItems: vm.locations,
annotationContent: { location in
MapAnnotation(coordinate: location.coordinates) {
//my custom map annotations
Circle().foregroundStyle(.red).frame(width: 50, height: 50)
.onTapGesture {
vm.showNextLocation(location: location)
}
}
})
}
}
class LocationsViewModel: ObservableObject {
@Published var locations: [LocationMap] // All loaded locations
@Published var mapLocation: LocationMap { // Current location on map
didSet {
updateMapRegion(location: mapLocation)
}
}
@Published var mapRegion: MKCoordinateRegion = MKCoordinateRegion() // Current region on map
let mapSpan = MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1)
init() {
let locations = LocationsDataService.locations
self.locations = locations
self.mapLocation = locations.first!
self.updateMapRegion(location: locations.first!)
}
private func updateMapRegion(location: LocationMap) {
withAnimation(.easeInOut) {
mapRegion = MKCoordinateRegion(
center: location.coordinates,
span: mapSpan)
}
}
func showNextLocation(location: LocationMap) {
withAnimation(.easeInOut) {
mapLocation = location
}
}
}
struct LocationMap: Identifiable {
var id: String = UUID().uuidString
let name: String
let cityName: String
let coordinates: CLLocationCoordinate2D
}
class LocationsDataService {
static let locations: [LocationMap] = [
LocationMap(name: "Colosseum", cityName: "Rome", coordinates: CLLocationCoordinate2D(latitude: 41.8902, longitude: 12.4922)),
LocationMap(name: "Pantheon", cityName: "Rome", coordinates: CLLocationCoordinate2D(latitude: 41.8986, longitude: 12.4769)),
LocationMap(name: "Trevi Fountain", cityName: "Rome", coordinates: CLLocationCoordinate2D(latitude: 41.9009, longitude: 12.4833))
]
}
struct SwiftfulMapAppApp: View {
@StateObject private var vm = LocationsViewModel()
var body: some View {
VStack {
LocationsView().environmentObject(vm)
}
}
}
#Preview(body: {
SwiftfulMapAppApp()
})
Upvotes: 1
Views: 359
Reputation: 270683
You should migrate to the new Map
APIs added in iOS 17.
First, change mapRegion
to a MapCameraPosition
:
@Published var mapCameraPosition = MapCameraPosition.automatic
so that you can do Map(position: $vm.mapCameraPosition) { ... }
You can set this to a MKCoordinateRegion
like this (in updateMapRegion
):
withAnimation(.easeInOut) {
mapCameraPosition = .region(MKCoordinateRegion(
center: location.coordinates,
span: mapSpan))
}
Second, I would add a new property in LocationMap
to indicate whether its label should be shown.
struct LocationMap: Identifiable {
let id: String = UUID().uuidString
let name: String
let cityName: String
let coordinates: CLLocationCoordinate2D
var shouldShowName = false // <---
}
This new property can then be set in onMapCameraChange
:
private var mapLayer: some View {
MapReader { mapProxy in
Map(position: $vm.mapCameraPosition) {
ForEach(vm.locations) { location in
Annotation(
location.shouldShowName ? location.name : "",
coordinate: location.coordinates) {
Circle().foregroundStyle(.red).frame(width: 50, height: 50)
.onTapGesture {
vm.showNextLocation(location: location)
}
}
}
}
.onMapCameraChange(frequency: .continuous) { context in
guard let center = mapProxy.convert(context.region.center, to: .local) else { return }
for i in vm.locations.indices {
if let point = mapProxy.convert(vm.locations[i].coordinates, to: .local) {
// the label should be shown when the annotation is within 50 points from the centre of the map
vm.locations[i].shouldShowName = abs(point.x - center.x) < 50 && abs(point.y - center.y) < 50
} else {
vm.locations[i].shouldShowName = false
}
}
}
}
}
Note that I am passing location.shouldShowName ? location.name : ""
as the label of the annotation. MapKit will automatically decide where and when to show this label, in addition to your own logic. If this is undesirable, build your own label e.g. as an overlay of the circle.
Full code:
struct LocationsView: View {
@EnvironmentObject private var vm: LocationsViewModel
var body: some View {
ZStack {
mapLayer.ignoresSafeArea()
}
}
private var mapLayer: some View {
MapReader { mapProxy in
Map(position: $vm.mapCameraPosition) {
ForEach(vm.locations) { location in
Annotation(
location.shouldShowName ? location.name : "",
coordinate: location.coordinates) {
Circle().foregroundStyle(.red).frame(width: 50, height: 50)
.onTapGesture {
vm.showNextLocation(location: location)
}
}
}
}
.onMapCameraChange(frequency: .continuous) { context in
guard let center = mapProxy.convert(context.region.center, to: .local) else { return }
for i in vm.locations.indices {
if let point = mapProxy.convert(vm.locations[i].coordinates, to: .local) {
vm.locations[i].shouldShowName = abs(point.x - center.x) < 250 && abs(point.y - center.y) < 250
} else {
vm.locations[i].shouldShowName = false
}
}
}
}
}
}
class LocationsViewModel: ObservableObject {
@Published var locations: [LocationMap] // All loaded locations
@Published var mapLocation: LocationMap { // Current location on map
didSet {
updateMapRegion(location: mapLocation)
}
}
@Published var mapCameraPosition = MapCameraPosition.automatic
let mapSpan = MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1)
init() {
let locations = LocationsDataService.locations
self.locations = locations
self.mapLocation = locations.first!
self.updateMapRegion(location: locations.first!)
}
private func updateMapRegion(location: LocationMap) {
withAnimation(.easeInOut) {
mapCameraPosition = .region(MKCoordinateRegion(
center: location.coordinates,
span: mapSpan))
}
}
func showNextLocation(location: LocationMap) {
withAnimation(.easeInOut) {
mapLocation = location
}
}
}
struct LocationMap: Identifiable {
let id: String = UUID().uuidString
let name: String
let cityName: String
let coordinates: CLLocationCoordinate2D
var shouldShowName = false
}
class LocationsDataService {
static let locations: [LocationMap] = [
LocationMap(name: "Colosseum", cityName: "Rome", coordinates: CLLocationCoordinate2D(latitude: 41.8902, longitude: 12.4922)),
LocationMap(name: "Pantheon", cityName: "Rome", coordinates: CLLocationCoordinate2D(latitude: 41.8986, longitude: 12.4769)),
LocationMap(name: "Trevi Fountain", cityName: "Rome", coordinates: CLLocationCoordinate2D(latitude: 41.9009, longitude: 12.4833))
]
}
struct SwiftfulMapAppApp: View {
@StateObject private var vm = LocationsViewModel()
var body: some View {
VStack {
LocationsView().environmentObject(vm)
}
}
}
Upvotes: 3
Reputation: 31
you can create class of custom annotation like below
class CustomAnnotation: NSObject, MKAnnotation {
var coordinate: CLLocationCoordinate2D
var location: SearchLocation?
var sizeOf : CGRect?
var title: String?
var subtitle: String?
init(location: SearchLocation? , sizeOf: CGRect? , coordinate: CLLocationCoordinate2D) {
self.location = location
self.sizeOf = sizeOf
self.coordinate = coordinate
self.title = location?.title ?? ""
self.subtitle = location?.full_address ?? ""
}
}
class CustomAnnotationView: MKAnnotationView {
override var annotation: MKAnnotation? {
willSet {
guard let customAnnotation = newValue as? CustomAnnotation else { return }
canShowCallout = false
// Create a container view
let containerView = UIView(frame: customAnnotation.sizeOf ?? CGRect(x: 0, y: 0, width: 40, height: 40))
// Create the image view for the user photo
let imageView = UIImageView(frame: CGRect(x: 8, y: 2, width: 24, height: 24))
let url = URL(string: Constant.COMMON_IMG_URL + (customAnnotation.location?.vendor_image ?? "") )
print(url)
imageView.kf.setImage(with: url,placeholder: offerPlaceHolderSquare)
imageView.contentMode = .scaleAspectFill
imageView.layer.cornerRadius = 12
imageView.clipsToBounds = true
// Add the image view to the container view
containerView.addSubview(imageView)
// Create the pin background
let pinImageView = UIImageView(frame: containerView.bounds)
pinImageView.image = UIImage(named: "pin2") // Add your custom pin background image to your assets
containerView.insertSubview(pinImageView, at: 0)
// Set the container view as the annotation view
addSubview(containerView)
// Set the frame of the annotation view to fit the container view
frame = containerView.frame
centerOffset = CGPoint(x: 0, y: -frame.size.height / 2)
}
}
}
after that you can apply that like below code
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
guard let customAnnotation = annotation as? CustomAnnotation else { return nil }
let identifier = "CustomAnnotationView"
var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? CustomAnnotationView
if annotationView == nil {
annotationView = CustomAnnotationView(annotation: customAnnotation, reuseIdentifier: identifier)
} else {
annotationView?.annotation = customAnnotation
}
return annotationView
}
and you can do zoomin and zoom out like below
func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
view.transform = CGAffineTransform(scaleX: 1.5, y: 1.5)
}
func mapView(_ mapView: MKMapView, didDeselect view: MKAnnotationView) {
view.transform = CGAffineTransform.identity
}
Upvotes: 1