In SwiftUI views are functions of the state. They are also reactive, which means they re-render whenever the state they are bound to changes. For value-type state owned by the view itself, we can use @State
property wrapper. For complex reference-type objects we need a different approach. To demonstrate that, I present in the following sections a pattern I commonly use in my SwiftUI projects to control the state of alert views. You will need basic understanding of SwiftUI to proceed.
Designing the view model class
The first step is to create the class that will model the Alert
. It initially needs a title, a message, buttons and another property to control the alert's visibility:
final class AlertViewModel: ObservableObject {
var title: String
var message: String
var primaryButton: Alert.Button
var secondaryButton: Alert.Button?
@Published var isPresented = false
init(title: String = "",
message: String = "",
primaryButton: Alert.Button = .default(Text("Ok")),
secondaryButton: Alert.Button? = nil) {
self.title = title
self.message = message
self.primaryButton = primaryButton
self.secondaryButton = secondaryButton
}
}
The ViewModel
suffix is because of the architectural pattern I most commonly use, MVVM (Model-View-ViewModel). If you don't know it, don't worry, just think of AlertViewModel
as an object that dictates the behavior and appearance of the alert.
Back to the code, notice the conformance with the ObservableObject
protocol. This is what makes it possible for views to bind to AlertViewModel
and listen for changes. Also, we have isPresented
marked with @Published
, a property wrapper which, in simple terms, publishes changes in the underlying property so any bound views know when to re-render.
And that's basically all we need, but before wiring this up to our views, let's define some helpers to allow us not to repeat ourselves. After all, showing alerts is a fairly common task, right?
Defining helper functions
You will notice over time that it becomes pretty annoying having to wrap button's label with Text
when all we need is a simple string button. To overcome that, let's start by defining some extension functions to Alert.Button
:
extension Alert.Button {
static func `default`(_ label: String, action: (() -> Void)? = nil) -> Alert.Button {
.default(Text(label), action: action)
}
static func destructive(_ label: String, action: (() -> Void)? = nil) -> Alert.Button {
.destructive(Text(label), action: action)
}
}
This code allows us to pass in a String
instead of a Text
. Now we can update our AlertViewModel
's initializer to use .default("Ok")
.
Next, if you take a look at Alert
's initializers, you will notice that not all of them receive a primary and secondary buttons. So, instead of handling this on a case-by-case basis, we can extend Alert
by providing a custom initializer that takes AlertViewModel
as a parameter:
extension Alert {
init(viewModel: AlertViewModel) {
if let secondaryButton = viewModel.secondaryButton {
self.init(title: Text(viewModel.title),
message: Text(viewModel.message),
primaryButton: viewModel.primaryButton,
secondaryButton: secondaryButton)
} else {
self.init(title: Text(viewModel.title),
message: Text(viewModel.message),
dismissButton: viewModel.primaryButton)
}
}
}
The initializer works by simply checking whether or not the view model provides a secondary button and dispatching to the proper built-in initializer based on that. Now we have all pieces we needed, so let's glue them all together.
Presenting the alert
Place the following in your content view:
struct ContentView: View {
@ObservedObject private var alertViewModel = AlertViewModel()
// 4
private func presentErrorAlert() {
alertViewModel.title = "Sorry :("
alertViewModel.message = "Could not fetch your data, please try again later"
alertViewModel.primaryButton = .default("Ok") {
print("Closed error alert")
}
alertViewModel.secondaryButton = .destructive("🔥") {
print("Some destructive action")
}
alertViewModel.isPresented = true
}
var body: some View {
// 1
Button("Present alert") {
self.presentErrorAlert()
}
// 2
.alert(isPresented: $alertViewModel.isPresented) {
// 3
Alert(viewModel: self.alertViewModel)
}
}
}
Let's break the code above and understand what's going on:
We add a
Button
that triggerspresentErrorAlert()
function when tapped.We add the
alert
modifier to the button. The first parameter,$alertViewModel.isPresented
, is aBinding
to theisPresented
property from the view model. A binding is a very powerful feature of SwiftUI, you can think of it as a read-write pipe that allows reading and writing values to it. Thus, whenisPresented
becomestrue
, view's body re-renders and the alert displays. When we tap to dismiss the alert, the modifier writesfalse
toisPresented
and the view re-renders once again, causing the alert to disappear. Wow, that's a lot for a single line of code!As the second parameter for the alert modifier we have a closure which returns the actual
Alert
that should be displayed. For that we use the initializer we defined in the previous section.This function sets the values that describe how the alert will look like. We define title, message and the companion buttons. Finally, we set
isPresented
to publish the changes so the view knows it should re-render.
And that's it! Check out the result:
Conclusion
If it felt like a lot to accomplish such a simple task, don't worry, it will feel more natural and simpler over time once you internalize SwiftUI's approach of doing things.
In addition, there are a couple of improvements we could perform to harness the power of SwiftUI. I decided to keep them out for the sake of simplicity and to not lose focus on the subject. But, if you have some time, try looking into how to use LocalizedStringKey
(rather than String
) and also how to create a custom view modifier to pass in the view model directly.
I hope you enjoyed this!
Top comments (0)