Swift Talk # 322

Custom Components: Making the Stepper Styleable

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 introduce a stepper style protocol that allows us to create a wide range of appearances for our custom stepper.

00:06 In the last episode, we started making our stepper styleable, but we can go much further, and we might want to move away from the capsule shape entirely. Taking inspiration from the ButtonStyle API, we'll write a protocol that allows us to create any style of stepper from a given configuration.

Stepper Style

01:48 The protocol has the same shape as ButtonStyle: it requires a function that creates a stepper from a configuration value. Since the style can return any type of stepper, we need to make it generic by adding an associated type:

protocol MyStepperStyle {
    associatedtype Body: View
    func makeBody(_ configuration: MyStepperStyleConfiguration) -> Body
}

02:47 The configuration value is a struct that contains a label and a value for the stepper. The value should be to a binding so that we can update it when a button is pressed:

struct MyStepperStyleConfiguration {
    var value: Binding<Int>

}

03:25 Our first instinct would be to define the label as a generic view, but it'd be cumbersome to always have to include the label's type when we define a stepper, because this would prevent us from easily switching between different steppers. In short, we need something more dynamic, and we can copy the idea of letting the configuration provide an opaque Label type from ButtonStyleConfiguration.

04:14 So instead of making the configuration struct generic, we define a locally scoped Label wrapper that stores the underlying label as an AnyView. This technique also allows us to provide additional APIs, such as properties and methods we might need for stepper labels specifically:

struct MyStepperStyleConfiguration {
    var value: Binding<Int>
    var label: Label
    
    struct Label: View {
        var underlyingLabel: AnyView
        
        var body: some View {
            underlyingLabel
        }
    }
}

05:27 Finally, we also want to know the range of possible values to — for instance — disable a button when the value is at one of the edges:

struct MyStepperStyleConfiguration {
    var value: Binding<Int>
    var label: Label
    var range: ClosedRange<Int>
    
    struct Label: View {
        var underlyingLabel: AnyView
        
        var body: some View {
            underlyingLabel
        }
    }
}

05:48 One thing to note is that there are different ways to write this configuration. The properties we've defined above constrain the stepper to work with integers. Depending on what we want our stepper to do, we might have to change these properties.

06:49 SwiftUI's stepper has an API like onIncrement — a function that gets called when the user presses the increment button. This doesn't give us access to the value or the range. It also prevents us from incrementing the value by 10 steps at once. Our choice for the configuration exposes more information, but it also means we might have to implement more logic when creating a stepper style, such as making sure the value stays within the allowed range.

Default Style

07:40 Now that we have the MyStepperStyle protocol and the MyStepperStyleConfiguration struct, we can define a first style that returns SwiftUI's stepper:

struct DefaultStepperStyle: MyStepperStyle {
    func makeBody(_ configuration: MyStepperStyleConfiguration) -> some View {
        Stepper(value: configuration.value, in: configuration.range) {
            configuration.label
        }
    }
}

Capsule Style

08:45 Most of what we wrote for the custom MyStepper can now be moved into a CapsuleStepperStyle. But to read values from the environment, we still need a view, so we create a CapsuleStepper view. The labels and values come from the configuration, which we'll pass in from the stepper style:

struct CapsuleStepperStyle: MyStepperStyle {
    func makeBody(_ configuration: MyStepperStyleConfiguration) -> some View {
        CapsuleStepper(configuration: configuration)
    }
}

struct CapsuleStepper: View {
    var configuration: MyStepperStyleConfiguration
    
    @Environment(\.controlSize)
    var controlSize
    
    var padding: Double {
        switch controlSize {
        case .mini: return 4
        case .small: return 6
        default: return 8
        }
    }
    
    var body: some View {
        HStack {
            configuration.label
            Spacer()
            HStack {
                Button("-") { configuration.value.wrappedValue -= 1 }
                Text(configuration.value.wrappedValue.formatted())
                Button("+") { configuration.value.wrappedValue += 1 }
            }
            .transformEnvironment(\.font, transform: { font in
                if font != nil { return }
                switch controlSize {
                case .mini: font = .footnote
                case .small: font = .callout
                default: font = .body
                }

            })
            .padding(.vertical, padding)
            .padding(.horizontal, padding * 2)
            .foregroundColor(.white)
            .background {
                Capsule()
                    .fill(.tint)
            }
        }
        .buttonStyle(.plain)
    }
}

11:56 We add a style property to MyStepper. This will have to be directly passed in when we create a stepper. Later on, we'll add a way that's more similar to how a button style is applied. In the stepper's body view, we call the style's makeBody method:

struct MyStepper<Label: View, Style: MyStepperStyle>: View {
    @Binding var value: Int
    var `in`: ClosedRange<Int> // todo
    @ViewBuilder var label: Label
    var style: Style
    
    var body: some View {
        style.makeBody(.init(value: $value, label: .init(underlyingLabel: AnyView(label)), range: `in`))
    }
}

13:14 We need to pass stepper styles into the MyStepper views used in our previews and in the cart view. In the previews, we'll use DefaultStepperStyle for the first two steppers and CapsuleStepperStyle for the third:

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

Helpers

14:10 To make it easier to get the default stepper style, we can define a static variable on MyStepperStyle:

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

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

15:24 It'd also be nice if we can skip the style parameter altogether and define the style in the environment — especially if we use multiple steppers of the same style. We'll need to do some more work for that, so let's look at it in the next episode.

Resources

  • Sample Code

    Written in Swift 5.6

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

136 Episodes · 48h46min

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