SwiftUI, can't change state from within delegate callback? (code attached) - swiftui

Anyone able to spot why when I set the "firstPass" state variable in the "updateUIView" callback it does NOT set the state? As output I see:
First Pass1: true
First Pass2: true. // <= not set to false as expected
Also I do not in Xcode where I set this state "firstPass = false" that there is an Xcode warning here: "Modifying state during view update, this will cause undefined behavior."
import SwiftUI
import MapKit
struct GCMapView {
#State var firstPass : Bool = true
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: GCMapView
init(_ parent: GCMapView) {
self.parent = parent
super.init()
}
}
}
extension GCMapView : UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
let map = MKMapView()
map.delegate = context.coordinator
map.showsUserLocation = true
return map
}
func updateUIView(_ view: MKMapView, context: Context) {
print("--- updateUIView ---")
if firstPass {
print("First Pass1: ", firstPass)
firstPass = false. // <<=== *** THIS DOES NOT WORK ****
print("First Pass2: ", firstPass)
} else {
print("Subsequent Passes")
}
}
}

This operation causes cycling, because it invalidates SwiftUI view that result in calling updateUIView and so forth, so SwiftUI rendering engine drops it (auto-fix).
Not really sure you need such if there at all, but possible solution would be to separate update on next event loop, like
if firstPass {
print("First Pass1: ", firstPass)
DispatchQueue.main.async {
firstPass = false
print("First Pass2: ", firstPass)
}
} else {
Tested with Xcode 13 / iOS 15

Related

Binding array is empty when accessed through Coordinator

I am having an issue with my Coordinator. I am interacting with a MKMapView via SwiftUI. I am passing in a Binding to the UIViewRepresentable and need to access that same Binding in the Coordinator. Inside the Coordinator I determine what strokeColor to use for my polyline. When I try to access the routes Binding from my Coordinator it is always empty. When I set a breakpoint inside the MapView on the updateUIView function the binding is indeed populated.
Heres the code:
import SwiftUI
import MapKit
struct MapView: UIViewRepresentable {
var region: MKCoordinateRegion
#Binding var routes: [RouteData]
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
mapView.showsUserLocation = true
mapView.setRegion(region, animated: true)
addOverlays(mapView)
return mapView
}
func updateUIView(_ view: MKMapView, context: Context) {
addOverlays(view)
removeOverlays(view)
}
private func addOverlays(_ view: MKMapView) {
for route in routes {
for point in route.points {
let waypoints = point.waypoints
let polyline = MKPolyline(coordinates: waypoints, count: waypoints.count)
polyline.title = route.routeID
view.addOverlay(polyline)
}
}
}
private func removeOverlays(_ view: MKMapView) {
for overlay in view.overlays {
if let routeID = overlay.title!, routes.first(where: { $0.routeID == routeID }) == nil {
view.removeOverlay(overlay)
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
}
class Coordinator: NSObject, MKMapViewDelegate {
let parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
if let routePolyline = overlay as? MKPolyline {
let renderer = MKPolylineRenderer(polyline: routePolyline)
// Always prints route is empty even though I set a break point inside the parents' updateUIView func and the route is populated.
print("parents routes: \(self.parent.routes)")
if let title = routePolyline.title, let route = self.parent.routes.first(where: { $0.routeID == title }) {
renderer.strokeColor = UIColor(convertRGBStringToColor(color: route.route.rtclr))
} else {
renderer.strokeColor = UIColor.blue
}
renderer.lineWidth = 5
return renderer
}
return MKOverlayRenderer()
}
}
A few mistakes
#Binding var routes: [RouteData] should be let routes: [RouteData] because you don’t change it so don’t need the write access.
Coordinator(self) Should be Coordinator(), self is an old value the Coordinator should not hang on to.
Subclass MKPolyline to add your colour properties eg https://stackoverflow.com/a/44294417/259521
makeUIView Should return context.coordinator.mapView
addOverlays should only add ones that are not already added. You need to essentially implement a diff in updateUIView.
Update is called after make so no need to add overlays in make.

Custom Annotations in SwiftUI (MKMapView)

I have implemented a MKMapView in SwiftUI and I am showing a list of annotations (stops) as well as the user's location.
I wanted to add the tap functionality to the "stop pins" but I wasn't able to find anything helpful to achieve this.
The problem with this code is that it changes the view of the user location pin and eventually crashes with the following error.
2021-07-10 18:31:21.434538+0900 Bus Finder[5232:2086940] *** Terminating app due to uncaught exception 'NSGenericException', reason: '<Bus_Finder.Stops: 0x2816c4cc0> must implement title, or view (null) must have a non-nil detailCalloutAccessoryView when canShowCallout is YES on corresponding view <MKAnnotationView: 0x13137cd60; frame = (-20 -20; 40 40); opaque = NO; layer = <CALayer: 0x2832e9e20>>'
*** First throw call stack:
(0x196f2a754 0x1ab9f17a8 0x1a6566410 0x1a65655bc 0x1a656464c 0x1a65641d0 0x1982fd458 0x196ea522c 0x196ea4e28 0x196ea4278 0x196e9e02c 0x196e9d360 0x1ae4db734 0x199918584 0x19991ddf4 0x19ddf3370 0x19ddf32fc 0x19d8ebb6c 0x100eacf54 0x100eacff4 0x196b59cf8)
libc++abi: terminating with uncaught exception of type NSException
*** Terminating app due to uncaught exception 'NSGenericException', reason: '<Bus_Finder.Stops: 0x2816c4cc0> must implement title, or view (null) must have a non-nil detailCalloutAccessoryView when canShowCallout is YES on corresponding view <MKAnnotationView: 0x13137cd60; frame = (-20 -20; 40 40); opaque = NO; layer = <CALayer: 0x2832e9e20>>'
terminating with uncaught exception of type NSException
I only want to change the view of the "stops pin" and add the tap functionality.
I pass a list of Stops to the MapView on appear. The Stops structure is at the end.
A visual concept of my problem:
(When commenting the viewFor annotation function)
I want to change the style of the stops pin and add tap functionality to it not the user's location pin.
When I use the viewFor annotation function (the same code as in this question) the user location view changes and then the app crashes.
The MapView file:
// MARK: MapView
struct MapView: UIViewRepresentable {
// MARK: Variables
#Binding var stops: [Stops]
#Binding var centerCoordinate: MKCoordinateRegion
#Binding var action: Action
// MARK: Action Lists
enum Action {
case idle
case reset(coordinate: MKCoordinateRegion)
case changeType(mapType: MKMapType)
}
// MARK: First Time Only
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
mapView.showsUserLocation = true
mapView.userTrackingMode = .follow
mapView.isUserInteractionEnabled = true
mapView.centerCoordinate = self.centerCoordinate.center
mapView.setRegion(self.centerCoordinate, animated: true)
return mapView
}
// MARK: Updating UI
func updateUIView(_ view: MKMapView, context: Context) {
switch action {
case .idle:
break
case .reset(let newCoordinate):
view.delegate = nil
DispatchQueue.main.async {
self.centerCoordinate.center = newCoordinate.center
self.action = .idle
view.setRegion(self.centerCoordinate, animated: true)
view.delegate = context.coordinator
}
case .changeType(let mapType):
view.mapType = mapType
}
view.addAnnotations(stops)
}
// MARK: Setting Coordinator
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
parent.centerCoordinate.center = mapView.centerCoordinate
}
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
let annotationView = MKAnnotationView(annotation: annotation, reuseIdentifier: "TESTING NOTE")
annotationView.canShowCallout = true
annotationView.image = UIImage(systemName: "location.circle")?.withTintColor(.systemGreen, renderingMode: .alwaysOriginal)
let size = CGSize(width: 40, height: 40)
annotationView.image = UIGraphicsImageRenderer(size:size).image {
_ in annotationView.image!.draw(in:CGRect(origin:.zero, size:size))
}
return annotationView
}
}
}
Stops structure file:
// MARK: StopPinPoint
final class Stops: NSObject, Codable, Identifiable, MKAnnotation {
var id: String?
var name: BusStopName
var images: [String]?
var landMarks: [String]?
var coordinate: CLLocationCoordinate2D
var prevNexStop: [String]?
init(id: String?, name: BusStopName, images: [String]?, landMarks: [String]?, coordinates: CLLocationCoordinate2D, prevNextStop: [String]?) {
self.id = id
self.name = name
self.coordinate = coordinates
self.images = images
self.landMarks = landMarks
self.prevNexStop = prevNextStop
}
var location: CLLocation {
return CLLocation(latitude: self.coordinate.latitude, longitude: self.coordinate.longitude)
}
func distance(to location: CLLocation) -> CLLocationDistance {
return location.distance(from: self.location)
}
}
I would appreciate it a lot if someone could help me! I have been working on this problem for weeks now!
So basically after a bit more searching, I found out the answer.
In order to not change the user's location pin, I have to check the type of annotation and if the type is MKUserLocation I should return nil.
Following that the reason for the crash was that I had to make the Stops structure confirm to MKPointAnnotation and remove or override the coordinate variable then when I am making a list of Stops I can simply define the title, subtitle and coordinates.

