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> @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> @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 }
}
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.