I wanted to create a simple custom segmented control component with animation similar to native component. I have seen lots of people using matchedGeometryEffect
for this, however when I implemented the component I noticed off behavior during animation: when my background rectangular slides to the right previously selected tab moves behind this view which creates unpleasant ripped motion instead of smooth animation. I suspect this problem is associated with the Z-Stack reordering of views, but I don't know how to fix it please help me.
Here is the code I'm using
import SwiftUI
struct TabSegmentedControlView: View {
@State private var currentTab = 0
var body: some View {
VStack {
TabBarView(currentTab: $currentTab)
}
}
}
struct TabBarView: View {
@Binding var currentTab: Int
@Namespace var namespace
var tabBarOptions: [String] = ["shield", "house", "hands.clap"]
var body: some View {
HStack(spacing: 0) {
ForEach(
Array(zip(self.tabBarOptions.indices,
self.tabBarOptions)),
id: \.0,
content: { id, name in
TabBarTabView(
currentTab: $currentTab,
namespace: namespace.self,
icon: name,
title: name,
tab: id
)
}
)
}
.padding(.all, 2)
.background(.gray.opacity(0.2))
.cornerRadius(16)
.padding(.horizontal, 16)
}
}
struct TabBarTabView: View {
@Binding var currentTab: Int
let namespace: Namespace.ID
let icon: String
let title: String
let tab: Int
var body: some View {
Button(action: {
self.currentTab = tab
}) {
ZStack {
if tab == currentTab {
Color.white
.frame(height: 60)
.frame(maxWidth: .infinity)
.cornerRadius(14)
.shadow(color: .black.opacity(0.04), radius: 0.5, x: 0, y: 3)
.shadow(color: .black.opacity(0.12), radius: 4, x: 0, y: 3)
.transition(.offset())
.matchedGeometryEffect(
id: "slidingRect",
in: namespace,
properties: .frame
)
}
VStack(spacing: 4) {
Image(systemName: icon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 24, height: 24)
Text(title)
}
.padding()
.frame(maxWidth: .infinity)
.font(Font.body.weight(.medium))
.foregroundColor(tab == currentTab ? .black : .gray)
.transition(.opacity)
.cornerRadius(14)
.frame(height: 60)
}
.animation(.easeInOut, value: self.currentTab)
}
}
}
#Preview {
TabSegmentedControlView()
}
preview: tap here
The way you curently have it, each button has its own white background. You are using .matchedGeometryEffect
to animate the change from one button background to another, but this is not working seamlessly.
I would suggest, a better way to implement the moving background is to have just one shape which moves between the buttons. The main changes for this are as follows:
Remove the backgrounds from the individual buttons and add the shape to the background of the HStack
instead.
The buttons should be used as the source for the .matchedGeometryEffect
, so each button should have a unique id.
The background should have isSource: false
and use the id of the selected tab. This way, the background is matched to the selected button.
Move the .animation
modifier from TabBarTabView
to the HStack
.
Other suggestions:
There is no need to apply any sizing to the background shape, because the size comes from the .matchedGeometryEffect
.
There is also no need for any .transition
modifiers, because no views are appearing or disappearing.
The modifier .foregroundColor
is deprecated, use .foregroundStyle
instead.
The modifier .cornerRadius
is also deprecated. You could use .clipShape
with a RoundedRectangle
instead, or just put a RoundedRectangle
in the background and fill it with the required color.
The modifier .scaledToFit()
works the same as .aspectRatio(contentMode: .fit)
, but is perhaps simpler.
There is no need to qualify variables with self
.
The array for the ForEach
could be built by applying .enumerated()
to the source array, instead of zipping the contents with the indices.
Use trailing closures where possible.
Here is the fully updated example:
struct TabBarView: View {
@Binding var currentTab: Int
@Namespace var namespace
var tabBarOptions: [String] = ["shield", "house", "hands.clap"]
var body: some View {
HStack(spacing: 0) {
ForEach(Array(tabBarOptions.enumerated()), id: \.offset) { id, name in
TabBarTabView(
currentTab: $currentTab,
namespace: namespace,
icon: name,
title: name,
tab: id
)
}
}
.background {
RoundedRectangle(cornerRadius: 14)
.fill(.white)
.shadow(color: .black.opacity(0.04), radius: 0.5, x: 0, y: 3)
.shadow(color: .black.opacity(0.12), radius: 4, x: 0, y: 3)
.matchedGeometryEffect(
id: currentTab,
in: namespace,
isSource: false
)
}
.padding(2)
.background {
RoundedRectangle(cornerRadius: 16)
.fill(.gray.opacity(0.2))
}
.padding(.horizontal, 16)
.animation(.easeInOut, value: currentTab)
}
}
struct TabBarTabView: View {
@Binding var currentTab: Int
let namespace: Namespace.ID
let icon: String
let title: String
let tab: Int
var body: some View {
Button {
currentTab = tab
} label: {
VStack(spacing: 4) {
Image(systemName: icon)
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
Text(title)
}
.padding()
.frame(maxWidth: .infinity)
.font(.body.weight(.medium))
.foregroundStyle(tab == currentTab ? .black : .gray)
.frame(height: 60)
}
.matchedGeometryEffect(
id: tab,
in: namespace,
isSource: true
)
}
}