MFMessageComposeViewController + SwiftUI Buggy Behavior

I'm using ViewControllerRepresentable to present a MFMessageComposeViewController so users can send texts from my app.
However, every time the view is presented, it's very buggy - elements randomly disappear, scrolling is off, and the screen flickers. Tested on iOS 14.2 and 14.3.
Here's the code:
import SwiftUI
import MessageUI
struct MessageView: UIViewControllerRepresentable {
var recipient: String
class Coordinator: NSObject, MFMessageComposeViewControllerDelegate {
var completion: () -> Void
init(completion: #escaping ()->Void) {
self.completion = completion
}
// delegate method
func messageComposeViewController(_ controller: MFMessageComposeViewController,
didFinishWith result: MessageComposeResult) {
controller.dismiss(animated: true, completion: nil)
completion()
}
}
func makeCoordinator() -> Coordinator {
return Coordinator() {} // not using completion handler
}
func makeUIViewController(context: Context) -> MFMessageComposeViewController {
let vc = MFMessageComposeViewController()
vc.recipients = [recipient]
vc.messageComposeDelegate = context.coordinator
return vc
}
func updateUIViewController(_ uiViewController: MFMessageComposeViewController, context: Context) {}
typealias UIViewControllerType = MFMessageComposeViewController
}
and my view
struct ContentView: View {
#State private var isShowingMessages = false
#State var result: Result<MFMailComposeResult, Error>? = nil
var body: some View {
VStack {
Button("Show Messages") {
self.isShowingMessages = true
}
.sheet(isPresented: self.$isShowingMessages) {
MessageView(recipient: "+15555555555")
}
.edgesIgnoringSafeArea(.bottom)
}
}
}
Is there something wrong with the way I'm presenting this view? Has anyone else experienced this behavior? Similar behavior happens with MFMailComposeViewController, but it's not as buggy.
5 minutes later, I realized I needed to add this when presenting the sheet:
MessageView(recipient: "+15555555555")
.ignoresSafeArea()
The view looked buggy because it was trying to account for the keyboard safe area and had a hard time doing it.

