SwiftUI patterns: view closures

SwiftUI Swift
25 May 2021

Swift's introduction brought us significant shifts in the way we build products. For example, we went from...

  • ...everything is an object to everything is a protocol (admittedly, we took this one too much to the letter)
  • ...everything is a class to prefer value types wherever possible

A paradigm that didn't make it into Swift is the Target-Action design pattern.

If we take a pre-iOS 14 UIButton for example, we used to do the following to link an action to a button:

final class FSViewController: UIViewController {
  ...

  override func viewDidLoad() {
    super.viewDidLoad()
    setupViews()
  }
    
  private func setupViews() {
    let button = UIButton(type: .system)
    button.addTarget(
      self,
      action: #selector(didTapButton(_:)),
      for: .touchUpInside
    )
    ...
  }

  @objc private func didTapButton(_ sender: UIButton) {
    // button action here
  }
}

Things got better in iOS 14, with a new, more Swifty, UIAction API:

final class FSViewController: UIViewController {
  ...

  override func viewDidLoad() {
    super.viewDidLoad()
    setupViews()
  }
    
  private func setupViews() {
    let button = UIButton(
      primaryAction: UIAction { _ in
        // button action here
      }
    )
    ...
  }
}

All of this was necessary because UIKit's buttons, and many other components such as UISlider and UIDatePicker, were a subclass of UIControl, which is based on the Target-Action pattern.

At the time, things had to work this way for many reasons, such as linking @IBAction methods to storyboard components/triggers.

When it comes to SwiftUI, the team at Apple had a chance to start fresh, Swift-first, and with no legacies:
in this article, let's see how they replaced the Target-Action pattern with view closures, and more.

This article focuses on closures accepted on view initialization and triggered when certain events happen, for closures used to build views, refer to SwiftUI patterns: passing & accepting views.

Buttons

// Definition
struct Button<Label: View>: View {
  init(action: @escaping () -> Void, @ViewBuilder label: () -> Label)
  init(_ titleKey: LocalizedStringKey, action: @escaping () -> Void)
  init<S>(_ title: S, action: @escaping () -> Void) where S : StringProtocol
}

// Use
struct ContentView: View {
  var body: some View {
    Button("tap me") { 
      // button action here
    }
  }
}

Creating a button without an associated action would probably make any app look broken:
in SwiftUI, the action closure is a required parameter of Button's initialization.

We can no longer "just" forget to associate an action to its button, or mistakenly associate more than one action to any given button. This approach solves the previous challenges right from the start.

TextField/SecureField

// Definition
extension TextField where Label == Text {
  init(
    _ titleKey: LocalizedStringKey, 
    text: Binding<String>, 
    onEditingChanged: @escaping (Bool) -> Void = { _ in }, 
    onCommit: @escaping () -> Void = {}
  )

  init<S: StringProtocol>(
    _ title: S, 
    text: Binding<String>, 
    onEditingChanged: @escaping (Bool) -> Void = { _ in }, 
    onCommit: @escaping () -> Void = {}
  )

  init<T>(
    _ titleKey: LocalizedStringKey, 
    value: Binding<T>, 
    formatter: Formatter, 
    onEditingChanged: @escaping (Bool) -> Void = { _ in }, 
    onCommit: @escaping () -> Void = {}
  )

  init<S: StringProtocol, T>(
    _ title: S, 
    value: Binding<T>, 
    formatter: Formatter, 
    onEditingChanged: @escaping (Bool) -> Void = { _ in }, 
    onCommit: @escaping () -> Void = {}
  )
}

extension SecureField where Label == Text {
  init(_ titleKey: LocalizedStringKey, text: Binding<String>, onCommit: @escaping () -> Void = {})
  init<S: StringProtocol>(_ title: S, text: Binding<String>, onCommit: @escaping () -> Void = {})
}

