UIViewRepresentable: Working with delegates in SwiftUI

In the first post of this series, we looked at how we can display a UIView in a SwiftUI hierarchy, as well as how we can change properties on said view. As a refresher, here is our example.

Imagine we have a third party SDK that provides their functionality in a pre-packaged UIView subclass. We’ll call this ThirdPartyUIView. It has properties set on it to change its behavior, methods that can be called on it, and provides internal feedback by means of a delegate object.

class ThirdPartyUIView: UIView {
    var shouldAdd: Bool
    var delegate: ThirdPartyViewDelegate?
    
    // Same as button tap in view. Results reported via delegate
    func changeInternalValue() { /*...*/ }
}

protocol ThirdPartyViewDelegate {
    func view(_ view: ThirdPartyUIView, changedValueTo newValue: Int)
}

And here is our UIViewRepresentable view thus far.

struct ThirdPartyViewRepresentable: UIViewRepresentable {
    var shouldAdd: Bool
    
    func makeUIView(context: Context) -> ThirdPartyUIView {
        let view = ThirdPartyUIView()
        view.shouldAdd = shouldAdd
        return view
    }
    
    func updateUIView(_ uiView: ThirdPartyUIView, context: Context) {
        uiView.shouldAdd = shouldAdd
    }
}

How can we provide a delegate when creating our view? Let’s look at a few approaches.

The Coordinator

The UIViewRepresentable protocol gives us one approach that we can take. The clue is found in the Context object that is provided in both the makeUIView(context:) and updateUIView(_:context:) methods. It has a coordinator property of type Coordinator. But what is this type?

Here is how it is defined in the UIViewRepresentable protocol:

/// A type to coordinate with the view
associatedtype Coordinator = Void

By default, Coordinator is type Void, essentially meaning an empty type. It is provided to the Context by means of the protocol method makeCoordinator(). By providing our own type here, we can create an object that will coordinate with the view.

Let’s use that to create a type conforms to the ThirdPartyViewDelegate protocol. We can then create an instance of it and provide it when we initialize our view.

struct ThirdPartyViewRepresentable: UIViewRepresentable {
    class Coordinator: ThirdPartyViewDelegate {
        func view(_ view: ThirdPartyUIView, changedValueTo newValue: Int) {
            // Respond to newValue
        }
    }
    
    var shouldAdd: Bool
    
    func makeUIView(context: Context) -> ThirdPartyUIView {
        let view = ThirdPartyUIView()
        view.shouldAdd = shouldAdd
        view.delegate = context.coordinator
        return view
    }
    
    func updateUIView(_ uiView: ThirdPartyUIView, context: Context) {
        uiView.shouldAdd = shouldAdd
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator()
    }
}

Just like that, we’ve been able to define and provide a delegate that can respond to changes to the ThirdPartyUIView!

However, we have some limitations here. First, this means that the delegate is defined and created entirely within the ThirdPartyViewRepresentable. There isn’t any way to provide a different delegate. We also have no way to allow this delegate to interface with other elements, such as some kind of UI within our SwiftUI hierarchy. How can we allow that?

Injecting a Delegate

The simplest way is by creating our delegate class outside of the view representable and providing an instance of it when we initialize the view.

class OurDelegate: ThirdPartyViewDelegate {
    func view(_ view: ThirdPartyUIView, changedValueTo newValue: Int) {
        // Respond to newValue
    }
}

struct ThirdPartyViewRepresentable: UIViewRepresentable {
    var delegate: ThirdPartyViewDelegate? = nil
    var shouldAdd: Bool
    
    func makeUIView(context: Context) -> ThirdPartyUIView {
        let view = ThirdPartyUIView()
        view.shouldAdd = shouldAdd
        view.delegate = delegate
        return view
    }
    
    func updateUIView(_ uiView: ThirdPartyUIView, context: Context) { ... }
}

Now, when we make use of the view representable, we have the option of providing a delegate.

struct ThirdPartyDemoView: View {
    @State private var shouldAdd = true
    private let delegate = OurDelegate()
    
    var body: some View {
        ThirdPartyViewRepresentable(delegate: delegate, shouldAdd: shouldAdd)
        Toggle("Add?", isOn: $shouldAdd)
    }
}

This is great because we can provide any delegate we want for the underlying view. If we use this view in multiple places or in multiple projects, we can create a different delegate based on each situation. However, we still don’t have a way for these updates to be exposed in our SwiftUI view. For instance, what if we wanted a Text view to display the new value? How could we change our view to support that?

To answer, let’s first take a step back and consider how we might want our code to be written.

Planning Our API

As we build our own implementations, it’s sometimes helpful to look at existing first party code. For example, let’s look at how SwiftUI exposes various actions that we can respond do.

One example is .onAppear(perform:), which lets us declare a closure that is called when the view appears. This sounds like what we want to emulate. Providing a closure of our own to perform when a delegate method is invoked would be a good way to hook into the delegate while letting us access the data in our SwiftUI view. So our destination is something like this:

