Swift Talk # 400

Positioning Badges (Part 1)

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 implement a badge view that scales with the content and position it using alignment.

00:06 Today, we'll build a badge that can be displayed on top of a view — for example, something like an alert badge. Doing this correctly turns out to be not so easy, because we want to build the badge in such a way that it always sits on top of other views. If we place a badge on an icon in a grid — like in the iOS Home Screen — we don't want the next icon to block the badge, and that'll be the tricky part. But first, we'll focus on drawing the badges themselves.

Icons

00:38 Let's build a view that sort of mimics the Home Screen grid. We place three images a row:

struct ContentView: View {
    var body: some View {
        HStack {
            Image(systemName: "phone")
            Image(systemName: "message")
            Image(systemName: "book")
        }
        .padding()
    }
}

01:05 In an extension of View, we write a helper that can turn the images into icons by applying some padding and drawing a background:

extension View {
    func asIcon(color: Color) -> some View {
        self
            .padding()
            .background(color)
    }
}

struct ContentView: View {
    var body: some View {
        HStack {
            Image(systemName: "phone")
                .asIcon(color: .green)
            Image(systemName: "message")
                .asIcon(color: .green)
                .badge(1000, alignment: .topTrailing)
            Image(systemName: "book")
                .asIcon(color: .orange)
        }
        .padding()
    }
}

01:48 A new API of the background modifier lets us specify a shape to fill. We could initialize our own RoundedRectangle shape for this, but we can also call a static helper like .rect(cornerRadius:):

extension View {
    func asIcon(color: Color) -> some View {
        self
            .padding()
            .background(color, in: .rect(cornerRadius: 8))
    }
}

02:08 To further style the icons, we set the foreground color to white, we choose the filled symbol variant, and we make them larger by changing the font:

extension View {
    func asIcon(color: Color) -> some View {
        self
            .font(.largeTitle)
            .symbolVariant(.fill)
            .foregroundStyle(.white)
            .padding()
            .background(color, in: .rect(cornerRadius: 8))
    }
}

02:28 We can now see that the icons have different sizes because the images from SF Symbols all have their own dimensions. To make the icons uniform in size, we can hardcode a frame at this point, just like the Home Screen does:

extension View {
    func asIcon(color: Color) -> some View {
        self
            .font(.largeTitle)
            .symbolVariant(.fill)
            .foregroundStyle(.white)
            .padding()
            .frame(width: 64, height: 64)
            .background(color, in: .rect(cornerRadius: 8))
    }
}

03:10 By tacking .gradient onto the color value, we get a subtle gradient that's a little lighter at the top of the icon:

extension View {
    func asIcon(color: Color) -> some View {
        self
            .font(.largeTitle)
            .symbolVariant(.fill)
            .foregroundStyle(.white)
            .padding()
            .frame(width: 64, height: 64)
            .background(color.gradient, in: .rect(cornerRadius: 8)
    }
}

03:26 As a finishing touch, we increase the corner radius, and we change the rounded rectangle's style to .continuous to make the corners slightly smoother:

extension View {
    func asIcon(color: Color) -> some View {
        self
            .font(.largeTitle)
            .symbolVariant(.fill)
            .foregroundStyle(.white)
            .padding()
            .frame(width: 64, height: 64)
            .background(color.gradient, in: .rect(cornerRadius: 16, style: .continuous))
    }
}

Badge

03:59 Now, let's try to render a badge on the middle icon. SwiftUI has a badge modifier, but that only affects tabs and list items, and it doesn't do anything if we call it here. We want to use a similar API, which lets us pass in a value and an alignment. We'll just use an integer as the value, but this could be extended to support strings or other views as well. The alignment parameter can have a default value to place a number badge at the top-right corner, but it would also allow us to move something like a delete button over to the top-left corner:

struct ContentView: View {
    var body: some View {
        HStack {
            Image(systemName: "phone")
                .asIcon(color: .green)
            Image(systemName: "message")
                .asIcon(color: .green)
                .badge(42, alignment: .topTrailing)
            Image(systemName: "book")
                .asIcon(color: .orange)
        }
        .padding()
    }
}

04:44 We start building the badge modifier by creating an overlay with the passed-in alignment and including a Badge view, which we'll write as a separate view:

extension View {
    func badge(_ value: Int, alignment: Alignment) -> some View {
        overlay(alignment: alignment) {
            Badge(value: value)
        }
    }
}

05:18 The Badge view shows the value in a text view with some padding and a red, capsule-shaped background:

struct Badge: View {
    var value: Int

    var body: some View {
        Text("\(value)")
            .foregroundStyle(.white)
            .padding(.horizontal, 8)
            .background(.red.gradient, in: .capsule)
    }
}

06:45 For smaller values, e.g. 1, it'd be good to make sure our badge is always at least as wide as it is high, so that it displays as a circle. For wider values, the badge can then grow wider, into a capsule shape. It'd be easy if we could set a width and height to display a circle:

struct Badge: View {
    var value: Int
    
