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 object
toeverything is a protocol
(admittedly, we took this one too much to the letter) - ...
everything is a class
toprefer 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 associatedTextField
becomes first responder and when it relinquishes its first responder statusonCommit
, triggered when the user taps the associatedTextField
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:
- 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
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 anaction: @escaping () -> Void
parameterTextField
s come with two closure with default valuesonEditingChanged: @escaping (Bool) -> Void = { _ in }
, andonCommit: @escaping () -> Void = {}
SecureField
s come only with theonCommit: @escaping () -> Void = {}
closureSlider
s also come withonEditingChanged: @escaping (Bool) -> Void = { _ in }
Stepper
s 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 optionalonIncrement: (() -> Void)?
andonDecrement: (() -> Void)?
closures
- in the binding form, they come only with
- Lastly, SwiftUI's implementation detail
SubscriptionView
comes with aaction: @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 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!