I am using SwiftUI to display a map and if user tapped on an annotation, it pops up a detail view in the VStack. I have made the map view and inserted annotations in another SwiftUI file. I also made the detail view.
How can I access the annotations of that map in the main view file to define a .tapaction for them to use it for the detailed view?
I tried defining the view as MKMapView but it is not possible to do it for a UIViewRepresentable inside another SwiftUI view.
The main view (ContentView) code is:
struct ContentView: View {
#State private var chosen = false
var body: some View {
VStack {
MapView()
.edgesIgnoringSafeArea(.top)
.frame(height: chosen ? 600:nil)
.tapAction {
withAnimation{ self.chosen.toggle()}
}
if chosen {
ExtractedView()
}
}
}
}
The MapView code is:
struct MapView : UIViewRepresentable {
#State private var userLocationIsEnabled = false
var locationManager = CLLocationManager()
func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}
func updateUIView(_ view: MKMapView, context: Context) {
view.showsUserLocation = true
.
.
.
let sampleCoordinates = [
CLLocation(latitude: xx.xxx, longitude: xx.xxx),
CLLocation(latitude: xx.xxx, longitude: xx.xxx),
CLLocation(latitude: xx.xxx, longitude: xx.xxx)
]
addAnnotations(coords: sampleCoordinates, view: view)
}
}
}
I expect to be able to access map view annotations and define tapaction in another view.
In SwiftUI DSL you don't access views.
Instead, you combine "representations" of them to create views.
A pin can be represented by an object - manipulating the pin will also update the map.
This is our pin object:
class MapPin: NSObject, MKAnnotation {
let coordinate: CLLocationCoordinate2D
let title: String?
let subtitle: String?
let action: (() -> Void)?
init(coordinate: CLLocationCoordinate2D,
title: String? = nil,
subtitle: String? = nil,
action: (() -> Void)? = nil) {
self.coordinate = coordinate
self.title = title
self.subtitle = subtitle
self.action = action
}
}
Here's my Map, which is not just UIViewRepresentable, but also makes use of a Coordinator.
(More about UIViewRepresentable and coordinators can be found in the excellent WWDC 2019 talk - Integrating SwiftUI)
struct Map : UIViewRepresentable {
class Coordinator: NSObject, MKMapViewDelegate {
#Binding var selectedPin: MapPin?
init(selectedPin: Binding<MapPin?>) {
_selectedPin = selectedPin
}
func mapView(_ mapView: MKMapView,
didSelect view: MKAnnotationView) {
guard let pin = view.annotation as? MapPin else {
return
}
pin.action?()
selectedPin = pin
}
func mapView(_ mapView: MKMapView, didDeselect view: MKAnnotationView) {
guard (view.annotation as? MapPin) != nil else {
return
}
selectedPin = nil
}
}
#Binding var pins: [MapPin]
#Binding var selectedPin: MapPin?
func makeCoordinator() -> Coordinator {
return Coordinator(selectedPin: $selectedPin)
}
func makeUIView(context: Context) -> MKMapView {
let view = MKMapView(frame: .zero)
view.delegate = context.coordinator
return view
}
func updateUIView(_ uiView: MKMapView, context: Context) {
uiView.removeAnnotations(uiView.annotations)
uiView.addAnnotations(pins)
if let selectedPin = selectedPin {
uiView.selectAnnotation(selectedPin, animated: false)
}
}
}
The idea is:
The pins are a #State on the view containing the map, and are passed down as a binding.
Each time a pin is added or removed, it will trigger a UI update - all the pins will be removed, then added again (not very efficient, but that's beyond the scope of this answer)
The Coordinator is the map delegate - I can retrieve the touched MapPin from the delegate methods.
To test it:
struct ContentView: View {
#State var pins: [MapPin] = [
MapPin(coordinate: CLLocationCoordinate2D(latitude: 51.509865,
longitude: -0.118092),
title: "London",
subtitle: "Big Smoke",
action: { print("Hey mate!") } )
]
#State var selectedPin: MapPin?
var body: some View {
NavigationView {
VStack {
Map(pins: $pins, selectedPin: $selectedPin)
.frame(width: 300, height: 300)
if selectedPin != nil {
Text(verbatim: "Welcome to \(selectedPin?.title ?? "???")!")
}
}
}
}
}
...and try zooming/tapping the pin on London, UK :)
Related
I am using SwiftUI with RealityKit. As displayed in the code below, I have a plane entity that when tapped simply prints the name of the entity. What approach should I take toward navigating to a new view when I tap the entity? It would be preferable to navigate as with a navigation link in a normal view, but if that is not possible then perhaps a fullScreenCover?
ARViewContainer.swift:
class Coordinator: NSObject {
weak var view: ARView?
#objc func handleTap(_ recognizer: UITapGestureRecognizer) {
guard let view = self.view else { return }
let tapLocation = recognizer.location(in: view)
if let entity = view.entity(at: tapLocation) as? ModelEntity {
print(entity.name)
}
}
}
struct ARViewContainer: UIViewRepresentable {
typealias UIViewType = ARView
func makeUIView(context: Context) -> ARView{
let arView = ARView(frame: .zero, cameraMode: .ar, automaticallyConfigureSession: true)
context.coordinator.view = arView
arView.addGestureRecognizer(UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap)))
arView.scene.anchors.removeAll()
let anchor = AnchorEntity()
let plane = MeshResource.generatePlane(width: 1, height: 1)
var material = UnlitMaterial()
material.color = .init(tint: .white,
texture: .init(try! .load(named: "instagram")))
let planeEntity = ModelEntity(mesh: plane, materials: [material])
planeEntity.generateCollisionShapes(recursive: true)
planeEntity.name = "Plane Entity"
planeEntity.position.z -= 1.0
planeEntity.setParent(anchor)
arView.scene.addAnchor(anchor)
return arView
}
func updateUIView(_ uiView: ARView, context: Context){
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
}
ContentView.swift
struct ContentView: View {
#State var open = false
var body: some View {
NavigationView{
ZStack {
ARViewContainer()
.ignoresSafeArea(.all)
}
}
}
}
View I want to navigate to:
struct TestView : View {
var body : some View {
VStack{
Text("Test View")
}
}
}
Manage the state of the view in an observable object and modify it from your AR view.
struct ContentView: View {
#ObservedObject var settings = Settings.shared
var body: some View {
NavigationView {
ZStack {
ARViewContainer()
.ignoresSafeArea(.all)
NavigationLink("", isActive: $settings.shouldOpenDetailsView) {
TestView()
}
}
}
}
}
class Settings: ObservableObject {
static let shared = Settings()
#Published var shouldOpenDetailsView = false
}
class Coordinator: NSObject {
weak var view: ARView?
#objc func handleTap(_ recognizer: UITapGestureRecognizer) {
guard let view = self.view else { return }
let tapLocation = recognizer.location(in: view)
if let entity = view.entity(at: tapLocation) as? ModelEntity {
Settings.shared.shouldOpenDetailsView = true
}
}
}
I want to build a map which shows the user location, the zoom scale and can be switched between standard maptype and hybrid maptype.
For testing reasons, I didn't integrate the mapstyle-picker yet. I helped me out by implementing the map.mapType variable, but that doesn't work.
Another problem I have is about the user location: I integrated a LocationManager class which returns the actual position - that works - but if I scroll or zoom on the map, after 5 seconds the screen jumps back to the user location.
I would be glad if you could help me with that. I attach my both files.
Thanks for your help!
UPDATE
So after I searched in stackoverflow I found this thread, SwiftUI mapkit set region to user's current location and implemented these into my code. But now I've the problem, that I didn't see my actual position when the map starts as centered view, I see only the hardcoded one.
MapModel.swift
struct MapModel: UIViewRepresentable {
#Binding var region: MKCoordinateRegion
var mapType : MKMapType
var userTracking: MKUserTrackingMode
var showsUserLocation: Bool
// var annotation: GCModel
init(
region: Binding<MKCoordinateRegion>,
mapType: MKMapType,
userTrackingMode: MKUserTrackingMode,
showsUserLocation: Bool = true
// annotation: GCModel = GCModel(title: "", coordinate: CLLocationCoordinate2D(latitude: 0, longitude: 0))
){
self._region = region
self.mapType = mapType
self.userTracking = userTrackingMode
self.showsUserLocation = showsUserLocation
// self.annotation = annotation
}
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.setRegion(region, animated: false)
mapView.mapType = mapType
mapView.showsUserLocation = showsUserLocation
mapView.userTrackingMode = userTracking
mapView.delegate = context.coordinator
// Add annotation to the map
// mapView.addAnnotation(annotation.pointAnnotation)
return mapView
}
func updateUIView(_ mapView: MKMapView, context: Context) {
mapView.mapType = mapType
// Update your region so that it is now your new region
mapView.setRegion(region, animated: false)
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapModel
init(_ parent: MapModel) {
self.parent = parent
}
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
// We should handle dequeue of annotation view's properly so we have to write this boiler plate.
// This basically dequeues an MKAnnotationView if it exists, otherwise it creates a new
// MKAnnotationView from our annotation.
guard annotation is MKPointAnnotation else { return nil }
let identifier = "Annotation"
guard let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) else {
let annotationView = MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier)
annotationView.canShowCallout = true
return annotationView
}
annotationView.annotation = annotation
return annotationView
}
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
// We need to update the region when the user changes it
// otherwise when we zoom the mapview will return to its original region
DispatchQueue.main.async {
self.parent.region = mapView.region
}
}
}
}
MapView.swift
struct MapView: View {
#State var trackingMode: MKUserTrackingMode = .follow
#ObservedObject private var managerDelegate = LocationManager()
#State private var mapType: MKMapType = .standard
var body: some View {
VStack {
MapModel(
region: $managerDelegate.region,
mapType: mapType,
userTrackingMode: trackingMode,
showsUserLocation: true
).edgesIgnoringSafeArea([.bottom,.top])
Picker("", selection: $mapType) {
Text("Standard").tag(MKMapType.standard)
Text("Satellite").tag(MKMapType.satellite)
Text("Hybrid").tag(MKMapType.hybrid)
}
.pickerStyle(SegmentedPickerStyle())
.opacity(0.5)
Spacer()
}
}
}
So....in my coordinator--which conforms to the mapbox delegate protocol, I can just do:
mapView.setCenter(mapView.userLocation!.coordinate, zoomLevel: 13, animated: true)
and this function works fine in the coordinator or when it is called in the mapView class. The only problem is I don't know how to pass this mapView instance around (specifically back into ContentView where I want to have a button that does the same thing). I also have a LocationManager struct but I don't know how much use that would be here. Is the passing of the MapView instance the simplest way to accomplish what I'd like to do?
Thank you in advance!
Here is a demo of solution (based on MapKit, but it not important for idea). Tested with Xcode 12.
struct DemoActionToMapView: View {
#State private var centerToUser: () -> () = {}
var body: some View {
VStack {
Button("Center", action: centerToUser)
MapView { map in
self.centerToUser = {
map.setCenter(map.userLocation.coordinate, animated: true)
}
}
}
}
}
struct MapView: UIViewRepresentable {
var configure: (MKMapView) -> () = { _ in }
func makeUIView(context: Context) -> MKMapView {
let map = MKMapView()
DispatchQueue.main.async {
configure(map)
}
return map
}
func updateUIView(_ uiView: MKMapView, context: Context) {
}
}
Building a mostly SwiftUI app... I'm trying to connect (bind) the value of a BindableObject in a parent view to a MapKit child view, such that when the annotation (callout accessory) is tapped (the little (i) button on the annotation label...
...it changes the value of $showDetails to true, which is wired up to a "sheet(isPresented: $showDetails...)" further up the view hierarchy, which then displays a modal:
struct MapView: UIViewRepresentable {
#Binding var mapSelected: Int
#Binding var showDetails: Bool // I WANT TO TOGGLE THIS WHEN CALLOUT ACCESSORY IS TAPPED
#Binding var location: Location
#Binding var previousLocation: Location
#Binding var autoZoom: Bool
#Binding var autoZoomLevel: Int
class Coordinator: NSObject, MKMapViewDelegate {
#Binding var showDetails: Bool
init(showDetails: Binding<Bool>) {
_showDetails = showDetails
}
func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
guard let coordinates = view.annotation?.coordinate else { return }
let span = mapView.region.span
let region = MKCoordinateRegion(center: coordinates, span: span)
mapView.setRegion(region, animated: true)
}
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
guard let annotation = annotation as? LocationAnnotation else { return nil }
let identifier = "Annotation"
var annotationView: MKMarkerAnnotationView? = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? MKMarkerAnnotationView
if annotationView == nil {
annotationView = MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier)
annotationView?.markerTintColor = UIColor(hex: "#00b4ffff")
annotationView?.animatesWhenAdded = true
annotationView?.canShowCallout = true
annotationView?.rightCalloutAccessoryView = UIButton(type: .detailDisclosure)
} else {
annotationView?.annotation = annotation
}
return annotationView
}
func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) {
guard let loc = view.annotation as? LocationAnnotation else {
print("sorry")
return
}
print(self.showDetails) // true, false, true, false, ...
self.showDetails.toggle() // THIS DOES TOGGLE, BUT THE VALUE IS NOT OBSERVED BY THE PARENT VIEW
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(showDetails: $showDetails)
}
func makeUIView(context: Context) -> MKMapView {
let map = MKMapView()
map.delegate = context.coordinator
return map
}
func updateUIView(_ uiView: MKMapView, context: Context) {
switch mapSelected {
case 0:
uiView.mapType = .standard
default:
uiView.mapType = .hybrid
}
let currentRegion = uiView.region // get the current region
var span: MKCoordinateSpan
var center: CLLocationCoordinate2D
var newRegion: MKCoordinateRegion
if currentRegion.span.latitudeDelta == 90.0 && currentRegion.span.longitudeDelta == 180.0 { // INITIAL
span = MKCoordinateSpan(latitudeDelta: 18.0, longitudeDelta: 18.0)
center = CLLocationCoordinate2D(latitude: 54.5, longitude: -110)
newRegion = MKCoordinateRegion(center: center, span: span)
uiView.setRegion(newRegion, animated: true)
}
updateAnnotations(from: uiView)
}
// ...
}
What I expect is that when the callout accessory is tapped, showDetails is toggled (which it is), but no effect is seen in the parent view—the sheet is not presented. Seems that the binding is not publishing its new state.
What am I missing / doing wrong? I find integrating UIKit with SwiftUI to be easy, then hard, then easy, then impossible. Help please!
As it turns out, I was looking in the wrong place. The problem lay not in the above code (which is 100% fine), but rather in a container view that should have been listening to the changed value of showDetails, but wasn't because of the way I passed showDetails into it, e.g.,
ContentView.swift
Footer(search: search,
locationStore: self.locationStore,
searchCoordinates: self.searchCoordinates,
showDetails: self.showDetails) // NOT PASSED AS A BINDING...
Footer.swift
struct Footer: View {
#EnvironmentObject var settingsStore: SettingsStore
#ObservedObject var search: SearchTerm
#ObservedObject var locationStore: LocationStore
#ObservedObject var searchCoordinates: SearchCoordinates
#State var showDetails: Bool // DECLARE LOCAL STATE, WILL NOT BE AWARE OF CHANGE TO showDetails FROM MapView
Very simple fix:
ContentView.swift
Footer(search: search,
locationStore: self.locationStore,
searchCoordinates: self.searchCoordinates,
showDetails: self.$showDetails)
Footer.swift
struct Footer: View {
#EnvironmentObject var settingsStore: SettingsStore
#ObservedObject var search: SearchTerm
#ObservedObject var locationStore: LocationStore
#ObservedObject var searchCoordinates: SearchCoordinates
#Binding var showDetails: Bool
It feels like bugs like this one are pretty easy to encounter in SwiftUI (which is awesome, btw) when accidentally using the wrong property wrapper on passed-in variables. The compiler won't warn you and there are no runtime errors, just... nothing happens. And you, like me, may be inclined to chase red herrings a.k.a. perfectly fine code.
I'm trying to learn Combine with SwiftUI and I'm struggling how to update my view (from UIKit) with ObservableObject (previously BindableObject). The issue is that, obviously, method updateUIView will not fire once the #Published object sends the notification it was changed.
class DataSource: ObservableObject {
#Published var locationCoordinates = [CLLocationCoordinate2D]()
var value: Int = 0
init() {
Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { timer in
self.value += 1
self.locationCoordinates.append(CLLocationCoordinate2D(latitude: 52, longitude: 16+0.1*Double(self.value)))
}
}
}
struct MyView: UIViewRepresentable {
#ObservedObject var dataSource = DataSource()
func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}
func updateUIView(_ view: MKMapView, context: Context) {
let newestCoordinate = dataSource.locationCoordinates.last ?? CLLocationCoordinate2D(latitude: 52, longitude: 16)
let annotation = MKPointAnnotation()
annotation.coordinate = newestCoordinate
annotation.title = "Test #\(dataSource.value)"
view.addAnnotation(annotation)
}
}
How to bind that locationCoordinates array to the view in such a way, that a new point is in fact added each time it refreshes?
To make sure your ObservedObject does not get created multiple times (you only want one copy of it), you can put it outside your UIViewRepresentable:
import SwiftUI
import MapKit
struct ContentView: View {
#ObservedObject var dataSource = DataSource()
var body: some View {
MyView(locationCoordinates: dataSource.locationCoordinates, value: dataSource.value)
}
}
class DataSource: ObservableObject {
#Published var locationCoordinates = [CLLocationCoordinate2D]()
var value: Int = 0
init() {
Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { timer in
self.value += 1
self.locationCoordinates.append(CLLocationCoordinate2D(latitude: 52, longitude: 16+0.1*Double(self.value)))
}
}
}
struct MyView: UIViewRepresentable {
var locationCoordinates: [CLLocationCoordinate2D]
var value: Int
func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}
func updateUIView(_ view: MKMapView, context: Context) {
print("I am being called!")
let newestCoordinate = locationCoordinates.last ?? CLLocationCoordinate2D(latitude: 52, longitude: 16)
let annotation = MKPointAnnotation()
annotation.coordinate = newestCoordinate
annotation.title = "Test #\(value)"
view.addAnnotation(annotation)
}
}
this solution worked for me but with EnvironmentObject https://gist.github.com/svanimpe/152e6539cd371a9ae0cfee42b374d7c4
I'm gonna provide a general solution for any UI/NS view representable using combine. There are performance benefits to my method.
Created an Observable Object and wrap the desired properties with
#Published wrapper
Inject the Observed object via the updateView method in the view
representable using a method you'll make in step 3
Subclass the desired view with the view model as a parameter.
Create an addViewModel method and use
combine operators/ subscribers and add them to cancellable.
Note - Works great with environment objects.
struct swiftUIView : View {
#EnvironmentObject var env : yourViewModel
...
...
UIViewRep(wm : env)
}
struct UIViewRep : UIViewRepresentable {
var wm : yourViewModel
func makeUIView {
let yv = yourView()
yv.addViewModel(wm)
return yv
}}
class yourView : UIView {
var viewModel : yourViewModel?
var cancellable = Set<AnyCancellable>()
...
...
func addViewModel( _ wm : yourViewModel) {
self.viewModel = wm
self.viewModel?.desiredProperty
.sink(receiveValue: { [unowned self] w in
print("Make changes with ", w)
}).store(in: &cancellable)
}
}