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 make the custom stepper styleable via the environment and create a hold-to-increment/decrement stepper style.

00:06 In the past few episodes, we've made good progress toward building our stepper component. But before we get to more interesting stuff, we have to fix a little problem with our previews; they fail with an error describing an invalid property name. The issue is that we declared the default button style with backticks, and for some reason, Xcode doesn't like this. We can fix this in two ways.

One way is to get rid of the backticks and choose a different name:

extension MyStepperStyle where Self == DefaultStepperStyle {
    static var defaultStyle: DefaultStepperStyle { return .init() }
}

// ...

struct MyStepper_Previews: PreviewProvider {
    static var previews: some View {
        VStack {
            MyStepper(value: .constant(10), in: 0...100, label: { Text("Value") }, style: .defaultStyle)
            MyStepper(value: .constant(10), in: 0...100, label: { Text("Value") }, style: .defaultStyle)
                .controlSize(.mini)
            MyStepper(value: .constant(10), in: 0...100, label: { Text("Value") }, style: CapsuleStepperStyle())
                .controlSize(.large)
                .font(.largeTitle)
        }.padding()
    }
}

01:30 Alternatively, we could move the extension into another file so that it doesn't conflict with the preview code generation. This would allow us to keep using the default name.

Stepper Style Environment Value

02:11 What we actually want to talk about today is the way we choose a stepper style. Rather than passing a style parameter into each stepper view, we'd like to use a modifier to define the style in the environment. For this, we need an environment key that provides the default style:

struct StepperStyleKey: EnvironmentKey {
    static let defaultValue: any MyStepperStyle = DefaultStepperStyle()
}

04:02 We can't define the default value's type as MyStepperStyle, because this is a protocol with an associated type. Xcode 14 suggests we fix it by writing any MyStepperStyle. If we were still using Xcode 13 or earlier, we'd have to write an AnyMyStepperStyle wrapper that erases the underlying type of the stepper style.

04:43 As always, we also write a computed property on EnvironmentValues to access the stepper style on the environment:

extension EnvironmentValues {
    var stepperStyle: any MyStepperStyle {
        get { self[StepperStyleKey.self] }
        set { self[StepperStyleKey.self] = newValue }
    }
}

05:25 Lastly, we also add a convenience method on View to update the stepper style stored in the environment. We can declare the parameter as any MyStepperStyle, but since this API will always be used with a specific type, we can say some MyStepperStyle:

extension View {
    func stepperStyle(_ style: some MyStepperStyle) -> some View {
        environment(\.stepperStyle, style)
    }
}

06:23 In our preview, we want to skip the style parameter if we're using the default style, so we change the style property of MyStepper to get its value from the environment:

struct MyStepper<Label: View>: View {
    @Binding var value: Int
    var `in`: ClosedRange<Int> // todo
    @ViewBuilder var label: Label
    @Environment(\.stepperStyle) var style
    
    // ...
}

07:04 Now that we're calling makeBody on something of the type any MyStepperStyle, we're getting back any View. This makes sense, because we have no information about the stepper style's type or the view it produces. But the body property needs to return some View. The only way we can satisfy this requirement is by wrapping the whole thing in an AnyView:

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`)))
    }
}

08:10 We've now created an API that feels familiar in SwiftUI, and we get to define a stepper style in the place where it makes sense, instead of having to repeat it for every stepper in our view:

struct MyStepper_Previews: PreviewProvider {
    static var previews: some View {
        VStack {
            MyStepper(value: .constant(10), in: 0...100, label: { Text("Value") })
            MyStepper(value: .constant(10), in: 0...100, label: { Text("Value") })
                .controlSize(.mini)
            MyStepper(value: .constant(10), in: 0...100, label: { Text("Value") })
                .controlSize(.large)
                .font(.largeTitle)
                .stepperStyle(CapsuleStepperStyle())
        }.padding()
    }
}

Vertical Stepper Style

08:47 Let's make use of this flexibility and create another stepper style — one with the increment and decrement buttons stacked vertically. And since we're working in the newest Xcode, we can make use of the new LabeledContent API, which allows this style to be used with the labelsHidden modifier:

@available(iOS 16.0, macOS 13.0, *)
struct VerticalStepperStyle: MyStepperStyle {
    func makeBody(_ configuration: MyStepperStyleConfiguration) -> some View {
        LabeledContent {
            // ...
        } label: {
            configuration.label
        }
    }
}

10:47 We use system images for the increment and decrement buttons:

@available(iOS 16.0, macOS 13.0, *)
struct VerticalStepperStyle: MyStepperStyle {
    func makeBody(_ configuration: MyStepperStyleConfiguration) -> some View {
        LabeledContent {
            HStack {
                Text(configuration.value.wrappedValue.formatted())
                
                VStack {
                    Image(systemName: "chevron.up")
                    Image(systemName: "chevron.down")
                }
            }
        } label: {
            configuration.label
        }
    }
}

11:32 By adding a preview, we can see what this looks like so far:

@available(iOS 16.0, macOS 13.0, *)
struct VerticalStepperStyle_Previews: PreviewProvider {
    static var previews: some View {
        MyStepper(value: .constant(1), in: 0...999) {
            Text("Quantity")
        }
        .stepperStyle(VerticalStepperStyle())
    }
}

13:06 The buttons are a bit too compact, so we add some padding, and we remove the spacing between them:

@available(iOS 16.0, macOS 13.0, *)
struct VerticalStepperStyle: MyStepperStyle {
    func makeBody(_ configuration: MyStepperStyleConfiguration) -> some View {
        LabeledContent {
            HStack {
                Text(configuration.value.wrappedValue.formatted())
                
                VStack(spacing: 0) {
                    Image(systemName: "chevron.up")
                        .padding(4)
                    Image(systemName: "chevron.down")
                        .padding(4)
                }
            }
        } label: {
            configuration.label
        }
    }
}

13:36 The up and down icons are quite small, so it'd be handy to add tap targets allowing the user to tap anywhere on the upper or lower half of the stepper view. By placing a VStack with two rectangles and no spacing in an overlay of the stepper, we divide the entire view's area in two equal halves that can each be tapped. We also add a background to make the bounds of the control visible:

@available(iOS 16.0, macOS 13.0, *)
struct VerticalStepperStyle: MyStepperStyle {
    func makeBody(_ configuration: MyStepperStyleConfiguration) -> some View {
        LabeledContent {
            HStack {
                Text(configuration.value.wrappedValue.formatted())
                
                VStack(spacing: 0) {
                    Image(systemName: "chevron.up")
                        .padding(4)
                    Image(systemName: "chevron.down")
                        .padding(4)
                }
            }
            .padding(.horizontal)
            .padding(.vertical, 4)
            .background {
                RoundedRectangle(cornerRadius: 8, style: .continuous)
                    .fill(.regularMaterial)
            }
            .overlay {
                VStack(spacing: 0) {
                    Rectangle()
                        .fill(.clear)
                        .contentShape(Rectangle())
                        .onTapGesture {
                            configuration.value.wrappedValue += 1
                        }
                    Rectangle()
                        .fill(.clear)
                        .contentShape(Rectangle())
                        .onTapGesture {
                            configuration.value.wrappedValue -= 1
                        }
                }
            }
        } label: {
            configuration.label
        }
    }
}

Due to the .clear fill color, tap gestures on the rectangle would normally not be registered, but by using contentShape, we force the shape to become a tap target.

16:10 To try these buttons out, we need a binding to a state property instead of a constant binding. We can't add a state variable to the preview provider directly, but we can write a view specifically for previewing:

@available(iOS 16.0, macOS 13.0, *)
private struct Preview: View {
    @State var value = 0
    
    var body: some View {
        MyStepper(value: $value, in: 0...999) {
            Text("Quantity")
        }
        .stepperStyle(VerticalStepperStyle())
    }
}

@available(iOS 16.0, macOS 13.0, *)
struct VerticalStepperStyle_Previews: PreviewProvider {
    static var previews: some View {
        Preview()
            .padding()
    }
}

17:20 When we change the stepper value, we notice the size of the value label changes slightly with each increment. By using a font with monospaced digits, the label only changes size when the value's number of digits changes, which looks better:

@available(iOS 16.0, macOS 13.0, *)
struct VerticalStepperStyle: MyStepperStyle {
    func makeBody(_ configuration: MyStepperStyleConfiguration) -> some View {
        LabeledContent {
            HStack {
                Text(configuration.value.wrappedValue.formatted())
                    .monospacedDigit()
                
                VStack(spacing: 0) {
                    Image(systemName: "chevron.up")
                        .padding(4)
                    Image(systemName: "chevron.down")
                        .padding(4)
                }
            }
            .padding(.horizontal)
            .padding(.vertical, 4)
            .background {
                // ...
            }
            .overlay {
                // ...
            }
        } label: {
            configuration.label
        }
    }
}

17:51 And since it's likely the stepper goes into the double digits, we can already reserve space for two digits using a hidden text label. This technique has the advantage that the reserved space automatically adapts to different fonts and sizes:

@available(iOS 16.0, macOS 13.0, *)
struct VerticalStepperStyle: MyStepperStyle {
    func makeBody(_ configuration: MyStepperStyleConfiguration) -> some View {
        LabeledContent {
            HStack {
                ZStack {
                    Text("99")
                        .hidden()
                    Text(configuration.value.wrappedValue.formatted())
                }
                .monospacedDigit()
                
                VStack(spacing: 0) {
                    Image(systemName: "chevron.up")
                        .padding(4)
                    Image(systemName: "chevron.down")
                        .padding(4)
                }
            }
            .padding(.horizontal)
            .padding(.vertical, 4)
            .background {
                // ...
            }
            .overlay {
                // ...
            }
        } label: {
            configuration.label
        }
    }
}

Hold Gesture

18:43 If the stepper needs to be used to span larger ranges of numbers, it'd be handy if we could hold down a button to make the value start running up or down. We could even make it so that the longer the button is held, the faster it counts. Rather than building this feature directly into the stepper style, we can create a new view modifier so that it can be used in other views as well:

struct OnHold: ViewModifier {
    var perform: () -> ()

    func body(content: Content) -> some View {
        content // todo
    }
}

extension View {
    func onHold(_ perform: @escaping () -> ()) -> some View {
        modifier(OnHold(perform: perform))
    }
}

21:13 We're currently calling onTapGesture with a closure to update the stepper's value. But we need something else to detect when the user keeps pressing down on the button. We could do this using a DragGesture, but there's also an underscored modifier that lets us provide two closures; the first one is called with the press state, and the second one is called when the tap is released:

struct OnHold: ViewModifier {
    var perform: () -> ()

    func body(content: Content) -> some View {
        content
            ._onButtonGesture { pressed in

            } perform: {

            }
    }
}

This is a public API, but the underscore tells us to treat it as if it were private. We therefore wouldn't recommend using this kind of API, but in this particular case, it's useful, and it'll work better than a drag gesture, which might interfere with scrolling when the view is part of a list.

23:03 We store the pressed state so that we can use it to kick off the process of repeating the given perform action until the gesture is released:

struct OnHold: ViewModifier {
    var perform: () -> ()
    @State private var isPressed = false
    
    func body(content: Content) -> some View {
        content
            ._onButtonGesture { pressed in
                isPressed = pressed
            } perform: {
            }
    }
}

23:33 Then we add a task that gets executed when the view appears and with each change of isPressed. If isPressed is true, we call perform. And since we're in an asynchronous context, we can make use of Task.sleep to first wait a short while and then enter a loop to repeatedly call perform:

struct OnHold: ViewModifier {
    var perform: () -> ()
    @State private var isPressed = false
    
    func step() async throws {
        perform()
        try await Task.sleep(nanoseconds: 500_000_000)
        while true {
            perform()
            try await Task.sleep(nanoseconds: 100_000_000)
        }
    }
    
    func body(content: Content) -> some View {
        content
            ._onButtonGesture { pressed in
                isPressed = pressed
            } perform: {
            }
            .task(id: isPressed) {
                guard isPressed else { return }
                do {
                    try await step()
                } catch {}
            }
    }
}

26:15 These numbers should be tweaked so that the gesture feels good or that it matches with similar gestures. Perhaps the timing should even be staggered so that it repeats faster with every second the gesture is held.

26:47 We call the onHold modifier on both tap targets of the vertical stepper:

@available(iOS 16.0, macOS 13.0, *)
struct VerticalStepperStyle: MyStepperStyle {
    func makeBody(_ configuration: MyStepperStyleConfiguration) -> some View {
        LabeledContent {
            HStack {
                ZStack {
                    Text("99")
                        .hidden()
                    Text(configuration.value.wrappedValue.formatted())
                }
                .monospacedDigit()
                
                VStack(spacing: 0) {
                    Image(systemName: "chevron.up")
                        .padding(4)
                    Image(systemName: "chevron.down")
                        .padding(4)
                }
            }
            .padding(.horizontal)
            .padding(.vertical, 4)
            .background {
                RoundedRectangle(cornerRadius: 8, style: .continuous)
                    .fill(.regularMaterial)
            }
            .overlay {
                VStack(spacing: 0) {
                    Rectangle()
                        .fill(.clear)
                        .contentShape(Rectangle())
                        .onHold {
                            configuration.value.wrappedValue += 1
                        }
                    Rectangle()
                        .fill(.clear)
                        .contentShape(Rectangle())
                        .onHold {
                            configuration.value.wrappedValue -= 1
                        }
                }
            }
        } label: {
            configuration.label
        }
    }
}

27:35 When we tap the stepper once, the value changes. And when we keep pressing the control (while staying near it), the value starts counting until we release.

Discussion

28:07 By only changing the style, we get an entirely different result, even though we're still using the same stepper. We've determined how the controls are laid out and how gestures work, all from the stepper style. This makes the stepper control very flexible.

28:33 But there are always more details we can pay attention to. In the next episode, we'll take at look at the accessibility of our stepper.

Resources

  • Sample Code

    Written in Swift 5.6

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

165 Episodes · 57h18min

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