00:06 Kasper is back for the final episode of this series. One of the
reasons we invited him is due to his knowledge about building detailed
components. He's going to show us some components — created by his company,
Moving Parts — which use many of the same techniques
we've been exploring in building the stepper. Later, we'll also look into the
accessibility of our stepper.
CreditCardLockup
00:49 The CreditCardLockup
component has fields to enter credit card
information. The credit card number we enter is automatically formatted
according to the specific conventions of the card's provider. A logo appears as
soon as the number is recognized. In the date fields, we can type in a "9" for
the month, and a leading zero is automatically added:

01:47 We can also open a hint showing where the security code can be
found, depending on the card type:

02:12 As with the stepper component, CreditCardLockup
is styleable
through a single protocol. The component comes with a few different default
styles. The rounded style shows icons in the different fields, and it displays
validation errors in a different way:

03:26 In SwiftUI, we can only style controls individually, or we can
wrap fields in a Form
to change their look, but having the ability to define
the look and behavior of a whole group of controls at once by setting a style is
very powerful.
03:41 Looking at the code of a custom style, we can see that the
component's configuration struct provides the various subviews that can be
dropped into the view. This means the style itself doesn't need to handle
details like formatting text:

04:56 Not only are these components easily styleable, but they're also
built with accessibility and localization in mind, and validation is built in.
05:34 The CreditCardLockup
component comprises and connects various
fields together, which makes it easy to tab between the fields, but we can also
use a field on its own. An input field, for example, lets us define special
validation rules. If the validation is asynchronous, the field automatically
shows a spinner while waiting for the result:

