Backporting SwiftUI APIs with Result Builders
When Apple announced SwiftUI back in 2019, there’s a new language feature called “function builder” that didn’t go through the Swift Evolution process but was shipped with Apple’s Swift toolchain to make SwiftUI a reality. I’m glad that the community decided to adopt this feature as result builders, allowing us to mimic SwiftUI’s API ourselves.
Motivation
When KDE Connect iOS tried to lower deployment target to iOS 14, we ran into the limitation of not being able to show multiple alerts in the same View, and had to find a very hacky workaround for iOS 14 only by adding many hidden views to the view hierarchy, and duplicating the buttons/texts:
1 | if #available(iOS 15.0, *) { |
Proposed Solution
By mimicking SwiftUI’s new alert
API but making it available on iOS 14, we could hide the complexity away from the call site allowing only writing code as in the iOS 15 branch, while doing all the dirty work inside the actual implementation:
1 | extension View { |
But wait, what is AlertActionBuilder
? To understand why we need to introduce this new thingy, let’s first look at what’s ViewBuilder
, and why we can’t use ViewBuilder
for our purpose.
Detailed Design
ViewBuilder
The new iOS 15 SwiftUI alert API is defined as follows:
1 | extension View { |
Note the @ViewBuilder
attribute in front of actions
and message
: this is what enables us to write code like:
1 | var body: some View { |
Normally, when the Swift compilers sees values that are not used as part of another expression, assigned to a variable, or returned, it will complain about “Result of … is unused,” as you can see by writing the same code but for a computed property that’s not a View
's body
:
1 | var buttons: some View { // Function declares an opaque return type, |
However, by annotating it with SwiftUI’s custom ViewBuilder
attribute, the code now compiles by building a combined result after getting transformed using rules specified by ViewBuilder
, which the final result made using its buildBlock
function is then implicitly returned:
1 |
|
Because code annotated with the ViewBuilder
attribute doesn’t follow how Swift code normally gets compiled, it’s kind of like a miniature language within Swift specialized for constructing views from closures (i.e. things within braces). We call this kind of language a Domain Specific Language, or DSL.
Differences Between iOS 14 and iOS 15 API
While the iOS 15 API allows arbitrary views as the list of actions
, the iOS 14 API requires either
- no buttons,
- a single dismiss button, or
- a primary and a secondary button.
This is fine as ViewBuilder
's buildBlock
function returns a TupleView
, from which we can extract the buttons to pass to iOS 14’s API. However, the bigger problem is that ButtonRole
is only available on iOS 15 and we can’t peek inside SwiftUI’s Button
struct
to figure out what role it has – nor can we do so on iOS 14 where the ButtonRole
type doesn’t exist. Thus, we’ll need our own type to store relevant information then later convert it to Button
on iOS 15 and Alert.Button
on iOS 14:
1 | struct _Button { |
Making AlertActionBuilder
- a DSL for Building Alert Actions
Making a DSL in Swift using result builder is very simple: declaring a new type and annotate it with the resultBuilder
attribute, then provide at least one static buildBlock
/buildPartialBlock
method:
1 | @resultBuilder |
To allow building the 3 types of alert buttons mentioned above, we can represent the result as an Optional<AlertActionBuilder.Buttons>
:
1 | extension AlertActionBuilder { |
The Swift compiler will try to match contents inside an @AlertActionBuilder
closure with the buildBlock
functions defined. For example, if there’s nothing inside the curly braces, it will choose:
1 | static func buildBlock() -> Buttons? { |
Similarly, it will do so for the one button and two buttons cases:
1 | static func buildBlock(_ button: _Button) -> Buttons? { |
That’s it! We can now write code like:
1 | content |
and switch on AlertActionBuilder.Buttons?
inside our alert
API implementation to call appropriate SwiftUI APIs on both iOS 14 and iOS 15. The need to use _Button
instead of Button
is annoying though, so let’s use the tricked mentioned in Shimming SwiftUI APIs by Hacking Overload Resolution to fix that:
1 | func Button( |
Way to go!
Further Readings
The full implementation can be found at [Refactor] Reduce code duplication for iOS 14 support. To learn more about building DSLs and using result builders, you can checkout:
- SE-0289: Result builders
- SE-0348:
buildPartialBlock
for result builders - WWDC21-10253: Write a DSL in Swift using result builders
- Improved Result Builder Implementation in Swift 5.8
ApolloZhu/BoolBuilder
:@resultBuilder
for building aBool
On a side note, I’m not sure what macros – which is different from result builders and property wrappers though all of them begin with an @
– will impact how people approach implementing DSLs in Swift in the future, but they are certainly interesting for library authors to explore as well.