    var body: some View {
        Text("\(value)")
            .foregroundStyle(.white)
            .padding(.horizontal, 8)
            .frame(width: 22, height: 22)
            .background(.red.gradient, in: .capsule)
    }
}

07:31 But this fixed-sized frame doesn't work if we want to display a larger number. If we'd pass 1000 to the badge modifier, it doesn't even show up, because of this frame. We might consider calling fixedSize on the text view, but that just makes the text draw outside the bounds of the frame. We can, however, call fixedSize on the badge view itself, so that the badge can size itself according to its contents:

extension View {
    func badge(_ value: Int, alignment: Alignment) -> some View {
        overlay(alignment: alignment) {
            Badge(value: value)
                .fixedSize()
        }
    }
}

08:11 To allow for wider values, we need to remove the hardcoded width from the frame. And to ensure that the badge is wide enough to display as a circle, we should add a minimum width that's equal to the height. We also keep the horizontal padding, so that the badge looks good if it contains a value that's wider than the circle:

struct Badge: View {
    var value: Int
    
    var body: some View {
        Text("\(value)")
            .foregroundStyle(.white)
            .padding(.horizontal, 8)
            .frame(minWidth: 22)
            .frame(height: 22)
            .background(.red.gradient, in: .capsule)
    }
}

08:56 This looks good, but the hardcoded dimension of 22 isn't ideal. This becomes a problem with Dynamic Type. If we change the Dynamic Type to a larger size in the preview, we'll see that 22 points is no longer correct; the badge grows to the width of the label, but it's still 22 points high, even though the text is taller than that. Luckily, there's an easy way to fix this. SwiftUI has a property wrapper, ScaledMetric, that can scale a value to match the current Dynamic Type setting:

struct Badge: View {
    var value: Int
    @ScaledMetric private var minWidth = 22
    
    var body: some View {
        Text("\(value)")
            .foregroundStyle(.white)
            .padding(.horizontal, 8)
            .frame(minWidth: minWidth)
            .frame(height: minWidth)
            .background(.red.gradient, in: .capsule)
    }
}

10:16 An important default about the different semantic font classes is that they don't scale the same way. To make sure the minWidth value is scaled together with the text view's contents, we should use the relativeTo parameter to specify the text style we want to scale with — i.e. .body — and apply the same text style to the text view:

struct Badge: View {
    var value: Int
    @ScaledMetric(relativeTo: .body) private var minWidth = 24
    
    var body: some View {
        Text("\(value)")
            .font(.body)
            .foregroundStyle(.white)
            .padding(.horizontal, 8)
            .frame(minWidth: minWidth)
            .frame(height: minWidth)
            .background(.red.gradient, in: .capsule)
    }
}

10:48 When choosing the base value of minWidth, we need to make sure that we test it with the default setting for Dynamic Type.

Positioning

11:10 The Badge view itself looks good now, so let's think about how we position it, relative to the view underneath. In the badge helper, we create an overlay with the given alignment — top-trailing, in this case. An overlay asks its primary subview — the icon — for its top alignment guide and its trailing alignment guide. Then it asks for the same values from the overlay view, and then it knows where to place the overlay view on top of the primary view. We can hook into this process to adjust the placement of our badge by basically modifying the alignment values of our overlay view.

11:51 The alignmentGuide modifier lets us return the view's value for a given alignment. Rather than specifying an alignment ourselves, we'll use the alignment parameter that gets passed into the badge helper. More specifically, we modify the vertical component of the given alignment to return the center of the badge, so that badge's center will be aligned to whatever corner is specified. In the closure we provide to the alignmentGuide modifier, we receive a ViewDimensions value that describes the view's width and height. We could return the view's vertical center as dimension.height / 2, but a more semantic way of describing this is by using a subscript on ViewDimensions that takes an alignment:

extension View {
    func badge(_ value: Int, alignment: Alignment) -> some View {
        overlay(alignment: alignment) {
            Badge(value: value)
                .alignmentGuide(alignment.vertical, computeValue: { dimension in
                    dimension[VerticalAlignment.center]
                })
                .fixedSize()
        }
    }
}

12:56 For some reason, there's no API to modify both components of an alignment at once, so we have to repeat this step for the overlay's horizontal alignment:

extension View {
    func badge(_ value: Int, alignment: Alignment) -> some View {
        overlay(alignment: alignment) {
            Badge(value: value)
                .alignmentGuide(alignment.vertical, computeValue: { dimension in
                    dimension[VerticalAlignment.center]
                })
                .alignmentGuide(alignment.horizontal, computeValue: { dimension in
                    dimension[HorizontalAlignment.center]
                })
                .fixedSize()
        }
    }
}

Layering Problem

13:19 We've arrived at the main challenge of this project: the badge being partially covered by the next icon. We can try to fix this by changing the icon’s z-index, but this is a half solution, because we don't know where a badge is going to be used. Something may still be on top of a badge, no matter how we layer the icon views. The only proper solution is to not render the badge together with the icon inside the HStack, but to render the badge on top of the HStack. Let's tackle that in the next episode.

Resources

  • Sample Code

    Written in Swift 5.9

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

140 Episodes · 49h57min

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