Codakuma
iOS apps and more

Optional view arguments in SwiftUI

16 Oct 2020

I came to SwiftUI from the React world, where it’s common to pass views (components in React terminology) to other views:

<Heading trailingAccessoryView={<Icon name="star">}>
  Hello World
</Heading>

I knew that this was possible in SwiftUI because some built-in views use it, like Section’s header and footer arguments:

Section(header: { Text("Header") }) {
  ...
}

I couldn’t figure out how to do it for ages. It’s pretty trivial to do it where the argument is always required, but I wanted to do it like Section, where I could omit the header argument entirely and it would still work.

Eventually I figured it out, using extensions. Here’s an example, using a Heading component from Personal Best’s codebase.

struct Heading<AccessoryView: View>: View {
  let title: String
  let accessoryView: AccessoryView

  init(_ title: String, @ViewBuilder accessoryView: () -> AccessoryView) {
    self.title = title
    self.accessoryView = accessoryView()
  }

  var body: some View {
    HStack {
      Text(title)
      Spacer()
      accessoryView
    }
  }
}

Let’s stop here. At this point, Heading takes two arguments — title, which is a string, and accessoryView which is a function builder, meaning it can take any SwiftUI code. Using it is pretty straightforward:

Heading("Hello World", accessoryView: { Color.blue })

But what about if we don’t want an accessory view?

TestView("Title") // Produces a compile error — "Generic parameter 'AccessoryView' could not be inferred

So now we need to make accessoryView optional somehow. Let’s try making the argument optional, with a default argument.

struct Heading<AccessoryView: View>: View {
  let title: String
  let accessoryView: AccessoryView?

  init(_ title: String, @ViewBuilder accessoryView: () -> AccessoryView? = { nil }) {
    self.title = title
    self.accessoryView = accessoryView()
  }

  ...
}

Unfortunately the compile error doesn’t go away. Let’s try a different approach. Instead of making accessoryView optional, let’s extend Heading to have a second initialiser that only takes a title, and passes an empty view as the accessory view. Looking at the source for Section, Apple uses a similar pattern there.

extension Heading where AccessoryView == EmptyView {
  init(_ title: String) {
    self.init(title, accessoryView: { EmptyView() })
  }
}
TestView("Title") // No compiler errors!

It works! Figuring out this pattern really helped me to make much more powerful views that can be composed from other views.