// What we'd like to build
ThirdPartyView()
    .viewChangedValueTo { thirdPartyView, newValue in 
        // Use newValue to update some state in our view
        // Access thirdPartyView if necessary
    }

What do we need to do to create the above?

To begin, let’s first add another layer of abstraction around our view representable.

struct ThirdPartyView: View {
    var shouldAdd: Bool
    
    var body: some View {
        ThirdPartyViewRepresentable(delegate: ???, shouldAdd: shouldAdd)
    }
}

Like before, we will initialize this view with a shouldAdd property so we can control it externally. But what about our delegate? What should we put there? We’ll need to create a class that can act as a ThirdPartyViewDelegate that takes the data from the delegate method and forwards it to a closure that we define.

class ThirdPartyCoordinator: ThirdPartyViewDelegate {
    var viewChangedValueTo: ((ThirdPartyUIView, Int) -> Void)?
    
    func view(_ view: ThirdPartyUIView, changedValueTo newValue: Int) {
        viewChangedValueTo?(view, newValue)
    }
}

When the delegate method is called, it’ll pass on its parameters to the viewChangedValueTo closure. We will now use the above coordinator as our representable’s delegate.

struct ThirdPartyView: View {
    var shouldAdd: Bool
    @State private var coordinator = ThirdPartyCoordinator()
    
    var body: some View {
        ThirdPartyViewRepresentable(delegate: coordinator, shouldAdd: shouldAdd)
    }
}

Note two things. First, we declare this private; the existence of the coordinator is purely an implementation detail of ThirdPartyView and not something that would need to be changed from the outside. Second, we declare it as a @State property. Though we won’t be changing its value, it now ties the existence of the coordinator along with the view. That will be important for what we do next.

Next, we’ll create our view modifier. However, we wouldn’t want it to work with all Views; this is only important if we’re using a ThirdPartyView. Well, looking again at SwiftUI, certain views, such as Image, come with their own modifiers. Instead of being declared as extensions on View, they exist as extensions of a particular type. We’ll do the same and create this view modifier in an extension of our view. Its purpose is to allow us to set the closure that will be called when the delegate method is hit.

extension ThirdPartyView {
    func viewChangedValueTo(_ closure: @escaping (_ view: ThirdPartyUIView, _ newValue: Int) -> Void) -> Self {
        coordinator.viewChangedValueTo = closure
        return self
    }
}

With this extension, we provide a closure that is called when the delegate method is called. If there were other methods as part of the delegate protocol, we could likewise expose them to SwiftUI in the same manner.

One additional change I like doing here is adding support for providing an entire delegate object to respond to these delegate calls. To do that, we’ll modify our coordinator to hold another delegate to forward calls to and add a view modifier that lets us set that delegate.

class ThirdPartyCoordinator: ThirdPartyViewDelegate {
    var viewChangedValueTo: ((ThirdPartyUIView, Int) -> Void)?
    var externalDelegate: ThirdPartyViewDelegate?
    
    func view(_ view: ThirdPartyUIView, changedValueTo newValue: Int) {
        viewChangedValueTo?(view, newValue)
        externalDelegate?.view(view, changedValueTo: newValue)
    }
}

extension ThirdPartyView {
    func setViewDelegate(_ delegate: ThirdPartyViewDelegate) -> Self {
        coordinator.externalDelegate = delegate
        return self
    }
}

With this addition, not only can we respond to delegate methods directly in our SwiftUI view, but we also can provide a delegate class to handle those as well.

The ThirdPartyUIView, its properties, and its delegate are now usable in SwiftUI like so.

struct ThirdPartyDemoView: View {
    @State private var shouldAdd = true
    @State private var currentValue = 0
    
    // Use our delegate for something like logging all delegate usage
    private let delegate = OurDelegate()
    
    var body: some View {
        Text("\(currentValue)")
        ThirdPartyView(shouldAdd: shouldAdd)
            .viewChangedValueTo { view, newValue in
                currentValue = newValue
            }
            .setViewDelegate(delegate)
        Toggle("Add?", isOn: $shouldAdd)
    }
}

ThirdPartyView communicates with our underlying ThirdPartyUIView via a view representable internally. The view’s shouldAdd property is exposed and can be changed by our Toggle. And we have full access to the view’s delegate by providing an object conforming to the delegate protocol or by providing closures via view modifiers for the specific delegate methods we want to work with.

However, there’s still one piece missing. Let’s add one more piece to the view:

struct ThirdPartyDemoView: View {
    @State private var shouldAdd = true
    @State private var currentValue = 0
    
    private let delegate = OurDelegate()
    
    var body: some View {
        Text("\(currentValue)")
        ThirdPartyView(shouldAdd: shouldAdd)
            .viewChangedValueTo { view, newValue in
                currentValue = newValue
            }
            .setViewDelegate(delegate)
        Toggle("Add?", isOn: $shouldAdd)
        Button("Perform Action") {
            // ???
        }
    }
}

Here, we’ve added a Button. Why? Simple: we want to trigger the view’s action programmatically! But how can we expose the view’s action to make it accessible to our Button?

Discover more from Josh Hrach

Subscribe now to keep reading and get access to the full archive.

Continue reading