Swift Talk # 321

Custom Components: Creating a Custom Stepper

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 start building a custom stepper component and make it adapt to SwiftUI's control size.

00:06 Last week, we looked at the built-in stepper control and saw how non-customizable it is, especially compared to buttons. Today, we want to create our own stepper that's a little more flexible.

My Stepper

00:40 We create a new view, MyStepper, and we let it take a binding to an integer for its state. To ease the transition of switching to this view, we replicate the API of SwiftUI's Stepper by including in and label properties, and by marking label as a view builder:

03:04 Our stepper looks a little different than the built-in one. Also, our buttons don't work, because List blocks tap gestures on buttons that aren't configured with some kind of style. We can fix this by giving our buttons the .plain style:

struct MyStepper<Label: View>: View {
    @Binding var value: Int
    var `in`: ClosedRange<Int> // todo
    @ViewBuilder var label: Label
    
    var body: some View {
        HStack {
            Button("-") { value -= 1 }
            label
            Button("+") { value += 1 }
        }
        .buttonStyle(.plain)
    }
}

03:55 Having the entire description of the stepper between the buttons looks a bit weird, so perhaps we should split the label and the value into separate views. This makes the actual stepper control part more compact:

struct MyStepper<Label: View>: View {
    @Binding var value: Int
    var `in`: ClosedRange<Int> // todo
    @ViewBuilder var label: Label

    var body: some View {
        HStack {
            label
            Spacer()
            Button("-") { value -= 1 }
            Text(value.formatted())
            Button("+") { value += 1 }
        }
        .buttonStyle(.plain)
    }
}

04:57 Before we start making our stepper more flexible and styleable, let's first tweak the default look. We add some padding around the buttons and the value:

struct MyStepper<Label: View>: View {
    @Binding var value: Int
    var `in`: ClosedRange<Int> // todo
    @ViewBuilder var label: Label

    var body: some View {
        HStack {
            label
            Spacer()
            HStack {
                Button("-") { value -= 1 }
                Text(value.formatted())
                Button("+") { value += 1 }
            }
            .padding(4)
            .background {
                Capsule()
                    .fill(.tint)
            }
        }
        .buttonStyle(.plain)
    }
}

06:16 We set a foreground color, and we increase the padding so that the stepper starts to look more in line with our checkout button:

struct MyStepper<Label: View>: View {
    @Binding var value: Int
    var `in`: ClosedRange<Int> // todo
    @ViewBuilder var label: Label

    var body: some View {
        HStack {
            label
            Spacer()
            HStack {
                Button("-") { value -= 1 }
                Text(value.formatted())
                Button("+") { value += 1 }
            }
            .padding(.vertical, 8)
            .padding(.horizontal, 16)
            .foregroundColor(.white)
            .background {
                Capsule()
                    .fill(.tint)
            }
        }
        .buttonStyle(.plain)
    }
}

Control Size

07:01 We already tried to change the control size of the built-in stepper last week, but that didn't work. However, we can make our stepper take the environment's control size into account by adjusting the font size and the padding.

07:37 In a computed property, we return a vertical padding for various control sizes. We then derive the horizontal padding from it:

struct MyStepper<Label: View>: View {
    @Binding var value: Int
    var `in`: ClosedRange<Int> // todo
    @ViewBuilder var label: Label
    
    @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 {
            label
            Spacer()
            HStack {
                Button("-") { value -= 1 }
                Text(value.formatted())
                Button("+") { value += 1 }
            }
            .padding(.vertical, padding)
            .padding(.horizontal, padding * 2)
            .foregroundColor(.white)
            .background {
                Capsule()
                    .fill(.tint)
            }
        }
        .buttonStyle(.plain)
    }
}

08:46 We create some previews to make it easy to compare steppers using different control sizes:

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)
        }
        .padding()
    }
}

Fonts

10:18 It makes sense to also adjust the font depending on the control size. But we should only do so if no custom font has been set. We add another computed property in which we switch over the control size, returning different fonts for the .mini and .small cases:

struct MyStepper<Label: View>: View {
    @Binding var value: Int
    var `in`: ClosedRange<Int> // todo
    @ViewBuilder var label: Label
    
    @Environment(\.controlSize)
    var controlSize
    
    var padding: Double {
        switch controlSize {
        case .mini: return 4
        case .small: return 6
        default: return 8
        }
    }

    var font: Font {
        switch controlSize {
        case .mini: return .footnote
        case .small: return .callout
        default: return .body
        }
    }
        
    var body: some View {
        HStack {
            label
            Spacer()
            HStack {
                Button("-") { value -= 1 }
                Text(value.formatted())
                Button("+") { value += 1 }
            }
            .padding(.vertical, padding)
            .padding(.horizontal, padding * 2)
            .foregroundColor(.white)
            .background {
                Capsule()
                    .fill(.tint)
            }
        }
        .font(font)
        .buttonStyle(.plain)
    }
}

12:22 Looking at the call site, we decide it makes more sense for the controlSize modifier to only affect the control part of the stepper. So we move the font modifier to just work on the buttons and on the value label:

struct MyStepper<Label: View>: View {
    // ...

    var font: Font {
        switch controlSize {
        case .mini: return .footnote
        case .small: return .callout
        default: return .body
        }
    }
        
    var body: some View {
        HStack {
            label
            Spacer()
            HStack {
                Button("-") { value -= 1 }
                Text(value.formatted())
                Button("+") { value += 1 }
            }
            .font(font)
            .padding(.vertical, padding)
            .padding(.horizontal, padding * 2)
            .foregroundColor(.white)
            .background {
                Capsule()
                    .fill(.tint)
            }
        }
        .buttonStyle(.plain)
    }
}

12:49 Changing the font in our view no longer works well: this updates only the stepper's label now. We should check if a font has been set in the environment, and if so, we should use that one instead of the font that's adjusted to the control size:

struct MyStepper<Label: View>: View {
    // ...

    @Environment(\.font) var font

    var adjustedFont: Font {
        if let font = font { return font }
        switch controlSize {
        case .mini: return .footnote
        case .small: return .callout
        default: return .body
        }
    }
        
    var body: some View {
        HStack {
            label
            Spacer()
            HStack {
                Button("-") { value -= 1 }
                Text(value.formatted())
                Button("+") { value += 1 }
            }
            .font(adjustedFont)
            .padding(.vertical, padding)
            .padding(.horizontal, padding * 2)
            .foregroundColor(.white)
            .background {
                Capsule()
                    .fill(.tint)
            }
        }
        .buttonStyle(.plain)
    }
}

14:14 The stepper control now also reacts when we set a font in our view:

15:45 One last tweak — rather than reading the font from the environment and adjusting it in our computations, we can also use the transformEnvironment modifier to directly overwrite the environment font with another value:

struct MyStepper<Label: View>: View {
    @Binding var value: Int
    var `in`: ClosedRange<Int> // todo
    @ViewBuilder var label: Label
    
    @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 {
            label
            Spacer()
            HStack {
                Button("-") { value -= 1 }
                Text(value.formatted())
                Button("+") { value += 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)
    }
}

16:56 It may seem easy to create a stepper — the first part took us a few minutes — but getting all the details right is hard work, and we've only just begun. The blue capsule we currently see works fine as a default style, but it's very specific, and it'd be nice if we could allow completely different looks. Let's look at that in the next episode.

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