SwiftUI: NavigationLink's Destination initialized whenever its parent is redrawn

So I have a ParentView, which has a NavigationLink, leading to a UIViewControllerRepresentable-conforming PageViewController.
Now that ParentView also has some subscription on some publisher. Whenever that one is fired, not only will the ParentView redraw all its content (which it should), it will also re-initialize the (already presenting) PageViewController.
That leads to stuttering/glitching, because the PageViewController is already presenting and using the controllers that are continually being resetted.
Below is the ParentView and PageViewController (without the Coordinator stuff), both is pretty vanilla. The commented guard line is a hack I tried to prevent it from updating if displayed already. It helps but it's still stuttering on every swipe.
So the question is: How can we prevent the updating of a presented ViewController-wrapped-View when its presenting View is redrawn?
struct ParentView: View {
#Binding var something: Bool
var body: some View {
NavigationLink(destination: PageViewController(controllers: controllers)) {
Text("Push me")
}
}
}
final class PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
private var currentPage = 0
init(controllers: [UIViewController]) {
self.controllers = controllers
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator
pageViewController.delegate = context.coordinator
return pageViewController
}
func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
// I tried this: guard pageViewController.viewControllers!.isEmpty else { return }
pageViewController.setViewControllers(
[controllers[currentPage]], direction: .forward, animated: true)
}
}
If your controllers don't change after being displayed once, you can simply call:
pageViewController.setViewControllers([controllers[currentPage]], direction: .forward, animated: true)
from the makeUIViewController(context:) function instead of the updateUIViewController(:) function.

