TEAM LICENSES: Save money and learn new skills through a Hacking with Swift+ team license >>

SOLVED: Updating the map region per item in DetailView

Forums > SwiftUI

Working on the Milestone project for day 78 with MapKit. How would you create a DetailView of a location on a map where each location from a list of populted locations has its own view?

Calling @Published var mapRegion: MKCoordinateRegion = MKCoordinateRegion() results in the coordinates (0,0) and I can't seem to find a way to update this variable.

I'm having trouble finding out where in the code I should initiate the mapRegion: in the ViewModel or in the DetailView?

Recommendations much appreciated.

See an example of DetailView and ViewModel:

DetailView

import SwiftUI
import MapKit

struct DetailView: View {
    let location: Location

  // Several trials
  //    @State private var mapRegion: MKCoordinateRegion = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 37.0, longitude: -112.0), span: MKCoordinateSpan(latitudeDelta: 25, longitudeDelta: 25))
  //    @Binding var mapRegion: MKCoordinateRegion

    @StateObject private var viewModel = ViewModel()

    var body: some View {
        ZStack {
            Map(coordinateRegion: $viewModel.mapRegion)
        }
    }
}

struct DetailView_Previews: PreviewProvider {
    static var previews: some View {
        DetailView(location: Location.example) 
    }
}

ViewModel

MainActor class ViewModel: ObservableObject {
    @Published private(set) var locations: [Location]

    @Published var mapRegion: MKCoordinateRegion = MKCoordinateRegion()

    let savePath = FileManager.documentsDirectory.appendingPathComponent("SavedLocations")

    let locationFetcher = LocationFetcher()

    init() {
        do {
            let data = try Data(contentsOf: savePath)
            locations = try JSONDecoder().decode([Location].self, from: data)
            self.updateMapRegion(location: locations.first!)
        } catch {
            locations = []
        }
    }

    private func updateMapRegion(location: Location) {
        withAnimation(.easeInOut) {
            mapRegion = MKCoordinateRegion(center: location.coordinate, span: MKCoordinateSpan(latitudeDelta: 25, longitudeDelta: 25))
        }
    }
}

Another option I could think of is changing DetailView to the following and update MapRegion based on the input coordinates in the Location struct:


struct DetailView: View {
    let location: Location
    @State private var mapRegion: MKCoordinateRegion?

    var body: some View {
        ZStack {
            Map(coordinateRegion: mapRegion?)
        }
        .onAppear(perform: loadMap)
    }

    func loadMap() {
        mapRegion = location.createMapRegion()
        // in Location:
        // func createMapRegion() -> MKCoordinateRegion {
        // MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), span: // MKCoordinateSpan(latitudeDelta: 25, longitudeDelta: 25))
    }
}

2      

You can either update the Map with a Button or you do it onAppear. Bear in mind onAppear is only called once when the view first appears.

If you choose to use a ViewModel put the code for the loadMap in the viewModel and call it from there. This is personal preference and not a general rule. For a simple example like that one could choose not to use a ViewModel at all. But it's nothing wrong with it.

2      

I might have set up my project quite a bit different from the way you did, but I'm not sure how different they are from how much code you have shared here. They might be similar enough for this to be helpful...

But I added a property to my NamedFace struct, so it now has an id, name, image, and meetLocation.

struct NamedFace: Codable, Comparable, Equatable, Identifiable {
    var id: UUID
    var name: String
    var image: UIImage?
    var meetLocation: CLLocationCoordinate2D?

    // More code for this struct here
}

So, I am only using the LocationFetcher in my main view, ContentView and it is declared in ContentView-ViewModel. It only fetches the last known location at the time when a new NamedFace is being added. Then, that location is added to the NamedFace instance just before it gets added to my array of namedFaces, which are shown in a List.

Then, when you tap on one of your items in your list, it would take you to the DetailView (I have actually named mine FaceDisplayView instead) and there should only be one location that needs to be shown in that view. That is, the meetLocation that has been stored as a property in the NamedFace that was tapped on.

I also have a FaceDisplayView-ViewModel, where you might have a DetailView-ViewModel. This is where I have added the mapRegion and locations array properties. However... I have done that a bit differently than you did as well...

import SwiftUI
import MapKit

extension FaceDisplayView {
  struct IdentifiableLocation: Identifiable {
    var id: UUID
    var coordinate: CLLocationCoordinate2D
  }

  @MainActor class ViewModel: ObservableObject {
    @Published var showingMeetLocation: Bool
    @Published var mapRegion: MKCoordinateRegion

    let namedFace: NamedFace

