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 build a picker with an animated underline using alignment and the matched geometry effect.

00:06 Today, we want to look at a few different ways to implement a custom picker animation. Our picker will draw a line under the currently selected item, and this line will move when we select another item. There are different ways this can be built in SwiftUI, each with their own advantages, and we'll go through all of them.

Setting Up

00:26 We start by creating a Picker view and defining an Item type. Since we want to loop over items, we make them Identifiable. In the picker, we initialize an array of items by mapping over the strings we want as the items' titles:

struct Item: Identifiable {
    var id = UUID()
    var title: String
}

struct Picker: View {
    var items = ["Inbox", "Sent", "Archive"].map { Item(title: $0) }
    
    var body: some View {
        
    }
}

struct ContentView: View {
    var body: some View {
        VStack {
            Picker()
            Spacer()
        }
        .padding()
    }
}

01:25 In the body, we use an HStack to display a row of Text views describing the items:

struct Picker: View {
    var items = ["Inbox", "Sent", "Archive"].map { Item(title: $0) }

    var body: some View {
        HStack {
            ForEach(items) { item in
                Text(item.title)
            }
        }
    }
}

01:39 We add some padding to the bottom of each item, and then we add a bottom-aligned overlay. Inside the overlay, we draw a line using a color shape with a height of 1 point:

struct Picker: View {
    var items = ["Inbox", "Sent", "Archive"].map { Item(title: $0) }
    
    var body: some View {
        HStack {
            ForEach(items) { item in
                Text(item.title)
                    .padding(.bottom, 4)
                    .overlay(alignment: .bottom) {
                        Color.accentColor
                            .frame(height: 1)
                    }
            }
        }
    }
}

02:17 To enable selection of an item, we add a state property in which we can store an item's identifier. We make this property optional, because we've defined the items in the view itself, which makes it hard to define an initial value. In the body, we create a local variable for the currently selected item, and if selection is nil, we use the first item in the array as a fallback, just so that we don't have to deal with an optional inside the rest of the view:

struct Picker: View {
    @State private var selection: UUID?
    var items = ["Inbox", "Sent", "Archive"].map { Item(title: $0) }
    
    var body: some View {
        let selectedItem = selection ?? items[0].id
        HStack {
            // ...
        }
    }
}

03:25 For each item, we check if it's selected before we draw the line in the overlay:

struct Picker: View {
    @State private var selection: UUID?
    var items = ["Inbox", "Sent", "Archive"].map { Item(title: $0) }
    
    var body: some View {
        let selectedItem = selection ?? items[0].id
        HStack {
            ForEach(items) { item in
                Text(item.title)
                    .padding(.bottom, 4)
                    .overlay(alignment: .bottom) {
                        if item.id == selectedItem {
                            Color.accentColor
                                .frame(height: 1)
                        }
                    }
            }
        }
    }
}

03:42 To change the selection, we replace the Text views with Buttons. We also set the button style to .plain to prevent the items from turning blue:

struct Picker: View {
    @State private var selection: UUID?
    var items = ["Inbox", "Sent", "Archive"].map { Item(title: $0) }
    
    var body: some View {
        let selectedItem = selection ?? items[0].id
        HStack {
            ForEach(items) { item in
                Button(item.title) {
                    selection = item.id
                }
                .padding(.bottom, 4)
                .overlay(alignment: .bottom) {
                    if item.id == selectedItem {
                        Color.accentColor
                            .frame(height: 1)
                    }
                }
            }
        }
        .buttonStyle(.plain)
    }
}

Animation

04:23 Now when we tap the items, we see the underline appearing under the selected item. If we add an .animation to the view, the lines fade in and out as they appear and disappear:

struct Picker: View {
    @State private var selection: UUID?
    var items = ["Inbox", "Sent", "Archive"].map { Item(title: $0) }
    
    var body: some View {
        let selectedItem = selection ?? items[0].id
        HStack {
            ForEach(items) { item in
                Button(item.title) {
                    selection = item.id
                }
                .padding(.bottom, 4)
                .overlay(alignment: .bottom) {
                    if item.id == selectedItem {
                        Color.accentColor
                            .frame(height: 1)
                    }
                }
                .animation(.default, value: selectedItem)
            }
        }
        .buttonStyle(.plain)
    }
}

The lines fade because each of these lines is a separate view — if we tap "Sent," the line below "Inbox" is faded out, and another one fades in.

05:00 We can change this animation by choosing a different transition than the default .opacity. For example, we could go for the .scale transition:

struct Picker: View {
    @State private var selection: UUID?
    var items = ["Inbox", "Sent", "Archive"].map { Item(title: $0) }
    
