This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We work on the accessibility of our custom stepper component.

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> // todo
    @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> // todo
    @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.

Resources

  • Sample Code

    Written in Swift 5.6

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

135 Episodes · 48h28min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes