skip to Main Content

I have reached an annoying issue with SwiftUI. I have a horizontal pager with vertical scroll views as pages. It is defined as simple as they come,

TabView(selection: $selected) {
    ForEach(focus!.list.things) { thing in
        FullView(thing: thing).tag(thing)
    }
}
.tabViewStyle(.page(indexDisplayMode: .always))
.indexViewStyle(.page(backgroundDisplayMode: .always))

and

struct FullView: View {
    let thing: Thing
    
    var body: some View {
        ScrollView {
            VStack {
                ...
            }
        }
    }
}

This produces a view which does what I want, except it does not reach all the way down below the home indicator.

I can solve this by adding .ignoresSafeArea(edges: .bottom) to the TabView, but that produces another displeasing result where the page indicator collides with the home indicator.


Is there any reasonable way accomplish full height vertical scroll while keeping the index page indicator above the home indicator?

Code to recreate issue:

struct ContentView: View {
    @State var isSheetUp = false
    
    var body: some View {
        Button("Present") {
            isSheetUp.toggle()
        }
        .sheet(isPresented: $isSheetUp) {
            Sheet()
        }
    }
    
    struct Sheet: View {
        var body: some View {
            NavigationView {
                TabView() {
                    Page()
                    Page()
                    Page()
                }
                // Comment this to switch layout issue
                .ignoresSafeArea(edges: .bottom)
                .tabViewStyle(.page(indexDisplayMode: .always))
                .indexViewStyle(.page(backgroundDisplayMode: .always))
                .navigationTitle("Title")
                .navigationBarTitleDisplayMode(.inline)
            }
        }
    }
    
    struct Page: View {
        var body: some View {
            ScrollView {
                VStack {
                    Rectangle()
                        .foregroundColor(.teal)
                        .padding()
                        .frame(minHeight: 10000)
                }
            }.background(Color.brown)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

2

Answers


  1. EDIT: See also @nekno’s fantastic additions!


    This is possible if you create a custom UIPageControl, manually tag each tab in the TabView, and make sure to keep track of the numberOfPages:

    struct PageControlView: UIViewRepresentable {
        @Binding var currentPage: Int
        @Binding var numberOfPages: Int
    
        func makeUIView(context: Context) -> UIPageControl {
            let uiView = UIPageControl()
            uiView.backgroundStyle = .prominent
            uiView.currentPage = currentPage
            uiView.numberOfPages = numberOfPages
            return uiView
        }
    
        func updateUIView(_ uiView: UIPageControl, context: Context) {
            uiView.currentPage = currentPage
            uiView.numberOfPages = numberOfPages
        }
    }
    
    struct ContentView: View {
        @State var isSheetUp = false
    
        var body: some View {
            Button("Present") {
                isSheetUp.toggle()
            }
            .sheet(isPresented: $isSheetUp) {
                Sheet()
            }
        }
    
        struct Sheet: View {
            @State var currentPage = 0
            @State var numberOfPages = 3
    
            var body: some View {
                NavigationView {
                    ZStack {
                        TabView(selection: $currentPage) {
                            Page().tag(0)
                            Page().tag(1)
                            Page().tag(2)
                        }
                        // Comment this to switch layout issue
                        .ignoresSafeArea(edges: .bottom)
                        .tabViewStyle(.page(indexDisplayMode: .never))
                        .indexViewStyle(.page(backgroundDisplayMode: .always))
                        .navigationTitle("Title")
                        .navigationBarTitleDisplayMode(.inline)
    
                        VStack {
                            Spacer()
                            PageControlView(currentPage: $currentPage, numberOfPages: $numberOfPages)
                        }
                    }
                }
            }
        }
    
        struct Page: View {
            var body: some View {
                ScrollView {
                    VStack {
                        Rectangle()
                            .foregroundColor(.teal)
                            .padding()
                            .frame(minHeight: 10000)
                    }
                }.background(Color.brown)
            }
        }
    }
    
    struct ContentView_Previews: PreviewProvider {
        static var previews: some View {
            ContentView()
        }
    }
    
    Login or Signup to reply.
  2. @Coder-256’s answer set me on the right path, and I added a couple enhancements you might find useful.

    1. The UIPageControl normally iterates through the pages when you tap on it. As written, the indicator in the page control was changing, but the pages weren’t actually changing, so I added a target for the page control’s .valueChanged event.

      When setting the current page based on the new changed value, wrapping the assignment in a withAnimation closure ensure the page animates to the next page, otherwise it just replaces the current page instantaneously.

    2. TabView will work with any valid tag values, which just need to conform to Hashable.

      To work with the page control, you need those tag values to be convertible to Int values, but it’s common practice to use a strongly-typed, named value for tags, so I added support for an enum that conforms to RawRepresentable with a backing type of Int.

      Others may find it easier to just use hard-coded integers for the tag values, so if you ever reordered the pages in your TabView you wouldn’t have to remember to reorder the cases in your enum, but to each their own.

    3. The UIPageControl and its parent ViewHost that hosts the UIViewRepresentable instance both have auto resizing masks that result in their frames expanding to consume the horizontal space of the containing superview.

      Both the page control and the view host participate in hit testing, so they intercept touches to the left and right of the page control when you actually intend to scroll the content underneath.

      Adding the allowsHitTesting(false) view modifier eliminates that behavior, but also disables all interaction with the page control, so it breaks the tap/paging functionality.

      I played around with various solutions, and the easiest seems to be to just set a frame on the page control that requests a maxWidth and maxHeight of 0, and as a result the view shrinks to its intrinsic content size.

    struct PageControlView<T: RawRepresentable>: UIViewRepresentable where T.RawValue == Int {
        @Binding var currentPage: T
        @Binding var numberOfPages: Int
        
        func makeCoordinator() -> Coordinator {
            Coordinator(self)
        }
    
        func makeUIView(context: Context) -> UIPageControl {
            let uiView = UIPageControl()
            uiView.backgroundStyle = .prominent
            uiView.currentPage = currentPage.rawValue
            uiView.numberOfPages = numberOfPages
            uiView.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged), for: .valueChanged)
            return uiView
        }
    
        func updateUIView(_ uiView: UIPageControl, context: Context) {
            uiView.currentPage = currentPage.rawValue
            uiView.numberOfPages = numberOfPages
        }
    }
    
    extension PageControlView {
        final class Coordinator: NSObject {
            var parent: PageControlView
            
            init(_ parent: PageControlView) {
                self.parent = parent
            }
            
            @objc func valueChanged(sender: UIPageControl) {
                guard let currentPage = T(rawValue: sender.currentPage) else {
                    return
                }
    
                withAnimation {
                    parent.currentPage = currentPage
                }
            }
        }
    }
    
    struct ContentView: View {
        @State private var currentPage: Pages = .myFirstPage
        @State private var numberOfPages = Pages.allCases.count
        
        var body: some View {
            ZStack(alignment: .bottom) {
                TabView(selection: $currentPage) {
                    MyFirstPage()
                        .tag(Pages.myFirstPage)
                    
                    MySecondPage()
                        .tag(Pages.mySecondPage)
                    
                    MyThirdPage()
                        .tag(Pages.myThirdPage)
                }
                .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
                
                UIPageControlView(currentPage: $currentPage, numberOfPages: $numberOfPages)
                    .frame(maxWidth: 0, maxHeight: 0)
                    .padding(22) // 22 seems to mimic SwiftUI's `PageIndexView` placement from the bottom edge
            }
        }
    }
    
    extension ContentView {
        enum Pages: Int, CaseIterable {
            case myFirstPage
            case mySecondPage
            case myThirdPage
        }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search