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.
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