We are just a mere 2 weeks away from Apple’s WWDC event, where they will undoubtedly celebrate 5 years of SwiftUI by introducing some amazing new features and capabilities for the framework.
And we would like to have a little pre-celebration for the event by discussing…UIKit! 😝
Today we begin a series of episodes that may sound a little strange, and we promise it isn’t a delayed April fools’ joke. We want to show what modern UIKit development can look like if you put in a little bit of effort to build tools that allow you to model your domains as concisely as possible. And this should be reminiscent of a very popular series of episodes we released a little over a year ago, where we did the same, but for SwiftUI.
So you may be wondering: why even devote any time to UIKit when clearly SwiftUI is all the rage?
Now certainly it is becoming increasingly uncommon for people to build apps that are 100% UIKit. Most apps being actively developed have some SwiftUI in them, and maybe soon the majority of apps will have mostly SwiftUI with just a bit of UIKit mixed in.
But we personally think it is going to be a long way off until every conceivable type of app can be built in pure, 100% SwiftUI. There are just quite a few powerful things that can be accomplished in UIKit that are not yet possible in SwiftUI. Each year SwiftUI gets a little bit of extra power, and that’s great, but also at the same time Apple keeps making improvements to UIKit too. So who knows when their trajectories will intersect and SwiftUI will overtake UIKit in all aspects of app development.
So, there are going to be times you need to drop down to UIKit to build your features, and then the question becomes: what is the best way to implement a UIKit feature? Typically features have complex logic and behavior, even possibly interacting with outside systems such as API clients, databases, location managers and more. And we want a very simple way for our views and controllers to observe changes to the state in our features, and update their UI accordingly.
And amazingly, Swift’s new observation tools can be a huge help in making modern UIKit apps, even though it’s clear that SwiftUI was the #1 priority in the design of the observation tools. In fact, it even allows us to build UIKit apps in a way that looks very, very similar to SwiftUI apps. Sure you don’t have the nice way of constructing view hierarchy with simple value types, but data flow and navigation can be performed in a nearly identical way as SwiftUI.
And it may seem a little risky for us to be starting a series called “Modern UIKit” a mere 2 weeks before WWDC. After all, everything we discuss could be out-of-date very soon, right? Well, we think the things we are going to talk about will transcend anything that is going to be discussed at WWDC. We aren’t here to discuss how to use UIKit APIs or SwiftUI APIs, or really any of Apple’s APIs. We are here to dive deep into the true essence of how one should structure their apps and model their domains. This knowledge goes beyond any specific platform, and really can be applied to many kinds of platforms, including Windows, Linux, terminal applications, WebAssembly, and who knows what else!
It’s honestly some of the most surprising and exciting stuff we’ve researched in awhile, and so let’s jump in!
We are going to begin by actually giving a quick sneak peek into what the tools look like that we will be building over the next many episodes. We are going to build a quick little demo that is inspired by one of Apple’s sample projects from a past WWDC.
Let’s dig in.
A few years ago Apple released a sample code project for the “Advances in Collection View Layout” session that consists of a whole bunch of demos showing how to build many different kinds collection views. I’ve got it running in the simulator right now, and as you can see there are a whole bunch of case studies in here, but the one we are most interested in is the the “Diffable Data Source” section, and it’s called “Settings: Wi-Fi”.
Correction In the episode we incorrectly state that this project was from last year’s WWDC, but it was in fact introduced back in 2019!
Apple has created a slimmed down version of the Wi-Fi settings screen from iOS. You can see that networks list periodically updates as it finds new networks or loses connection to existing networks. Also one special network is called out at the top, which is the currently selected network. And you can toggle Wi-Fi off to see the whole list collapse with a nice animation.
Now they didn’t implement much more than just the collection view aspect of this because the whole point of these case studies is to showcase specific powers of UICollectionView
. But in the real iOS settings there is a lot more you can do. In fact, I’ve got a QuickTime screen recording of my phone running right now to show that there’s quite a bit more to Wi-Fi settings:
I can drill down to my current network to see its details.
I can choose to forget the network I want, but first see an alert to confirm.
And I can tap any other network to get a sheet that asks me to enter the password. If I enter the password correctly the sheet dismisses and the network moves to the top, and otherwise I see an alert letting me know I got something wrong.
So this settings feature is quite complex. It features a list of items that update dynamically, there are UI controls that need to bind to values, such as the toggle and text field, and there are two kinds of navigation, a sheet and a drill-down. And this kind of feature is where SwiftUI really excels. It can be amazing to see just how easy it is to build this in SwiftUI.
But we are going to do things the hard way. We are going to build this in UIKit, but we are going to be using an early version of toolkit that we will be building from scratch in episodes we are releasing in the upcoming weeks. We will have a beta of this toolkit to share soon, and then ultimately someday it will also be released.
We are going to use this toolkit to show that it is possible to write very complex UIKit features, like this one, in a style that is heavily inspired by SwiftUI and powered by Swift’s observation tools, but also still true to the spirit of UIKit. We are not trying to build tools that seem unnatural to the UIKit paradigm.
By focusing on domain modeling as our #1 concern we are going to clear the fog of complexity from our feature, and show that it’s possible to build a feature for multiple view paradigms at once, like UIKit, AppKit and SwiftUI, and even multiple platforms at once, such as Apple’s platforms, Windows, Wasm, and more!
Let’s start.
I’m have a brand new project here called WiFiSettings and I am going to crank up the concurrency warnings to their max so that we can make sure we are doing everything in a safe way from the beginning.
The majority of our initial work is going to be in domain modeling only. We are not going to worry about the view at all for a very long time, and that’s because we shouldn’t let view concerns infiltrate our core business domain. Doing so leads us too quickly down a path of boxing ourselves into very specific paradigms and platforms, and we should try to be as open as possible to new platforms for our business. Some day we may want to launch on Windows, Android, and web because it makes great business sense.
And even if you don’t have dreams of porting your app to another platform like Windows, you may often find yourself in a situation where you start building your feature in SwiftUI and then realize that SwiftUI isn’t actually capable of doing what you need. Instead you need to drop down to UIKit, AppKit, RealityKit, or some other view paradigm. Ideally you would be free to make that kind of decision without needing to reimplement all of your feature’s logic. You should be able to take your core domain model from one view paradigm to another with no changes at all.
This first domain modeling exercise we will undertake is that of representing a network that can be displayed in the collection. As we saw a moment ago a network consists of a name, a boolean that determines if it is secure or not, and some kind of measure of connectivity:
struct Network: Identifiable, Hashable {
let id = UUID()
var name = ""
var isSecured = true
var connectivity = 1.0
}
We will also add a unique identifier to the network.
We now have a core domain type for our app, but this type is not appropriate to encapsulate logic and behavior of our features. For one thing it’s a value type, which can’t encapsulate behavior. But also it’s nice to have a simple data representation of a network that is free from any behavior.
To model behavior in a feature we need to turn to some kind of reference type. Let’s start with one of the simpler features so we can work our way up to more complex features. For example, the detail screen for a network. Recall this was a screen that showed the details for a network, and had the option of “forgetting” the network, but we were shown an alert first.
Let’s represent this as an observable model with some state. We are just going to copy-and-paste the code in because at this point we aren’t super concerned with all the details behind these models:
import Observation
@MainActor
@Observable
class NetworkDetailModel {
var forgetAlertIsPresented = false
let onConfirmForget: () -> Void
let network: Network
init(
network: Network,
onConfirmForget: @escaping () -> Void
) {
self.onConfirmForget = onConfirmForget
self.network = network
}
}
We have some state for the network that we are currently viewing the details for, a boolean that determines if the confirmation alert is presented, as well as a onConfirmForget
callback closure that the parent will override in order to be notified when “forgetting” is confirmed and can execute the actual logic for forgetting the network.
And then we will have some simple endpoints defined on the model that are named after exactly what the user does in the UI:
func forgetNetworkButtonTapped() {
forgetAlertIsPresented = true
}
func confirmForgetNetworkButtonTapped() {
onConfirmForget()
}
And that is all we need from this detail model for right now. Of course in the future there is a lot more complex logic we could implement for this feature. For one thing, in the real iOS Wi-Fi settings you are able to customize all types of things for a network, such as low data mode, auto-join, DNS, proxies, and more. So this model would get a lot more complicated if we started building out all of those features.
And believe it or not, we are going to stop right there for the detail feature. We are not going to spend any time on the view layer right now. As we said before, we want to concentrate on domain modeling as a #1 priority without giving any thought whatsoever to the view. Ideally the view should bend to the will of our model, and not the other way around. This allows us to be as precise as possible in a vacuum, and allows us to be flexible with what kinds of view paradigms we can support.
As we also said before, we may want to run this feature on both iOS and Windows in the future, and if we cram a bunch of UIKit or SwiftUI related concepts into the domain we are going to make our work more difficult when it comes time to share logic with Windows. Or maybe you don’t even have dreams of porting your app to another platform, but rather you started building your feature in SwiftUI and then hit a dead end of what SwiftUI is actually capable of achieving. And so you decide to create the feature in UIKit instead. Ideally you would be able to do so without having to touch the model at all.
Let’s move onto the next feature which is just a little more complex than this one, the “connect” feature. This is the feature that is presented in a sheet and allows the user to type in the password to the network they want to connect to.
The observable model for this feature will hold onto some basic state:
import Observation
@Observable
@MainActor
class ConnectToNetworkModel {
var incorrectPasswordAlertIsPresented = false
var isConnecting = false
var onConnect: (Network) -> Void
let network: Network
var password = ""
init(
network: Network,
onConnect: @escaping (Network) -> Void
) {
self.onConnect = onConnect
self.network = network
}
}
Again we have some state that determines if an alert is shown, this time for when an incorrect password is entered, and we have some internal state that tracks if we are currently attempting a connection. We also have state for the password the user entered, and we have a callback closure for telling the parent when a successful connection has been made.
And we will have an endpoint on this model for when the “Join network” button is tapped, and for right now we will just fake the connection logic by only checking if the password is “blob”:
func joinButtonTapped() async {
isConnecting = true
defer { isConnecting = false }
try? await Task.sleep(for: .seconds(1))
if password == "blob" {
onConnect(network)
} else {
incorrectPasswordAlertIsPresented = true
}
}
But of course in a real version of the app there would be a lot more work happening here.
And that is all there is for this feature’s model. And again we are not going to spend anytime on the view at all. That will come in due time, but we are still laser focused on just the domain modeling of our features.
Next we’ve got the main feature of the demo, which is the root list of networks:
import Observation
@Observable
@MainActor
class WiFiSettingsModel {
var foundNetworks: [Network]
var isOn = true
var selectedNetworkID: Network.ID?
init(
foundNetworks: [Network],
isOn: Bool = true,
selectedNetworkID: Network.ID? = nil
) {
self.foundNetworks = foundNetworks
self.isOn = isOn
self.selectedNetworkID = selectedNetworkID
}
}
We’ve got state for all the networks found so far, as well as a boolean that determines if Wi-Fi is even on right now, and an optional ID that singles out the currently selected network.
This model will also have some endpoints that are called from the view and named exactly after what the user does. For example, the user can tap on a network in the list:
func networkTapped(_ network: Network) {
}
And then the user can tap the “info” button on a network:
func infoButtonTapped(network: Network) {
}
And you may wonder why we are calling these two things out separately. Well, the logic for each action is subtly different. When tapping a row in the list one of two things can happen: if you are tapping the selected network then you drill-down to its details, but if you tap a non-selected network a sheet is presented asking you to connect. We wouldn’t want that logic in the view because then the logic isn’t testable and if we ever go cross platform we will have to implement that logic twice. So instead we create very clearly named methods on our model and just invoke those methods from the view with no additional logic.
The logic for the infoButtonTapped
method is the simplest, so let’s start with that. When one taps the “info” button on a row we want to drill-down to the detail feature for that network. But even the concept of a “drill-down” is very platform specific. It’s very natural for iOS, but maybe on Windows we’ll show the detail in a modal, and on the web we will just go to a whole new webpage.
To be maximally flexible we should not let any view-specific concepts infiltrate our model, and instead we should just hold onto a bit of optional state that represents whether or not the detail feature is presented:
var detail: …?
In fact, the NetworkDetailModel
we designed a moment ago is the perfect optional type to hold onto:
var detail: NetworkDetailModel?
When this becomes non-nil
we can construct the view that represents the detail feature and hand it the detail model.
So, when the info button is tapped, all we have to do is populate this state to communicate to the view that its time to show the detail feature, in whichever way it wants to do that:
func infoButtonTapped(network: Network) {
self.detail = NetworkDetailModel(
…
)
}
To construct this model we need to provide the network we are viewing the details of, and a closure that is called when the user decides to forget the network:
func infoButtonTapped(network: Network) {
self.detail = NetworkDetailModel(
network: network,
onConfirmForget: {
}
)
}
And we can even implement this logic already. When forgetting the network we just want to clear out the selectedNetworkID
state, but we also need to dismiss the detail feature, which we can do by nil
’ing out the detail
state:
func infoButtonTapped(network: Network) {
self.detail = NetworkDetailModel(
network: network,
onConfirmForget: { [weak self] in
guard let self else { return }
detail = nil
selectedNetworkID = nil
}
)
}
This little bit of code right here is abstractly describing a complex navigation flow from parent feature to child feature, and expressing a communication wormhole from child to parent. The settings feature can navigate to the detail feature by populating some state, and the child feature can communicate to the parent by invoking a closure, at which point the parent performs another navigation event by nil
-ing out the state to signify unwinding the navigation back to settings.
And we are doing all of this without ever once thinking about the view layer. The view could be a UIViewController
, a SwiftUI view, an AppKit NSViewController
, or who knows what else! This shows the power of first concentrating on domain modeling before getting into the messy details of the view.
The networkTapped
endpoint can be implemented similarly:
func networkTapped(network: Network) {
}
But this time we have 3 paths ahead of us: first, if the user tapped on the already-selected network we want to navigate to the detail screen, which we now know should be as easy as just populating the detail
state:
if network.id == selectedNetworkID {
self.detail = NetworkDetailModel(
network: network,
onConfirmForget: { [weak self] in
guard let self else { return }
detail = nil
selectedNetworkID = nil
}
)
}
And if the network is secure we want to go “connect” screen:
} else if network.isSecure {
}
That’s a new destination we can navigate to, so it sounds like we need a new piece of optional state to represent this:
var connect: ConnectToNetworkModel?
And then populating this state would signal to the view that its time to present the feature that asks the user for the password for the network:
} else if network.isSecure {
connect = ConnectToNetworkModel(
…
)
}
To construct the model we need to provide the network, and a closure that is called once the connect feature has determined that the user has entered the correct password and a successful Wi-Fi connection has been made:
} else if network.isSecure {
connect = ConnectToNetworkModel(
network: network,
onConnect: {
}
)
}
When a connection is made we want to do something similar that we did above for forgetting a network, except this time we will set the selectedNetworkID
state rather than nil
’ing it out:
} else if network.isSecure {
connect = ConnectToNetworkModel(
network: network,
onConnect: { [weak self] network in
guard let self else { return }
detail = nil
selectedNetworkID = network.id
}
)
}
And then finally if the network is not secure we can just connect to the network right away:
} else {
selectedNetworkID = network.id
}
And that right there encapsulates the core logic of our Wi-Fi settings feature. It has a fair amount of complexity in it, but we did this domain modeling exercise fully in the abstract without ever thinking about what we would need for the view.
However, there is one thing that is not so optimal about our domain, and that’s this right here:
var connect: ConnectToNetworkModel?
var detail: NetworkDetailModel?
We are holding onto two pieces of optional state to represent that we can navigate to two different destinations. But it’s not possible to navigate to both features at once even though it is totally possible to write code this code:
connect = ConnectToNetworkModel(…)
detail = NetworkDetailModel(…)
If we accidentally allowed both pieces of state to be non-nil
at the same time, then that would tell UIKit or SwiftUI to try presenting two things at once. That is an invalid thing to do, and currently logs are spit out from those frameworks letting you know that is wrong, and they even warn that in the future it will be a hard crash.
So this is an imprecisely modeled domain. We have 4 possible states of these two values being nil
or non-nil
, yet only 3 of the states are valid: either both or nil
or exactly one is non-nil
. And the number of invalid states grows exponentially the more destinations you have. If this feature could navigate to 5 different features then there would be 32 total combinations of nil
and non-nil
, yet only 6 of them are valid.
And for this reason we like to bundle the destinations a feature can navigate to in an enum:
enum Destination {
case connect(ConnectToNetworkModel)
case detail(NetworkDetailModel)
}
And then hold onto a single piece of optional state:
var destination: Destination?
We now have compile-time proof that only one destination can be active at a time, and it’s easy for us to inspect destination
to see exactly what is being presented. We don’t have to analyze a bunch of disconnected optionals in order to guess what is being presented.
We do have a few small things to fix, so let’s do that real quick. And in fact in this refactor we automatically caught where we had introduced a bug. We had copy-pasted nil
-ing out the detail
feature when the ConnectToNetworkModel
established a connection, when it should have been nil
-ing out the connect
feature. But now that all destinations are unified in a single optional endpoint, we’ve eliminated the possibility of this bug.
And believe it or not, we are now done with our core domain modeling exercise, and we have implemented all of the logic and behavior for our app.
We will not touch the models a single time for the rest of the episode because regardless of what challenges we come across in the views, it will have no impact on our domain. And nor should it. We should not contort our domain to satisfy the will of the view, but rather the opposite! We should bend the view to make it fit our domain.
We could even extract out these domain models into their own SPM packages so that they could be used across a variety of view paradigms and platforms, such as iOS, macOS, Windows, Linux, Wasm, and more!
And that finally brings us to the view layer of our application, which we are going to build in UIKit. It of course takes a lot more work to make nice-looking views in UIKit, but we will do the best we can. And a lot of people probably also think app architecture is a lot more difficult in UIKit since it doesn’t have a good state management model like SwiftUI.
But we are going to show that thanks to Swift’s observation tools, and thanks to a small set of tools that we will be building in our upcoming episodes, we will be able to bind the data of our models to the views in a super short, succinct syntax. And we can even handle UIControl
bindings and navigation in a manner that is reminiscent of SwiftUI.
Let’s dig in.
Let’s start with the simplest feature, which is the detail screen for a network. In the real iOS Wi-Fi settings there is a lot of info shown on the screen, along with a button to forget the network. We are going to keep things simple for right now and only show the name of the network, the “Forget network” button, and we will handle the alert that asks the user to confirm
And we are just going to paste in a majority of this controller code:
final class NetworkDetailViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let forgetButton = UIButton(
type: .system,
primaryAction: UIAction { _ in
}
)
forgetButton.setTitle("Forget network", for: .normal)
forgetButton.setTitleColor(.red, for: .normal)
forgetButton
.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(forgetButton)
NSLayoutConstraint.activate([
forgetButton.centerXAnchor
.constraint(equalTo: view.centerXAnchor),
forgetButton.centerYAnchor
.constraint(equalTo: view.centerYAnchor),
])
}
}
…because we aren’t really interested in teaching people how to build UIKit view hierarchy. It’s messy to do and takes a lot of code, but it’s still pretty straightforward.
Here we have a controller that will hold onto all of the UI components that represent the detail screen. In the viewDidLoad
we create a “Forget network” button, we add it to the controller’s view, and then we center it.
Now we don’t want to put any logic of our feature directly in this view, and instead we want to call out to various endpoints on our model, which has already implemented the logic. So we will add a model to the controller:
let model: NetworkDetailModel
init(model: NetworkDetailModel) {
self.model = model
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
And we will invoke the forgetNetworkButtonTapped
method from the button:
navigationItem.title = model.network.name
let forgetButton = UIButton(
type: .system,
primaryAction: UIAction { [weak self] _ in
self?.model.forgetNetworkButtonTapped()
}
)
It is important to note that we are not performing any logic whatsoever in the view. We are just telling the model what happened, and letting it handle the rest.
Now the only bit of view behavior that we have to account for is the showing of the alert. The model has a piece of state called forgetAlertIsPresented
, and when it becomes true
we want to show an alert asking the user to confirm forgetting the network.
And this is where we can start using our toolkit. I am going to import our UIKitNavigation library into this file:
import UIKitNavigation
Which will be available in our swiftui-navigation package. If you didn’t already know, SwiftUINavigation is our lightweight library that adds a few conveniences and helpers to make navigation in SwiftUI more concise and more powerful.
Now, you may be wondering why we are importing a library called “SwiftUINavigation” in a project where we are explicitly deciding to create views in UIKit. Well, we decided to put this new toolkit in SwiftUINavigation because, as you will soon see, the tools are very much inspired by SwiftUI, even though they are tuned to work with UIKit. And so we are evolving our SwiftUINavigation library to be a more general set of navigation tools that are inspired by SwiftUI.
And one of the tools it provides us is a present
method defined on UIViewController
:
present(
Now UIKit already has a present
method, and it is the easiest way to present sheets, popovers and full screen covers in UIKit:
present(<#UIViewController#>, animated: <#Bool#>)
It takes the controller you want to present, as well as a boolean that determines if animation should be used.
This API is nice, but it’s also it’s what is known as a “fire-and-forget” API. It is not state-driven at all. That means you tell UIKit to present the controller, and UIKit does it, but the fact that the controller is presented is not represented in your domain at all. And that is just how most people probably interact with UIKit.
However, there are probably some out there that do try to bridge their model to UIKit’s navigation tools so that they can do state-driven navigation. However, there are a lot of subtleties to get right. It’s your responsibility to observe state changes in your model, detect when state flips from nil
to non-nil
, and then invoke present
. And further you have to observe when the state flips from non-nil
to nil
so that you can dismiss. And further you also have to detect when the view controller is dismissed by the user so that you can then clean up your state in order to keep the model consistent with what is visually on the screen.
That is a lot of work and it is very difficult to get right, and that’s what our present
helps with. It is a fully state-driven API that looks a lot like SwiftUI’s sheet
, popover
and fullScreenCover
modifiers. There are even two flavors, one is state-driven via a piece of optional state:
present(
item: <#UIBinding<Item?>#>,
content: <#(Item) -> UIViewController#>
)
…and the other is driven by a piece of boolean state:
present(
isPresented: <#UIBinding<Bool>#>,
content: <#() -> UIViewController#>
)
And these APIs are very, very similar to SwiftUI’s APIs. They even take a binding for the item
or isPresented
arguments, though they are UIBinding
s and not regular Binding
s, and they also take a trailing closure for constructing the view controller that is going to be presented.
And the way you provide this binding is by deriving it from the model, and to do that you use a @UIBindable
property wrapper:
@UIBindable var model: NetworkDetailModel
This property works much like the @Bindable
property wrapper, but it derives UIBinding
s instead of regular SwiftUI Binding
s.
And to deriving bindings, we use $
syntax to get access to the projected value of the model, and then regular dot syntax to determine which field of the model we want a binding to:
present(isPresented: $model.forgetAlertIsPresented) {
}
This looks very similar to SwiftUI, and it takes care of all the complications we saw a moment ago. It observes state to know when to present and dismiss the controller, and it detects user-initiated dismissal so that it can clean up the state automatically.
All we have to do is construct a UIAlertController
in the trailing closure to ask the user to confirm forgetting the network, and if they do confirm we will let the model know:
present(
isPresented: $model.forgetAlertIsPresented
) { [model] in
let controller = UIAlertController(
title: "Forget Wi-Fi Network “\(model.network.name)”?",
message: """
Your iPhone and other devices using iCloud Keychain \
will no longer join this Wi-Fi network.
""",
preferredStyle: .alert
)
controller.addAction(
UIAlertAction(title: "Cancel", style: .cancel)
)
controller.addAction(
UIAlertAction(
title: "Forget",
style: .destructive,
handler: { _ in
model.confirmForgetNetworkButtonTapped()
}
)
)
return controller
}
And amazingly that is all it takes. We can even run this feature in a preview:
#Preview {
UIViewControllerRepresenting {
UINavigationController(
rootViewController: NetworkDetailViewController(
model: NetworkDetailModel(
network: Network(name: "Blob's WiFi"),
onConfirmForget: { }
)
)
)
}
}
…to see that it works.
Now we want to take a moment to focus on this line:
present(
isPresented: $model.forgetAlertIsPresented
) { [model] in
…
}
It’s such a seemingly simple line of code that it may not be immediately clear why we think it’s actually so amazing. You may even think it’s not so different from just doing this:
let controller = UIAlertController(
title: "Forget Wi-Fi Network “\(model.network.name)”?",
message: """
Your iPhone and other devices using iCloud Keychain \
will no longer join this Wi-Fi network.
""",
preferredStyle: .alert
)
controller.addAction(
UIAlertAction(title: "Cancel", style: .cancel)
)
controller.addAction(
UIAlertAction(
title: "Forget",
style: .destructive,
handler: { _ in
model.confirmForgetNetworkButtonTapped()
}
)
)
present(controller, animated: true)
However, this will just present an alert immediately as soon as the controller appears, unconditionally. The showing of the alert is not driven by state at all.
So then you may think it’s a simple matter of doing:
if model.forgetAlertIsPresented {
…
}
But this is not correct for a few reasons. First, viewDidLoad
is only called once when the view first appears, and so we actually need to observe the state in the model to know when to present.
Further, the model may decide to flip the forgetAlertIsPresented
state to false
on its own, and so we would also need to observe that state change so that we can programmatically dismiss the alert. But then in order to do that we would need to keep track of the controller being presented somehow.
And further, there are things the user can do themselves to dismiss the alert, such as tapping an action button or even typing the “escape” key if they have a keyboard connected, and we would need to make sure to update our forgetAlertIsPresented
state when that happens.
Long story short: correct and consistent state-driven navigation is actually quite hard, and a lot of work has to be done to get it right. But the tools we will be building in the upcoming episodes handle all of this for you.
And when you drive navigation from state you get all types of powerful capabilities, like instant deep linking. If we update our preview to start the model in a state with the alert already presented:
#Preview {
UIViewControllerRepresenting {
UINavigationController(
rootViewController: NetworkDetailViewController(
model: NetworkDetailModel(
forgetAlertIsPresented: true
network: Network(name: "Blob's WiFi"),
onConfirmForget: { }
)
)
)
}
}
And our preview now starts with the alert showing, and we can even live-refactor the alert’s message without having to tap the button each time to display it.
We now have a very basic view controller being powered by the model that we created earlier. We have bent the view to the will of the model rather than the other way around. It is our responsibility to observe the model correctly and replay user actions back to it from the view. But most importantly, we are not letting view-level concerns infiltrate our domain.
Let’s move onto the next complex view, the connect view. It is going to force us to come face-to-face with what it means to observe state in the model for updating UI components, as well as 2-way bindings for UI controls.
Let’s take a look
The connect view has a text field for the user to enter their password into, and a “Join network” button, and further while the connection is being attempted we would like to disable the text field and button and show an activity indicator.
Let’s start by pasting in the scaffolding of a basic view controller and preview:
final class ConnectToNetworkViewController: UIViewController
{
@UIBindable var model: ConnectToNetworkModel
init(model: ConnectToNetworkModel) {
self.model = model
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
navigationItem.title = """
Enter the password for “\(model.network.name)”
"""
let passwordTextField = UITextField(frame: .zero)
passwordTextField.borderStyle = .line
passwordTextField.isSecureTextEntry = true
passwordTextField.becomeFirstResponder()
let joinButton = UIButton(
type: .system,
primaryAction: UIAction { _ in
Task {
await self.model.joinButtonTapped()
}
}
)
joinButton.setTitle("Join network", for: .normal)
let activityIndicator = UIActivityIndicatorView(
style: .medium
)
activityIndicator.startAnimating()
let stack = UIStackView(arrangedSubviews: [
passwordTextField,
joinButton,
activityIndicator,
])
stack.axis = .vertical
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.centerXAnchor
.constraint(equalTo: view.centerXAnchor),
stack.centerYAnchor
.constraint(equalTo: view.centerYAnchor),
stack.widthAnchor.constraint(equalToConstant: 200)
])
}
}
import SwiftUI
#Preview {
UIViewControllerRepresenting {
UINavigationController(
rootViewController: ConnectToNetworkViewController(
model: ConnectToNetworkModel(
network: Network(name: "Blob's WiFi"),
onConnect: { _ in }
)
)
)
}
}
Again there’s nothing too special in here right now. Just creating a UITextField
, UIButton
, and UIActivityIndicator
, adding them to a stack, and centering it in the view.
But now we need to do some work to bind the model to this view, and there are a few things to accomplish. First of all, we want the UITextField
’s value to be bound to the password
field in our model. Anytime the text field changes it should change the model, and conversely if the model changes it should update the text field.
Well, our UIKitNavigation library comes with just the tool for that. You can initialize a UITextField
that is driven by a binding derived from the model:
let passwordTextField = UITextField(text: $model.password)
That right there will keep everything in sync.
Next we want to observe changes in the model in order to enable/disable certain controls, and to show/hide the activity indicator. Well, our UIKitNavigation library comes with just the tool:
observe {
}
Any fields accessed inside observe
will be automatically observed so that when they are mutated the trailing closure will be called again.
This makes it the perfect place to update UI elements from the state in the model:
observe { [weak self] in
guard let self else { return }
passwordTextField.isEnabled = !model.isConnecting
joinButton.isEnabled = !model.isConnecting
activityIndicator.isHidden = !model.isConnecting
}
And then finally we will show an alert when the incorrectPasswordAlertIsPresented
state flips to true
:
present(
isPresented: $model.incorrectPasswordAlertIsPresented
) { [model] in
let controller = UIAlertController(
title: "Incorrect password for “\(model.network.name)”",
message: nil,
preferredStyle: .alert
)
controller.addAction(
UIAlertAction(title: "OK", style: .default)
)
return controller
}
And that is all it takes for the connect view! We can run the preview to see that it works as we expect.
Again its amazing to see that with the right tools you can bind a model to a UIKit view controller in a very simple manner. Bindings can be used with controls just like in SwiftUI, navigation APIs can be used just like in SwiftUI, and minimal observation can happen by just accessing fields on the model. Honestly we think it’s just hands down the best way to build UIKit features.
Now let’s move onto the last feature without a view, and it is by far the most complex one since it deals with a collection view. This is the view that searches for existing networks and populates a collection view with all the found networks, and each cell in the collection view has various icons for the connection strength, security and more. And further, this feature can navigate to the other two features we built by either presenting a sheet or drilling down to a new screen.
Let’s see what it takes…next time!