I have four main functional areas of my app that can be accessed by the user via a custom tab bar at the bottom of the the ContentView. I want to use a slide transition to move between the views when the user taps the desired function in the tab bar.
I also want the direction of the slide to be based on the relative position of the options on the tab bar. That is, if going from tab 1 to tab 3, the views will slide from right to left, or if going from tab 3 to tab 2, the views will slide from left to right.
This works perfectly on the first change of view and for any subsequent change of view that changes direction of the slide. E.g., the following sequence of view changes work: 1->3, 3->2, 2->4, 4->1.
However, any time there is a change of view where the direction is the same as the previous direction, it doesn't work correctly. E.g., the bolded changes in the following sequence don't work properly. 1->2, 2->3, 3->4, 4->3, 3->2.
In the above-mentioned transitions that don't work properly, the incoming view enters from the appropriate direction, but the outgoing view departs in the wrong direction. For example, the image at the bottom of this post shows the new view moving in appropriately from right to left, but the departing view is moving from left to right, leaving the white space on the left (it should also be moving from right to left along with the incoming view).
Any thoughts on why this might be happening / how to correct it?
I'm using iOS 16 for my app.
Following is a complete code sample demonstrating this issue:
import SwiftUI
@main
struct TabBar_testingApp: App {
@StateObject var tabOption = TabOption()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(tabOption)
}
}
}
class TabOption: ObservableObject {
@Published var tab: TabItem = .tab1
@Published var slideLeft: Bool = true
}
enum TabItem: Int, CaseIterable {
// MARK: These are the four main elements of the app that are navigated to via the custom tab or sidebar controls
case tab1 = 0
case tab2 = 1
case tab3 = 2
case tab4 = 3
var description: String {
switch self {
case .tab1: return "Tab 1"
case .tab2: return "Tab 2"
case .tab3: return "Tab 3"
case .tab4: return "Tab 4"
}
}
var icon: String {
switch self {
case .tab1: return "1.circle"
case .tab2: return "2.circle"
case .tab3: return "3.circle"
case .tab4: return "4.circle"
}
}
}
struct ContentView: View {
@EnvironmentObject var tabOption: TabOption
var body: some View {
NavigationStack {
VStack {
// Content
Group {
switch tabOption.tab {
case TabItem.tab1:
SlideOneView()
case TabItem.tab2:
SlideTwoView()
case TabItem.tab3:
Slide3View()
case TabItem.tab4:
SlideFourView()
}
}
// Use a slide transition when changing the tab views
.transition(.move(edge: tabOption.slideLeft ? .leading : .trailing))
Spacer()
// Custom tab bar
HStack {
Spacer()
// Open tab 1
Button(action: {
withAnimation {
// Set the direction the tabs will slide when transitioning between the tabs
tabOption.slideLeft = true
// Change to the selected tab
tabOption.tab = TabItem.tab1
}
}) {
VStack {
Image(systemName: TabItem.tab1.icon).font(.title2)
Text(TabItem.tab1.description).font(.caption2)
}
.foregroundStyle(tabOption.tab == .tab1 ? .primary : .secondary)
.font(.title)
}
Spacer()
// Open tab 2
Button(action: {
withAnimation {
// Set the direction the tabs will slide when transitioning between the tabs
if tabOption.tab.rawValue == TabItem.tab1.rawValue {
tabOption.slideLeft = false
} else {
tabOption.slideLeft = true
}
// Change to the selected tab
tabOption.tab = TabItem.tab2
}
}) {
VStack {
Image(systemName: TabItem.tab2.icon).font(.title2)
Text(TabItem.tab2.description).font(.caption2)
}
.foregroundStyle(tabOption.tab == .tab2 ? .primary : .secondary)
.font(.title)
}
Spacer()
// Open tab 3
Button(action: {
withAnimation {
// Set the direction the tabs will slide when transitioning between the tabs
if tabOption.tab.rawValue == TabItem.tab4.rawValue {
tabOption.slideLeft = true
} else {
tabOption.slideLeft = false
}
// Change to the selected tab
tabOption.tab = TabItem.tab3
}
}) {
VStack {
Image(systemName: TabItem.tab3.icon).font(.title2)
Text(TabItem.tab3.description).font(.caption2)
}
.foregroundStyle(tabOption.tab == .tab3 ? .primary : .secondary)
.font(.title)
}
Spacer()
// Open tab 4
Button(action: {
withAnimation {
// Set the direction the tabs will slide when transitioning between the tabs
tabOption.slideLeft = false
// Change to the selected tab
tabOption.tab = TabItem.tab4
}
}) {
VStack {
Image(systemName: TabItem.tab4.icon).font(.title2)
Text(TabItem.tab4.description).font(.caption2)
}
.foregroundStyle(tabOption.tab == .tab4 ? .primary : .secondary)
.font(.title)
}
Spacer()
} // HStack closure
.foregroundStyle(.blue)
.padding(.top, 5)
}
}
}
}
struct SlideOneView: View {
var body: some View {
ZStack {
Group {
Color.blue
Text("Tab Content 1")
.font(.largeTitle)
.foregroundColor(.white)
}
}
}
}
struct SlideTwoView: View {
var body: some View {
ZStack {
Group {
Color.green
Text("Tab Content 2")
.font(.largeTitle)
.foregroundColor(.white)
}
}
}
}
struct Slide3View: View {
var body: some View {
ZStack {
Group {
Color.purple
Text("Tab Content 3")
.font(.largeTitle)
.foregroundColor(.white)
}
}
}
}
struct SlideFourView: View {
var body: some View {
ZStack {
Group {
Color.red
Text("Tab Content 4")
.font(.largeTitle)
.foregroundColor(.white)
}
}
}
}
And finally, here's the screenshot where the bottom (departing) view is moving incorrectly from left to right which briefly leaves white space on the left, while the incoming view is correctly moving from right to left.
HERE'S MY REVISED CODE PER COMMENTS BELOW:
class TabOption: ObservableObject {
@Published var tab: TabItem = .tab1
@Published var slideLeft: Bool = true
func changeTab(to newTab: TabItem) {
switch newTab.rawValue {
// case let allows you to make a comparison in the case statement
// This determines the direction is decreasing, so we want a right slide
case let t where t < tab.rawValue:
slideLeft = false
// This determines the direction is increasing, so we want a left slide
case let t where t > tab.rawValue:
slideLeft = true
// This determines that the user tapped this tab, so do nothing
default:
return
}
// We have determined the proper direction, so change tabs.
withAnimation(.easeInOut) {
tab = newTab
}
}
}
enum TabItem: Int, CaseIterable {
// MARK: These are the four main elements of the app that are navigated to via the custom tab or sidebar controls
case tab1 = 0
case tab2 = 1
case tab3 = 2
case tab4 = 3
var description: String {
switch self {
case .tab1: return "Tab 1"
case .tab2: return "Tab 2"
case .tab3: return "Tab 3"
case .tab4: return "Tab 4"
}
}
var icon: String {
switch self {
case .tab1: return "1.circle"
case .tab2: return "2.circle"
case .tab3: return "3.circle"
case .tab4: return "4.circle"
}
}
}
struct ContentView: View {
@EnvironmentObject var tabOption: TabOption
var body: some View {
NavigationStack {
VStack {
// Content
Group {
switch tabOption.tab {
case TabItem.tab1:
SlideOneView()
case TabItem.tab2:
SlideTwoView()
case TabItem.tab3:
Slide3View()
case TabItem.tab4:
SlideFourView()
}
}
// Use a slide transition when changing the tab views
.transition(
.asymmetric(
insertion: .move(edge: tabOption.slideLeft ? .trailing : .leading),
removal: .move(edge: tabOption.slideLeft ? .leading : .trailing)
)
)
Spacer()
// Custom tab bar
HStack {
Spacer()
// Open tab 1
Button(action: {
withAnimation {
tabOption.changeTab(to: .tab1)
}
}) {
VStack {
Image(systemName: TabItem.tab1.icon).font(.title2)
Text(TabItem.tab1.description).font(.caption2)
}
.foregroundStyle(tabOption.tab == .tab1 ? .primary : .secondary)
.font(.title)
}
Spacer()
// Open tab 2
Button(action: {
withAnimation {
tabOption.changeTab(to: .tab2)
}
}) {
VStack {
Image(systemName: TabItem.tab2.icon).font(.title2)
Text(TabItem.tab2.description).font(.caption2)
}
.foregroundStyle(tabOption.tab == .tab2 ? .primary : .secondary)
.font(.title)
}
Spacer()
// Open tab 3
Button(action: {
withAnimation {
tabOption.changeTab(to: .tab3)
}
}) {
VStack {
Image(systemName: TabItem.tab3.icon).font(.title2)
Text(TabItem.tab3.description).font(.caption2)
}
.foregroundStyle(tabOption.tab == .tab3 ? .primary : .secondary)
.font(.title)
}
Spacer()
// Open tab 4
Button(action: {
tabOption.changeTab(to: .tab4)
}) {
VStack {
Image(systemName: TabItem.tab4.icon).font(.title2)
Text(TabItem.tab4.description).font(.caption2)
}
.foregroundStyle(tabOption.tab == .tab4 ? .primary : .secondary)
.font(.title)
}
Spacer()
} // HStack closure
.foregroundStyle(.blue)
.padding(.top, 5)
}
}
}
}
Here's a GIF of the issue using the revised code (apologies for the gif compression "squashing" the screen image, but you get the idea):
This is a very common UX requirement, but very difficult to get right.
In your case, you are trying to have all panels owned by the same parent and to modify the transition edge according to the latest selection. I tried to do it this way too, but here is what I discovered:
However, there is a way to solve it. This is to make the overall view more hierarchical and to build it up as pairs. I have tested the following with iOS 14, 15, and 16 and it works reliably on all.
import SwiftUI
/// An enum to describe the possible tab selections
enum TabItem: Int, CaseIterable, Comparable {
case tab1 = 0
case tab2 = 1
case tab3 = 2
case tab4 = 3
var description: String {
"Tab \(self.rawValue + 1)"
}
var icon: String {
"\(self.rawValue + 1).circle"
}
static func < (lhs: TabItem, rhs: TabItem) -> Bool {
lhs.rawValue < rhs.rawValue
}
}
/// View modifier that applies a move transition on the leading edge
struct TransitionLeading: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 16.0, *) {
content.transition(.move(edge: .leading))
} else {
content.transition(
.asymmetric(
insertion: .move(edge: .leading),
removal: .move(edge: .trailing)
)
)
}
}
}
/// View modifier that applies a move transition on the trailing edge
struct TransitionTrailing: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 16.0, *) {
content.transition(.move(edge: .trailing))
} else {
content.transition(
.asymmetric(
insertion: .move(edge: .trailing),
removal: .move(edge: .leading)
)
)
}
}
}
/// A container for two alternative display panels
struct PanelPair<TabType: Comparable, LeftContent: View, RightContent: View>: View {
/// The identifier for the left panel
private let leftTab: TabType
/// Function that delivers the content for the left panel
private let leftContent: () -> LeftContent
/// Function that delivers the content for the right panel
private let rightContent: () -> RightContent
/// Read-only value of the state variable that controls the panel selection
private let selectedTab: TabType
/// Creates a container for two alternative views
init(
leftTab: TabType,
selectedTab: TabType,
leftContent: @escaping () -> LeftContent,
rightContent: @escaping () -> RightContent
) {
self.leftTab = leftTab
self.selectedTab = selectedTab
self.leftContent = leftContent
self.rightContent = rightContent
}
var body: some View {
// Important: the alternative content needs to be in a ZStack
ZStack {
if selectedTab <= leftTab {
leftContent()
.modifier(TransitionLeading())
} else {
rightContent()
.modifier(TransitionTrailing())
}
}
}
}
/// Working example
struct ContentView: View {
/// State variable that controls the panel selection
@State private var selectedTab = TabItem.tab1
/// Factory function for a panel relating to a particular tab
private func panel(tab: TabItem, color: Color) -> some View {
HStack {
Spacer()
VStack {
Spacer()
Text("Tab Content \(tab.rawValue + 1)")
.font(.largeTitle)
.foregroundColor(.white)
Spacer()
}
Spacer()
}
.background(color)
}
/// Callback for a tab button
private func changeTab(to: TabItem) {
withAnimation(.easeInOut(duration: 0.5)) {
selectedTab = to
}
}
var body: some View {
VStack {
// The panels
// Panel 1 + others
PanelPair(
leftTab: TabItem.tab1,
selectedTab: selectedTab,
leftContent: { panel(tab: .tab1, color: .blue) },
rightContent: {
// Panel 2 + others
PanelPair(
leftTab: TabItem.tab2,
selectedTab: selectedTab,
leftContent: { panel(tab: .tab2, color: .green) },
rightContent: {
// Panels 3 + 4
PanelPair(
leftTab: TabItem.tab3,
selectedTab: selectedTab,
leftContent: { panel(tab: .tab3, color: .purple) },
rightContent: { panel(tab: .tab4, color: .red) }
)
}
)
}
)
// The tab buttons
HStack {
ForEach(TabItem.allCases, id: \.self) { tabItem in
Button(action: { changeTab(to: tabItem) }) {
VStack {
Image(systemName: tabItem.icon)
.resizable()
.scaledToFit()
.frame(width: 40, height: 40)
Text(tabItem.description)
}
.frame(maxWidth: .infinity)
}
.foregroundColor(selectedTab == tabItem ? .primary : .secondary)
}
}
.padding()
}
}
}
This works correctly for all transitions, forwards and backwards, including jumps:
Edit notes
PanelPair
updated to use a generic type as the data type (in this example, the enum TabItem
). This makes re-use easier..zIndex
to the lower content, to fix an issue with back jumps (such as from 4 to 1). It seems that this fix is not needed if a PanelPair
is supplied with the value of the current selection as a read-only value, instead of as a Binding
. Example updated accordingly.