UIViewRepresentable: "Modifying state during view update, this will cause undefined behavior" - swiftui

I have made a simple UIViewRepresentable from MKMapView. You can scroll the mapview, and the screen will be updated with the coordinates in the middle.
Here's the ContentView:
import SwiftUI
import CoreLocation
let london = CLLocationCoordinate2D(latitude: 51.50722, longitude: -0.1275)
struct ContentView: View {
#State private var center = london
var body: some View {
VStack {
MapView(center: self.$center)
HStack {
VStack {
Text(String(format: "Lat: %.4f", self.center.latitude))
Text(String(format: "Long: %.4f", self.center.longitude))
}
Spacer()
Button("Reset") {
self.center = london
}
}.padding(.horizontal)
}
}
}
Here's the MapView:
struct MapView: UIViewRepresentable {
#Binding var center: CLLocationCoordinate2D
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ uiView: MKMapView, context: Context) {
uiView.centerCoordinate = self.center
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
parent.center = mapView.centerCoordinate
}
init(_ parent: MapView) {
self.parent = parent
}
}
}
Tapping the reset button should simply set mapView.center to london. The current method will make the map scrolling super slow, and when the button is tapped, cause the error "Modifying state during view update, this will cause undefined behavior."
How should resetting the coordinates be communicated to the MKMapView, such that the map scrolling is fast again, and the error is fixed?

The above solution with an ObservedObject will not work. While you wont see the warning message anymore, the problem is still occurring. Xcode just isn't able to warn you its happening anymore.
Published properties in ObservableObjects behave almost identically to #State and #Binding. That is, they trigger a view update any time their objectWillUpdate publisher is triggered. This happens automatically when an #Published property is updated. You can also trigger it manually yourself with objectWillChange.send()
Because of this, it is possible to make properties that do not automatically cause view state to update. And we can leverage this to prevent unwanted state updates for UIViewRepresentable and UIViewControllerRepresentable structs.
Here is an implementation that will not loop when you update its view model from the MKMapViewDelegate methods:
struct MapView: UIViewRepresentable {
#ObservedObject var viewModel: Self.ViewModel
func makeUIView(context: Context) -> MKMapView{
let mapview = MKMapView()
mapview.delegate = context.coordinator
return mapview
}
func updateUIView(_ uiView: MKMapView, context: Context) {
// Stop update loop when delegate methods update state.
guard viewModel.shouldUpdateView else {
viewModel.shouldUpdateView = true
return
}
uiView.centerCoordinate = viewModel.centralCoordinate
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
private var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView){
// Prevent the below viewModel update from calling itself endlessly.
parent.viewModel.shouldUpdateView = false
parent.viewModel.centralCoordinate = mapView.centerCoordinate
}
}
class ViewModel: ObservableObject {
#Published var centerCoordinate: CLLocationCoordinate2D = .init(latitude: 0, longitude: 0)
var shouldUpdateView: Bool = true
}
}
If you really dont want to use an ObservableObject, the alternative is to put the shouldUpdateView property into your coordinator. Although I still prefer to use a viewModel because it keeps your UIViewRepresentable free of multiple #Bindings. You can also use the ViewModel externally and listen to it via combine.
Honestly, I'm surprised apple didn't consider this exact issue when they created UIViewRepresentable.
Almost all UIKit views will have this exact problem if you need to keep your SwiftUI state in sync with view changes.

Related

SwiftUI UIViewRepresentable ScrollView is not zooming