// Use
struct ContentView: View {
  @State var username = ""
  var body: some View {
    VStack {
      TextField(
        "Username:", 
        text: $username, 
        onEditingChanged: { isOnFocus in 
          // ...
        }, 
        onCommit: {
          // ...
        }
      )

      TextField("Username:", text: $username)
    }
  }
}

TextField comes with two optional closures:

  1. onEditingChanged, triggered when the associated TextField becomes first responder and when it relinquishes its first responder status
  2. onCommit, triggered when the user taps the associated TextField keyboard action key (e.g. Search, Go, or, more commonly, Return).

Both these actions are not required to make any TextField work, which is why both parameters come with a default value (that does nothing).

Sometimes, we would like to trigger some side effects while the user is typing into the TextField: for example, to do some validation on the input (for email or password criteria, or ..).

With UIKit's Target-Action approach, this was easy: at every text change, our action was triggered, which we could then use to both fetch the current text value, and trigger our side effect.

In SwiftUI, there doesn't seem to be a direct equivalent. However, it's right in front of us: it's the text: Binding<String> parameter.

The binding main function is to have a single source of truth: the value our app sees is the same as the value displayed in the TextField (unlike UIKit, where each UITextField had its storage, and our view/view controller would have another one).

However, because it's a binding, we can observe its changes with iOS 14's onChange(of:perform:) view modifier:

struct ContentView: View {
  @State var username = ""

  var body: some View {
    TextField("Username:", text: $username)
      .onChange(of: username, perform: validate(_:))
  }

  func validate(_ username: String) {
    // validate here
  }
}

If we're targeting iOS 13, the same can be done by providing our own binding, for example:

struct ContentView: View {
  @State var username = ""

  var body: some View {
    let binding = Binding {
      username
    } set: {
      validate($0)
      username = $0
    }

    TextField("Username:", text: binding)
  }

  func validate(_ username: String) {
    // validate here
  }
}

SwiftUI's Bindings are SwiftUI's most elegant replacement of UIKit's Target-Action pattern: they beautifully solve data synchronization challenges and other kinds of bugs.

We took a deep dive into all SwiftUI bindings in another article of the series SwiftUI patterns: @Bindings.

Sliders

// Definition
extension Slider {
  init<V>(
    value: Binding<V>, 
    in bounds: ClosedRange<V> = 0...1, 
    onEditingChanged: @escaping (Bool) -> Void = { _ in }, 
    minimumValueLabel: ValueLabel, 
    maximumValueLabel: ValueLabel, 
    @ViewBuilder label: () -> Label
  ) where V: BinaryFloatingPoint, V.Stride: BinaryFloatingPoint

  init<V>(
    value: Binding<V>, 
    in bounds: ClosedRange<V>, 
    step: V.Stride = 1, 
    onEditingChanged: @escaping (Bool) -> Void = { _ in }, 
    minimumValueLabel: ValueLabel, 
    maximumValueLabel: ValueLabel, 
    @ViewBuilder label: () -> Label
  ) where V: BinaryFloatingPoint, V.Stride: BinaryFloatingPoint
}

extension Slider where ValueLabel == EmptyView {
  init<V>(
    value: Binding<V>, 
    in bounds: ClosedRange<V> = 0...1, 
    onEditingChanged: @escaping (Bool) -> Void = { _ in }, 
    @ViewBuilder label: () -> Label
  ) where V: BinaryFloatingPoint, V.Stride: BinaryFloatingPoint
  
  init<V>(
    value: Binding<V>, 
    in bounds: ClosedRange<V>, 
    step: V.Stride = 1, 
    onEditingChanged: @escaping (Bool) -> Void = { _ in }, 
    @ViewBuilder label: () -> Label
  ) where V: BinaryFloatingPoint, V.Stride: BinaryFloatingPoint
  
  init<V>(
    value: Binding<V>, 
    in bounds: ClosedRange<V> = 0...1, 
    onEditingChanged: @escaping (Bool) -> Void = { _ in }
  ) where V: BinaryFloatingPoint, V.Stride: BinaryFloatingPoint
  
