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 use matched geometry effect to revisit an old problem and then give the LLM a shot at solving it.

00:06 In the previous episodes, we spent time recreating a very simple agent using OpenAI's API combined with local tools to read files and run scripts. The original plan was to explore working with agents further — not necessarily our small custom agent, but agents in general. But it hasn't worked out yet. It's still quite hard to predict what they'll do.

01:01 We tried to make the agent recreate the keyframe animation work we did over six episodes some time ago, just to see how quickly we could get it to reach the same point. It quickly became too messy to continue, so we'll have come back to that later and try another task in the meantime.

Drawing Badges on Top of Icons

01:32 So today we'll return to an older problem and try a potential new solution. The problem is drawing badges on top of views. Let's say we want to add an alert badge on one of these icons, created with the asIcon helper we extracted earlier:

struct ContentView: View {
    var body: some View {
        HStack {
            Image(systemName: "globe")
                .asIcon(color: .blue)
                .badge("10")
            Image(systemName: "phone")
                .asIcon(color: .green)
            Image(systemName: "message")
                .asIcon(color: .green)
        }
    }
}

01:49 We create a badge modifier that takes a string. In it, we create an overlay to draw the text in the top-right corner. We give the text some padding, set a minimum width and height of 24 points, apply a red capsule background, and call fixedSize so that the badge draws the text view at its preferred width and height. Finally, we set the foreground style to white and we add a gradient to the background:

extension View {
    func badge(_ text: String) -> some View {
        overlay(alignment: .topTrailing) {
            Text(text)
                .foregroundStyle(.white)
                .padding(8)
                .frame(minWidth: 24, minHeight: 24)
                .background(.red.gradient, in: .capsule)
                .fixedSize()
        }
    }
}

02:54 For positioning, we override the alignment guides. For the vertical alignment, we align the badge's center to the icon's top. We do the same for the horizontal alignment, aligning the badge's center to the icon's trailing edge:

extension View {
    func badge(_ text: String) -> some View {
        overlay(alignment: .topTrailing) {
            Text(text)
                .foregroundStyle(.white)
                .padding(8)
                .frame(minWidth: 24, minHeight: 24)
                .background(.red.gradient, in: .capsule)
                .fixedSize()
                .alignmentGuide(.top) {
                    $0[VerticalAlignment.center]
                }
                .alignmentGuide(.trailing) {
                    $0[HorizontalAlignment.center]
                }
        }
    }
}

03:17 This positions the badge exactly where we want it. However, the next icon draws over the top part of the badge:

03:41 Setting a zIndex so the first icon renders above the second can fix this specific case:

struct ContentView: View {
    var body: some View {
        HStack {
            Image(systemName: "globe")
                .asIcon(color: .blue)
                .badge("10")
                .zIndex(10)
            Image(systemName: "phone")
                .asIcon(color: .green)
            Image(systemName: "message")
                .asIcon(color: .green)
        }
    }
}

03:49 But this isn't a general solution, because what if the second icon also gets a badge? A hardcoded zIndex won't reliably solve the layering. The proper approach is to render all badges above the entire HStack. Previously, we propagated badge anchors upward and resolved them with a GeometryReader outside the stack. Today, we'll try something similar with a twist: instead of manually propagating anchors, we'll use matchedGeometryEffect to match positions and see if that approach works.

Extracting a View Modifier

04:50 As a first step, we package up the badge styling into a view modifier:

struct Badge: ViewModifier {
    func body(content: Content) -> some View {
        content
            .foregroundStyle(.white)
            .padding(8)
            .frame(minWidth: 24, minHeight: 24)
            .background(.red.gradient, in: .capsule)
    }
}

extension View {
    func badge(_ text: String) -> some View {
        overlay(alignment: .topTrailing) {
            Text(text)
                .modifier(Badge())
                .fixedSize()
                .alignmentGuide(.top) {
                    $0[VerticalAlignment.center]
                }
                .alignmentGuide(.trailing) {
                    $0[HorizontalAlignment.center]
                }
        }
    }
}