Accessibility Element
07:17 We've already used some of the discussed techniques in our custom
stepper component, but we haven't yet looked at accessibility. When we run our
project on a device with VoiceOver enabled and we tap the stepper, all we hear
is "stepper, coffee bag, image." It'd be nice if each item in the list were
treated as a single element.
08:24 If we change from the default style to our custom
CapsuleStepperStyle
, we notice that VoiceOver reads out the hyphen from the
decrement button label instead of saying "minus" or "decrement."
09:14 First, we want to mark each list item's HStack
as an
accessibility element that combines its children. With this change, the name of
the coffee, the price, and the quantity are all read out together to describe
the cart item, rather than each subview being treated as a separate element:
struct ContentView: View {
var body: some View {
List($items) { $item in
HStack {
Image("coffee-bag")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 60, height: 60)
.background(Color(white: 0.9))
VStack {
HStack {
Text(item.name)
Spacer()
Text(item.price.formatted(.currency(code: "EUR")))
}
MyStepper(value: $item.quantity, in: 0...99, label: { Text("Quantity") })
}
}
.accessibilityElement(children: .combine)
}
}
}
Accessibility Actions
10:10 After describing the cart item, VoiceOver says "actions
available," because the view contains a stepper control and an image. The
image's action is to open a detail view, but we don't really need that. By
changing the image's initializer to mark it as decorative, we get rid of the
detail action:
Image(decorative: "coffee-bag")
11:39 The remaining actions are related to the stepper. VoiceOver reads
out "hyphen" and "plus," because those are the characters used in the decrement
and increment button labels. We shouldn't use a hyphen to communicate a
decrement action, so we replace the text labels with the system images for
"minus" and "plus":
HStack {
Button { configuration.value.wrappedValue -= 1 } label: {
Image(systemName: "minus")
}
Text(configuration.value.wrappedValue.formatted())
Button { configuration.value.wrappedValue += 1 } label: {
Image(systemName: "plus")
}
}
12:43 By default, the minus and plus buttons get described as "remove"
and "activate," but "decrement" and "increment" would be more appropriate in
this case. We can tell the system to use those descriptions by setting them as
accessibility labels for the buttons:
HStack {
Button { configuration.value.wrappedValue -= 1 } label: {
Image(systemName: "minus")
.accessibilityLabel("Decrement")
}
Text(configuration.value.wrappedValue.formatted())
Button { configuration.value.wrappedValue += 1 } label: {
Image(systemName: "plus")
.accessibilityLabel("Increment")
}
}
Accessibility Adjustment Action
13:45 We can further improve the interface by using another feature: the
accessibility adjustment action. This lets us provide a closure that gets called
with the direction the user swipes over the stepper. Depending on the direction,
we increment or decrement the value:
HStack {
Button { configuration.value.wrappedValue -= 1 } label: {
Image(systemName: "minus")
.accessibilityLabel("Decrement")
}
Text(configuration.value.wrappedValue.formatted())
Button { configuration.value.wrappedValue += 1 } label: {
Image(systemName: "plus")
.accessibilityLabel("Increment")
}
}
.accessibilityAdjustableAction({ direction in
switch direction {
case .decrement:
configuration.value.wrappedValue -= 1
case .increment:
configuration.value.wrappedValue += 1
}
})
15:08 VoiceOver now tells us we can adjust the stepper by swiping up or
down. And indeed, the value changes as we swipe, but these changes aren't read
out loud, which would be valuable feedback to the user. If we tell the system
about the value of the stepper using accessibilityValue
, it'll read out the
value with each change:
HStack {
Button { configuration.value.wrappedValue -= 1 } label: {
Image(systemName: "minus")
.accessibilityLabel("Decrement")
}
Text(configuration.value.wrappedValue.formatted())
Button { configuration.value.wrappedValue += 1 } label: {
Image(systemName: "plus")
.accessibilityLabel("Increment")
}
}
.accessibilityValue(configuration.value.wrappedValue.formatted())
.accessibilityAdjustableAction({ direction in
switch direction {
case .decrement:
configuration.value.wrappedValue -= 1
case .increment:
configuration.value.wrappedValue += 1
}
})
16:07 Now the value is read out twice: once because it's in the label of
the stepper, and once as the accessibility value. By completely ignoring the
contents of the stepper controls, we can prevent the value label from being read
out loud.
Finishing Up
This is a good moment to reorganize our code a bit. We've been working on the
accessibility features in CapsuleStepper
, but they aren't style-specific, so
we should move them into the MyStepper
component itself so that they work in
every style.
17:12 It could be the case that a custom stepper style wants to define
its own accessibility features. We could add a property to the protocol to let
the style indicate this, or we could have a separate protocol to which styles
can conform if they want to opt out of our accessibility implementation. But for
now, we can just ignore the children of the style's view:
struct MyStepper<Label: View>: View {
@Binding var value: Int
var `in`: ClosedRange<Int> @ViewBuilder var label: Label
@Environment(\.stepperStyle) var style
var body: some View {
AnyView(style.makeBody(.init(value: $value, label: .init(underlyingLabel: AnyView(label)), range: `in`)))
.accessibilityElement(children: .ignore)
.accessibilityValue(value.formatted())
.accessibilityAdjustableAction({ direction in
switch direction {
case .decrement:
value -= 1
case .increment:
value += 1
}
})
}
}
18:53 The "quantity" label is no longer read out because we're ignoring
all content inside the style, but we can bring that back by passing the label
view to the accessibilityRepresentation
modifier. We need to use this modifier
rather than accessibilityLabel
, because it takes any view, and we have no
information about label
other than that it conforms to View
:
struct MyStepper<Label: View>: View {
@Binding var value: Int
var `in`: ClosedRange<Int> @ViewBuilder var label: Label
@Environment(\.stepperStyle) var style
var body: some View {
AnyView(style.makeBody(.init(value: $value, label: .init(underlyingLabel: AnyView(label)), range: `in`)))
.accessibilityElement(children: .ignore)
.accessibilityRepresentation(representation: {
label
})
.accessibilityValue(value.formatted())
.accessibilityAdjustableAction({ direction in
switch direction {
case .decrement:
value -= 1
case .increment:
value += 1
}
})
}
}
19:57 VoiceOver now describes the items in our cart, it describes how
we can change the quantity, and it correctly tells us about the actions we take.
We can improve this a little bit by allowing styles to opt in or out of the
accessibility features. This could be handy for a custom stepper that allows us
to increment the value with 10 steps at once, for example. But our default
implementation seems to be pretty good for simple use cases.