I'm a beginner to SwiftUI and I'm experimenting with animations. The animation styling for my bottom tabs work as expected when I press on each icon as well as if I swipe on the screen to switch between the tabs. But when I try something similar for my top tabs, the slide animation for transitioning the selected background color doesn't work when I swipe between the nested screens, it only works when I press on the tab name (button).
I've found a post that's also having animation issues using GeometryReader but it doesn't seem to match my use case.
The only difference between my top and bottom tabs is that the top tabs use a GeometryReader, is this a common issues? Are there any workarounds for this problem? Any guidance on how to solve this is greatly appreciated.
BottomTabs.swift
import SwiftUI;
struct BottomTabs: View {
@Binding var tab: Int;
var body: some View {
HStack {
Button(action: {
withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
tab = 0;
}
}) {
VStack {
Image(systemName: "baseball.fill").resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundStyle(
LinearGradient(colors: [(tab == 0) ? .green : .white, (tab == 0) ? .purple : .white],
startPoint: .top, endPoint: .bottom)
).scaleEffect((tab == 0) ? 1.2 : 1)
.animation(.easeInOut(duration: 0.3), value: tab);
Circle()
.fill(.yellow)
.frame(width: (tab == 0) ? 5 : 0, height: (tab == 0) ? 5 : 0)
.padding(.top, (tab == 0) ? 2 : 0)
.opacity((tab == 0) ? 1 : 0)
.animation(.easeInOut(duration: 0.3), value: tab);
}
}.padding(.horizontal, 10)
Button(action: {
withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
tab = 1;
}
}) {
VStack {
Image(systemName: "heart.fill").resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundStyle(
LinearGradient(colors: [(tab == 1) ? .green : .white, (tab == 1) ? .purple : .white],
startPoint: .top, endPoint: .bottom)
)
.scaleEffect((tab == 1) ? 1.2 : 1)
.animation(.easeInOut(duration: 0.3), value: tab);
Circle().fill(.yellow)
.frame(width: (tab == 1) ? 5 : 0, height: (tab == 1) ? 5 : 0)
.opacity((tab == 1) ? 1 : 0)
.padding(.top, (tab == 1) ? 2 : 0)
.animation(.easeInOut(duration: 0.3), value: tab);
}
}.padding(.horizontal, 10)
Button(action: {
withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
tab = 2;
}
}) {
VStack {
Image(systemName: "basketball.fill").resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundStyle(
LinearGradient(colors: [(tab == 2) ? .green : .white, (tab == 2) ? .purple : .white],
startPoint: .top, endPoint: .bottom)
)
.scaleEffect((tab == 2) ? 1.2 : 1)
.animation(.easeInOut(duration: 0.3), value: tab);
Circle().fill(.yellow)
.frame(width: (tab == 2) ? 5 : 0, height: (tab == 2) ? 5 : 0)
.opacity((tab == 2) ? 1 : 0)
.padding(.top, (tab == 2) ? 2 : 0)
.animation(.easeInOut(duration: 0.3), value: tab);
}
}.padding(.horizontal, 10)
}.frame(height: 30)
.padding(.vertical, 20)
.padding(.horizontal, 30)
.background(
RoundedRectangle(cornerRadius: 20).fill(.black)
).padding(.bottom, 10);
}
}
ContentView.swift
import SwiftUI;
struct ContentView: View {
@State private var tab: Int = 0;
@State private var tag: Int = 0;
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $tab) {
NavigationStack {
ZStack(alignment: .top) {
TabView(selection: $tag) {
ScrollView(showsIndicators: false) {
LazyVStack {
ForEach(0..<500, id: \.self) { i in
Text("\(i + 1). TRENDING HOME SCREEN");
}
}
}.tag(0)
ScrollView(showsIndicators: false) {
LazyVStack {
ForEach(0..<500, id: \.self) { i in
Text("\(i + 1). FOLLOWING HOME SCREEN");
}
}
}.tag(1)
ScrollView(showsIndicators: false) {
LazyVStack {
ForEach(0..<500, id: \.self) { i in
Text("\(i + 1). EVENTS HOME SCREEN");
}
}
}.tag(2)
}.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.toolbar {
ToolbarItemGroup(placement: .navigationBarLeading) {
Button(action: {
}) {
Image(systemName: "basketball.fill")
.resizable()
.scaledToFit()
.frame(width: 50, height: 50)
.foregroundColor(.black);
}
.padding(.top, 50)
.padding(.leading, 20);
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button(action: {
}) {
Image(systemName: "baseball.fill")
.resizable()
.scaledToFit()
.frame(width: 50, height: 50)
.foregroundColor(.black);
}.padding(.top, 50)
.padding(.trailing, 20);
}
}
.toolbarBackground(.hidden, for: .navigationBar)
TopTabs(tab: $tag).padding(.top, 60)
}
.ignoresSafeArea()
}
.tag(0)
ScrollView(showsIndicators: false) {
LazyVStack {
ForEach(0..<1000, id: \.self) { i in
Text("\(i + 1). LIKES SCREEN");
}
}
}.tag(1)
ScrollView(showsIndicators: false) {
LazyVStack {
ForEach(0..<2000, id: \.self) { i in
Text("\(i + 1). SPORTS SCREEN");
}
}
}.tag(2)
}.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never));
BottomTabs(tab: $tab);
}
.ignoresSafeArea()
}
}
#Preview {
ContentView();
}
TopTabs.swift
import SwiftUI
struct TopTabs: View {
@Binding var tab: Int
@Namespace private var animation
var body: some View {
GeometryReader { geometry in
let containerWidth = geometry.size.width * 0.9
let tabWidth = containerWidth / 3
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 50)
.fill(Color.yellow)
.frame(width: tabWidth, height: 40)
.matchedGeometryEffect(id: "tabBackground", in: animation)
.offset(x: CGFloat(tab) * tabWidth)
HStack(spacing: 0) {
Button(action: {
withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
tab = 0
}
}) {
Text("Trending")
.font(Font.custom("Gilroy-Medium", size: 14))
.foregroundColor(tab == 0 ? .black : .white)
}
.frame(width: tabWidth, height: 40)
Button(action: {
withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
tab = 1
}
}) {
Text("Following")
.font(Font.custom("Gilroy-Medium", size: 14))
.foregroundColor(tab == 1 ? .black : .white)
}
.frame(width: tabWidth, height: 40)
Button(action: {
withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
tab = 2
}
}) {
Text("Events")
.font(Font.custom("Gilroy-Medium", size: 14))
.foregroundColor(tab == 2 ? .black : .white)
}
.frame(width: tabWidth, height: 40)
}
}
.frame(width: containerWidth, height: 40)
.background(
RoundedRectangle(cornerRadius: 50)
.fill(Color.gray)
)
.mask(RoundedRectangle(cornerRadius: 50))
.padding(.top, 20)
.frame(maxWidth: .infinity)
}
.transition(AnyTransition.opacity.animation(.easeInOut(duration: 0.2)))
}
}
To animate the change of tab when this is triggered by a swipe, you just need to add an .animation
modifier to the HStack
containing the tab buttons.
However, there is a fundamental issue with the code that also needs to be resolved. You currently have an outer TabView
which contains a NavigationStack
which contains an inner TabView
. A TabView
inside a NavigationStack
is not a configuration that Apple supports and when animation is added to the tab buttons, they do not work properly.
As far as I can tell, the only reason for the NavigationStack
is so that you can add a toolbar. So as an alternative approach, the buttons that you are adding as toolbar buttons can simply be added as another ZStack
layer instead. Then the NavigationStack
can be removed and everything works.
Other points and suggestions:
The tab buttons are being built using a native Button
. This is fine, but you need to be aware that the buttons will take on a different appearance when button shapes is turned on in the accessibility settings. Also, the styling of the buttons is being duplicated on each one. So if you want to use a native Button
(instead of Text
+ .onTapGesture
), I would suggest using a custom ButtonStyle
for applying the styling. This resolves the issue with button shapes too.
Duplication can be reduced by using a function to create a tab button. The same goes for the dummy tab content.
You were applying .matchedGeometryEffect
to the yellow background behind the tabs, but there was no other view to match it to. Using .matchedGeometryEffect
is actually a good way to move the background to the selected tab. By using it properly, there is no need to apply an x-offset to the background.
A simpler way to show a rectangle with fully rounded ends is to use Capsule
, instead of RoundedRectangle
.
You were asking whether the GeometryReader
could be a cause of issues. Using a GeometryReader
is fine when you need to measure the size of the space available and then use that size for constraining content that is nested inside the GeomtryReader
. The only thing you might want to bear in mind is that the content will be aligned with top-leading alignment. To change to center-alignment, apply maxWidth: .infinity
and/or maxHeight: .infinity
(you were doing this already).
You are applying .ignoresSafeArea()
to the TabView
. This is fine too, but it means that the buttons will be shown in the safe area. You were compensating for this by adding arbitrary top padding. It would be better to use a GeometryReader
to measure the size of the top inset and use this to determine the padding. So in fact, the GeometryReader
can be removed from TopTabs
and made the parent for the top-level container instead.
Empty areas can be made receptive to interaction by adding a .contentShape
.
In Swift, it is not conventional to use semi-colons at the end of code lines. In particular, Apple never uses semi-colons in their examples.
You are also using a few deprecated modifiers. So instead of .foregroundColor
, use foregroundStyle
. And instead of .cornerRadius
, use a clip shape, or just fill the background in
the required shape.
Here is an updated version of ContentView
and TopTabs
to show it all working. BottomTabs
is unchanged.
struct TopTabs: View {
@Binding var tab: Int
@Namespace private var animation
struct TabButtonStyle: ButtonStyle {
let tabIndex: Int
let selectedTab: Int
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(Font.custom("Gilroy-Medium", size: 14))
.foregroundStyle(tabIndex == selectedTab ? .black : .white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
}
private func tabButton(label: String, tabIndex: Int) -> some View {
Button(LocalizedStringKey(label)) {
withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
tab = tabIndex
}
}
.buttonStyle(TabButtonStyle(tabIndex: tabIndex, selectedTab: tab))
.matchedGeometryEffect(id: tabIndex, in: animation)
}
var body: some View {
HStack(spacing: 0) {
tabButton(label: "Trending", tabIndex: 0)
tabButton(label: "Following", tabIndex: 1)
tabButton(label: "Events", tabIndex: 2)
}
.background {
Capsule()
.fill(.yellow)
.matchedGeometryEffect(id: tab, in: animation, isSource: false)
}
.frame(height: 40)
.background(.gray, in: .capsule)
.animation(.spring(response: 0.4, dampingFraction: 0.6), value: tab)
.transition(.opacity.animation(.easeInOut(duration: 0.2)))
}
}
struct ContentView: View {
@State private var tab: Int = 0
@State private var tag: Int = 0
private func dummyContent(label: String, nLines: Int) -> some View {
ScrollView(showsIndicators: false) {
LazyVStack {
ForEach(0..<nLines, id: \.self) { i in
Text("\(i + 1). \(label)")
}
}
}
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
}
var body: some View {
GeometryReader { geometry in
let topInsets = geometry.safeAreaInsets.top
ZStack(alignment: .bottom) {
TabView(selection: $tab) {
ZStack(alignment: .top) {
TabView(selection: $tag) {
dummyContent(label: "TRENDING HOME SCREEN", nLines: 500)
.tag(0)
dummyContent(label: "FOLLOWING HOME SCREEN", nLines: 500)
.tag(1)
dummyContent(label: "EVENTS HOME SCREEN", nLines: 500)
.tag(2)
}
.tabViewStyle(.page(indexDisplayMode: .never))
HStack {
Button {} label: {
Image(systemName: "basketball.fill")
.resizable()
.scaledToFit()
.frame(width: 50, height: 50)
}
Spacer()
Button {} label: {
Image(systemName: "baseball.fill")
.resizable()
.scaledToFit()
.frame(width: 50, height: 50)
}
}
.foregroundStyle(.black)
.padding(.top, topInsets)
.padding(.horizontal, 20)
TopTabs(tab: $tag)
.frame(width: geometry.size.width * 0.9)
.padding(.top, topInsets + 60)
}
.ignoresSafeArea()
.tag(0)
dummyContent(label: "LIKES SCREEN", nLines: 1000)
.tag(1)
dummyContent(label: "SPORTS SCREEN", nLines: 2000)
.tag(2)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.ignoresSafeArea()
BottomTabs(tab: $tab)
}
}
}
}