06:15 We commit this into version control as a clean starting point we can come back to later.

06:27 Then, we add an overlay on the HStack of icons. As a prototype, we add a Text("100") with the Badge modifier and a matchedGeometryEffect. We create a namespace locally and use the same ID and namespace on the source and destination views. The overlay instance of the matched geometry effect is set to not be the source view:

struct ContentView: View {
    @Namespace var ns

    var body: some View {
        HStack {
            Image(systemName: "globe")
                .asIcon(color: .blue)
                .matchedGeometryEffect(id: "id", in: ns)
            Image(systemName: "phone")
                .asIcon(color: .green)
            Image(systemName: "message")
                .asIcon(color: .green)
        }
        .overlay {
            Text("100")
                .modifier(Badge())
                .matchedGeometryEffect(id: "id", in: ns, isSource: false)
        }
    }
}

07:32 The alignment isn't quite right yet, but we can fix it by adjusting the anchor. We specify .topTrailing for the source and .center for the matched geometry effect in the overlay so the badge's center aligns with the propagated position. We also specify that the effect should only apply the propagated position, and ignore the size property:

struct ContentView: View {
    @Namespace var ns

    var body: some View {
        HStack {
            Image(systemName: "globe")
                .asIcon(color: .blue)
                .matchedGeometryEffect(id: "id", in: ns, anchor: .topTrailing)
            Image(systemName: "phone")
                .asIcon(color: .green)
            Image(systemName: "message")
                .asIcon(color: .green)
        }
        .overlay {
            Text("100")
                .modifier(Badge())
                .matchedGeometryEffect(id: "id", in: ns, properties: .position, anchor: .center, isSource: false)
        }
    }
}

08:27 With that, the positioning looks correct. Now we need to turn this manual prototype into proper helpers — one for applying a badge to a view and another for handling the overlay logic at the container level.

Using Preferences and Namespaces

08:50 We could share a manually created namespace like we're already doing, or we could generate the namespace inside the badge helper and propagate it up to the overlay using a preference. The second option is cleaner because we don't have to manage namespaces ourselves.

09:12 We define a BadgePreference key with a default value of an array of Payloads:

struct BadgePreference: PreferenceKey {
    static var defaultValue: [Payload] = []
    static func reduce(value: inout [Payload], nextValue: () -> [Payload]) {
        value.append(contentsOf: nextValue())
    }
}

09:31 The Payload struct should be Hashable. For now, it contains the badge text and a namespace. The namespace also lets us uniquely identify each badge when iterating:

struct Payload: Hashable, Identifiable {
    var text: String
    var namespace: Namespace.ID

    var id: Namespace.ID { namespace }
}

10:05 We then implement a BadgeHelper view modifier. This modifier generates a namespace internally and applies matchedGeometryEffect to the content using that namespace:

extension View {
    func badge(_ text: String) -> some View {
        modifier(BadgeHelper(text: text))
    }

    func badgeOld(_ text: String) -> some View {
        overlay(alignment: .topTrailing) {
            Text(text)
                .modifier(Badge())
                .fixedSize()
                .alignmentGuide(.top) {
                    $0[VerticalAlignment.center]
                }
                .alignmentGuide(.trailing) {
                    $0[HorizontalAlignment.center]
                }
        }
    }
}

10:33 Inside the modifier, we attach the matchedGeometryEffect to the content and emit a preference value containing the badge text and namespace. Since each badge has its own namespace, we can reuse the same ID across them:

struct BadgeHelper: ViewModifier {
    @Namespace private var ns
    var text: String
    func body(content: Content) -> some View {
        content
            .matchedGeometryEffect(id: "id", in: ns, anchor: .topTrailing)
            .preference(key: BadgePreference.self, value: [
                .init(text: text, namespace: ns)
            ])
    }
}

11:33 On the container side, we use overlayPreferenceValue with the BadgePreference key. We iterate over all payloads and draw each badge in the overlay. For each one, we apply the Badge modifier for visual styling and we attach a matchedGeometryEffect:

struct ContentView: View {
    var body: some View {
        HStack {
            Image(systemName: "globe")
                .asIcon(color: .blue)
                .badge("100")
            Image(systemName: "phone")
                .asIcon(color: .green)
            Image(systemName: "message")
                .asIcon(color: .green)
        }
        .overlayPreferenceValue(BadgePreference.self) { badges in
            ForEach(badges) { badge in
                Text(badge.text)
                    .modifier(Badge())
                    .matchedGeometryEffect(id: "id", in: badge.namespace, properties: .position, anchor: .center, isSource: false)
            }
        }
    }
}

13:00 This overlay logic can easily be extracted into a View extension, something like applyBadges. We could even enhance it further by using an environment value to signal to badges that they should propagate their data up instead of rendering themselves inline.

13:30 Overall, this solution doesn't necessarily reduce the amount of code compared to the anchor-based approach. We've essentially replaced geometry anchors with namespaces. Usually, the advantage of matchedGeometryEffect is that it allows us to get rid of preferences, because it has a built-in database of geometry values, but we still need a preference in this case. Perhaps it's made our code more expressive.

Trying Codex

14:24 Since this required quite a bit of typing, we'll now try letting Codex in Xcode implement it. First, we commit our current state and return to a clean baseline.

15:04 As an experiment, we start with a very naive prompt:

The badge 10 is drawn behind the front icon. Please fix this.

15:32 As expected, it applies a quick fix by adding a zIndex. That's similar to our first manual attempt, but it places the zIndex inside the badge, which won't work at all.

16:04 We could refine the prompt slightly, but we'd probably be faster typing a zIndex ourselves. Instead, let's try to steer it toward a more general solution. At this point, we have a choice: iterate incrementally or provide a better prompt from scratch. In general, it seems more effective to provide a clearer prompt early on instead of incrementally correcting a wrong direction.

16:51 We remove the zIndex and provide more guidance:

It's drawn behind the front icon. We want to solve this problem by drawing the badges on top of the HStack.

17:32 This gives it a hint about where we're trying to go, but many details are still open. We let it run and see what it produces.

18:08 It generates a solution using anchors and a GeometryReader, similar to what we implemented in the older episode. It defines a badge anchor, sets a preference, and resolves it in an overlay.

18:27 With multiple badges, it still works. So it's a valid solution, just not the one we were aiming for. If we already have a specific implementation in mind, we need to be much more explicit.

19:05 We revert again and add to the previous prompt:

We want to use matched geometry effect on the view, matching just the position. The badge function should generate a namespace, and then propagate up the namespace and text using a preference. The namespace can also be used to make the value Identifiable. Then we overlay the badges by iterating over the preference value. Make sure to set the anchor correctly as well (top trailing and center).

20:24 There's some risk that it confuses different anchor concepts, but we let it try. Let's take a look at the code now generated. The new code adds two namespaces to the Payload, one for the matched geometry effect and one for the identifier. That's not necessary, but it can be easily fixed. Then, in the overlay, it hides the original badge by setting its opacity to zero and overlays a separate badge. That's close, but not quite what we want.

21:26 We refine the prompt further:

I want the matched geometry effect directly on the content, not using a hidden badge.

21:36 Now the result is much closer to our own implementation. It removes the hidden badge and applies matchedGeometryEffect at the correct place. The structure resembles what we wrote manually, and the preference-based overlay looks correct. The agent works much better when we can already tell it exactly what we want.

22:20 Sometimes we know the precise APIs and structure we want, and it's faster to describe them in a few sentences than to type everything out. Other times we're exploring and unsure, and in those cases it may help to keep the conversation more open and discuss the solution before coding.

23:01 In any case, the sunk cost problem feels real. If we start down a path and keep iterating without stepping back, things can easily keep getting worse. After making some interventions, the final result isn't bad. We'll keep experimenting and perhaps try another example like this in the next episode.

Resources

  • Sample Code

    Written in Swift 6.2

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

212 Episodes · 73h53min

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