SwiftUI patterns: view closures
Swift's introduction brought us significant shifts in the way we build products. For example, we went from...
- ...
everything is an objecttoeverything is a protocol(admittedly, we took this one too much to the letter) - ...
everything is a classtoprefer 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:
onEditingChanged, triggered when the associatedTextFieldbecomes first responder and when it relinquishes its first responder statusonCommit, triggered when the user taps the associatedTextFieldkeyboard 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
Slideruses 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:
- a binding form
- 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:
onIncrement, triggered when the user taps the increment buttononDecrement, equivalent to the above for the decrement buttononEditingChanged, 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
contentparameter, which is what we will display on the screen - a publisher, which
SubscriptionViewwill subscribe to - an
actionclosure, 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:
Buttondefinitions take in anaction: @escaping () -> VoidparameterTextFields come with two closure with default valuesonEditingChanged: @escaping (Bool) -> Void = { _ in }, andonCommit: @escaping () -> Void = {}SecureFields come only with theonCommit: @escaping () -> Void = {}closureSliders also come withonEditingChanged: @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
onEditingChangedclosure, they also have optionalonIncrement: (() -> Void)?andonDecrement: (() -> Void)?closures
- in the binding form, they come only with
- Lastly, SwiftUI's implementation detail
SubscriptionViewcomes with aaction: @escaping (PublisherType.Output) -> Voidclosure parameter
The rule seems to be:
- use
actionwhen 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 an 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!