Using result builders for action sheets in SwiftUI

5 min read––– views

Using result builders for action sheets in SwiftUI

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.

Initial preview

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 🙏

References