SwiftUI and UIKit interoperability - Part 1
Discover page available: SwiftUIOne 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! 🚀