How Swift 5.3 enhances SwiftUI’s DSL
Discover page available: SwiftUIWhen SwiftUI was first introduced at WWDC 2019, it definitely pushed many aspects of both Swift and Xcode to its very limits — through its heavy use of generics, closure-based APIs, and brand new features, such as property wrappers and function builders.
So its therefore not very surprising that a big focus of the upcoming new version of Swift, 5.3, is to continue to expand the ways in which Swift can be used to build SwiftUI-style domain-specific languages (or DSLs), and to smoothen out a few “rough edges” that many developers have encountered when using SwiftUI with Swift 5.2 and earlier.
This week, let’s take a look at some of those advancements, and how they collectively enhance the overall experience of building views using SwiftUI.
Implicit self capturing
Since the very beginning, Swift has required us to explicitly specify self
when accessing an instance method or property within an escaping closure, which sort of acts as an “opt-in” for having that closure capture the enclosing object or value — since doing so could end up causing retain cycles in certain situations.
However, since the risk of retain cycles is often quite negligible when using value types, that requirement of always having to specify self
has been somewhat relaxed in Swift 5.3 — by enabling the compiler to implicitly capture struct instances, which SwiftUI views and modifiers are almost exclusively implemented as.
As an example, let’s say that we’ve built the following FavoriteButton
using Swift 5.2, which requires us to use self
when referencing its isOn
property within its underlying button’s action
closure:
struct FavoriteButton: View {
@Binding var isOn: Bool
var body: some View {
Button(action: {
self.isOn.toggle()
}, label: {
Image(systemName: "heart" + (isOn ? ".fill" : ""))
})
}
}
When upgrading to Swift 5.3, however, that self
reference can now be completely removed — which gives us a slightly simpler implementation:
struct FavoriteButton: View {
@Binding var isOn: Bool
var body: some View {
Button(action: {
isOn.toggle()
}, label: {
Image(systemName: "heart" + (isOn ? ".fill" : ""))
})
}
}
While the above might be a quite minor change in the grand scheme of things, it does make SwiftUI’s DSL feel slightly more lightweight and easier to use.
Also, as a side-effect, since explicitly specifying self
is now only required in situations where doing so really matters (such as when dealing with captured reference types), that should arguably make those parts of our code base “stand out” a bit more, which in turn could make it easier to spot potential retain cycle-related issues within such code.
View building body properties
When building UIs in general, it’s incredibly common to want to use separate view implementations depending on what kind of state that a given app or feature is currently in.
For example, let’s say that we’re currently building an app that uses an AppState
object to keep track of its overall state, which includes properties like whether the user has gone through the app’s onboarding flow. We then check that state within the app’s root view to determine whether we should display either a HomeView
or an OnboardingView
— like this:
struct RootView: View {
@ObservedObject var state: AppState
var body: some View {
if state.isOnboardingCompleted {
return AnyView(HomeView(state: state))
} else {
return AnyView(OnboardingView(
isCompleted: $state.isOnboardingCompleted
))
}
}
}
Note how we’re performing type erasure on both of our above view instances using SwiftUI’s AnyView
type — which is done to give our body
property a single, unified return type. However, using AnyView
like that doesn’t just add a fair amount of “clutter” to our code, it also makes SwiftUI’s type-based diffing algorithm less efficient, since all of the type information contained within our view’s body
is currently completely erased.
Thankfully, there’s a better way to implement conditions like the one above — even when using Swift 5.2 or earlier — and that’s to manually add the @ViewBuilder
attribute to our view’s body
property, which lets us make full use of SwiftUI’s function builder-powered DSL directly within that property’s implementation:
struct RootView: View {
@ObservedObject var state: AppState
@ViewBuilder var body: some View {
if state.isOnboardingCompleted {
HomeView(state: state)
} else {
OnboardingView(isCompleted: $state.isOnboardingCompleted)
}
}
}
What’s new in Swift 5.3 is that all views now automatically gain the above capability, since views now directly inherit the @ViewBuilder
attribute from the declaration of the View
protocol itself — meaning that we can keep using the above approach without having to add any additional attributes to our view’s body
:
struct RootView: View {
@ObservedObject var state: AppState
var body: some View {
if state.isOnboardingCompleted {
HomeView(state: state)
} else {
OnboardingView(isCompleted: $state.isOnboardingCompleted)
}
}
}
The above might also be a relatively minor change, but it definitely makes it much simpler and more intuitive to conditionally create separate view types — which in turn should lead to fewer AnyView
instances, and thus simpler code and better overall performance within many SwiftUI-based apps.
Function builder control flow improvements
Like alluded to above, SwiftUI’s overall API is to a large extent powered by Swift’s function builders feature — which is what makes it possible for us to simply express the various views that we’re looking to render, and SwiftUI will then automatically combine those expressions in order to form our final UI.
However, that power and convenience also comes with certain limitations and drawbacks. For example, in Swift 5.2 and earlier, it was only possible to use a very limited set of control flow mechanisms — such as basic if
and else
statements — within function builder contexts.
So if we wanted to use slightly more sophisticated ways of handling multiple states, for example by using a switch
statement, then we’d again have to resort to explicitly returning AnyView
-wrapped views as separate expressions within our body
implementation — like this:
struct ContentView<Content: View>: View {
enum State {
case loading
case loaded(Content)
case failed(Error)
}
var state: State
var body: some View {
switch state {
case .loading:
return AnyView(LoadingSpinner())
case .loaded(let content):
return AnyView(content)
case .failed(let error):
return AnyView(ErrorView(error: error))
}
}
}
However, in Swift 5.3, switch
statements are now fully supported within function builder contexts — meaning that we can once again remove our AnyView
wrappers and simply express the views that we’re looking to render within each code branch:
struct ContentView<Content: View>: View {
...
var body: some View {
switch state {
case .loading:
LoadingSpinner()
case .loaded(let content):
content
case .failed(let error):
ErrorView(error: error)
}
}
}
Along the same lines, optional-unwrapping if let
conditions are now also fully supported — which means that we no longer need to come up with our own techniques for rendering views that rely on some form of optional data. One such technique that’s been commonly used in Swift 5.2 and earlier is to combine a regular if
statement with force unwrapping — for example like this:
struct HomeView: View {
@ObservedObject var userController: UserController
var body: some View {
VStack {
if userController.loggedInUser != nil {
ProfileView(user: userController.loggedInUser!)
}
...
}
}
}
For a few alternatives to the above pattern that are all fully Swift 5.2-compatible, check out “Optional SwiftUI views”.
Now, once we’re ready to upgrade to Swift 5.3, we can simply write the above type of expression using a standard if let
condition — which both makes that sort of code much simpler, and removes a force-unwrapped optional (big win!):
struct HomeView: View {
@ObservedObject var userController: UserController
var body: some View {
VStack {
if let user = userController.loggedInUser {
ProfileView(user: user)
}
...
}
}
}
Multiple trailing closures
Swift 5.3 also introduces a new (somewhat controversial) feature called multiple trailing closures, which — like its name implies — enables us to attach multiple trailing closures when calling a function or initializer that takes more than one closure.
While the exact syntax of that feature has been heavily debated on the Swift forums ever since it was first introduced, it does arguably make the call sites of certain APIs slightly cleaner and easier to read. For example, here’s what our FavoriteButton
implementation from before would look like if we were to use multiple trailing closures when creating its underlying Button
instance:
struct FavoriteButton: View {
@Binding var isOn: Bool
var body: some View {
Button {
isOn.toggle()
} label: {
Image(systemName: "heart" + (isOn ? ".fill" : ""))
}
}
}
The main advantage of the above syntax is that it makes APIs that use multiple closures (which almost all SwiftUI views that offer some form of event handling do) feel more “at home” within SwiftUIs DSL, and enables us to gradually extend a given call with additional trailing closures without having to rewrite the entire expression.
However, especially in cases like the one above, it could also be argued that it’s no longer crystal clear what the first trailing (now unlabelled) closure does — so we might still want to explicitly label each closure in certain situations, which is of course still an option.
Type-based program entry points
Finally, let’s take a look at how the version of SwiftUI that ships with Xcode 12 makes use of Swift 5.3’s new @main
attribute to enable us to declare an app’s main entry point in a very similar way to how we define our various views.
As a language feature, the @main
attribute enables any Swift program to define a type-based entry point — that is, a type that implements a static main
method used to run the program’s root logic:
@main struct MyApp {
static func main() {
// Run our program's root logic
}
}
While the above approach of defining an app’s main entry point might work really well for completely custom programs — such as scripts and command line tools — when it comes to things like iOS and macOS apps, we might not want to take complete control over everything that’s involved in getting an app up and running — and thanks to SwiftUI’s new App
protocol, we don’t have to.
By combining the @main
attribute with that new protocol, we can instead simply use SwiftUI’s DSL to define our app’s various scenes, as well as the root views contained within those scenes — meaning that entire apps can now be built directly using SwiftUI — for example like this:
@main struct MyApp: App {
@StateObject var state = AppState()
var body: some Scene {
WindowGroup {
if state.isOnboardingCompleted {
HomeView(state: state)
} else {
OnboardingView(isCompleted: $state.isOnboardingCompleted)
}
}
}
}
While the above new API is (at the time of writing) quite limited when compared to all of the functionality that UIKit’s UIApplicationDelegate
has to offer — the good news is that we can also easily bridge those two worlds using the UIApplicationDelegateAdaptor
property wrapper. To learn more about that, check out this mini-article.
Conclusion
Swift 5.3 brings a number of very welcome enhancements to SwiftUI’s overall API, and while it might not fundamentally change the way we use Swift (which would be strange, given that it’s just a minor version bump), it shows just how tightly Swift and SwiftUI continue to simultaneously evolve.
However, being features of the language itself, rather than any specific SDK, we can both make use of these new capabilities outside of SwiftUI and Apple’s platforms all together, and we can also use them without having to increase our app’s minimum deployment target to iOS 14 or macOS Big Sur. All that we have to do is to build our projects using Xcode 12, and we can make full use of all that Swift 5.3 has to offer.
The one exception to that is the App
protocol, which is only available on the 2020 editions of Apple’s operating systems, as its a concrete SwiftUI-specific implementation of the more generic (and backward compatible) @main
attribute.
Got questions, comments or feedback? You’re always more than welcome to contact me. You can reach me either via Twitter or email.
Thanks for reading! 🚀