Issue #814

From iOS 14, TabView has the PageTabViewStyle that turns TabView into the equivalent UIPageViewController.

We can of course implement our own Pager but the simple DragGesture does not bring the true experience of a paging UIScrollView or paging TabView. We can also use UIPageViewController under the hood but it’s hard to do lazy. Paging TabView in iOS 14 is built int and optimized for us.

We just need to specify PageTabViewStyle, or just .page from iOS 15 to achieve the paging carousel horizontal scrolling effect.

struct Book {
    let id: UUID
    let title: String
    let coverUrl: URL
}

struct BookCarouselView: View {
    let books: [Book]
    @Binding var selectedBookIndex: Int

    var body: some View {
        TabView(selection: $selectedBookIndex) {
            ForEach(books) { book in
                BookView(book: book)
            }
        }
        .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
    }
}

Swipe down to dismiss

There’s 1 caveat though, which is that this paging TabView also has vertical scrolling. This may not be what we want if we want to add our own swipe down or up to dismiss.

Now we can easily make a Modifier to swipe down to dismiss but that collides with the vertical scrolling gesture of our TabView

So we need to either cancel the default vertical scrolling of this TabView, or leverage that for our swipe down to dismiss gesture.

Simulator Screen Recording - iPhone 12 - 2021-06-29 at 21 33 00

Use Xcode View Debugger

The first thing I do is to examine the view hierarchy using Xcode View Debugger. It reveals one view of type UIKitPagingView

We can potentially act on this but for now, it’s not sure if this is for the horizontal or vertical scrolling of the TabView

Use SwiftUI Introspect

From what I’ve found, there seems to be 2 UIScrollView in the paging TabView. One UIScrollView for the horizontal scrolling, and 1 UICollectionView for the vertical scrolling, which we’re trying to cancel here.

There’s this library SwiftUI-Introspect that helps to introspect UIKit views under the hood, so let’s try that. I will hook it into different places and see

struct BookCarouselView: View {
    let books: [Book]
    @Binding var selectedBookIndex: Int

    var body: some View {
        TabView(selection: $selectedBookIndex) {
            ForEach(books) { book in
                BookView(book: book)
                    .introspectScrollView { scrollView in
                        // Get called here
                    }
            }
            .introspectScrollView { scrollView in
                // Get called here
            }
        }
        .introspectScrollView { scrollView in
            // Not called
        }
        .introspectViewController { viewController in
            // Get called
            // viewController is PresentationHostingController
            // viewController.view is UIHostingView
            if let collectionView = viewController.view.find(for: UICollectionView.self) {
                collectionView.alwaysBounceVertical = false
            }
        }
        .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
    }
}

As you can see, placing introspectScrollView on either the ForEach or the View inside it has the same effect. For the TabView I need to introspectViewController the UIViewController, which is of type PresentationHostingController.

Now the fun thing is to examine its hierarchy for UIScrollView. For that, I have written an extension method on UIView to quickly find subview of certain types deep some levels.

extension UIView {
    func find<T: UIView>(for type: T.Type, maxLevel: Int = 3) -> T? {
        guard maxLevel >= 0 else {
            return nil
        }

        if let view = self as? T {
            return view
        } else {
            for view in subviews {
                if let found = view.find(for: type, maxLevel: maxLevel - 1) {
                    return found
                }
            }
        }

        return nil
    }
}

With this, I can quickly find the UICollectionView and set its alwaysBounceVertical to false to prevent vertical scrolling. Another thing I can do is to set a UIScrollViewDelegate on this UICollectionView so I can observe the scrolling offset to do my other logic.

While this works, these is merely assumptions based on inspecting view hierarchy, which may be changed in the future. I hope we have more proper ways to fine-tune these behaviors