From 227b0af78c8036a7657093af1a64fe19f268a80a Mon Sep 17 00:00:00 2001 From: zewillze Date: Mon, 21 Sep 2026 19:45:52 +0800 Subject: [PATCH] fix(ios): respect scrollEnabled 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` toggled `isScrollEnabled` and `bounces` on the collection view only, so on iOS 15 a pager with `scrollEnabled={false}` could still be swiped even though the prop did reach the native view (outer `isScrollEnabled` is `false`, the embedded paging scroll view keeps `isScrollEnabled = true` and an enabled pan gesture). Apply both values to the embedded paging scroll views as well. The traversal stops at `UICollectionViewCell` boundaries so scroll views belonging to the pages' React Native content are never touched. On iOS 16+ the embedded scroll view does not exist and the collection view itself is the pager, so behaviour there is unchanged. This also makes `overdrag` work on iOS 15 for the same reason: `bounces` was only applied to the collection view, leaving over-drag enabled. Refs #1028 --- ios/PagerView.swift | 55 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/ios/PagerView.swift b/ios/PagerView.swift index 5d90f7f6..677ccb36 100644 --- a/ios/PagerView.swift +++ b/ios/PagerView.swift @@ -16,6 +16,49 @@ struct PagerView: View { props.orientation == .vertical } + /// SwiftUI's paged `TabView` is backed by a `UICollectionView` whose section relies on + /// orthogonal paging. On iOS 15 the horizontal paging is performed by an embedded + /// `UIScrollView` subview of that collection view (private class + /// `_UICollectionViewOrthogonalScrollerEmbeddedScrollView`), while the collection view + /// itself only scrolls vertically. Turning scrolling off on the collection view alone + /// therefore leaves the pager swipeable even with `scrollEnabled={false}`. + /// + /// 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 var pagingScrollViews: [UIScrollView] { + guard let collectionView else { return [] } + return PagerView.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 + } + + private func applyScrollEnabled(_ scrollEnabled: Bool) { + collectionView?.isScrollEnabled = scrollEnabled + pagingScrollViews.forEach { $0.isScrollEnabled = scrollEnabled } + } + + private func applyOverdrag(_ overdrag: Bool) { + collectionView?.bounces = overdrag + pagingScrollViews.forEach { $0.bounces = overdrag } + } + var body: some View { GeometryReader { proxy in TabView(selection: $props.currentPage) { @@ -39,8 +82,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 @@ -58,8 +101,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 @@ -79,10 +122,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