PMaier
PMaier

Reputation: 654

SwiftUI: Trigger view update after user pans the map

I'm new to Swift/SwiftUI, and so please forgive me if this is trivial.

In an app I am attempting to retrieve the user's location, and fetch nearby websites from Wikipedia to display. A simple example is below:

//
//  ContentView.swift
//  MRE
//
//  Created by Philipp Maier on 2/25/22.
//

import SwiftUI
import CoreLocationUI
import MapKit
import Foundation

struct ContentView: View {
    
    enum ViewState {
        case waiting, fetching
    }
    
    @State private var viewState = ViewState.waiting
    @StateObject private var viewModel = LocationViewModel()
    
    // some values to initialize
    @State var listEntries = [
        Geosearch(pageid: 45348219,
                  title: "Addison Apartments",
                  lat: 35.21388888888889,
                  lon: -80.84472222222222,
                  dist: 363.7 ),
        Geosearch(pageid: 35914731,
                  title: "Midtown Park (Charlotte, North Carolina)",
                  lat: 35.2108,
                  lon: -80.8363,
                  dist: 1034.5    )
    ]
    
    var body: some View {
        NavigationView{
            VStack{
                ZStack(alignment: .leading){
                    Map(coordinateRegion: $viewModel.mapRegion, showsUserLocation: true)
                         .frame(height: 300)
                    LocationButton(.currentLocation, action: {
                        viewModel.requestAllowLocationPermission()
                        viewState = .fetching
                        print("Button Press: Latitude: \(viewModel.mapRegion.center.latitude), Longitude: \(viewModel.mapRegion.center.longitude)")
                    })
                }
                HStack{
                    Text("Latitude: \(viewModel.mapRegion.center.latitude), Longitude: \(viewModel.mapRegion.center.longitude)")
                }
                
                switch viewState {
                case .waiting:
                    Text("Waiting for your location")
                    Spacer()
                case .fetching:
                    
                    List{
                        ForEach(listEntries) {location in
                            NavigationLink(destination: Text("Target") ) {
                                HStack(alignment: .top){
                                    Text(location.title)
                                }
                            }
                            .task{
                                await fetchNearbyLandmarks(lat: viewModel.mapRegion.center.latitude, lon: viewModel.mapRegion.center.longitude)
                            }
                        }
                    }
                    
                }
            }
        }
    }
    func fetchNearbyLandmarks(lat: Double, lon: Double) async {
        
        let urlString = "https://en.wikipedia.org/w/api.php?action=query&list=geosearch&gscoord=\(lat)%7C\(lon)&gsradius=5000&gslimit=25&format=json"
        
        print(urlString)
        
        guard let url = URL(string: urlString) else {
            print("Bad URL: \(urlString)")
            return
            
        }
        
        do {
            
            let (data, _) = try await URLSession.shared.data(from: url)
            let items = try JSONDecoder().decode(wikipediaResult.self, from: data)
            
            listEntries = items.query.geosearch
            print ("Loadingstate: Loaded")
            
        } catch {
            print ("Loadingstate: Failed")
            
        }
    }
}



final class LocationViewModel: NSObject, ObservableObject, CLLocationManagerDelegate {
    
    @Published var mapRegion = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 40, longitude: -80.5), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
   
    let locationManager = CLLocationManager()
    
    override init() {
        super.init()
        locationManager.delegate = self
    }
    
    func requestAllowLocationPermission() {
        locationManager.requestLocation()
    }
    
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let latestLocation = locations.first else {
            print("Location Error #1")
            return
        }
        
        DispatchQueue.main.async {
            self.mapRegion = MKCoordinateRegion(center: latestLocation.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1))
        }
       
    }
    
    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print("Location Error #2:")
        print(error.localizedDescription)
    }
    
}


struct wikipediaResult: Codable {
    let batchcomplete: String
    let query: Query
}

struct Query: Codable {
    let geosearch: [ Geosearch ]
}

struct Geosearch: Codable, Identifiable {
    
    var id: Int { pageid }
    let pageid: Int
    let title: String
    let lat: Double
    let lon: Double
    let dist: Double
}



struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

The core function of the app works, in that once the user pushes the "Location" button, the list of Wikipedia pages underneath updates. I've also added to Text fields to track the center of the map as the user pans around.

Here's my issue: If I pan the map, the list of locations underneath does not update. However, once I click on a link to display the page, and hit the "back" button, the updated list is built "correctly", i.e. with the new coordinates.

I'm sure that I'm overlooking something simple, but how can I track panning of the user and dynamically adjust the list underneath "in real time"?

Thanks, Philipp

Upvotes: 3

Views: 1671

Answers (2)

Paul
Paul

Reputation: 2047

In iOS 17 and later you can use the onMapCameraChange modifier, which takes a closure to execute when the map camera changes. The context passed into the closure includes the region:

Map(coordinateRegion: $viewModel.mapRegion, showsUserLocation: true)
    .onMapCameraChange { cameraContext in
        Task {
            await fetchNearbyLandmarks(lat: cameraContext.region.center.latitude, lon: cameraContext.region.center.longitude)
        }
    }        

The default behaviour is to execute the closure when the camera finishes changing.

In iOS 16 and earlier you can use onChange(of:) - as suggested by @ChrisR - or you can use onReceive and debounce the region change, so you only fetch the landmarks when the user finishes panning or zooming, like this:

.onReceive(viewModel.$mapRegion.debounce(for: .milliseconds(500), scheduler: RunLoop.main)) { region in
    Task {
        await fetchNearbyLandmarks(lat: viewModel.mapRegion.center.latitude, lon: viewModel.mapRegion.center.longitude)
    }
}

Hope that helps

Upvotes: 5

ChrisR
ChrisR

Reputation: 12105

For being new to Swift/SwiftUI this is quite cool!

You want to refetch when the coordinates of your Map change. So you could use .onChange, e.g.on the ZStack of the Map:

            .onChange(of: viewModel.mapRegion.center.latitude) { _ in
                Task {
                    await fetchNearbyLandmarks(lat: viewModel.mapRegion.center.latitude, lon: viewModel.mapRegion.center.longitude)
                }
            }

Generally this works, but it might be fetching too often (on every change of latitude). So you might want to add some kind of delay, or try to figure out when the drag ended.

Upvotes: 2

Related Questions