SwiftUI TabView PageTabViewStyle prevent changing tab?
Asked Answered
Z

5

16

I have a TabView in SwiftUI in the PageViewTabStyle so i can swipe from page to page. I'd like to have a setting that "locks" the current view in place, so the user cannot swipe. Googling and reading docs isn't turning up anything obvious for me, so I was hoping the gurus on SO could help me out.

In short, my code looks like

TabView {
   ForEach(0..<5) { idx in
      Text("Cell: \(idx)")
   }
}
.tabViewStyle(PageTabViewStyle())

I have found the disabled property, but then it appears that all tap events are ignored on the entire view - I just want to prevent the user from switching tabs (or, in this particular case, swiping or pressing the page dots to switch pages). I've tried the solution from here where the gesture property is set to nil, but that doesn't appear to actually stop the swipe gesture from changing the page (the indexDisplayMode bit was nice, though!)

Any help is greatly appreciated! Thanks!

Zeeland answered 31/12, 2020 at 18:28 Comment(2)
See below - the above gesture solution works! However, when there is a subview with eg onTapGesture, the subview still intercepts the swipe to change page. I'd like to have subviews that allow touch interaction :)Zeeland
What do you mean by subview? In a ZStack? For the code as-is, the Text elements don't have any subviews so you'd need to add the blocking gesture to other superviews as wellTatiania
R
18

The solution from mentioned reference works, just the swipe is blocked not by gesture(nil), but by gesture(DragGesture()). And view should be full-tab-content-view-wide, like

    TabView {
      ForEach(0..<5) { idx in
        Text("Cell: \(idx)")
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .contentShape(Rectangle())
                .gesture(DragGesture())      // this blocks swipe
      }
    }
    .tabViewStyle(PageTabViewStyle())

Tested with Xcode 12.1 / iOS 14.1

* and, of course, it can be made conditional as in https://mcmap.net/q/599057/-swiftui-2-0-tabview-disable-swipe-to-change-page

Ryle answered 31/12, 2020 at 18:47 Comment(6)
I see - If i have a subview with an onTapGesture property, that subview still allows swipe-to-change-pages. ``` GeometryReader { geom in TabView { ForEach(0..<5) { idx in HStack { Text("Cell: (idx)") .onTapGesture { print ("TAP!") } // !!! .border(Color.green) } .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) .gesture(DragGesture()) // this blocks swipe } } .tabViewStyle(PageTabViewStyle()) } ```Zeeland
ugh, sorry for the formatting there, I'm not sure how to best reply with a chunk of code in comments. The point was, the subview with the onTapGesture still swipes to change the page - what's the best (if any) way to disable that ? Thanks!Zeeland
haven't tested this, but does it work if you change .gesture(DragGesture()) to .simulataneousGesture(DragGesture())?Tatiania
If either of you want to make an answer with simultaneousGesture, I can accept it. Does that allocate the bounty? I'm not sure how that works...Zeeland
This doesn't seem to reliably disable two-finger swipe gestures for me.Humblebee
Seems unfortunately is not reliable. For me does not work if you try to swipe from a control (for example a button).Coaming
H
9

To block all the swipe gestures in a TabView you have to use .simultaneousGesture(DragGesture()) that blocks all the swipe gestures in the subviews as well

TabView {
          ForEach(0..<5) { idx in
            Text("Cell: \(idx)")
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .contentShape(Rectangle())
                    .simultaneousGesture(DragGesture())
          }
        }
        .tabViewStyle(PageTabViewStyle())
Hearsay answered 4/7, 2021 at 14:49 Comment(3)
This also doesn't seem to reliably disable two-finger swipe gestures for me.Humblebee
Has anyone been able to figure out a way to get two-finger drag gestures to not break this?Kunlun
It's not just a two finger issue. For me does not work if you try to swipe from a control (for example a button)Coaming
O
1

The solution that worked for me is this one. It disables changing tabs by swiping and it keeps the drag gestures enabled on screens as I'm using List .onDelete on certain screens.

It is available only from iOS 16

@State private var selectedTab = 1

TabView(selection: $selectedTab) {
    Text("Tab 1")
        .tag(0)
        .toolbar(.hidden, for: .tabBar)
    Text("Tab 2")
        .tag(1)
        .toolbar(.hidden, for: .tabBar)
    Text("Tab 3")
        .tag(2)
        .toolbar(.hidden, for: .tabBar)
}
Olszewski answered 18/4, 2023 at 11:44 Comment(2)
You lose that animation of page change with this approach.Antlia
This is the only solution that worked for me, though it is unfortunate that you lose the page change animation.Entice
C
1

It may look to a different direction compared to the TabView, but I achieve a good result by using simply a VStack + transition + programmatic changes

Try something like this:


@State private var selectedIndex: Int = 0

VStack {
 switch selectedIndex {
  case 0:
    FirstView().tag(0)
      .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
  case 1:
    SecondView().tag(1)
      .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
   case 2:
     ThirdView().tag(2)
      .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
// ....
   default:
     Text("")
  }
}

// You need some logic to change the selectedIndex. 
// You can implement this here, our pass it as Binding to the views

Coaming answered 16/5 at 12:9 Comment(1)
Great idea. However, you need to wrap the selectedIndex change with withAnimation {}Internationalist
V
0