I am developing an iOS app using the SwiftUI. I need to implement the "pinch to zoom" feature in the app so i tried using the SwiftUI's ScrollView but went in to the problems of not able to pinch and drag the content at the same time as discussed in the question here. So i tried using the UIKit's UIScrollView in an UIViewRepresentable as suggested in the same thread. The problem i am facing now is when i pinch zoom the view the following error is displayed in the console and view doesn't zoom.
[Assert] -[UIScrollView _clampedZoomScale:allowRubberbanding:]: Must be called with non-zero scale
My UIViewRepresentable does not occupy the entire screen and is embedded in a VStack that occupies some portion on the screen along with other elements. I am using NavigationView that holds the VStack. I have an ObservableObject as a state on the main view. I update few #Published properties on this ObservableObject from .onAppear() of the main view. When i stop updating these properties, the zoom seems to be working as expected. I am not sure what is causing the issue, can some one please help if you have faced the above error? Thanks in advance.
I could replicate this with a sample code provided below.
TestScrollViewApp.swift
#main
struct TestScrollViewApp: App {
var body: some Scene {
WindowGroup {
TestView(interactor: TestInteractor())
}
}
}
TestView.swift
import Foundation
import SwiftUI
struct TestView: View {
#ObservedObject var interactor : TestInteractor
var body: some View {
ZStack{
NavigationView{
VStack{
Text("Hi there!")
HStack{
Text("HI i am beside map")
NativeScrollView()
}
Text("Hi I am footer!")
}
}.navigationViewStyle(StackNavigationViewStyle())
}.onAppear{
interactor.setUp()
}
}
}
struct NativeScrollView: UIViewRepresentable {
let imageview = UIImageView()
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIScrollViewDelegate {
let parent: NativeScrollView
var zoomableView: UIView?
init(_ parent: NativeScrollView) {
self.parent = parent
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return zoomableView
}
}
func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator
scrollView.isScrollEnabled = true
scrollView.bouncesZoom = true;
imageview.contentMode = .scaleAspectFit
imageview.autoresizingMask = [.flexibleWidth,.flexibleHeight]
//add the image view
imageview.image = UIImage(named: "mapscreen")
scrollView.addSubview(imageview)
scrollView.minimumZoomScale = 1.0
scrollView.maximumZoomScale = 4.0
return scrollView
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
context.coordinator.zoomableView = imageview
}
}
TestInteractor.swift
class TestInteractor:ObservableObject{
#Published var hasFooter = true
func setUp(){
hasFooter = false
}
}

SwiftUI and UIKit Interoperability with displaying multiple views

Overview: I'm using SwiftUI, but wanted to use UIKit-MapKit. I used UIViewRepresentable to be able to wrap the UIKit feature.
Problem: I'm learning about swiftui-uikit-interoperability and I'm getting stuck on being able to display multiple SwiftUI views.
Code Snippet:
ContentView
struct ContentView: View {
#ObservedObject var viewModel: MapView.PinViewModel
init() {
self.viewModel = MapView.PinViewModel()
}
var body: some View {
NavigationView {
MapView()
.sheet(isPresented: $viewModel.showPinForm) {
PinForm()
}
.navigationTitle("SwiftUI UIKit Interop").scaledToFill()
}
}
}
MapView
struct MapView: UIViewRepresentable {
class PinViewModel: ObservableObject {
#Published var showPinForm: Bool
init() {
self.showPinForm = false
}
func updateShowPinVar() {
self.showPinForm = true
}
}
func showPinForm() {
pinViewModel.updateShowPinVar()
}
func makeCoordinator() -> MapViewCoordinator {
let coordinator = MapViewCoordinator()
coordinator.delegate = self
return coordinator
}
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
let coordinate = CLLocationCoordinate2D(latitude: 40.7209, longitude: -74.0007)
let span = MKCoordinateSpan(latitudeDelta: 0.03, longitudeDelta: 0.03)
let mapRegion = MKCoordinateRegion(center: coordinate, span: span)
mapView.setRegion(mapRegion, animated: true)
return mapView
}
func updateUIView(_ uiView: MKMapView, context: Context) {
}
}
In this I have a #Published var showPinForm that gets toggled in MapView. ContentView is supposed to watch this variable and when it is true it will cause the sheet to pull up. However, I believe when I enter MapView() from ContentView() then I no longer recognize ContentView.
Using the UIViewRepresentable, what is the best way to display another swiftui view? Does not have to use .sheet (Although, it would be nice)
I have tried to simplify the code to show the main problem, so I left out a lot of additional info and took out basic patterns that I used (MVVM)
Please let me know if you need any clarifications
try to follow this pattern, you can toggle the flag both inside and outside your MapView
struct MapView: UIViewRepresentable {
#Binding var switcher: Bool // -> use binding
func makeUIView(context: Context) -> MKMapView { MKMapView() }
func updateUIView(_ uiView: MKMapView, context: Context) { }
}
struct MainView: View {
#ObservedObject var viewModel = MainViewModel()
var body: some View {
MapView(switcher: $viewModel.flag)
.sheet(isPresented: $viewModel.flag) {
Text("Pin pin")
}
}
}
class MainViewModel: ObservableObject {
#Published var flag: Bool = false
}

Unexpected acting of MapKit in SwiftUI

