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
From iOS 14, TabView has the PageTabViewStyle that turns
TabView
into the equivalentUIPageViewController
.We can of course implement our own Pager but the simple
DragGesture
does not bring the true experience of a paging UIScrollView or pagingTabView
. We can also useUIPageViewController
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.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. OneUIScrollView
for the horizontal scrolling, and 1UICollectionView
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
As you can see, placing
introspectScrollView
on either theForEach
or the View inside it has the same effect. For theTabView
I need tointrospectViewController
the UIViewController, which is of typePresentationHostingController
.Now the fun thing is to examine its hierarchy for
UIScrollView
. For that, I have written an extension method onUIView
to quickly find subview of certain types deep some levels.With this, I can quickly find the
UICollectionView
and set itsalwaysBounceVertical
tofalse
to prevent vertical scrolling. Another thing I can do is to set aUIScrollViewDelegate
on thisUICollectionView
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