    var body: some View {
        let selectedItem = selection ?? items[0].id
        HStack {
            ForEach(items) { item in
                Button(item.title) {
                    selection = item.id
                }
                .padding(.bottom, 4)
                .overlay(alignment: .bottom) {
                    if item.id == selectedItem {
                        Color.accentColor
                            .frame(height: 1)
                            .transition(.scale)
                    }
                }
                .animation(.default, value: selectedItem)
            }
        }
        .buttonStyle(.plain)
    }
}

05:12 But what we really want is for the line to move from left to middle to right when we change the selection. And that's not really possible as long as we have separate lines for the items. We somehow need to use a single line and then move it around.

Custom Alignment Guide

05:31 One way to achieve this is by using a custom horizontal alignment guide. We create a custom alignment guide by conforming a type to AlignmentID. For the default value, we return the value for a .center alignment. Then, in an extension of HorizontalAlignment, we write a helper to construct our custom alignment:

struct SelectionID: AlignmentID {
    static func defaultValue(in context: ViewDimensions) -> CGFloat {
        context[HorizontalAlignment.center]
    }
}

extension HorizontalAlignment {
    static let selection = HorizontalAlignment(SelectionID.self)
}

06:16 By defining the .selection alignment guide for the selected item in the HStack and using the same alignment for the underline view, we can align the underline to the selected item.

06:43 We wrap the stack of items in a VStack, and we place a single line in the same VStack:

struct Picker: View {
    @State private var selection: UUID?
    var items = ["Inbox", "Sent", "Archive"].map { Item(title: $0) }
    
    var body: some View {
        let selectedItem = selection ?? items[0].id
        VStack(spacing: 4) {
            HStack {
                ForEach(items) { item in
                    Button(item.title) {
                        selection = item.id
                    }
                }
            }
            .buttonStyle(.plain)
            
            Color.accentColor
                .frame(width: 40, height: 1)
        }
        .animation(.default, value: selectedItem)
    }
}

07:24 We change the alignment of VStack to our custom .selection guide. For each picker item, we use alignmentGuide to propagate the center point up. If the item is the selected item, we propagate a value for the .selection guide. Otherwise, we have to choose some other alignment guide, and it doesn't really matter which one:

struct Picker: View {
    @State private var selection: UUID?
    var items = ["Inbox", "Sent", "Archive"].map { Item(title: $0) }
    
    var body: some View {
        let selectedItem = selection ?? items[0].id
        VStack(alignment: .selection, spacing: 4) {
            HStack {
                ForEach(items) { item in
                    Button(item.title) {
                        selection = item.id
                    }
                    .alignmentGuide(selectedItem == item.id ? .selection : .listRowSeparatorLeading, computeValue: { dimension in
                        dimension[HorizontalAlignment.center]
                    })
                }
            }
            .buttonStyle(.plain)
            
            Color.accentColor
                .frame(width: 40, height: 1)
        }
        .animation(.default, value: selectedItem)
    }
}

08:46 Now, only the center of the selected item's button is propagated up via the HStack, and we can see that the line is aligned to the selected item. Coincidentally, the line matches the width of the first item, but the width is hardcoded, so if we change the selection to the second or third item, we see that the line no longer matches up.

09:08 But it animates perfectly now — the line moves to the selected item. If the design we're implementing lets us hardcode a width, then this is all we need. We could also align the line in different ways to the selected item — for example, we could align the leading edges:

struct SelectionID: AlignmentID {
    static func defaultValue(in context: ViewDimensions) -> CGFloat {
        context[HorizontalAlignment.leading]
    }
}
Button(item.title) {
    selection = item.id
}
.alignmentGuide(selectedItem == item.id ? .selection : .listRowSeparatorLeading, computeValue: { dimension in
    dimension[HorizontalAlignment.leading]
})

Matched Geometry Effect

10:03 If we want to match the line's width to the selected item, we can use a matched geometry effect instead of a custom alignment guide.

10:36 We remove the alignment code, and we place the line shape in an overlay added to the stack of items:

struct Picker: View {
    @State private var selection: UUID?
    var items = ["Inbox", "Sent", "Archive"].map { Item(title: $0) }

    var body: some View {
        let selectedItem = selection ?? items[0].id
        HStack {
            ForEach(items) { item in
                Button(item.title) {
                    selection = item.id
                }
            }
        }
        .padding(.bottom, 4)
        .overlay {
            Color.accentColor
                .frame(height: 1)
        }
        .buttonStyle(.plain)
        .animation(.default, value: selectedItem)
    }
}