How can I have a UIButton (UIViewControllerRepresentable) trigger an action in my model?

I wish to have a UIButton in a UIViewController run a function in my ObservableObject in my SwiftUI app. My needs are larger than this, so I've trimmed out things to this code:
UIViewController & Delegate
protocol TestVCDelegate {
func runTest()
}
class TestVC: UIViewController {
let btnTest = UIButton()
let lblTest = UILabel()
var delegate:TestVCDelegate! = nil
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.red
btnTest.translatesAutoresizingMaskIntoConstraints = false
lblTest.translatesAutoresizingMaskIntoConstraints = false
btnTest.backgroundColor = UIColor.green
btnTest.addTarget(self, action: #selector(runTest), for: .touchUpInside)
view.addSubview(btnTest)
btnTest.heightAnchor.constraint(equalToConstant: 100).isActive = true
btnTest.widthAnchor.constraint(equalToConstant: 100).isActive = true
btnTest.topAnchor.constraint(equalTo: view.topAnchor, constant: 100).isActive = true
btnTest.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 100).isActive = true
view.addSubview(lblTest)
lblTest.heightAnchor.constraint(equalToConstant: 100).isActive = true
lblTest.widthAnchor.constraint(equalToConstant: 100).isActive = true
lblTest.topAnchor.constraint(equalTo: btnTest.bottomAnchor, constant: 10).isActive = true
lblTest.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 100).isActive = true
}
#objc func runTest() {
delegate.runTest()
lblTest.text = "hello world!"
}
}
UIViewControllerRepresentable & Coordinator
struct Take2: UIViewControllerRepresentable {
#EnvironmentObject var model: Model
let testVC = TestVC()
func makeUIViewController(context: Context) -> TestVC {
testVC.delegate = context.coordinator
return testVC
}
func updateUIViewController(_ uiViewController: TestVC, context: Context) {
//
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, TestVCDelegate {
var parent: Take2
init(_ testViewController: Take2) {
parent = testViewController
}
func runTest() {
print("hello world")
}
}
}
Model
class Model : ObservableObject {
var objectWillChange = PassthroughSubject<Void, Never>()
#Published var lblText = "" {
willSet {
objectWillChange.send()
}
}
func runTest() {
print("yet again, hello world")
lblText = "hello world!"
}
}
Every time I try to execute runTest() in my model I get this error:
Fatal error: Reading EnvironmentObject outside View.body
Now, this error happens whether I try to do this in Take2, it's Coordinator, or in my TestVC. I (mostly) understand the error - if I should try to execute this in some View via onAppear it works. BUT - I thought a UIViewControllerRepresentable is a View to the SwiftUI stack (even though it isn't some View).
I've succeeded in exposing UIKit properties (both get and set) to the SwiftUI stack. I also have UIKit delegates (specifically, UIimagepickerController) delegate successfully updating SwiftUI properties.
But how can I execute a function in an ObservableObject from a UIKit UIButton?