  init<V>(
    value: Binding<V>, 
    in bounds: ClosedRange<V>, 
    step: V.Stride = 1, 
    onEditingChanged: @escaping (Bool) -> Void = { _ in }
  ) where V: BinaryFloatingPoint, V.Stride: BinaryFloatingPoint
}

// Use
struct ContentView: View {
  @State var value = 5.0

  var body: some View {
    Slider(value: $value, in: 1...10) { isOnFocus in
      // ..
    }
  }
}

Did you know that Slider uses styles internally? We might be able to define our own soon! (FB9079800)

Similar to TextField, Slider comes with an onEditingChanged closure used to communicate the focus status. Besides this, the binding takes care of everything.

Steppers

Stepper comes in two forms:

  1. a binding form
  2. a free form

Stepper binding form

// Definition
extension Stepper {
  init<V: Strideable>(
    value: Binding<V>, 
    step: V.Stride = 1, 
    onEditingChanged: @escaping (Bool) -> Void = { _ in }, 
    @ViewBuilder label: () -> Label
  )
        
  init<V: Strideable>(
    value: Binding<V>, 
    in bounds: ClosedRange<V>, 
    step: V.Stride = 1, 
    onEditingChanged: @escaping (Bool) -> Void = { _ in }, 
    @ViewBuilder label: () -> Label
  )
}

extension Stepper where Label == Text {
  init<V: Strideable>(
    _ titleKey: LocalizedStringKey, 
    value: Binding<V>, 
    step: V.Stride = 1, 
    onEditingChanged: @escaping (Bool) -> Void = { _ in }
  )
  
  init<S: StringProtocol, V: Strideable>(
    _ title: S, 
    value: Binding<V>, 
    step: V.Stride = 1, 
    onEditingChanged: @escaping (Bool) -> Void = { _ in }
  )
  
  init<V: Strideable>(
    _ titleKey: LocalizedStringKey, 
    value: Binding<V>, 
    in bounds: ClosedRange<V>, 
    step: V.Stride = 1, 
    onEditingChanged: @escaping (Bool) -> Void = { _ in }
  )
  
  init<S: StringProtocol, V: Strideable>(
    _ title: S, value: Binding<V>, 
    in bounds: ClosedRange<V>, 
    step: V.Stride = 1, 
    onEditingChanged: @escaping (Bool) -> Void = { _ in }
  )
}

// Use
struct ContentView: View {
  @State var value = 5

  var body: some View {
    Stepper("Value:", value: $value, step: 1) { isOnFocus in
      // ..
    }
  }
}

In this form, we're essentially looking at a component similar to Slider, just with a different look.

Stepper free form

// Definition
extension Stepper {
  init(
    onIncrement: (() -> Void)?, 
    onDecrement: (() -> Void)?, 
    onEditingChanged: @escaping (Bool) -> Void = { _ in }, 
    @ViewBuilder label: () -> Label
  )
}

extension Stepper where Label == Text {
  init(
    _ titleKey: LocalizedStringKey, 
    onIncrement: (() -> Void)?, 
    onDecrement: (() -> Void)?, 
    onEditingChanged: @escaping (Bool) -> Void = { _ in }
  )
  
  init<S: StringProtocol>(
    _ title: S, 
    onIncrement: (() -> Void)?, 
    onDecrement: (() -> Void)?, 
    onEditingChanged: @escaping (Bool) -> Void = { _ in }
  )
}

// Use
struct ContentView: View {
  var body: some View {
    Stepper(
      "Value",
      onIncrement: {
        // called on increment tap
      }, onDecrement: {
        // called on decrement tap
      }) { isOnFocus in
      //
    }
  }
}

The SwiftUI team could have stopped at the @Binding form, instead, they went ahead and provided us a more generic and stateless Stepper to be used as we please.

This form offers three closures:

  1. onIncrement, triggered when the user taps the increment button
  2. onDecrement, equivalent to the above for the decrement button
  3. onEditingChanged, for the usual focus event

