For some reason updating the selection does not update the selected tab in this very simple example. My expectation would be that it would show Tab 1 initially, and when I press the Toggle Tab button, it should slide over to Tab 0. Instead I have to toggle the tab 3 times before it finally slides to Tab 0.
Tested in iOS 16
struct Test: View {
#State var currentTab = 1
var body: some View {
VStack {
Text("Current Tab: \(currentTab)")
TabView(selection: $currentTab) {
Text("Tab 0")
.tag(0)
Text("Tab 1")
.tag(1)
}
.tabViewStyle(.page(indexDisplayMode: .never))
Button {
withAnimation(Animation.easeIn(duration: 0.2)) {
if currentTab == 0 {
currentTab = 1
} else {
currentTab = 0
}
}
} label: {
Text("Toggle Tab")
}
}
}
}
Oh boy. There seem to be bugs specifically around when TabView's initial selection is the second available tag and your first programmatic change to the selection value is to go down to the first tag. I say this because I've tried this with an Int range of 0 to 30 and can reproduce specifically only when attempting to 1) start with an initial selection of 1 and then 2) decrementing to 0. Incrementing to 3 works. Starting at 0 and incrementing works. I feel like I'm missing something weird, but it's also possible that this is simply a bug. I would not be shocked, given that the behavior of TabView is so different when shown as PageTabView, and you don't see a ton of usage of PageTabView-like UI elements that don't start on their first page.
Anyways. As dirty as it is, the only reliable fix I currently have is to simply programmatically toggle down and up in .onAppear. I can think of other ideas for possible workarounds, but for now I felt that posting this is better than leaving you without any feedback.
Note that the placement of .onAppear on a Group around the children is crucial. Place it on the initially-displayed child and it will interfere with later usage because .onAppear is called as the user returns to the initially-displayed child. Place it on the TabView and it seems to be called too early to have the desired hacky impact.
struct Test: View {
#State var currentTab = 1
var body: some View {
VStack {
Text("Current Tab: \(currentTab)")
TabView(selection: $currentTab) {
Group {
Text("Tab 0")
.tag(0)
Text("Tab 1")
.tag(1)
}
.onAppear {
currentTab = 0
currentTab = 1
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
Button {
withAnimation(Animation.easeIn(duration: 0.2)) {
if currentTab == 0 {
currentTab = 1
} else {
currentTab = 0
}
}
} label: {
Text("Toggle Tab")
}
}
}
}
Related
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):
So, a couple of things. First, you had way too much logic in your view code. Remember the DRY principal(Don't Repeat Yourself). Essentially, you are using TabOption, so your logic should go in there. I added a function to TabOption that contains all the logic to change tabs:
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
}
}
}
With that in place, things are easier to reason. In the end, the views were not sliding in the directions you expected because you didn't realize you were dealing with two views that you wanted to do different things with. If you have a slide left, you want the original view to exit by moving its trailing edge, and the new view moving its leading edge. The right slide is reversed. Your transition was telling them to enter and exit from the same direction. What you want is an .asymmetric() transition like this:
.transition(
.asymmetric(
insertion: .move(edge: tabOption.slideLeft ? .trailing : .leading),
removal: .move(edge: tabOption.slideLeft ? .leading : .trailing)
)
)
Lastly, to complete this, each of your button actions are simply like this:
// Open tab 1
Button(action: {
tabOption.changeTab(to: .tab1)
}) {
...
}
Edit:
Using the code provided, this is the result following your comments:
As you can see, there are no issues. Please make sure you adopted all of my code, and not just the asymmetric transition. I am not sure that having the tabOption.slideLeft = true inside the animation block is not also causing problems.
I have the following simplified code in a project using XCode 13.2. The intended functionality is to have 2 tabs (Tab1 and Tab2), and the user is able to tap the screen on Tab2 to toggle the background from red to green.
Instead, however, when Tab2 is clicked, the screen displays tab 1, instead of Tab2 with the changed color. I'm wondering why the app goes to Tab1 when Tab2 is tapped.
There are no NavigationLinks or actions that make the user go to Tab1. My only thought is that maybe the app is rebuilding when Tab2 is clicked, which is why it goes back to the first tab? If so, are there any good workarounds for this?
Note: the color still changes on Tab2, but not before the app switches to Tab1.
struct ContentView: View {
var body: some View {
TabView {
Tab1()
.tabItem {
Text("Tab 1")
}
Tab2()
.tabItem {
Text("Tab 2")
}
}
}
}
struct Tab1: View {
var body: some View {
Text("Tab 1")
}
}
struct Tab2: View {
#State var isRed: Bool = false
var body: some View {
if !isRed {
Color.green
.onTapGesture {
isRed = true
}
} else {
Color.red
.onTapGesture {
isRed = false
}
}
}
}
The reason why tapping on Tab2 causes it to jump back to Tab1 is that the structural identity of Tab2 changes whenever you tap on it causing swiftUI to reload the body. See the explanation here.
A simple fix to the example you have above would be to wrap the entire body in a ZStack
var body: some View {
ZStack {
if !isRed {
Color.green
.onTapGesture {
isRed = true
}
} else {
Color.red
.onTapGesture {
isRed = false
}
}
}
}
I have noticed that using if/else in that way causes strange behavior. Refactor Tab2 as shown below and it should work as expected.
struct Tab2: View {
#State var isRed: Bool = false
var body: some View {
ZStack {
Color.red
Color.green
.opacity(isRed ? 0 : 1)
}
.onTapGesture {
isRed.toggle()
}
}
}
I'm trying to keep track of what page the user is on in a TabView that is PageTabViewStyle in SwiftUI but I can't figure out the best way to keep track of the page index? Using .onAppear doesn't work well as it gets called multiple times and pages 2 and 3 get called even when not on the screen. 🤔
#State var pageIndex = 0
var body: some View {
VStack {
Text("current page = \(0) ")
TabView {
Text("First")
.onAppear {
pageIndex = 0
}
Text("Second")
.onAppear {
pageIndex = 1
}
Text("Third")
.onAppear {
pageIndex = 2
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
}
}
}
You can pass a selection binding to the TabView and use tag to identify the pages:
struct ContentView: View {
#State var pageIndex = 0
var body: some View {
VStack {
Text("current page = \(pageIndex) ")
TabView(selection: $pageIndex) {
Text("First").tag(0)
Text("Second").tag(1)
Text("Third").tag(2)
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
}
}
}
Note that in your original code, it was always going to say current page = 0 because you weren't interpolating the pageIndex variable into the string
If you want to be able to get the current page/index and act on that data, what I found useful is using the .onChange() modifier on the view, like so.
TabView(selection: $currentIndex) {
Text("First view")
.tag(0)
Text("Middle view")
.tag(1)
Text("Last view")
.tag(2)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onChange(of: currentIndex) { newValue in
print("New page: \(newValue)")
}
Hope that helps!
I have a List of counters - every element in the List is a counter with a number and plus/minus buttons users can click to increase or decrease the counter. Now I added a NavigationLink so that users can go to a detail view of every counter. The problem is now wherever you click on the list the detail view gets always pushed - even if you click on one of the buttons (counter increases then the detail view gets pushed via the NavigationLink) - I only want to use the NavigationLink if the user clicks on the number or somewhere else but of course not if the users clicks on of the buttons. How can this be done?
NavigationView {
List {
ForEach(counters, id: \.self) { counter in
NavigationLink(destination: SingleCounterView(currentCounter: counter)) {
CounterCell(counter: counter)
}
}
}
.buttonStyle(PlainButtonStyle())
.listStyle(GroupedListStyle())
}
I've made this test, to show a CounterCell with plus and minus buttons in a NavigationView. If you tap on the buttons the counter increments, if you tap on the chevron or outside the buttons the destination appears.
#State var counters = ["a","b","c"]
var body: some View {
NavigationView {
List {
ForEach(counters, id: \.self) { counter in
NavigationLink(destination: Text(counter)) {
CounterCell(counter: counter)
}
}
}
.buttonStyle(PlainButtonStyle())
.listStyle(GroupedListStyle())
}.navigationViewStyle(StackNavigationViewStyle())
}
struct CounterCell: View {
#State var counter: String
#State var inc = 0
var body: some View {
HStack {
Button(action: { self.inc += 1 }) {
Text("plus")
}
Button(action: { self.inc -= 1 }) {
Text("minus")
}
Text(" counter: \(counter) value: \(inc)")
}
}
}
Following tutorials, I have the following code to show a tab view with 3 tab items all with an icon on them, When pressed they navigate between the three different views. This all works fine, however, I want to be able to handle the selection and only show views 2 or 3 if certain criteria are met.
Is there a way to get the selected value and check it then check my own criteria and then show the view is criteria is met, or show an alert if it is not saying they can't use that view at the moment.
Essentially I want to be able to intercept the selection value before it switches out the view, maybe I need to rewrite all of this but this is the functionality I'm looking for as this is how I had my previous app working using the old framework.
#State private var selection = 1
var body: some View
{
TabbedView(selection: $selection)
{
View1().tabItemLabel(
VStack
{
Image("icon")
Text("")
})
.tag(1)
View2().tabItemLabel(
VStack
{
Image("icon")
Text("")
}).tag(2)
View3().tabItemLabel(
VStack
{
Image("icon")
Text("")
}).tag(3)
}
}
You can do it by changing the value of selection on tap. You can use .onAppear() method for a particular tab to check your condition:
#State private var selection = 1
var conditionSatisfied = false
var body: some View
{
TabbedView(selection: $selection)
{
View1().tabItemLabel(
VStack
{
Image("icon")
Text("")
})
.tag(1)
View2().tabItemLabel(
VStack
{
Image("icon")
Text("")
}).tag(2)
.onAppear() {
if !conditionSatisfied {
self.selection = 1
}
}
View3().tabItemLabel(
VStack
{
Image("icon")
Text("")
}).tag(3)
.onAppear() {
if !conditionSatisfied {
self.selection = 1
}
}
}
}