Articles, podcasts and news about Swift development, by John Sundell.

SwiftUI and UIKit interoperability - Part 1

Published on 08 Nov 2020
Discover page available: SwiftUI

One of SwiftUI’s major strengths is just how well it integrates with both UIKit and AppKit. Not only does that act as a useful “escape hatch” for whenever a given use case is not yet natively supported by SwiftUI itself, it also enables us to incrementally migrate an existing UIKit or AppKit-based project to Apple’s new UI framework, all while reusing many of our core UI components.

While several aspects of this topic have already been covered on this site before, this week and the week after, let’s dive much deeper into how SwiftUI and UIKit can be combined in various ways — starting with how we can bring increasingly complex UIKit-based views and view controllers into the declarative world of SwiftUI.

Although all of the examples within this article will be UIKit-based, the same tools and techniques can also be used with AppKit as well. All SwiftUI-provided protocols and methods that we’ll use are identical between iOS and macOS in this case, with the only difference being that the macOS ones use NS instead of UI within their names.

Reusing existing components

Although it might be tempting to start out fresh when migrating a given project to SwiftUI, and rewrite the entire app from the ground up, that’s often not a wise decision, as doing so means throwing away working, battle-tested production code just because it happens to be implemented using a somewhat older UI framework.

Instead, let’s explore how we can reuse existing UI components, all while making them fit in perfectly alongside our new, SwiftUI-based views. As an example, let’s say that we’re working on an iOS app for managing various events, which includes the following EventDetailsView:

class EventDetailsView: UIView {
    let imageView = UIImageView()
    let nameLabel = UILabel()
    let descriptionLabel = UILabel()
    ...
}

The above view follows the common UIKit pattern of letting views remain simple UI containers, while making their enclosing view controllers responsible for populating them with data. However, since there are no view controllers in SwiftUI, we’ll have to take a slightly different approach within that context — by using the UIViewRepresentable protocol.

That protocol (and its NSViewRepresentable equivalent on the Mac) lets us implement bridging types that each wrap a UIView instance in order to make it SwiftUI-compatible. For non-interactive views, such as our EventDetailsView, creating such a wrapper involves implementing two methods — one for creating our view, and one for updating it:

struct EventDetailsComponent: UIViewRepresentable {
    var event: Event

    func makeUIView(context: Context) -> EventDetailsView {
        EventDetailsView()
    }

    func updateUIView(_ view: EventDetailsView, context: Context) {
        view.imageView.image = UIImage(named: event.icon.imageName)
        view.nameLabel.text = event.name
        view.descriptionLabel.text = event.description
    }
}

Note how we named the above wrapper EventDetailsComponent, since EventDetailsView is already taken, and since the purpose of our wrapper is to turn our existing UIKit-based view into a custom SwiftUI component.

Since all SwiftUI View types are simply descriptions of views, rather than concrete representations of them, we should ideally not make any assumptions about what kind of lifecycle that each of our wrappers will have. Instead, we should always lazily create each underlying UIView in our wrapper’s makeUIView method, and then update it according to the current state in updateUIView.

That’s particularly important since SwiftUI will reuse our underlying UIView instances as much as possible, even when their wrapping UIViewRepresentable values are recreated — meaning that any properties that we assign in makeUIView won’t be continuously updated as our state changes.

However, sometimes we might want to persist some form of state within our wrappers themselves, which SwiftUI also provides a dedicated API for. To explore that, let’s now say that we also wanted to bring in a custom UIButton subclass into SwiftUI as well:

class EventSchedulingButton: UIButton {
    ...
}

Since the above control is a UIButton subclass, we’re using UIKit’s built-in target/action pattern to handle its events, which in turn means that we’re going to need some form of object to act as our button’s target.

While an initial idea might be to simply make our button’s UIViewRepresentable wrapper a class and then have it take on that target role, that’s not going to work very well given that our wrappers can be destroyed and recreated at any point (even if they’re classes). Instead, let’s give our new EventSchedulingButton wrapper a Coordinator by implementing the optional makeCoordinator method — like this:

struct EventSchedulingComponent: UIViewRepresentable {
    // Although our component will keep using target/action
    // internally, we'll make our SwiftUI-facing API closure-
    // based, since that's a much better fit within that context:
    var handler: () -> Void

    func makeCoordinator() -> Coordinator {
        Coordinator()
    }

    func makeUIView(context: Context) -> UIView {
        let button = EventSchedulingButton()

        button.addTarget(context.coordinator,
            action: #selector(Coordinator.callHandler),
            for: .touchUpInside
        )

        return button
    }

    func updateUIView(_ uiView: UIViewType, context: Context) {
        context.coordinator.handler = handler
    }
}

A SwiftUI Coordinator always has a one-to-one relationship to a given UIView instance, meaning that we can use it to persist state even if our UIViewRepresentable struct ends up getting recreated.