    var annotations: [IdentifiableLocation] {
      var array = [IdentifiableLocation]()
      if let meetLocation = namedFace.meetLocation {
        array.append(IdentifiableLocation(id: UUID(), coordinate: meetLocation))
      }
      return array
    }

    init(namedFace: NamedFace) {
      self.namedFace = namedFace
      self.showingMeetLocation = false
      let mapSpan = MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
      let mapCenter = namedFace.meetLocation ?? CLLocationCoordinate2D(latitude: 0, longitude: 0)
      self.mapRegion = MKCoordinateRegion(center: mapCenter, span: mapSpan)
    }
  }
}

For one thing, I didn't initialize mapRegion with a value at all when I declared it. It just gets set in the initializer. However, in my project, that will mean that it is either set to the lastKnownLocation that was set when the NamedFace was created, or if the lastKnownLocation wasn't available at that time, then it will just use (0,0) and show a map in the middle of the ocean, which I decided I was fine with.

You really only set the mapRegion when the map is first created. That is what tells it what point the map should be centered on, and how far it is zoomed in when the map first loads. From there, the mapRegion is automatically adjusted anytime the user drags around on, or zooms in or out on the map. But if you use the MKCoordinateRegion() initializer with no parameters to set it, then it is going to default to a center point at (0,0).

For another thing, I just made my annotations property (what I think is your locations property) a computed property. It basically just always returns an array with one item in it. That is, the NamedFace.meetLocation. However, it is being converted into an IdentifiableLocation first, and I can't remember right now exactly why I needed it to be identifiable.

Also, I have a showingMeetLocation property in there, but that is just because I decided to only make the map view show up with the tap of a button. If you make yours show up instantly when the view loads, you probably won't need that.

Sorry if this doesn't exactly answer your question, or if I showed you more of my code than you wanted. But I didn't exactly know how to answer this in any other way.

3      

@Fly0strich Thank you very much for taking me through your approach! I've been trying to implement the version of using let namedFace: NamedFace instead of the array that I'm calling. As initializing this struct seems more flexible by adding the variables you actually want to update automatically, like the self.mapRegion. But unsuccessful so far. When I move the mapRegion variable to the ContentView-ViewModel I get the error to not initialize all the defined properties. So I would like to use your approach to solve that, but don't know yet how.

As such I went back to my original approach and using a Button instead of an .onAppear @Hatsushira Would you have any suggestions how to solve the error: "Cannot convert value of type '() -> MKCoordinateRegion' to expected argument type '() -> Void'"?

import SwiftUI
import MapKit

struct DetailView: View {
    var location: Location

    @StateObject private var viewModel = ViewModel()

    @State var mapRegion: MKCoordinateRegion

    var body: some View {
        ZStack {
            Map(coordinateRegion: $mapRegion, annotationItems: viewModel.locations) { location in
                MapMarker(coordinate: location.coordinate)
            }
        }
        .toolbar {
            Button("Update Map View", action: updateMapRegion)
        }
        .ignoresSafeArea()
    }

    func updateMapRegion() -> MKCoordinateRegion {
        mapRegion = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude), span: MKCoordinateSpan(latitudeDelta: 25, longitudeDelta: 25))
    }
}

struct DetailView_Previews: PreviewProvider {
    static var previews: some View {
        DetailView(location: Location.example, mapRegion: MKCoordinateRegion())
    }
}

2      

@Hatsushira Would you have any suggestions how to solve the error: "Cannot convert value of type '() -> MKCoordinateRegion' to expected argument type '() -> Void'"?

I would need to see the code where this happens. Basically, it says your closure returned a value of type MKCoordinateRegion but was supposed to return nothing (aka Void).

3      

This method is returning a MKCoordinateRegion when it doesnot return anything only update mapRegion, try and remove -> MKCoordinateRegion

func updateMapRegion() -> MKCoordinateRegion {
    mapRegion = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude), span: MKCoordinateSpan(latitudeDelta: 25, longitudeDelta: 25))
}

3      

Thank you all so much! (Learning so much from my errors here)

func updateMapRegion (leaving out the returned type) displays all marked items on the map.

Updating the map view seemed so simple, referring to the mapRegion.center coordinates directly.

.onAppear {
        mapRegion.center = location.coordinate
}

2      

Hacking with Swift is sponsored by Superwall.

SPONSORED Superwall lets you build & test paywalls without shipping updates. Run experiments, offer sales, segment users, update locked features and more at the click of button. Best part? It's FREE for up to 250 conversions / mo and the Superwall team builds out 100% custom paywalls – free of charge.

Learn More

Sponsor Hacking with Swift and reach the world's largest Swift community!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.