From 18c93fa9b7198cb889c5ef0b9a5b14f9c8d1b5e3 Mon Sep 17 00:00:00 2001 From: zewillze Date: Thu, 8 Oct 2026 22:14:00 +0800 Subject: [PATCH] fix(ios): respect scrollEnabled and overdrag 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 (`_UICollectionViewOrthogonalScrollerEmbeddedScrollView`), while the collection view itself only scrolls vertically. `PagerView` can only toggle the collection view, so on iOS 15 a pager with `scrollEnabled={false}` could still be swiped even though the prop reached the native view (the outer `isScrollEnabled` is `false` while the embedded paging scroll view keeps `isScrollEnabled = true` and an enabled pan gesture). `overdrag` had the same problem: `bounces` never reached the view that actually scrolls. Mirror both values onto the scroll views SwiftUI embedded in the paging collection view. The traversal stops at `UICollectionViewCell` boundaries so scroll views belonging to the pages' React Native content are never touched. On iOS 16 and later the collection view itself is the pager and none of this is needed, so the whole workaround is isolated in the new `PagerViewIOS15Compat.swift` and every entry point starts with `if #available(iOS 16, *) { return }`. Deleting that file and its two call sites in `PagerView.swift` is all that is required once iOS 15 support is dropped. Refs #1028 --- ios/PagerView.swift | 24 +++++++++---- ios/PagerViewIOS15Compat.swift | 63 ++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 ios/PagerViewIOS15Compat.swift 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 + } +}