SwiftUI and UIKit interoperability - Part 2
Discover page available: SwiftUILast week, we took a closer look at how UIKit views can be imported into the declarative world of SwiftUI, which both gives us an opportunity to reuse existing UIView
-based components, and also acts as an important “escape hatch” for when SwiftUI does not yet support a given use case.
But SwiftUI’s interoperability with UIKit goes the complete opposite direction as well, since we’re also able to embed SwiftUI views within UIKit-based view controllers — and that’s exactly what we’ll take a look at this week.
If you haven’t read part one of this article, then I recommend doing that before proceeding with this one.
Hosting a SwiftUI view within a view controller
Continuing with the event app-based examples from part one, let’s say that the screen that our app is using to display a single event is currently implemented as a UIViewController
, which uses a view model to keep track of its current state, and it then asks that view model to update itself within viewWillAppear
— like this:
class EventViewController: UIViewController {
private let viewModel: EventViewModel
private lazy var descriptionLabel = UILabel()
private lazy var tagListView = EventTagListView()
init(viewModel: EventViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
viewModel.delegate = self
}
...
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(descriptionLabel)
view.addSubview(tagListView)
// Add layout constraints and perform other kinds of setup
...
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.update()
}
...
}
As part of the above implementation, we’re assigning our view controller as its view model’s delegate
, which in turn requires us to conform to an EventViewModelDelegate
protocol that’s used to handle events like when the view model was updated, or when an error was encountered:
extension EventViewController: EventViewModelDelegate {
func eventViewModelDidUpdate(_ viewModel: EventViewModel) {
// Updating our views according to our view model's
// current state:
descriptionLabel.text = viewModel.event.description
tagListView.tags = viewModel.event.tags
}
func eventViewModelDidEncounterError(_ viewModel: EventViewModel,
error: Error) {
// Show a description of the error and a retry button
...
}
}
The patterns used above all work really great within the world of UIKit, but what if we now wanted to add a SwiftUI view to the mix?
For example, let’s say that we’ve been wanting to add a header view to our EventViewController
, and since that’s going to be a new stand-alone view, then that’s an excellent opportunity for us to use SwiftUI. Here’s what an initial implementation of such a header view could look like:
struct EventHeaderView: View {
var event: Event
var body: some View {
ZStack {
EventGradient().edgesIgnoringSafeArea(.top)
VStack {
Image(systemName: event.icon.imageName)
Text(event.name)
.foregroundColor(.white)
.font(.title)
}
}
}
}
But now the question is — how will we integrate the above EventHeaderView
into our existing view controller? At the most basic level, all that we actually have to do is to wrap our new SwiftUI view within a UIHostingController
, which will automatically bridge the gap between SwiftUI and UIKit in terms of rendering:
class EventViewController: UIViewController {
private let viewModel: EventViewModel
private lazy var header = makeHeader()
private lazy var descriptionLabel = UILabel()
private lazy var tagListView = EventTagListView()
...
private func makeHeader() -> UIHostingController<EventHeaderView> {
let headerView = EventHeaderView(event: viewModel.event)
let headerVC = UIHostingController(rootView: headerView)
headerVC.view.translatesAutoresizingMaskIntoConstraints = false
return headerVC
}
}
To then actually display our header view, we’ll first need to add its wrapping UIHostingController
to our EventViewController
as a child, and we’ll then apply a series of layout constraints to that wrapping view controller’s view
in order to give it our desired layout:
class EventViewController: UIViewController {
...
override func viewDidLoad() {
super.viewDidLoad()
// Add our header view controller as a child:
addChild(header)
view.addSubview(header.view)
header.didMove(toParent: self)
// Apply a series of Auto Layout constraints to its view:
NSLayoutConstraint.activate([
header.view.topAnchor.constraint(equalTo: view.topAnchor),
header.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
header.view.widthAnchor.constraint(equalTo: view.widthAnchor),
header.view.heightAnchor.constraint(
equalTo: view.heightAnchor,
multiplier: 0.25
)
])
...
}
...
}
To avoid having to repeat the three method calls required to add a view controller as a child, we can extend UIViewController
with a convenience API, just like we did in “Using child view controllers as plugins in Swift”, which would let us simply call add(header)
when adding our header’s UIHostingController
.
That’s a great start, and the above implementation will work just fine as long as our SwiftUI view isn’t expected to be updated during our view controller’s lifecycle. In this case, however, we do want our EventHeaderView
to be updated whenever our view model’s event
was changed, which can be accomplished in a few different ways.
Perhaps the simplest way would be to just assign a new header view to our UIHostingController
whenever our eventViewModelDidUpdate
delegate method was called — like this:
extension EventViewController: EventViewModelDelegate {
func eventViewModelDidUpdate(_ viewModel: EventViewModel) {
let event = viewModel.event
header.rootView = EventHeaderView(event: event)
descriptionLabel.text = event.description
tagListView.tags = event.tags
}
...
}
At first glance, the above might look awfully inefficient, since we’re essentially recreating our view on every state change. But then we have to remember that SwiftUI views are not concrete representations of the actual pixels that are being rendered on-screen, but are rather lightweight descriptions of our desired UI, and that SwiftUI will automatically reuse its underlying views and layers as much as possible.
So, at least for simpler use cases, the above might work perfectly fine, as long as the SwiftUI view that we’re wrapping doesn’t contain any state of its own, since that state would be lost when we manually swap out instances like we do above.
Updating an embedded SwiftUI view
Another option would be to let our EventHeaderView
observe our view model’s state on its own, which would further make it more of a stand-alone component, and would also enable it to modify that state as well.
To make that happen, let’s start by turning our EventViewModel
into an ObservableObject
, which was also how we enabled state to be shared between embedded UIKit-based views and their SwiftUI wrappers in part one:
class EventViewModel: ObservableObject {
@Published private(set) var event: Event
weak var delegate: EventViewModelDelegate?
...
}
With the above change in place, our EventHeaderView
can now observe our view model directly using @ObservedObject
, and it will be automatically updated every time that the view model’s event
property was changed:
struct EventHeaderView: View {
@ObservedObject var viewModel: EventViewModel
var body: some View {
ZStack {
EventGradient().edgesIgnoringSafeArea(.top)
VStack {
Image(systemName: viewModel.event.icon.imageName)
Text(viewModel.event.name)
.foregroundColor(.white)
.font(.title)
}
}
}
}
Finally, we’ll inject our view controller’s viewModel
into our SwiftUI view when creating it, which lets us do that just once, rather than every time that our state was changed:
class EventViewController {
...
private func makeHeader() -> UIHostingController<EventHeaderView> {
let headerView = EventHeaderView(viewModel: viewModel)
let headerVC = UIHostingController(rootView: headerView)
headerVC.view.translatesAutoresizingMaskIntoConstraints = false
return headerVC
}
}
The beauty of the above approach is that our UIKit-based code can keep using the delegate pattern, or any other pattern that perfectly matches the overall design of UIKit itself, while our SwiftUI-based code is free to make full use of Combine and SwiftUI’s declarative state management system.
Two-way data bindings
So far, our data has only been flowing in a single direction — from our EventViewModel
to our view controller and its hosted EventHeaderView
. But let’s also take a look at how we can set up two-way bindings between an embedded SwiftUI view and its hosting view controller.
For example, let’s say that we wanted to enable our users to change the name of a given event directly using our EventHeaderView
. One way to make that happen would be by giving our view model a dedicated method for performing that mutation:
class EventViewModel: ObservableObject {
@Published private(set) var event: Event
weak var delegate: EventViewModelDelegate?
...
func updateName(to newName: String) {
event.name = newName
delegate?.eventViewModelDidUpdate(self)
}
}
We could then replace our header view’s previously static title with a completely dynamic TextField
. However, since that control uses a Binding
reference to propagate state changes, we’d also need a way to forward those changes to our view model’s updateName
method — which could be done using a manually constructed Binding
, like this:
struct EventHeaderView: View {
@ObservedObject var viewModel: EventViewModel
var body: some View {
ZStack {
EventGradient().edgesIgnoringSafeArea(.top)
VStack {
Image(systemName: viewModel.event.icon.imageName)
TextField("Event name", text: nameBinding)
.foregroundColor(.white)
.font(.title)
.multilineTextAlignment(.center)
}
}
}
private var nameBinding: Binding<String> {
Binding(
get: { viewModel.event.name },
set: { viewModel.updateName(to: $0) }
)
}
}
The benefit of the above approach is that our SwiftUI-based state conversions can take place entirely within our SwiftUI views themselves, which in turn lets us keep our UIKit-based code free of any such complexity.
However, always having to manually create Binding
instances can get a bit tedious, so let’s also explore a second approach, which involves making our view model’s event
property writable, instead of being read-only. When doing that, we also need to make sure that we’re always propagating any external changes to that property, which could be done using a didSet
property observer — like this:
class EventViewModel: ObservableObject {
@Published var event: Event {
didSet { delegate?.eventViewModelDidUpdate(self) }
}
weak var delegate: EventViewModelDelegate?
...
}
With the above change in place, we can now bind our header view’s TextField
directly to our view model’s event
property, which lets us get rid of the manually constructed Binding
that we were using before:
struct EventHeaderView: View {
@ObservedObject var viewModel: EventViewModel
var body: some View {
ZStack {
EventGradient().edgesIgnoringSafeArea(.top)
VStack {
Image(systemName: viewModel.event.icon.imageName)
TextField("Event name", text: $viewModel.event.name)
.foregroundColor(.white)
.font(.title)
.multilineTextAlignment(.center)
}
}
}
}
Which of the above two approaches that we’ll end up going for is likely going to depend on the situation at hand, as well as our own personal preferences. The main question is whether we want to make changes to our existing UIKit-based code in order to accommodate our new SwiftUI-based components, or whether we’d prefer to encapsulate that complexity within our SwiftUI views themselves.
Embracing reactive rendering
Finally, let’s also take a look at how the changes that we’ve made so far can give us an interesting opportunity to tweak the way we handle state within our UIKit-based code as well.
Since our EventViewModel
now publishes all changes to its event
property, we could also make our EventViewController
observe that property directly, rather than using the delegate pattern. All that we have to do to make that happen is to use Combine to sink
that property into an observing closure — like this:
class EventViewController: UIViewController {
private let viewModel: EventViewModel
private lazy var header = makeHeader()
private lazy var descriptionLabel = UILabel()
private lazy var tagListView = EventTagListView()
private var cancellable: AnyCancellable?
...
override func viewDidLoad() {
super.viewDidLoad()
cancellable = viewModel.$event.sink { [weak self] event in
self?.updateViews(with: event)
}
...
}
...
private func updateViews(with event: Event) {
descriptionLabel.text = event.description
tagListView.tags = event.tags
}
}
One benefit of the above approach, besides the consistency aspects, is that it enables us to setup much more granular observations within our views and view controllers — since we can now choose which properties that we wish to observe, rather than relying on a single delegate method for all types of changes.
Of course, that doesn’t mean that we should immediately replace all uses of the delegate pattern with Combine and @Published
-marked properties, but the above technique is definitely worth keeping in mind when gradually migrating an existing code base to Apple’s latest tools and frameworks.
Conclusion
Even though SwiftUI and UIKit are indeed very different — both in terms of their overall API design, and how state changes are propagated when using them — there are still multiple ways that we can connect and integrate them.
I hope that this two-part article has given you a few tips and ideas on how to do just that, and that some of these techniques will enable you to keep making great use of any existing UIKit-based code that you have, even as you keep adventuring into the exciting world of SwiftUI.
Like always, you’re more than welcome to reach out if you have questions, comments or feedback. You can contact me through Twitter, or send me an email, whichever you prefer.
Thanks for reading! 🚀