loading...

Creating an observable object in SwiftUI

alanpaiva profile image Alan Paiva ・4 min read

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:

  1. We add a Button that triggers presentErrorAlert() function when tapped.

  2. We add the alert modifier to the button. The first parameter, $alertViewModel.isPresented, is a Binding to the isPresented 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, when isPresented becomes true, view's body re-renders and the alert displays. When we tap to dismiss the alert, the modifier writes false to isPresented and the view re-renders once again, causing the alert to disappear. Wow, that's a lot for a single line of code!

  3. 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.

  4. 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:

Alert GIF

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!

Discussion

pic
Editor guide