onIncrement and onDecrement closures replace UIStepper's single .valueChanged event, removing the need for our views to distinguish and manage these events ourselves.

Note also how onIncrement and onDecrement do not come with a default value, meaning that we cannot mistakenly define a stepper that does nothing, e.g. Stepper("Value").

Instead, while both onIncrement and onDecrement are optional, we're required to define them ourselves.

If we really want a Stepper that does nothing, we'd need to write Stepper("Value", onIncrement: nil, onDecrement: nil). I'm sure any PR reviewer would have some questions, though!

SubscriptionView

For completeness's sake, the last public SwiftUI view making uses of closures is a view that has no UIKit equivalent, as it's a SwiftUI implementation detail:

struct SubscriptionView<PublisherType: Publisher, Content: View>: View where PublisherType.Failure == Never {
  init(
    content: Content, 
    publisher: PublisherType, 
    action: @escaping (PublisherType.Output) -> Void
  )
}

SubscriptionView is a view that takes in:

  • another view via the content parameter, which is what we will display on the screen
  • a publisher, which SubscriptionView will subscribe to
  • an action closure, triggered when said publisher publishes anything

In other words, it associates an observer + action to another view, for example:

SubscriptionView(
  content: FSView(),
  publisher: NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification),
  action: { output in
    // received didBecomeActiveNotification
  }
)

Which is equivalent to writing:

FSView()
  .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
    // received didBecomeActiveNotification
  }

Using the same technique from Inspecting SwiftUI views, we see that these two views are indeed identical, making SubscriptionView an implementation detail of onReceive(_:perform:), here's the Mirror output for both views:

SubscriptionView<Publisher, FSView>(
  content: FSView,
  publisher: Publisher(
    center: NSNotificationCenter,
    name: NSNotificationName(
      _rawValue: __NSCFConstantString
    ),
    object: Optional<AnyObject>
  ),
  action: (Notification) -> ()
)

When building our own views, the preferred way is using onReceive(_:perform:).

Recap

It's always interesting to see how SwiftUI takes old patterns and replaces them with a more modern approach. Here's a quick summary of all view closures used in SwiftUI views:

  • Button definitions take in an action: @escaping () -> Void parameter
  • TextFields come with two closure with default values onEditingChanged: @escaping (Bool) -> Void = { _ in }, and onCommit: @escaping () -> Void = {}
  • SecureFields come only with the onCommit: @escaping () -> Void = {} closure
  • Sliders also come with onEditingChanged: @escaping (Bool) -> Void = { _ in }
  • Steppers come in two forms:
    • in the binding form, they come only with onEditingChanged: @escaping (Bool) -> Void = { _ in }
    • in the free form, beside the usual onEditingChanged closure, they also have optional onIncrement: (() -> Void)? and onDecrement: (() -> Void)? closures
  • Lastly, SwiftUI's implementation detail SubscriptionView comes with a action: @escaping (PublisherType.Output) -> Void closure parameter

The rule seems to be:

  • use action when the closure is a core part of the view definition
  • use onEditingChanged: @escaping (Bool) -> Void = { _ in } for the view focus
  • use on... when the closure is triggered only on associated event (in the name of the parameter), and provide a default implementation when it's not a core part of the view definition

Conclusions

This article explored how SwiftUI has replaced UIKit's Target-Action pattern with bindings and Swift closures.

These replacements are a core part of what makes SwiftUI what it is:

  • by using bindings, we eliminate all possible inconsistencies that come with having multiple sources of truth
  • by requiring all view closures directly in their initializers, it's clear from the start what each view offers out of the box, and it's far less likely to misuse or misunderstand any view

What other patterns have you seen emerge or sunset?
Please let me know via email or twitter, thank you for reading!

⭑⭑⭑⭑⭑

Related articles

More SwiftUI articles

Browse all

More Swift articles

Browse all