Articles, podcasts and news about Swift development, by John Sundell.

The lifecycle and semantics of a SwiftUI view

Published on 06 Dec 2020
Discover page available: SwiftUI

One of the key differences between SwiftUI and its predecessors, UIKit and AppKit, is that views are primarily declared as value types, rather than as concrete references to what’s being drawn on screen.

Not only does that shift in design play a major part in making SwiftUI’s API feel so lightweight, it can also often become a source of confusion, especially for developers who (like me) have been used to the very object-oriented conventions that Apple’s UI frameworks have used up until this point.

So this week, let’s take a more thorough look at what it means for SwiftUI to be a declarative, value-driven UI framework, and how we might need to break certain assumptions and previous best practices that were based on UIKit and AppKit when starting to adopt SwiftUI within our projects.

Instabug

Instabug: Whether it’s crashes, slow screen transitions, delayed network calls, or unresponsive UIs — Instabug automatically gives you all of the logs you need to fix bugs and issues, and to ship high-quality apps. Get started now.

The role of the body property

The View protocol’s body property is perhaps the most common source of misunderstandings about SwiftUI as a whole, especially when it comes to that property’s relationship to its view’s update and rendering cycle.

In the imperative world of UIKit and AppKit, we have methods like viewDidLoad and layoutSubviews, which essentially act as hooks that let us respond to a given system event by executing a piece of logic. While it’s easy to look at the SwiftUI body property as another such event (that lets us re-render our view), that’s really not the case.

Instead, the body property lets us describe how we want our view to be rendered given its current state, and the system will then use that description to determine if, when, and how our view should actually be rendered.

For example, when building a UIKit-based view controller, it’s really common to trigger model updates within the viewWillAppear method, to ensure that the view controller is always rendering the latest available data:

class ArticleViewController: UIViewController {
    private let viewModel: ArticleViewModel
    
    ...

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        viewModel.update()
    }
}

Then, when moving to SwiftUI, an initial idea on how to replicate the above pattern might be to do the following, and perform our view model update when computing our view’s body — like this:

struct ArticleView: View {
    @ObservedObject var viewModel: ArticleViewModel

    var body: some View {
        viewModel.update()

        return VStack {
            Text(viewModel.article.text)
            ...
        }
    }
}

However, the problem with the above approach is that our view’s body will be re-evaluated whenever our view model was changed, and also every time that any of our parent views were updated — meaning that the above implementation will very likely lead to a lot of unnecessary model updates (or even update cycles).

To learn more about what causes a SwiftUI view to be updated, check out this guide to the SwiftUI state management system.

So it turns out that a view’s body is not a great place for triggering side effects. Instead, SwiftUI provides a few different modifiers that act very similarly to those hooks that we had access to in UIKit and AppKit. In this case, we can use the onAppear modifier to get the same behavior as when using the viewWillAppear method within a view controller:

struct ArticleView: View {
    @ObservedObject var viewModel: ArticleViewModel

    var body: some View {
        VStack {
            Text(viewModel.article.text)
            ...
        }
        .onAppear(perform: viewModel.update)
    }
}

In general, whenever we need to use the return keyword within a SwiftUI body, we’re likely doing something wrong, as the role of that property is to describe our view hierarchy using SwiftUI’s DSL — not to perform operations, and not to trigger side effects.

The initializer problem

Along the same lines, we also should be careful not to make any assumptions about the lifecycles of our views themselves. In fact, it could be argued that SwiftUI views don’t even have proper lifecycles, given that they’re values, not references.

For example, let’s now say that we wanted to modify the above ArticleView to make it update its view model whenever the app was resumed after being moved to the background, rather than every time that the view appears. One way to make that happen would be to once again follow a very object-oriented approach, and observe the app’s default NotificationCenter from within our view’s initializer — like this:

struct ArticleView: View {
    @ObservedObject var viewModel: ArticleViewModel
    private var cancellable: AnyCancellable?

    init(viewModel: ArticleViewModel) {
        self.viewModel = viewModel

        cancellable = NotificationCenter.default.publisher(
    for: UIApplication.willEnterForegroundNotification
)
.sink { _ in
    viewModel.update()
}
    }

    var body: some View {
        VStack {
            Text(viewModel.article.text)
            ...
        }
    }
}

However, while the above implementation will work perfectly fine in complete isolation, as soon as we start embedding our ArticleView within other views, it’ll start to become quite problematic.

To illustrate, here we’re creating multiple ArticleView values within an ArticleListView, which uses the built-in List and NavigationLink components to enable the user to navigate to each article that’s shown within a scrollable list:

struct ArticleListView: View {
    @ObservedObject var store: ArticleStore