12:01 By putting a matched geometry effect on the items and the line, we can make the frame of the line match the frame of the currently selected item.

12:22 The matched geometry effect needs a namespace, so we define it in our view using the @Namespace property wrapper. Then we call .matchedGeometryEffect on each of the item buttons, passing in the item identifier as the ID for the effect:

struct Picker: View {
    @State private var selection: UUID?
    var items = ["Inbox", "Sent", "Archive"].map { Item(title: $0) }
    @Namespace private var namespace

    var body: some View {
        let selectedItem = selection ?? items[0].id
        HStack {
            ForEach(items) { item in
                Button(item.title) {
                    selection = item.id
                }
                .padding(.bottom, 4)
                .matchedGeometryEffect(id: item.id, in: namespace)
            }
        }
        // ...
    }
}

13:20 We also add a matched geometry effect to the line view, this time using the selected item's ID and the same namespace. We specify that this instance of the matched geometry effect isn't the source view, so that the line view will adopt the geometry from the selected item button:

struct Picker: View {
    @State private var selection: UUID?
    var items = ["Inbox", "Sent", "Archive"].map { Item(title: $0) }
    @Namespace private var namespace

    var body: some View {
        let selectedItem = selection ?? items[0].id
        HStack {
            ForEach(items) { item in
                Button(item.title) {
                    selection = item.id
                }
                .padding(.bottom, 4)
                .matchedGeometryEffect(id: item.id, in: namespace)
            }
        }
        .padding(.bottom, 4)
        .overlay(alignment: .bottom) {
            Color.accentColor
                .frame(height: 1)
                .matchedGeometryEffect(id: selectedItem, in: namespace, isSource: false)
        }
        .buttonStyle(.plain)
        .animation(.default, value: selectedItem)
    }
}

13:47 The line now moves to the selected item, and it also changes its width to match the item. To make the padding work, we have to move it to the item button, so that it becomes part of the view geometry that gets propagated up:

struct Picker: View {
    @State private var selection: UUID?
    var items = ["Inbox", "Sent", "Archive"].map { Item(title: $0) }
    @Namespace private var namespace

    var body: some View {
        let selectedItem = selection ?? items[0].id
        HStack {
            ForEach(items) { item in
                Button(item.title) {
                    selection = item.id
                }
                .padding(.bottom, 4)
                .matchedGeometryEffect(id: item.id, in: namespace)
            }
        }
        .overlay {
            Color.accentColor
                .frame(height: 1)
                .matchedGeometryEffect(id: selectedItem, in: namespace, isSource: false)
        }
        .buttonStyle(.plain)
        .animation(.default, value: selectedItem)
    }
}

14:16 In our understanding, the first time this view renders, SwiftUI doesn't yet know about the geometry of any views. After rendering the buttons with the matched geometry effects, their frames are propagated up, probably as anchors. The body is then rerendered, hitting the matched geometry effect — whose isSource parameter is set to false — in the overlay. There, it applies the same geometry by proposing the same size and position the selected button has.

A Closer Look at Alignment

15:02 What's weird is that the bottom alignment still seems to work. Upon closer inspection — we can change the alignment to .top — it becomes clear that it actually doesn't work at all: the line stays below the button. The matched geometry effect can't do its work because we're applying it to a view with a fixed height of one point.

15:40 If we wrap the view in another, flexible frame and then apply the effect, we immediately see that the alignment works. We can also see that the frame matches the button when we add a red border:

struct Picker: View {
    @State private var selection: UUID?
    var items = ["Inbox", "Sent", "Archive"].map { Item(title: $0) }
    @Namespace private var namespace

    var body: some View {
        let selectedItem = selection ?? items[0].id
        HStack {
            ForEach(items) { item in
                Button(item.title) {
                    selection = item.id
                }
                .padding(.bottom, 4)
                .matchedGeometryEffect(id: item.id, in: namespace)
            }
        }
        .overlay {
            Color.accentColor
                .frame(height: 1)
                .frame(maxHeight: .infinity, alignment: .bottom)
                .border(.red)
                .matchedGeometryEffect(id: selectedItem, in: namespace, isSource: false)
        }
        .buttonStyle(.plain)
        .animation(.default, value: selectedItem)
    }
}

16:25 It makes sense that the view matching another view's geometry should have at least the same flexibility as the source view. Otherwise, unexpected effects can occur.

16:33 In conclusion, the picker animation now works. Using a matched geometry effect is probably the easiest solution. Next time, we'll look into all the preference-based versions of making this work, and how they'll give us a bit more control over the views.

Resources

  • Sample Code

    Written in Swift 5.9

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

144 Episodes · 51h10min

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