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’re joined by Kasper to take a look at building custom components, beginning with exploring SwiftUI's built-in component styling.

00:06 We're joined by Kasper from Moving Parts, a company that builds high-quality components for SwiftUI that are easily styleable, accessible, and localized.

00:49 In this series, we want to focus on building a stepper control that's accessible and can be styled just like built-in controls. The goal is to make the stepper's API feel like it belongs in SwiftUI, so that we can use it like any view we're already used to.

Shopping Cart

01:39 To get started, we set up the context in which we'll use the control — a simple shopping cart screen. We already have some sample data in our project, which we can use to populate a List of cart items, which is wrapped in a NavigationView:

struct ContentView: View {
    @State var items = CartItem.sample
   
    var body: some View {
        List($items) { $item in
            HStack {
                // ...
            }
        }
        .listStyle(.plain)
        .navigationTitle("Cart")
    }
}

03:28 For each item, we display a placeholder image and the item's name and price. We use the new formatted method available on numeral types to format the price as currency:

struct ContentView: View {
    @State var items = CartItem.sample
   
    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))
                Text(item.name)
                Spacer()
                Text(item.price.formatted(.currency(code: "EUR")))
            }
        }
        .listStyle(.plain)
        .navigationTitle("Cart")
    }
}

05:33 We inset the safe area bottom so we can show a summary of the order. We give the summary a background material, which makes the contents of the cart shine through if the list of items is long enough:

struct ContentView: View {
    @State var items = CartItem.sample
   
    var shipping: Decimal = 5
    
    var body: some View {
        List($items) { $item in
            // ...
        }
        .safeAreaInset(edge: .bottom, spacing: 0, content: {
            VStack {
                HStack {
                    Text("Shipping")
                    Spacer()
                    Text(shipping.formatted(.currency(code: "EUR")))
                }
                HStack {
                    Text("Total")
                    Spacer()
                    Text(total.formatted(.currency(code: "EUR")))
                }
            }
            .padding()
            .background(.regularMaterial)
        })
        .listStyle(.plain)
        .navigationTitle("Cart")
    }
}

08:12 We calculate the total costs by adding the item's prices and the shipping costs:

var total: Decimal {
    items.map(\.price).reduce(0, +) + shipping
}

09:14 Finally, we add a checkout button. The default button style doesn't give this button the emphasis it needs, so we choose .borderedProminent instead:

Button("Checkout") {

}
.buttonStyle(.borderedProminent)

Adding Steppers

10:16 Next, we want to add steppers so that we can adjust the quantity of each cart item. We move the name and price labels into a VStack, together with a stepper:

struct ContentView: View {
    @State var items = CartItem.sample
   
    var shipping: Decimal = 5
    
    var total: Decimal {
        items.map { $0.price * Decimal($0.quantity) }.reduce(0, +) + shipping
    }
    
    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")))
                    }
                    Stepper(value: $item.quantity, in: 0...99, label: { Text("Quantity \(item.quantity)") })
                }
            }
        }
        .safeAreaInset(edge: .bottom, spacing: 0, content: {
            // ...
        })
        .listStyle(.plain)
        .navigationTitle("Cart")
    }
}

11:56 When we change the quantity using the stepper, we notice that the price of the row doesn't update. We forgot to multiply the item's price by its quantity in our calculation of the total:

var total: Decimal {
    items.map { $0.price * Decimal($0.quantity) }.reduce(0, +) + shipping
}

Button Style

12:55 This is the basic skeleton we'll use to work on our stepper. But let's look at how we can further style the checkout button. We've already applied the built-in prominent, bordered style. If we also wanted to make the button span the entire width of the view, we could open the button's label view and adjust its maximum frame width:

Button(action: {}, label: {
    Text("Checkout")
        .frame(maxWidth: .infinity)
})
.buttonStyle(.borderedProminent)

13:57 But it'd be kind of awkward if we had to write this for every button we want to style in this way; the code would stay much cleaner if we could keep using the initializer that takes the label as a simple string. So let's go back to that and work on a button style that makes the button stretch out to fill the available width.

15:04 We create a new button style by conforming to the ButtonStyle protocol. This requires us to implement makeBody, which gets passed a configuration parameter containing elements we need to make the button, such as a label view:

struct FullWidthButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .frame(maxWidth: .infinity)
    }
}

16:19 Now we can replace .borderProminent with our own style:

Button("Checkout") { }
    .buttonStyle(FullWidthButtonStyle())

16:34 We finish the button style by placing a rounded rectangle in the background, setting the foreground color to white, and adding some padding to the label:

struct FullWidthButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .frame(maxWidth: .infinity)
            .foregroundColor(.white)
            .padding()
            .background {
                RoundedRectangle(cornerRadius: 8, style: .continuous)
                    .fill(.tint)
            }
    }
}

Styling Steppers

17:55 We find the ButtonStyle protocol interesting. It's very flexible, because we get a lot of information — the isPressed state, and the label view defined by the user of the API — and we get to use it in any way we need to create the button we want. Also, by defining the button style as its own entity, it becomes very easy to reuse the same style throughout our application.

18:24 Unfortunately, not all built-in controls provide this kind of API. We might want to change the style of our steppers — e.g. to make them a little less prominent — but the options are very limited.

19:16 We can apply some modifiers to try changing the tint color or the size, but neither has an effect on the built-in SwiftUI stepper:

Stepper(value: $item.quantity, in: 0...99, label: { Text("Quantity \(item.quantity)") })
    .tint(.green) // no effect
    .controlSize(.mini) // no effect

20:11 In the next few episodes, we'll look into how we can create our own stepper with an API that provides the same level of flexibility as ButtonStyle, and which also reacts to modifiers like controlSize.

Resources

  • Sample Code

    Written in Swift 5.6

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

84 Episodes · 30h14min

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