    var body: some View {
        List(store.articles) { article in
            NavigationLink(article.title,
                destination: ArticleView(
    viewModel: ArticleViewModel(
        article: article,
        store: store
    )
)
            )
        }
    }
}

Since NavigationLink requires us to specify each destination up-front (which initially might seem rather strange, but does make a lot of sense once we start thinking of our SwiftUI views as simple values), and since we’re currently setting up our NotificationCenter observations when initializing our ArticleView values, all of those observations will be immediately activated — even if those views haven’t actually been rendered yet.

So let’s instead implement that functionality in a much more granular way, so that only the currently displayed ArticleView will be updated when the app moves to the foreground, rather than updating every single ArticleViewModel at once, which would be rather inefficient.

To do that, we’ll again use a dedicated modifier, onReceive, instead of manually configuring our NotificationCenter observation as part of our view’s initializer. As an added bonus, when doing that, we no longer need to maintain a Combine cancellable ourselves — since the system will now manage that subscription on our behalf:

struct ArticleView: View {
    @ObservedObject var viewModel: ArticleViewModel

    var body: some View {
        VStack {
            Text(viewModel.article.text)
            ...
        }
        .onReceive(NotificationCenter.default.publisher(
    for: UIApplication.willEnterForegroundNotification
)) { _ in
    viewModel.update()
}
    }
}

For more on various ways to observe Combine publishers in SwiftUI views, check out this article.

So just because a SwiftUI view is created doesn’t mean that it will be rendered or otherwise used, which is why most SwiftUI APIs require us to create all of our views up-front, rather than once each view is about to be displayed. Again, we’re only creating descriptions of our views, rather than actually rendering them ourselves — so just like how we should ideally keep our body properties free of side effects, the same thing is also true for view initializers as well (and, arguably, initializers in general).

Ensuring that UIKit and AppKit views can be properly reused

Correctly following SwiftUI’s intended design is perhaps especially important when bringing UIKit or AppKit views into SwiftUI using protocols like UIViewRepresentable — since when doing so, we are in fact responsible for creating and updating the underlying instances that our views are rendered using.

All variants of SwiftUI’s various bridging protocols include two methods — one for creating (or, in factory method parlance, making) the underlying instance, and one for updating it. However, initially it might seem like the update method is only needed for dynamic, interactive components, and that the make method could simply configure an instance that’s being created elsewhere.

For example, here we’re doing just that in order to render an NSAttributedString using an instance of UIKit’s UILabel, which we’re managing using a private property:

struct AttributedText: UIViewRepresentable {
    var string: NSAttributedString

    private let label = UILabel()

    func makeUIView(context: Context) -> UILabel {
        label.attributedText = string
        return label
    }

    func updateUIView(_ view: UILabel, context: Context) {
        // No-op
    }
}

However, there are two quite major problems with the above implementation:

To fix those two issues, let’s instead create our UILabel lazily within the makeUIView method, and rather than retaining it ourselves, we’ll let the system manage it for us. We’ll then always re-assign our string to our label’s attributedText property every time that updateUIView is called — which gives us the following implementation:

struct AttributedText: UIViewRepresentable {
    var string: NSAttributedString

    func makeUIView(context: Context) -> UILabel {
        UILabel()
    }

    func updateUIView(_ view: UILabel, context: Context) {
        view.attributedText = string
    }
}

With the above in place, our UILabel will now be correctly reused, and its attributedText will always be kept up to date with our wrapper’s string property. Really nice.

The beauty of the above changes is that they actually made our code much simpler, since we’re once again leveraging the system’s own conventions and state management mechanisms, rather than inventing our own. In fact, that’s perhaps the single most important thing when working with SwiftUI, to try to always lean into the way that it was designed, and to make proper use of its built-in mechanisms — which might require us to “unlearn” certain patterns that we’ve been used to when working with frameworks like UIKit and AppKit.

Support Swift by Sundell by checking out this sponsor:

Instabug

Instabug: Whether it’s crashes, slow screen transitions, delayed network calls, or unresponsive UIs — Instabug automatically gives you all of the logs you need to fix bugs and issues, and to ship high-quality apps. Get started now.

Conclusion

What makes being a developer both fun, and occasionally quite exhausting, is the fact that we’re never done learning. Every year, month or sometimes even week, there’s some form of new framework, tool or API that we need to learn, and while that knowledge tends to be quite incremental when working with a single platform (such as iOS), SwiftUI is far from incremental when compared to its predecessors.

So making the very best use of SwiftUI — either within an existing project, or when building a brand new one — often requires us to use new patterns and techniques that let us fully utilize the semantics and lifecycles of the views that we’ll create. I hope that this article has given you a bit of additional insight into how to do just that, and feel free to let me know if you have any questions, comments or feedback. You can easily reach me via either Twitter or email.

Thanks for reading! 🚀