The good news is that SwiftUI will automatically manage all of the complexity involved in doing that on our behalf — the only thing that we need to do (apart from implementing the above makeCoordinator method) is to define our Coordinator type itself:

extension EventSchedulingComponent {
    class Coordinator {
        var handler: (() -> Void)?

        @objc func callHandler() {
            handler?()
        }
    }
}

The beauty of the above approach is that it lets us make full use of our existing UIKit-based components — without even making any modifications to them — while also enabling us to implement dedicated, SwiftUI-friendly APIs for the new views that our components will be embedded in.

If we now add the two wrappers that we’ve created so far to an actual SwiftUI view, it’s actually quite hard to tell that those wrappers are not “SwiftUI-native” views (which is definitely a good design goal to have):

struct EventInvitationView: View {
    var event: Event
    ...
    
    var body: some View {
        VStack {
            Text("Invitation").font(.title)
            EventDetailsComponent(event: event)
            EventSchedulingComponent { ... }
        }
    }
}

Importing view controllers into SwiftUI

Both of the UIKit-based views that we’ve been importing so far have been stand-alone, lower-level components, but we can also bring entire view controllers into SwiftUI as well.

For example, let’s say that we wanted to reuse the following EventListViewController, which uses an injected EventListLoader to load a list of Event models that it then renders using a UITableView:

class EventListViewController: UIViewController {
    private let loader: EventListLoader
    private lazy var tableView = UITableView()
    ...

    init(loader: EventListLoader) {
        self.loader = loader
        super.init(nibName: nil, bundle: nil)
    }
    
    ...

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        showActivityIndicator()

        loader.loadEvents { [weak self] result in
            switch result {
            case .success(let events):
                self?.eventsDidLoad(events)
            case .failure(let error):
                self?.showError(error)
            }
        }
    }
    
    ...
}

Given that the above view controller manages its own lifecycle — from loading its models, to rendering them, to handling user input and displaying errors — we’re going to need to use a somewhat different approach to integrate it into one of our SwiftUI views.

To get started, let’s use SwiftUI’s UIViewControllerRepresentable protocol to implement another wrapper, only this time we’ll simply create an instance of our view controller using a given EventListLoader:

struct EventList: UIViewControllerRepresentable {
    var loader: EventListLoader

    func makeUIViewController(context: Context) -> EventListViewController {
        EventListViewController(loader: loader)
    }

    func updateUIViewController(_ viewController: EventListViewController,
                                context: Context) {
        // Nothing to do here, since our view controller is
        // read-only from the outside.
    }
}

The above approach might be completely fine if our list is going to be rendered in isolation (for example by being the destination of a NavigationLink, or when presented using the sheet modifier). However, if we also wanted one of our SwiftUI views to use the same underlying state as our view controller does, then we’d currently need to load that state twice, which would be quite wasteful.

One way to address that problem would be to make our EventListLoader an ObservableObject, which would let us observe its state directly from within a SwiftUI view, without requiring us to change our view controller in any way. Here’s how we could make that happen by exposing our loader’s last result as an @Published-marked property:

class EventListLoader: ObservableObject {
    typealias Result = Swift.Result<[Event], Error>
    typealias Handler = (Result) -> Void

    @Published private(set) var result: Result?
    private let networking: Networking

    init(networking: Networking) {
        self.networking = networking
    }

    func loadEvents(then handler: @escaping Handler) {
        // Here we wrap the handler that was passed in, in order
        // to ensure that we'll always update our result property:
        let handler: Handler = { [weak self] result in
            self?.result = result
            handler(result)
        }

        networking.request(.eventList) { result in
            do {
                let decoder = JSONDecoder()
                let data = try result.get()
                let events = try decoder.decode([Event].self, from: data)
                handler(.success(events))
            } catch {
                handler(.failure(error))
            }
        }
    }
}

With the above in place, we can now easily connect any SwiftUI view to an EventListLoader instance — for example in order to render a dashboard that displays the user’s next upcoming event on top of our view controller-based EventList view. Since our underlying view controller will manage the actual loading of our models, we simply have to annotate the property that’s storing our EventListLoader with @ObservedObject, and our dashboard will be automatically updated as soon as the user’s events have been loaded:

struct EventDashboard: View {
    @ObservedObject var eventListLoader: EventListLoader

    private var nextEvent: Event? {
        try? eventListLoader.result?.get().first
    }

    var body: some View {
        VStack(alignment: .leading) {
            if let event = nextEvent {
                VStack(alignment: .leading) {
                    Text("Your next event:").font(.headline)
                    NextEventView(event: event)
                }
                .padding(.horizontal)
            }

            EventList(loader: eventListLoader)
        }
    }
}

