SwiftUI Tab Bar
In the third of our Thinking in SwiftUI challenges, we asked you to build a tab bar component with an animated selection indicator.
Here's the interface we would expect:
struct ContentView: View {
var body: some View {
return TabBar(items: [
(Image(systemName: "tray"), Text("Inbox")),
(Image(systemName: "archivebox"), Text("Archive")),
(Image(systemName: "doc.text"), Text("Drafts"))
]).padding().border(Color.blue)
}
}
It should animate both the indicator's width and position:
We'll solve the challenge in two parts. First, we use anchors and preferences to collect the frames of each tab item. Second, we draw the selection indicator by resolving the anchor inside a geometry reader.
An anchor is an opaque layout value (for example, a CGPoint
or CGRect
) combined with a particular view. You cannot access the underlying values directly: to get access to the underlying value (e.g. a CGRect
) you resolve the anchor using a geometry proxy. This makes for safer layout code. You can safely pass around these anchor values without having to manually convert between coordinate spaces.
As a first step, we'll define our tab bar:
struct TabBar: View {
var items: [(Image, Text)]
@State var selectedIndex: Int = 0
// ...
}
We'll wrap each item in the tab bar using a Button
, and set the selected index when the user taps the button. We'll also set a preference value to communicate the button's frame up the view hierarchy, but only if the item is currently selected. We set the item's color to be blue if selected, and the primary color otherwise. We'll add all of this code as a method on TabBar
:
private func item(at index: Int) -> some View {
Button(action: {
withAnimation(.default) {
self.selectedIndex = index
}
}) {
VStack {
items[index].0
items[index].1
}
}
.anchorPreference(key: AnchorKey.self, value: .bounds, transform: { self.selectedIndex == index ? $0 : nil})
.accentColor(index == selectedIndex ? .blue : .primary)
}
To create the indicator, we'll create a method that takes an optional CGRect
anchor: if the anchor is not nil
, it'll resolve it via a geometry proxy and draw a rectangle. To position the rectangle at the bottom of the proposed frame, we'll use a flexible frame with a maximum width and height set to .infinity
. The actual size of that frame will be exactly the proposed size, and we can use the frame's alignment parameter to align the indicator with the bottom-leading corner.
From there, we use offset
and frame
to set the position and size of the indicator:
private func indicator(_ bounds: Anchor<CGRect>?) -> some View {
GeometryReader { proxy in
if bounds != nil {
Rectangle()
.fill(Color.blue)
.frame(width: proxy[bounds!].width, height: 1)
.offset(x: proxy[bounds!].minX, y: 3)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
}
}
}
To tie everything together, we'll implement the view's body
property: we loop over all the indices, draw the item, and use an overlayPreference
to pass the anchor preference to the indicator. Unfortunately, the overlayPreferenceValue
modifier does not have an alignment
property; if it did, we could dispose the flexible frame in the snippet above:
var body: some View {
HStack {
ForEach(items.indices, id: \.self) {
self.item(at: $0)
}
}.overlayPreferenceValue(AnchorKey.self, {
self.indicator($0)
})
}
The only missing piece is the preference key itself. It simply takes the first non-nil
value it finds (since there will always be, at most, one tab bar item selected, this is perfectly safe):
struct AnchorKey: PreferenceKey {
static func reduce(value: inout Anchor<CGRect>?, nextValue: () -> Anchor<CGRect>?) {
value = value ?? nextValue()
}
}
For an alternative approach, we also liked this generic version by Lukasz Klekot.
Our new book, Thinking in SwiftUI, discusses the layout system in more detail in chapter four and five. At the time of writing, the first four chapters are already available for early access readers, with a new chapter and Q&A video released every week until the book is complete.
You can join the early access here.