Skip to main content

Designing custom SwiftUI view APIs like Apple

Declarative SwiftUI is great. Once you get your hands on it, there’s no going back. Public APIs are well defined and composable, clear and concise. They have some quirks, but in general, they are rock solid.

We often face the challenge of implementing custom UI controls.

How can we design these controls to feel as though they were created by Apple?

Let’s have a look at a custom control example from the Bear notes application.

The screenshot of the Bear app tag list.

This is a list of tags. A single tag item can have the following properties:

The first idea for the implementation could be injecting parameters via a constructor:

struct TagItem: View {
  var icon: IconImage
  var title: String
  var isSelected: Bool
}

// Usage
TagItem(icon: .manga, title: "manga", isSelected: false)
TagItem(icon: .hash, title: "matcha", isSelected: true)

At first glance, the public API looks decent. It’s easy for a client to know what they can configure just by looking at the constructor. On the flip side, because there can be a lot of parameters to customize a control, it can be overwhelming and hard to find what they are looking for.

Max Roche wrote a great article about a better way to build SwiftUI components. He makes the point that a UI component can be divided into mandatory and optional parts.

For instance, a Text component is meaningless without text. If no text is provided, it would simply render a blank space. In contrast, if a text color or font isn’t set, nothing happens because these parameters are optional. The Text component comes with defaults for customizable properties.

Text("Hello, world!") // mandatory
  .foregroundStyle(.orange) // optional

We can apply this concept to our TagItem component. A tag requires a title, and the rest of the parameters are optional.

struct TagItem: View {
  var icon: IconImage = .hash
  var title: String
  var isSelected: Bool = false
}

// Usage
TagItem(icon: .manga, title: "manga")
TagItem(title: "matcha", isSelected: true)

Although the API is improved, there’s still room for enhancement. Optional parameters can be styled using dedicated modifiers:

struct TagItem: View {
  private var icon: IconImage = .hash
  private var title: String
  private var isSelected: Bool = false

  init(_ title: String) {
    self.title = title
  }

  func icon(_ value: IconImage) -> Self {
    var copy = self
    copy.icon = value
    return copy
  }

  func isSelected(_ value: IconImage) -> Self {
    var copy = self
    copy.isSelected = value
    return copy
  }
}

// Usage
TagItem("manga")
  .icon(.manga)

TagItem("matcha")
  .isSelected(true)

The API now feels more aligned with SwiftUI, but the work isn’t done yet. We still need to write boilerplate code for each custom property. We can solve it by introducing a custom macro:

@Customizable private var icon: IconImage = .hash

// Macro expands to
func icon(_ value: IconImage) -> Self {
  var copy = self
  copy.icon = value
  return copy
}

It’s uncommon to see optional values in modifiers, so by default, the @Customizable macro converts optional values to non-optional. If we want to retain optionals, we can add additional options to the macro:

@Customizable(preserveOptional: true) private var subtitle: String?

// Macro expands to
func subtitle(_ value: String?) -> Self {
  var copy = self
  copy.subtitle = value
  return copy
}

The @Customizable macro is going to help with many custom properties, but there will be cases where custom modifier does better job. For example, if we want to add accessory view to our TagItem, a manually written modifier would be ideal for this task:

TagItem("matcha") 
  .accessoryView {
    AccessoryView()
  }

To summarize, here is how the final implementation for TagItem might look:

struct TagItem: View {
  @Customizable private var icon: IconImage
  @Customizable private var title: String
  @Customizable private var isSelected: Bool

  init(_ title: String) { ... }

  func accessoryView<Content>(
    @ViewBuilder content: () -> Content
  ) -> some View where Content : View { ... }
}

Transitioning from parameter injection via a constructor to using modifiers for styling optional properties simplifies API usage, improves code readability, and aligns with Apple’s design principles.

A big thanks to Max Roche for writing his article, which was a big inspiration for this one!