However, while the above is certainly very convenient, it could also be considered somewhat of a hack. After all, we’re currently making a very strong assumption that our EventList and its underlying view controller will actually start loading our data, meaning that our EventDashboard ends up having an implicit data dependency on one of its child views, which isn’t ideal.

While we could of course always call our loader’s loadEvents method directly within our EventDashboard view as well, doing so would both cause two separate network requests to be performed (which we’ve been trying to avoid), and would be somewhat awkward in this case — given that we’d simply discard the result that was passed into that method’s required completion handler.

Instead, let’s introduce a new type that’ll be responsible for synchronizing our state between the SwiftUI-based EventDashboard and the UIKit-based EventListViewController. Since that new type will be all about storing a collection of Event models, let’s call it EventStore. We’ll once again use the ObservableObject protocol to make it connectable to a SwiftUI view, while also enabling a UIKit-friendly completion handler to be optionally attached when loading its events:

class EventStore: ObservableObject {
    @Published private(set) var events = [Event]()
    @Published private(set) var error: Error?

    private let loader: EventListLoader
    private var isLoading = false
    private var pendingHandlers = [EventListLoader.Handler]()

    init(loader: EventListLoader) {
        self.loader = loader
    }

    func loadEvents(then handler: EventListLoader.Handler? = nil) {
        if let handler = handler {
            pendingHandlers.append(handler)
        }

        // This time, we only start loading if a loading operation
        // isn't already in progress, meaning that this method
        // can be called multiple times without causing duplicate
        // network requests to be performed:
        if !isLoading {
            isLoading = true

            loader.loadEvents { [weak self] result in
                self?.didFinishLoading(withResult: result)
            }
        }
    }
}

Note how we store each handler that was passed into the above loadEvents method in an array, rather than attaching those closures directly to each loading operation. That way we’re able to call all pending handlers at once when our didFinishLoading method is called — like this:

private extension EventStore {
    func didFinishLoading(withResult result: EventListLoader.Result) {
        isLoading = false

        switch result {
        case .success(let loadedEvents):
            events = loadedEvents
            error = nil
        case .failure(let encounteredError):
            error = encounteredError
        }

        let handlers = pendingHandlers
        pendingHandlers.removeAll()
        handlers.forEach { $0(result) }
    }
}

We deliberately keep our events array intact even when an error was encountered, to avoid erasing existing view data if the user went offline, or if a given network request failed for another reason.

With the above in place, we can now go back to our EventDashboard and make it use our new EventStore type, rather than calling EventListLoader directly. We’ll also make it call loadEvents when it appears (which can safely be called multiple times), which makes it completely self-sufficient in terms of its data:

struct EventDashboard: View {
    @ObservedObject var store: EventStore

    var body: some View {
        VStack(alignment: .leading) {
            if let event = store.events.first {
                VStack(alignment: .leading) {
                    Text("Your next event:").font(.headline)
                    NextEventView(event: event)
                }
                .padding(.horizontal)
            }

            EventList(store: store)
        }
        .onAppear { store.loadEvents() }
    }
}

Much better! However, this approach does also require us to make a few small changes to EventListViewController (and its EventList wrapper type) as well. Thankfully, those changes are really minor, and basically just involves changing all calls to EventListLoader to instead use our new EventStore type, for example like this:

class EventListViewController: UIViewController {
    private let store: EventStore
    private lazy var tableView = UITableView()
    ...

    init(store: EventStore) {
        self.store = store
        super.init(nibName: nil, bundle: nil)
    }
    
    ...

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        showActivityIndicator()

        store.loadEvents { [weak self] result in
            switch result {
            case .success(let events):
                self?.eventsDidLoad(events)
            case .failure(let error):
                self?.showError(error)
            }
        }
    }
    
    ...
}

With the above changes in place, we can now revert our EventListLoader back to being a simple stateless loader that can remain completely focused on just loading our list of events.

Another approach (or perhaps next logical step in our overall migration process) would be to instead make the above view controller also use the @Published-marked properties that our EventStore provides, rather than relying on a separate, completion-handler based API. That’s something that we’ll explore in great detail next week, when we’ll take a look at the other side of the coin — how to bring SwiftUI-based views into UIKit-based view controllers.

Conclusion

SwiftUI’s strong interoperability with both UIKit and AppKit often gives us quite a lot of flexibility when it comes to adopting it. However, while using protocols like UIViewRepresentable might be relatively simple at the most basic level, sharing mutable state and complex interactions between UIKit-based views and ones built using SwiftUI can often be quite complicated, and might require us to build various bridging layers between those two worlds.

I hope that this article has given you some tips and inspiration on how to approach importing UIKit-based components into SwiftUI. In part two, we’ll go the opposite direction, by taking a look at how SwiftUI views can be imported into view controllers.

Got questions, comments, or feedback? Feel free to reach out via either Twitter or email.

Thanks for reading! 🚀