Skip to content

Commit 21e39b0

Browse files
authored
feat: migrate pager view to SwiftUI (#1020)
* feat: rewrite to SwiftUI * feat: improve safe area handling * feat: improve scroll delegate * feat: move to didMoveToWindow * feat: page margin * fix: remove not needed ignoresSafeArea
1 parent 9cd5c13 commit 21e39b0

13 files changed

+534
-381
lines changed

bun.lockb

9.57 KB
Binary file not shown.

example/ios/Podfile.lock

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,7 +1332,7 @@ PODS:
13321332
- React-jsiexecutor
13331333
- React-RCTFBReactNativeSpec
13341334
- ReactCommon/turbomodule/core
1335-
- react-native-pager-view (7.0.0):
1335+
- react-native-pager-view (7.0.2):
13361336
- DoubleConversion
13371337
- glog
13381338
- hermes-engine
@@ -1355,6 +1355,7 @@ PODS:
13551355
- ReactCodegen
13561356
- ReactCommon/turbomodule/bridging
13571357
- ReactCommon/turbomodule/core
1358+
- SwiftUIIntrospect (~> 1.0)
13581359
- Yoga
13591360
- react-native-safe-area-context (5.4.0):
13601361
- DoubleConversion
@@ -2032,6 +2033,7 @@ PODS:
20322033
- ReactCommon/turbomodule/core
20332034
- Yoga
20342035
- SocketRocket (0.7.1)
2036+
- SwiftUIIntrospect (1.3.0)
20352037
- Yoga (0.0.0)
20362038

20372039
DEPENDENCIES:
@@ -2120,6 +2122,7 @@ DEPENDENCIES:
21202122
SPEC REPOS:
21212123
trunk:
21222124
- SocketRocket
2125+
- SwiftUIIntrospect
21232126

21242127
EXTERNAL SOURCES:
21252128
boost:
@@ -2321,7 +2324,7 @@ SPEC CHECKSUMS:
23212324
React-logger: 8edfcedc100544791cd82692ca5a574240a16219
23222325
React-Mapbuffer: c3f4b608e4a59dd2f6a416ef4d47a14400194468
23232326
React-microtasksnativemodule: 054f34e9b82f02bd40f09cebd4083828b5b2beb6
2324-
react-native-pager-view: 39dffe42e6c5d419a16e3b8fe6522e43abcdf7e3
2327+
react-native-pager-view: 52b8363d55d54603806f0ac4149783ee11f2f2ce
23252328
react-native-safe-area-context: 562163222d999b79a51577eda2ea8ad2c32b4d06
23262329
React-NativeModulesApple: 2c4377e139522c3d73f5df582e4f051a838ff25e
23272330
React-oscompat: ef5df1c734f19b8003e149317d041b8ce1f7d29c
@@ -2362,6 +2365,7 @@ SPEC CHECKSUMS:
23622365
RNScreens: 5621e3ad5a329fbd16de683344ac5af4192b40d3
23632366
RNSVG: 8a1054afe490b5d63b9792d7ae3c1fde8c05cdd0
23642367
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
2368+
SwiftUIIntrospect: fee9aa07293ee280373a591e1824e8ddc869ba5d
23652369
Yoga: c758bfb934100bb4bf9cbaccb52557cee35e8bdf
23662370

23672371
PODFILE CHECKSUM: c21f5b764d10fb848650e6ae2ea533b823c1f648

ios/Extensions.swift

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import Foundation
2+
import SwiftUI
3+
import UIKit
4+
5+
/**
6+
Helper used to render UIView inside of SwiftUI.
7+
*/
8+
struct RepresentableView: UIViewRepresentable {
9+
var view: UIView
10+
11+
// Adding a wrapper UIView to avoid SwiftUI directly managing React Native views.
12+
// This fixes issues with incorrect layout rendering.
13+
func makeUIView(context: Context) -> UIView {
14+
let wrapper = UIView()
15+
wrapper.addSubview(view)
16+
return wrapper
17+
}
18+
19+
func updateUIView(_ uiView: UIView, context: Context) {}
20+
}
21+
22+
extension Collection {
23+
// Returns the element at the specified index if it is within bounds, otherwise nil.
24+
subscript(safe index: Index) -> Element? {
25+
indices.contains(index) ? self[index] : nil
26+
}
27+
}
28+
29+
extension UIView {
30+
func pinEdges(to other: UIView) {
31+
NSLayoutConstraint.activate([
32+
leadingAnchor.constraint(equalTo: other.leadingAnchor),
33+
trailingAnchor.constraint(equalTo: other.trailingAnchor),
34+
topAnchor.constraint(equalTo: other.topAnchor),
35+
bottomAnchor.constraint(equalTo: other.bottomAnchor)
36+
])
37+
}
38+
}
39+
40+
extension UIHostingController {
41+
convenience public init(rootView: Content, ignoreSafeArea: Bool) {
42+
self.init(rootView: rootView)
43+
44+
if ignoreSafeArea {
45+
disableSafeArea()
46+
}
47+
}
48+
49+
/// Disables safe area insets by dynamically subclassing the hosting controller's view
50+
/// and overriding safeAreaInsets to return .zero.
51+
func disableSafeArea() {
52+
guard let viewClass = object_getClass(view) else { return }
53+
54+
let viewSubclassName = String(cString: class_getName(viewClass)).appending("_IgnoreSafeArea")
55+
if let viewSubclass = NSClassFromString(viewSubclassName) {
56+
object_setClass(view, viewSubclass)
57+
}
58+
else {
59+
guard let viewClassNameUtf8 = (viewSubclassName as NSString).utf8String else { return }
60+
guard let viewSubclass = objc_allocateClassPair(viewClass, viewClassNameUtf8, 0) else { return }
61+
62+
if let method = class_getInstanceMethod(UIView.self, #selector(getter: UIView.safeAreaInsets)) {
63+
let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { _ in
64+
return .zero
65+
}
66+
class_addMethod(viewSubclass, #selector(getter: UIView.safeAreaInsets), imp_implementationWithBlock(safeAreaInsets), method_getTypeEncoding(method))
67+
}
68+
69+
objc_registerClassPair(viewSubclass)
70+
object_setClass(view, viewSubclass)
71+
}
72+
}
73+
}
74+

ios/PagerScrollDelegate.swift

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import UIKit
2+
3+
/**
4+
Scroll delegate used to control underlying TabView's collection view.
5+
*/
6+
class PagerScrollDelegate: NSObject, UIScrollViewDelegate, UICollectionViewDelegate {
7+
weak var originalDelegate: UICollectionViewDelegate?
8+
weak var delegate: PagerViewProviderDelegate?
9+
var orientation: UICollectionView.ScrollDirection = .horizontal
10+
11+
private let handledSelectors: Set<Selector> = [
12+
#selector(scrollViewDidScroll(_:)),
13+
#selector(scrollViewWillBeginDragging(_:)),
14+
#selector(scrollViewWillBeginDecelerating(_:)),
15+
#selector(scrollViewDidEndDecelerating(_:)),
16+
#selector(scrollViewDidEndScrollingAnimation(_:)),
17+
#selector(scrollViewDidEndDragging(_:willDecelerate:)),
18+
#selector(collectionView(_:didEndDisplaying:forItemAt:)),
19+
#selector(collectionView(_:willDisplay:forItemAt:))
20+
]
21+
22+
func scrollViewDidScroll(_ scrollView: UIScrollView) {
23+
let isHorizontal = orientation == .horizontal
24+
let pageSize = isHorizontal ? scrollView.frame.width : scrollView.frame.height
25+
let contentOffset = isHorizontal ? scrollView.contentOffset.x : scrollView.contentOffset.y
26+
27+
guard pageSize > 0 else { return }
28+
29+
let offset = contentOffset.truncatingRemainder(dividingBy: pageSize) / pageSize
30+
let position = round(contentOffset / pageSize - offset)
31+
32+
let eventData = OnPageScrollEventData(position: position, offset: offset)
33+
delegate?.onPageScroll(data: eventData)
34+
originalDelegate?.scrollViewDidScroll?(scrollView)
35+
}
36+
37+
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
38+
delegate?.onPageScrollStateChanged(state: .dragging)
39+
originalDelegate?.scrollViewWillBeginDragging?(scrollView)
40+
}
41+
42+
func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
43+
delegate?.onPageScrollStateChanged(state: .settling)
44+
originalDelegate?.scrollViewWillBeginDecelerating?(scrollView)
45+
}
46+
47+
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
48+
delegate?.onPageScrollStateChanged(state: .idle)
49+
originalDelegate?.scrollViewDidEndDecelerating?(scrollView)
50+
}
51+
52+
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
53+
delegate?.onPageScrollStateChanged(state: .idle)
54+
originalDelegate?.scrollViewDidEndScrollingAnimation?(scrollView)
55+
}
56+
57+
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
58+
if !decelerate {
59+
delegate?.onPageScrollStateChanged(state: .idle)
60+
}
61+
originalDelegate?.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate)
62+
}
63+
64+
func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
65+
originalDelegate?.collectionView?(collectionView, didEndDisplaying: cell, forItemAt: indexPath)
66+
}
67+
68+
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
69+
originalDelegate?.collectionView?(collectionView, willDisplay: cell, forItemAt: indexPath)
70+
}
71+
72+
override func responds(to aSelector: Selector!) -> Bool {
73+
handledSelectors.contains(aSelector) || (originalDelegate?.responds(to: aSelector) ?? false)
74+
}
75+
76+
override func forwardingTarget(for aSelector: Selector!) -> Any? {
77+
handledSelectors.contains(aSelector) ? nil : originalDelegate
78+
}
79+
}
80+

