Working With UIViewRepresentable
Preventing Runtime Warnings and Loops
When we work with SwiftUI, we can always drop down to UIKit level by using UIViewRepresentable
, NSViewRepresentable
or UIViewControllerRepresentable
. The documentation around these protocols is still pretty sparse, and it can be hard to get them to work exactly the way we want. I tried to come up with some rules and patterns for using them. These patterns are not
final, if you have feedback about missing things or mistakes, please let me know.
There are a few different challenges. In this article, I want to focus on communicating state between SwiftUI and UIKit/AppKit. Communication can happen in either direction: we’ll need to update our UIView
when SwiftUI’s state changes, and we’ll need to update our SwiftUI state based on UIView changes.
Here are two rules for working with representables. (Matt helped me with this, thank you):
-
When updating a UIView in response to a SwiftUI state change, we need to go over all the representable’s properties, but only change the UIView properties that need it.
-
When updating SwiftUI in response to a UIKit change, we need to make sure these updates happen asynchronously.
If we don’t follow these rules, there are a few issues we might see:
-
The dreaded “Modifying state during view update, this will cause undefined behavior” warning
-
Unnecessary redraws of our
UIViewRepresentable
, or even infinite loops -
Strange behavior where the state and the view are a little bit out of sync
In my testing, these issues are becoming less relevant with UIKit, but are very relevant when dealing with AppKit. My guess is that UIKit components have seen some internal changes to make writing view representables simpler. However, as we’ll see, this isn’t the case for every UIKit view.
Building a MapView wrapper
MapKit’s Map
view for SwiftUI used to be very limited, and a popular target for wrapping in a representable. As of iOS 17 it gained a lot of new capabilities, but we’ll still use it as our first example.
We’ll be writing a simple wrapper that takes a binding to the map view’s center coordinate. As a first step, we’ll create an MKMapView
and set the delegate to be our coordinator.
struct HybridMap: UIViewRepresentable {
@Binding var position: CLLocationCoordinate2D
// ...
func makeUIView(context: Context) -> MKMapView {
let view = MKMapView()
view.delegate = context.coordinator
view.preferredConfiguration = MKHybridMapConfiguration()
return view
}
// ...
}
For the coordinator, there is a nice technique to pass in all properties of HybridMap
directly (this is especially useful when we have more than one property). You can simply pass a copy of self
, as HybridMap
is a struct:
// ...
class Coordinator: NSObject, MKMapViewDelegate {
var parent: HybridMap
init(parent: HybridMap) {
self.parent = parent
}
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
parent.position = mapView.centerCoordinate
}
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
// ...
Finally, here’s our updateUIView
method:
func updateUIView(_ view: MKMapView, context: Context) {
context.coordinator.parent = self
view.centerCoordinate = position
}
We can now create a simple view with a state property for the position:
let initialPosition = CLLocationCoordinate2D(latitude: 52.518611, longitude: 13.408333)
struct ContentView: View {
@State private var position = initialPosition
var body: some View {
VStack {
Text("\(position.latitude) \(position.longitude)")
Button("Reset Position") { position = initialPosition }
HybridMap(position: $position)
}
}
}
When we launch the above app, we’ll immediately get a runtime warning: “Modifying state during view update, this will cause undefined behavior.”. To debug this, we can add print statements to the beginning and ending of both our updateUIView
as well as mapViewDidChangeVisibleRegion
methods:
func updateUIView(_ view: MKMapView, context: Context) {
print("Begin updateUIView", position)
defer { print("End updateUIView") }
context.coordinator.parent = self
view.centerCoordinate = position
}
// ...
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
print("Begin didChange", mapView.centerCoordinate)
defer { print("End didChange") }
parent.position = mapView.centerCoordinate
}
When we launch the app, we get the following print statements:
Begin updateUIView CLLocationCoordinate2D(latitude: 52.518611, longitude: 13.408333)
End updateUIView
Begin didChange CLLocationCoordinate2D(latitude: 51.117027, longitude: 10.333652000000006)
End didChange
Begin didChange CLLocationCoordinate2D(latitude: 52.51861099999999, longitude: 13.40833300000003)
End didChange
Begin updateUIView CLLocationCoordinate2D(latitude: 52.51861099999999, longitude: 13.40833300000003)
Begin didChange CLLocationCoordinate2D(latitude: 52.51861099999999, longitude: 13.40833300000003)
[SwiftUI] Modifying state during view update, this will cause undefined behavior.
End didChange
End updateUIView
Here’s what happens: first, the view is rendered and put on screen. After that, the didChange
runs. We can see that map views don’t store their center coordinate directly, my guess is that they only store the visible region as their source of truth. This is why the values in the print statements are different from our initial value. Towards the end, we see that a didChange
runs from within our updateUIView
method, updating our SwiftUI state. This causes the “Modifying state” error.
As far as I know, the only reliable way I know of to get rid of this warning is by doing this state change asynchronously. The simplest way is by enqueueing another block on the main queue:
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
DispatchQueue.main.async {
self.parent.position = mapView.centerCoordinate
}
}
This makes the runtime warning go away. However, we can’t drag the map anymore, after a single drag movement it halts. There is one more step left to make this representable work:
func updateUIView(_ view: MKMapView, context: Context) {
context.coordinator.parent = self
if view.centerCoordinate != position {
view.centerCoordinate = position
}
}
By only doing the view update when necessary we’re not triggering another didChange
. Both of these changes are specific instances of the rules at the beginning of the article:
-
We need to change our SwiftUI state asynchronously in response to UIKit changes.
-
We need to only update properties of UIKit views when absolutely necessary
Building a Text View wrapper
As a second example, we’ll build an NSTextView
wrapper. My goal is to write a MyTextView
component that takes a binding for both the text and the selected range. This is for the Mac, so we’ll be using NSView
instead of UIView
.
Here’s the (broken) initial version, with a structure very similar to our map view:
struct MyTextView: NSViewRepresentable {
@Binding var text: String
@Binding var selection: NSRange
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: MyTextView
unowned var textView: NSTextView!
init(parent: MyTextView) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
self.parent.text = textView.string
}
func textViewDidChangeSelection(_ notification: Notification) {
self.parent.selection = textView.selectedRange()
}
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
func makeNSView(context: Context) -> NSTextView {
let t = NSTextView()
context.coordinator.textView = t
t.delegate = context.coordinator
return t
}
func updateNSView(_ t: NSTextView, context: Context) {
t.textStorage?.setAttributedString(text.highlight())
t.selectedRanges = [.init(range: selection)]
}
}
When we run the example above, we can see that we get the dreaded “Modifying state during view update, this will cause undefined behavior” warning. This happens because when we set the attributed string from within updateNSView
, the text view will fire a textViewDidChangeSelection
notification. This notification isn’t posted asynchronously, but actually does happen during the updateNSView
.
Updating SwiftUI in Response to an NSView Change
Similar to the map view, we can now wrap our update by enqueueing it on the main queue:
func textViewDidChangeSelection(_ notification: Notification) {
let r = self.textView.selectedRange()
DispatchQueue.main.async {
self.parent.selection = r
}
}
Unfortunately, things are still broken: we don’t get a runtime warning anymore, but the cursor behaves weirdly. (The insertion point is rendered at the start of the current selection whenever the length of the selection is zero).
Updating an NSView in Response to a SwiftUI State Change
In principle, this is simple. Whenever something changes, SwiftUI will call updateNSView(:context:)
. However, we don’t know what
changed, it could be any number of properties. In the above implementation, we simply set the two properties, but that’s not enough.
In our update method, we should take care to inspect each property, but only set the corresponding NSView
value if it’s really necessary. For example:
func updateNSView(_ t: NSTextView, context: Context) {
context.coordinator.parent = self
if t.string != text {
t.textStorage?.setAttributedString(text.highlight())
}
if t.selectedRange() != selection {
t.selectedRanges = [.init(range: selection)]
}
}
This solves almost all our problems. However, there is still a weird issue. When I’m typing at the end of the text field, some characters get inserted just before the last character, instead of at the end.
Here’s what happens (bear with me):
When I type a character at the end of the string, the selection changes. A self.parent.setSelection…
is enqueued. The main queue now looks like this:
Before that runs, however, the text is updated, and updateNSView
happens. It will set the attributed string, which in turn causes the selection to change, which adds another setSelection
to the queue (with the old selection value). The main queue now looks like this:
The main queue then runs the first (correct) block to set the selection, and then the second (incorrect) block.
To keep the order of events correct, this means we also have to enqueue the changing of our text. That way, all events will happen in the order we expect:
func textDidChange(_ notification: Notification) {
let str = textView.string
DispatchQueue.main.async {
self.parent.text = str
}
}
This is tricky to find and tricky to debug. A simple rule of thumb would be that once we start enqueueing one change asynchronously, we probably need to do all of the other updates asynchronously as well.
Now, when the user types a character, the following happens. First, the selection event is enqueued:
Then, the text setting event is enqueued:
The main queue is a first-in, first-out serial queue. During the next iteration of the run loop, the first block runs while the other is still in the queue:
Because the setSelection
changes the internal SwiftUI view, the view is marked as needing to redraw. This causes the updateView
to be enqueued:
After the setSelection
block is done, the setText
is removed from the queue:
This setText
also wants the view to redraw. However, because the view is already flagged as “dirty” by the setSelection
block, no further block will be enqueued. So after setText
runs, the queue looks like this:
Then the updateView
runs and because we have the if-conditions in our method, no further changes will happen. So in practice, even though we enqueued multiple blocks asynchronously, these got coalesced into a single updateView
.
More Issues and Gotchas
As mentioned, we need to check each property in updateNSView(:context:)
and make sure to only update the NSView
when it’s really needed. This relies on us being able to compare values. Sometimes this isn’t possible. For example, when dealing with the map view, we saw that the center coordinate conversion is lossy. In the cases where we can’t read out the current value, we can cache each value that we set. Inside our coordinator, we could add a helper like this:
private var previousValues: [String: Any] = [:]
func setIfNeeded<Value: Equatable>(value: Value, name: String, update: (NSTextView) -> ()) {
if let previous = previousValues[name] as? Value, previous == value {
return
}
previousValues[name] = value
update(textView)
}
When we set the new value, we first check whether or not we’ve previously set this value. Only if it’s different, we proceed to update the underlying platform view. In a way, this is similar to SwiftUI’s onChange(of:)
modifier (only run the closure when something changed).
Similarly, when we want to start animations in SwiftUI, we’ll need to have some kind of state, and our coordinator needs to track when that state changes and only start a new animation then. You could use a method similar to setIfNeeded
to achieve this.
Sometimes, we want to communicate events back from an NSView to SwiftUI. If the event modifies a value, we could simply modify the corresponding binding. For example, if the event would be scrollViewDidScroll
, we can change the scrollPosition
binding. However, for other events it’s more appropriate to just call a closure (this is what Button
does each time the user taps). Of course, this closure could have parameters as well.
I’m sure there are many more issues when doing this in practice, if you have any feedback or comments I’d love to hear about it.
If you want to learn more about SwiftUI, check out our book Thinking in SwiftUI.
Updates
-
After some discussion on Mastodon (and some research) I added a section on multiple events (which includes the main queue diagrams).