diff --git a/ios/PagerView.swift b/ios/PagerView.swift index a4a850af..9d55fa93 100644 --- a/ios/PagerView.swift +++ b/ios/PagerView.swift @@ -16,6 +16,18 @@ struct PagerView: View { props.orientation == .vertical } + private func applyScrollEnabled(_ scrollEnabled: Bool) { + collectionView?.isScrollEnabled = scrollEnabled + // iOS 15 only, see PagerViewIOS15Compat.swift (delete together with the iOS 15 support). + PagerViewIOS15Compat.applyScrollEnabled(scrollEnabled, to: collectionView) + } + + private func applyOverdrag(_ overdrag: Bool) { + collectionView?.bounces = overdrag + // iOS 15 only, see PagerViewIOS15Compat.swift (delete together with the iOS 15 support). + PagerViewIOS15Compat.applyOverdrag(overdrag, to: collectionView) + } + var body: some View { GeometryReader { proxy in TabView(selection: $props.currentPage) { @@ -39,8 +51,8 @@ struct PagerView: View { .environment(\.layoutDirection, props.layoutDirection.converted) .introspect(.tabView(style: .page), on: .iOS(.v14...)) { collectionView in self.collectionView = collectionView - collectionView.bounces = props.overdrag - collectionView.isScrollEnabled = props.scrollEnabled + applyOverdrag(props.overdrag) + applyScrollEnabled(props.scrollEnabled) collectionView.keyboardDismissMode = props.keyboardDismissMode collectionView.showsVerticalScrollIndicator = false collectionView.showsHorizontalScrollIndicator = false @@ -70,8 +82,8 @@ struct PagerView: View { // Apply initial prop values that .onChange won't catch // (.onChange only fires on changes, not on initial values) DispatchQueue.main.async { - collectionView?.isScrollEnabled = props.scrollEnabled - collectionView?.bounces = props.overdrag + applyScrollEnabled(props.scrollEnabled) + applyOverdrag(props.overdrag) } // `onChange` does not fire for the initial `currentPage` value. Emit the @@ -91,10 +103,10 @@ struct PagerView: View { delegate?.onPageSelected(position: newValue) } .onChange(of: props.scrollEnabled) { newValue in - collectionView?.isScrollEnabled = newValue + applyScrollEnabled(newValue) } .onChange(of: props.overdrag) { newValue in - collectionView?.bounces = newValue + applyOverdrag(newValue) } .onChange(of: props.keyboardDismissMode) { newValue in collectionView?.keyboardDismissMode = newValue diff --git a/ios/PagerViewIOS15Compat.swift b/ios/PagerViewIOS15Compat.swift new file mode 100644 index 00000000..569033a7 --- /dev/null +++ b/ios/PagerViewIOS15Compat.swift @@ -0,0 +1,63 @@ +import UIKit + +/// Workarounds for the SwiftUI paged `TabView` on iOS 15. +/// +/// On iOS 15 SwiftUI implements `.tabViewStyle(.page)` with a compositional layout that pages +/// orthogonally: the horizontal paging is performed by an embedded `UIScrollView` subview of the +/// collection view (private class `_UICollectionViewOrthogonalScrollerEmbeddedScrollView`), while +/// the collection view itself only scrolls vertically. Because `PagerView` can only toggle the +/// collection view, on iOS 15 `scrollEnabled={false}` / `overdrag={false}` never reached the view +/// that actually handles the swipe. +/// +/// On iOS 16 and later the collection view itself is the pager, so none of this is needed — the +/// `#available` checks below make every function in here a no-op on those versions. +/// +/// - Note: Delete this file together with the two `PagerViewIOS15Compat` calls in `PagerView.swift` +/// once the minimum supported iOS version is 16. +enum PagerViewIOS15Compat { + /// Mirrors `scrollEnabled` onto the scroll views SwiftUI embedded in the paging collection view. + static func applyScrollEnabled(_ scrollEnabled: Bool, to collectionView: UICollectionView?) { + if #available(iOS 16, *) { + return + } + + pagingScrollViews(in: collectionView).forEach { $0.isScrollEnabled = scrollEnabled } + } + + /// Mirrors `overdrag` onto the scroll views SwiftUI embedded in the paging collection view. + static func applyOverdrag(_ overdrag: Bool, to collectionView: UICollectionView?) { + if #available(iOS 16, *) { + return + } + + pagingScrollViews(in: collectionView).forEach { $0.bounces = overdrag } + } + + /// Scroll views that SwiftUI embedded in the paging collection view. + /// + /// Scroll views that belong to the pages' React Native content live inside the paging cells, so + /// the traversal stops at cell boundaries and never touches the user's own scroll views. + private static func pagingScrollViews(in collectionView: UICollectionView?) -> [UIScrollView] { + guard let collectionView else { return [] } + + return collectPagingScrollViews(in: collectionView) + } + + private static func collectPagingScrollViews(in view: UIView) -> [UIScrollView] { + var scrollViews: [UIScrollView] = [] + + for subview in view.subviews { + if subview is UICollectionViewCell { + continue + } + + if let scrollView = subview as? UIScrollView { + scrollViews.append(scrollView) + } + + scrollViews.append(contentsOf: collectPagingScrollViews(in: subview)) + } + + return scrollViews + } +}