Search code examples
iosswiftuitransition

SwiftUI bi-directional move transition moving the wrong way in certain cases


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.

enter image description here

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):

enter image description here


Solution

  • 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:

    1. The edges seem to work differently in iOS 16 compared to iOS 14/15, at least for removal cases.
    2. In iOS 16 (at least), I suspect that the edge for the removal transition is already defined at time of insertion. This means, trying to make the edge depend on state values that can change after the view is showing doesn't work.
    3. Therefore, to get the edge correct for the removal transition, you need to know which way the user is going to go next. For the first and last panels, this is easy because there is only one way to go, but for the middle panels you have to guess / predict it.
    4. If you are not able to predict the next direction of movement correctly every time, then it will never work correctly every time for the middle panels if all the panels share the same parent.

    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:

    Animation

    Edit notes

    • PanelPair updated to use a generic type as the data type (in this example, the enum TabItem). This makes re-use easier.
    • In a previous incarnation, this solution applied a .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.