Acknowledgements

Thanks to @Kyokook Hwang who came up with the solution.

TL;DR

struct ContentView: View {
    var action: (() -> Void)?

    var body: some View {
        Button(action: { self.action?() })
    }

    func onAction(perform: (() -> Void)?) -> some View {
        var new = self
        new.action = perform
        return new
    }
}

Introduction

When using the Swift UI API, you have probably realised there are a few ways of passing closures to a View. One of them is passing them in the constructor, either explicitly or using trailing closures, even multiple of them.

Button {
    print("you pressed me")
} label: {
    Image(systemName: "gear")
}

But there is another way deeply ingrained within Swift, which can be seen when setting lifecycle callbacks, such as onAppear.

VStack {
    Text("Hello world")
}.onAppear {
    print("Text appeared")
}

But how can you make use of this technique yourself?

Method chaining

My first attempt at doing this relied on method chaining, which can be used to modify an object multiple times within the same statement. You may have seen this technique used in Alamofire, for instance.

let r = await AF.request("https://httpbin.org/get")
                .authenticate(username: "user", password: "pass")
                .validate()

Let’s take a look at the source code to understand how this works.

@discardableResult
public func validate(_ validation: @escaping Validation) -> Self {
    /// implementation
    return self
}

By returning self within a method, we can call another method on the result, and so on.

Chaining with View

However, when you attempt to apply method chaining to a View things get complicated. First of all, you cannot modify self without annotating the method as mutating, but if you do that the compiler will let you know that Views are immutable, and you cannot use mutating methods on them.

Views are immutable

You may try to get around this using @State to hold the closure, but this won’t work either.

@Kyokook Hwang came up with a solution for this. Instead of returning self, it is possible to create a copy of self, modify it, then return it. The only requirement is for the callback to be a public var.

struct ContentView: View {
    var action: (() -> Void)?

    func onAction(_ perform: @escaping () -> Void) -> some View {
        var new = self
        new.callback = perform
        return new
    }

    var body: some View {
        Button(action: {action})
    }
}

We can even go one step further and support optional callbacks without requiring them to be wrapped in any way. Most of the View lifecycle callbacks do this. Another advantage of this is that you can get rid of @escaping

func onAction(_ perform: (() -> Void)?) -> some View {
    var new = self
    new.callback = perform
    return new
}

From here, you could write a protocol to enforce this interface.

Alternative: @Environment and ViewModifier

Originally, I was going to write about this method, until I found the technique I showed above. Though verbose, it is also possible to use @Environment to set a closure, and then use ViewModifier to simplify the syntax.

The only benefit of using this approach is the fact that you can use a private var.

You can still read about that method on StackOverflow.