I decided to roll my own solution using some reflection. It supports both if statements (conditional views) and ForEach views as well as using the .tag() modifier for identification.

private enum PagingTransition {
    case next, previous
    var value: AnyTransition {
        switch self {
        case .next:
            return AnyTransition.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))
        case .previous:
            return AnyTransition.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing))
        }
    }
}

private func isOptional(_ instance: Any) -> Bool {
    let mirror = Mirror(reflecting: instance)
    let style = mirror.displayStyle
    return style == .optional
}

/// Erases generics to get views out of `ForEach`
fileprivate protocol ViewGeneratable {
    func generateViews() -> [any View]
}

extension ForEach: ViewGeneratable {
    func generateViews() -> [any View] {
        self.data.map { self.content($0) as! any View }
    }
}

/// A paging `TabView` replacement that doesn't allow for the user to interact
/// Follows SwiftUI calling conventions as best as possible with dirty reflection
/// https://www.fivestars.blog/articles/inspecting-views/
struct RestrictedPagingView<SelectionType: Hashable & Comparable, Content: View>: View {
    let selection: SelectionType
    @State private var selectionInternal: SelectionType
    @State private var transition: AnyTransition = PagingTransition.next.value
    private var views: [SelectionType: any View] = [:]
    
    init(selection: SelectionType, @ViewBuilder content: () -> Content) {
        self.selection = selection
        self._selectionInternal = State(initialValue: selection)
        
        // Attempt reflection
        generateViews(from: content(), withBaseTag: selection)
    }
    
    /// This is the most big brain shit I've coded in a long time
    /// Reflects SwiftUI views and puts them in a dictionary to use within the paging view
    private mutating func generateViews(from instance: Any, withBaseTag baseTag: SelectionType) {
        let mirror = Mirror(reflecting: instance)
        
        // Is this a tuple view?
        if let value = mirror.descendant("value") {
            // Yes, so call this function recusrively until it isn't
            let count = Mirror(reflecting: value).children.count
            for i in 0..<count {
                generateViews(from: mirror.descendant("value", ".\(i)")!, withBaseTag: baseTag)
            }
        } else if isOptional(instance) {
            // This is an Optional, so check if it has a value
            if let child = mirror.children.first?.value {
                // It does, send it back through the function
                generateViews(from: child, withBaseTag: baseTag)
            }
        } else if let content = mirror.descendant("content") {
            // This is a ForEach loop, so unwrap and deal with all views separately
            if mirror.descendant("contentID") != nil {
                for view in (instance as! ViewGeneratable).generateViews() {
                    generateViews(from: view, withBaseTag: baseTag)
                }
                return
            }
            // This is a tagged view, extract the tag and the content and put them in the dictionary
            let tag: SelectionType = mirror.descendant("modifier", "value", "tagged") as! SelectionType

            views[tag] = (content as! any View)
        } else {
            // Just insert the view with a baseline tag
            views[baseTag] = (instance as! any View)
        }
    }
    
    // TODO: Handle removed conditional views more gracefully
    var body: some View {
        ForEach(views.keys.sorted(by: >), id: \.self) { idx in
            if idx == selectionInternal {
                AnyView(views[idx]!)
                    .transition(transition)
            }
        }
        .onChange(of: selection) { newSelection in
            if newSelection > selectionInternal {
                transition = PagingTransition.next.value
            } else {
                transition = PagingTransition.previous.value
            }
            withAnimation(.easeInOut(duration: 0.25)) {
                selectionInternal = newSelection
            }
        }
    }
}

private struct RestrictedPagingViewPreview: View {
    @State private var index = 1
    @State private var allow = false
    
    var body: some View {
        VStack {
            RestrictedPagingView(selection: index) {
                ZStack {
                    Rectangle().foregroundColor(.blue)
                    Text("Hi")
                }.tag(1)
                ZStack {
                    Rectangle().foregroundColor(.green)
                    Text("Second")
                }.tag(2)
                ZStack {
                    Rectangle().foregroundColor(.red)
                    Text("Third")
                }.tag(3)
                ZStack {
                    Rectangle().foregroundColor(.yellow)
                    Button("FOURTH") {
                        print("button activated")
                    }
                }.tag(4)
                if allow {
                    ZStack {
                        Rectangle().foregroundColor(.orange)
                        Text("Should be hidden (5)")
                    }.tag(5)

                    ZStack {
                        Rectangle().foregroundColor(.purple)
                        Text("Should be hidden (6)")
                    }.tag(6)
                }
                ForEach(7..<11, id: \.self) { tagVal in
                    ZStack {
                        Rectangle().foregroundColor(.cyan)
                        Text("This view is generated by a ForEach loop! (\(tagVal))")
                    }
                    .tag(tagVal)
                }
            }
            .border(Color.green)
            Button("INCR") {
                index += 1
            }
            Button("INCR 2") {
                index += 2
            }
            Button("DECR") {
                index -= 1
            }
            Button("DECR 2") {
                index -= 2
            }
            Toggle("Show", isOn: $allow)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .border(Color.red)
    }
}

struct RestrictedPagingView_Previews: PreviewProvider {
    static var previews: some View {
        RestrictedPagingViewPreview()
    }
}
Venice answered 11/6, 2023 at 1:22 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.