Using result builders for action sheets in SwiftUI
5 min read • ––– views
One of the key features of SwiftUI is a declarative syntax for layout. It is available thanks to result builders
, previously called function builders. With result builders, we can implicitly build up a final value from a sequence of components. The final revision of this feature is released in Swift 5.4, and Xcode 12.5 suggests code completions and fix-its for it. I guess it's a good sign for exploring it and making action sheets more declarative!
Preparation
We'll create a simple SwiftUI app, where we can select ingredients for sandwich.
struct ContentView: View {
@State private var ingredients: [String] = []
@State private var isActionSheetPresented = false
var body: some View {
VStack {
Text(ingredients.joined())
.font(.system(.title))
Button("Make a sandwich") {
isActionSheetPresented = true
}
}
.padding()
.actionSheet(isPresented: $isActionSheetPresented) {
let buttons = [ActionSheet.Button.default(Text("🍞")) {
ingredients.append("🍞")
},
ActionSheet.Button.cancel()]
return ActionSheet(title: Text("Select an ingredient"), message: nil, buttons: buttons)
}
}
}
When we tap on the button, ActionSheet
is presented with buttons from the array in the initializer. The syntax for action buttons, especially with defined actions, looks a bit complicated. Let's improve it with a custom result builder.
Basics
We create a ButtonsBuilder
struct with @resultBuilder
attribute. To start using it, we must implement at least one static buildBlock
function:
@resultBuilder
struct ButtonsBuilder {
static func buildBlock(_ components: ActionSheet.Button...) -> [ActionSheet.Button] {
components
}
}
Here we have a variadic parameter with ActionSheet.Button
and just return it as is.
Because ActionSheet
knows nothing about our builder, we create a new initializer with title, message, and the builder:
extension ActionSheet {
init(title: Text, message: Text? = nil, @ButtonsBuilder buttons: () -> [ActionSheet.Button]) {
self.init(title: title, message: message, buttons: buttons())
}
}
Now we're ready to refactor ActionSheet
configuration:
.actionSheet(isPresented: $isActionSheetPresented) {
ActionSheet(title: Text("Select an ingredient"), message: nil) {
ActionSheet.Button.default(Text("🍞")) {
ingredients.append("🍞")
}
ActionSheet.Button.cancel()
}
}
Looks great!
What if?.. Working with conditions
Result builders may build a partial result depending on some conditions. In our app, we add a new State
and Toggle
. If it is enabled, we add cucumbers and tomatoes otherwise.
// In States section
@State private var likeCucumbers = true
// Below Text in ContentView
Toggle("I love cucumbers", isOn: $likeCucumbers)
To support if-else
conditions in our builder, we must implement buildEither(first:)
and buildEither(second:)
functions:
@resultBuilder
struct ButtonsBuilder {
...
static func buildEither(first components: [ActionSheet.Button]) -> [ActionSheet.Button] {
components
}
static func buildEither(second components: [ActionSheet.Button]) -> [ActionSheet.Button] {
components
}
}
If we try to add if-else
statement like this:
if likeCucumbers {
ActionSheet.Button.default(Text("🥒")) {
ingredients.append("🥒")
}
}
else {
ActionSheet.Button.default(Text("🍅")) {
ingredients.append("🍅")
}
}
We have an error:
Cannot pass array of type '[ActionSheet.Button]' (aka 'Array<Alert.Button>') as variadic arguments of type 'ActionSheet.Button' (aka 'Alert.Button')
We can solve the error by defining a new protocol and implementing it by both a single ActionSheet.Button
and a collection of ButtonsConvertible
:
protocol ButtonsConvertible {
var buttons: [ActionSheet.Button] { get }
}
extension ActionSheet.Button: ButtonsConvertible {
var buttons: [ActionSheet.Button] {
[self]
}
}
extension Array: ButtonsConvertible where Element == ButtonsConvertible {
var buttons: [ActionSheet.Button] { self.flatMap(\.buttons) }
}
In ButtonsBuilder
we replace all ActionSheet.Button
with ButtonsConvertible
. And finally, we implement buildFinalResult
function that gets all ButtonsConvertible
and maps it to buttons:
@resultBuilder
struct ButtonsBuilder {
static func buildBlock(_ components: ButtonsConvertible...) -> [ButtonsConvertible] {
components
}
...
static func buildFinalResult(_ components: [ButtonsConvertible]) -> [ActionSheet.Button] {
components.flatMap(\.buttons)
}
}
Now likeCucumbers
check builds successfully.
Using ForEach for Actions
SwiftUI has an awesome ForEach
element. It gets different data collections and converts them to views via @ViewBuilder
. I was wondering if there is any chance to use it for buttons 🤔. Of course, let's start with an extension:
extension ForEach: ButtonsConvertible where Content == ActionSheet.Button {
var buttons: [ActionSheet.Button] {
data.map(content)
}
}
Here we declare that Content
generic must be ActionSheet.Button
and map data to buttons via content
closure.
ActionSheet.Button
is a simple typealias for Alert.Button
, and Alert.Button
is just a struct that doesn't conform View
protocol. To solve it, we implement it and return Never
for the body:
extension ActionSheet.Button: View {
public var body: Never {
fatalError()
}
}
Because we don't use ForEach
for rendering, the body will never be called. And it works now!
ForEach(["🧅", "🧄"], id: \.self) { string in
ActionSheet.Button.default(Text(string)) {
ingredients.append(string)
}
}
We can explicitly add ids like in the example, use Identifiable
array or even ranges inside ForEach
. The downside of this trick is that we can accidentally use ActionSheet.Button
inside any body and get fatalError
in runtime.
Conclusion
Result builder is a great enhancement in Swift language. In certain cases, it improves code readability dramatically. If you want to play with the example, check ResultBuilderExample repo.
Thanks for reading 🙏