ios/PagerView.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import SwiftUI
2+
@_spi(Advanced) import SwiftUIIntrospect
3+
4+
struct PagerView: View {
5+
@ObservedObject var props: PagerViewProps
6+
@State private var scrollDelegate = PagerScrollDelegate()
7+
weak var delegate: PagerViewProviderDelegate?
8+
9+
@Weak var collectionView: UICollectionView?
10+
11+
var body: some View {
12+
TabView(selection: $props.currentPage) {
13+
ForEach(props.children) { child in
14+
if let index = props.children.firstIndex(of: child) {
15+
RepresentableView(view: child.view)
16+
.tag(index)
17+
}
18+
}
19+
}
20+
.id(props.children.count)
21+
.background(.clear)
22+
.tabViewStyle(.page(indexDisplayMode: .never))
23+
.environment(\.layoutDirection, props.layoutDirection.converted)
24+
.introspect(.tabView(style: .page), on: .iOS(.v14...)) { collectionView in
25+
self.collectionView = collectionView
26+
collectionView.bounces = props.overdrag
27+
collectionView.isScrollEnabled = props.scrollEnabled
28+
collectionView.keyboardDismissMode = props.keyboardDismissMode
29+
30+
if let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout {
31+
layout.scrollDirection = props.orientation
32+
}
33+
34+
if scrollDelegate.originalDelegate == nil {
35+
scrollDelegate.originalDelegate = collectionView.delegate
36+
scrollDelegate.delegate = delegate
37+
scrollDelegate.orientation = props.orientation
38+
collectionView.delegate = scrollDelegate
39+
}
40+
}
41+
.onChange(of: props.children) { newValue in
42+
if props.currentPage >= newValue.count && !newValue.isEmpty {
43+
props.currentPage = newValue.count - 1
44+
}
45+
}
46+
.onChange(of: props.currentPage) { newValue in
47+
delegate?.onPageSelected(position: newValue)
48+
}
49+
.onChange(of: props.scrollEnabled) { newValue in
50+
collectionView?.isScrollEnabled = newValue
51+
}
52+
.onChange(of: props.overdrag) { newValue in
53+
collectionView?.bounces = newValue
54+
}
55+
.onChange(of: props.keyboardDismissMode) { newValue in
56+
collectionView?.keyboardDismissMode = newValue
57+
}
58+
}
59+
}

ios/PagerViewProps.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import SwiftUI
2+
import UIKit
3+
4+
struct IdentifiablePlatformView: Identifiable, Equatable {
5+
let id = UUID()
6+
let view: UIView
7+
8+
init(_ view: UIView) {
9+
self.view = view
10+
}
11+
}
12+
13+
@objc public enum PagerLayoutDirection: Int {
14+
case ltr
15+
case rtl
16+
17+
var converted: LayoutDirection {
18+
switch self {
19+
case .ltr:
20+
return .leftToRight
21+
case .rtl:
22+
return .rightToLeft
23+
}
24+
}
25+
}
26+
27+
class PagerViewProps: ObservableObject {
28+
@Published var children: [IdentifiablePlatformView] = []
29+
@Published var currentPage: Int = -1
30+
@Published var scrollEnabled: Bool = true
31+
@Published var overdrag: Bool = false
32+
@Published var keyboardDismissMode: UIScrollView.KeyboardDismissMode = .none
33+
@Published var layoutDirection: PagerLayoutDirection = .ltr
34+
@Published var orientation: UICollectionView.ScrollDirection = .horizontal
35+
}

0 commit comments

Comments
 (0)