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
Button
s. 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.