I'm stuck with the really strange problem. I'm implementing map into my SwiftUI app. It should act like a normal map (drag, scroll and so on). When changing position (that is binding point) the app gets an address via geocoder.
Also user can click "Change" button and enter address manually (with autocompletion). After selecting the address, the map should move to the reverse geocoded point.
Built-in SwiftUI Map() is a good thing, but... it's unreal to make it show buildings. And in the app it's something that matters. So, going with UIViewRepresentable gives me another strange problem.
If I set the center coordinate in UpdateUIView, the map stops any interactivity. Otherwise changing the address manually doesn't work.
What could be wrong with this?
struct MapView: UIViewRepresentable {
#Binding var point: CLLocationCoordinate2D
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.showsBuildings = true
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ uiView: MKMapView, context: Context) {
// uiView.setCenter(point, animated: true)
}
func makeCoordinator() -> MapView.Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
self.parent.point = mapView.centerCoordinate
}
}
}
I tried wrapping everything into DispatchQueue.main.async {} - not working (and honestly I don't think it could)
I also tried this solution, but it worked neither: https://www.reddit.com/r/SwiftUI/comments/kti9r9/uiviewrepresentable_how_to_update_bindings/
I also had the same problem. I solved this using #state. So every time the mapView changes, the corresponding function of the coordinator is definitely called. Hope it helps.
struct YourView: View {
#State mapView: MKMapView = .init()
#State var point: CLLocationCoordinate2D = [...]
var body: some View {
MapView(mapView: $mapView, point: $point)
...
}
struct MapView: UIViewRepresentable {
#Binding var mapView: MKMapView
#Binding var point: CLLocationCoordinate2D
func makeUIView(context: Context) -> MKMapView {
self.mapView = MKMapView()
self.mapView.showsBuildings = true
self.mapView.delegate = context.coordinator
return self.mapView
}
...
func updateUIView(_ uiView: MKMapView, context: Context) {
// uiView.setCenter(point, animated: true)
}
func makeCoordinator() -> MapView.Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
self.parent.point = self.mapView.centerCoordinate
}
}
}

How to add a customized InfoWindow to markers in google-maps swift ui?

i tried to make a view like bellow in SwiftUi without any success Customized info window swift ui
Since this question doesn't have too much detail, I will be going off of some assumptions. First, I am assuming that you are calling the MapView through a UIViewControllerRepresentable.
I am not too familiar with the Google Maps SDK, but this is possible through the GMSMapViewDelegate Methods. After implementing the proper GMSMapViewDelegate method, you can use ZStacks to present the image that you would like to show.
For example:
struct MapView: UIViewControllerRepresentable {
var parentView: ContentView
func makeUIViewController(context: Context) {
let mapView = GMSMapView()
return mapView
}
func updateUIViewController(_ uiViewController: GMSMapView, context: Context) {
}
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
class Coordinator: NSObject, GMSMapViewDelegate {
var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
//Use the proper Google Maps Delegate method to find out if a marker was tapped and then show the image by doing: parent.parentView.isShowingInformationImage = true.
}
}
In your SwiftUI view that you would like to put this MapView in, you can do the following:
struct ContentView: View {
#State var isShowingInformationImage = false
var body: some View {
ZStack {
if isShowingInformationImage {
//Call the View containing the image
}
MapView(parentView: self)
}
}
}

Customize Mapbox style with layers in SwiftUI

I have an issue to customize a Mapbox view's style, like for instance adding some information on the map if a switch is switched on. Not sure if it's important, but the layers I need to add are MGLSymbolStyleLayer and MGLLineStyleLayer.
Let's start with code for the main view containing a switch representing a state used to customize the map's style, and an UIViewRepresentable for the Mapbox view.
struct Test_MapBox: View {
#State private var styleURL: URL = MGLStyle.outdoorsStyleURL
#State private var switchButton: Bool = false
var body: some View {
ZStack(alignment: .bottom) {
MapView(switchButton: switchButton)
.styleURL(styleURL)
.edgesIgnoringSafeArea(.all)
Toggle(isOn: $switchButton, label: {
Text("Switch")
})
}
}
}
struct MapView: UIViewRepresentable {
var switchButton: Bool
var mapView = MGLMapView(frame: .zero)
func makeUIView(context: UIViewRepresentableContext<MapView>) -> MGLMapView {
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ uiView: MGLMapView, context: UIViewRepresentableContext<MapView>) {
print("Style: \(uiView.style)")
print("Update view, switch: \(switchButton)")
}
func makeCoordinator() -> MapView.Coordinator {
Coordinator(self)
}
func styleURL(_ styleURL: URL) -> MapView {
mapView.styleURL = styleURL
return self
}
final class Coordinator: NSObject, MGLMapViewDelegate {
var parent: MapView
init(_ control: MapView) {
self.parent = control
}
func mapViewDidFinishLoadingMap(_ mapView: MGLMapView) {
print("Map loaded, switch: \(parent.switchButton)")
}
func mapView(_ mapView: MGLMapView, didFinishLoading style: MGLStyle) {
print("Style loaded, switch: \(parent.switchButton)")
}
}
}
The issue is the following: inside the delegate functions, switchButton is never up to date, always false (and I don't understand why)... And in updateUIView(), switchButton is OK, but the style is not yet loaded, so usually you get a nil when accessing it ...
Have you got a solution ?