The lifecycle and semantics of a SwiftUI view
Discover page available: SwiftUIOne 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.
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:
- First, the fact that we’re creating our underlying
UILabel
by assigning it to a property means that we’ll end up recreating that instance every time that our struct is recreated (which, as we’ve already explored, can happen for a number of reasons, including when one of our parent views were updated). - Second, by not updating our view within the
updateUIView
method, our label will continue to render the sameattributedText
that it was assigned withinmakeUIView
, even if ourstring
property has been modified.
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.
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! 🚀