I am wanting to animate switching between tabs but the animation are very hard. I tried using matched geometry but the animation was wacky. Could be lack of understanding on matched geometry.
The expectation:
Tapping on a tab will show the image icon and the title of the tab behind a capsule. The untapped tab will not show the title or have the capsule behind it. Once a new tab is selected the capsule will navigate to the selected tab. and the title of the previously selected tab will hide.
import SwiftUI
struct Tabbar: View {
@Namespace private var animation
@State var selectedTab: RootTab = .home
var body: some View {
ZStack {
HStack {
ForEach(RootTab.allCases, id: \.self) { tab in
TabButton(tab: tab)
}
}
.padding(.horizontal, 30)
.padding(.vertical, 10)
.background(Color.blue.opacity(0.4))
.cornerRadius(30, corners: .allCorners)
}
.frame(maxWidth: .infinity)
.background(Color(uiColor: .systemBackground))
}
private func TabButton(tab: RootTab) -> some View {
HStack {
Image(systemName: tab.systemName)
.font(.title2)
.foregroundStyle(.white)
.bold()
if selectedTab == tab {
Text(tab.title)
.font(.callout)
.bold()
.foregroundColor(.white)
.animation(.default, value: selectedTab)
}
}
.onTapGesture {
withAnimation {
selectedTab = tab
}
}
.if(selectedTab == tab) { view in
view
.padding(.vertical, 8)
.padding(.horizontal, 8)
.background { Color.blue }
.cornerRadius(15, corners: .allCorners)
.animation(.default, value: selectedTab)
}
}
}
enum RootTab: String, CaseIterable {
case home
case profile
var systemName: String {
switch self {
case .home: return "house"
case .profile: return "person.crop.circle"
}
}
var title: String {
switch self {
case .home: return "Home"
case .profile: return "Profile"
}
}
}
extension View {
@ViewBuilder
func `if`(_ condition: @autoclosure () -> Bool, transform: (Self) -> some View) -> some View {
if condition() {
transform(self)
} else {
self
}
}
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
clipShape(RoundedCorner(radius: radius, corners: corners))
}
}
As you were suggesting, .matchedGeometryEffect
can be used to achieve this animation:
The moving background should be shown as background to the HStack
, before applying padding and a colored background. This way, it does not impact the size of the ZStack
. In fact, the ZStack
is not actually needed.
The position and size of the moving background is matched to the selected tab button using .matchedGeometryEffect
with isSource: false
. The id of the selected tab is used as the id to match to.
Since the size of the background marker is determined by the selected button, the padding should be applied to the buttons themselves.
The view extension .if
is not needed.
Other suggested changes:
The tab buttons are performing the change using withAnimation
, so there is no need for any .animation
modifiers.
The modifier .cornerRadius
is deprecated, use .clipShape
instead. An easy way to get a shape with fully rounded ends is to use a Capsule
.
The modifier .foregroundColor
is also deprecated, use .foregroundStyle
instead.
If you want to apply the system background as background color, there is no need to create a Color
from a UIColor
, just use ShapeStyle.background
instead.
struct Tabbar: View {
@Namespace private var animation
@State var selectedTab: RootTab = .home
var body: some View {
HStack {
ForEach(RootTab.allCases, id: \.self) { tab in
TabButton(tab: tab)
.padding(.vertical, 8)
.padding(.horizontal, 8)
.matchedGeometryEffect(id: tab, in: animation)
}
}
.background {
RoundedRectangle(cornerRadius: 15)
.fill(.blue)
.matchedGeometryEffect(id: selectedTab, in: animation, isSource: false)
}
.padding(.horizontal, 30)
.padding(.vertical, 10)
.background(.blue.opacity(0.4))
.clipShape(Capsule())
.frame(maxWidth: .infinity)
.background(.background)
}
private func TabButton(tab: RootTab) -> some View {
HStack {
Image(systemName: tab.systemName)
.font(.title2)
.foregroundStyle(.white)
.bold()
if selectedTab == tab {
Text(tab.title)
.font(.callout)
.bold()
.foregroundStyle(.white)
}
}
.onTapGesture {
withAnimation {
selectedTab = tab
}
}
}
}
You will notice that the width of the tab bar changes a little when the selection changes, because the two labels have different widths. This also causes the left-most icon to move a little.
To prevent this, the footprint for the largest label can be found by using a hidden ZStack
that contains all of the labels shown on top of each other. The visible label can then be shown as an overlay over the footprint:
// function TabButton
if selectedTab == tab {
ZStack {
ForEach(RootTab.allCases, id: \.self) { tab in
Text(tab.title)
}
}
.hidden()
.overlay(alignment: .leading) {
Text(tab.title)
}
.font(.callout)
.bold()
.foregroundStyle(.white)
}
If in fact you know which label is always the longest (in all languages) then you don't need the ZStack
, you could just use a hidden version of this wide label instead.