diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cf512c0..192a643e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,17 @@ jobs: pull-requests: write uses: ./.github/workflows/ios.yml + ios-spm: + name: iOS Build (SwiftPM) + needs: changes + if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.ios == 'true') }} + permissions: + contents: read + pull-requests: write + uses: ./.github/workflows/ios.yml + with: + spm: true + required: name: Required checks needs: [changes, lint, android, ios] diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 93580d4c..07ab4215 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -2,6 +2,11 @@ name: iOS Build on: workflow_call: + inputs: + spm: + description: Convert the example to Swift Package Manager before building + type: boolean + default: false jobs: ios-build: @@ -32,6 +37,7 @@ jobs: # Exact key only: a Pods dir restored from an older Podfile.lock keeps stale # `Local Podspecs` and makes `pod install` bail on the version mismatch. - name: Cache Pods + if: ${{ !inputs.spm }} uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: example/ios/Pods @@ -44,13 +50,27 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .ccache - key: ccache-ios-${{ github.run_id }}-${{ github.run_attempt }} - restore-keys: ccache-ios- + key: ccache-ios${{ inputs.spm && '-spm' || '' }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: ccache-ios${{ inputs.spm && '-spm' || '' }}- - name: Install example Pods + if: ${{ !inputs.spm }} run: bun pods working-directory: example + - name: Convert example to SwiftPM + if: ${{ inputs.spm }} + working-directory: example/ios + run: | + npx react-native spm scaffold --deintegrate --yes + + # The scaffolder misses screens' `` header path. + manifest=../../node_modules/react-native-screens/Package.swift + search_path='.headerSearchPath("common/cpp/react/renderer/components")' + grep -qF "$search_path" "$manifest" || + perl -pi -e 's|\.headerSearchPath\("common/cpp"\),|$&'" $search_path,"'|g' "$manifest" + grep -qF "$search_path" "$manifest" + # Simulator.app attaches the Mac keyboard by default, which hides the # software keyboard and leaves no Return key for the keyboard tests. - name: Detach hardware keyboard from simulator @@ -74,7 +94,7 @@ jobs: if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: e2e-report-ios + name: e2e-report-ios${{ inputs.spm && '-spm' || '' }} path: | e2e/.e2e/report.json e2e/.e2e/artifacts diff --git a/.gitignore b/.gitignore index 290766f0..c577eb46 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,8 @@ example/ios/PagerViewExample.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist !.yarn/sdks !.yarn/versions .agent-device + +# Swift Package Manager +.build/ +.swiftpm/ +ios/Package.resolved diff --git a/ios/Package.swift b/ios/Package.swift new file mode 100644 index 00000000..25d09711 --- /dev/null +++ b/ios/Package.swift @@ -0,0 +1,77 @@ +// swift-tools-version: 6.0 + +// Swift Package Manager manifest consumed by React Native's SPM autolinking +// (`npx react-native spm`). CocoaPods keeps using react-native-pager-view.podspec. +// +// The autolinker references this package through a symlink at +// /ios/build/generated/autolinking/libs/react-native-pager-view, and SwiftPM +// resolves the relative `.package(path:)` entries below against that symlink, +// so they point at the app's prebuilt React Native and codegen packages. + +import PackageDescription + +let reactNativeHeaders: [Target.Dependency] = [ + .product(name: "ReactHeaders", package: "ReactNative"), + .product(name: "ReactNativeHeaders", package: "ReactNative"), + .product(name: "ReactNativeDependenciesHeaders", package: "ReactNative"), + .product(name: "ReactAppHeaders", package: "React-GeneratedCode"), +] + +// Must match the prebuilt React.framework's config-gated C++ ABI, otherwise the +// Release link fails on DebugStringConvertible's vtable. +let reactCxxDefines: [CXXSetting] = [ + .define("DEBUG", .when(configuration: .debug)), + .define("NDEBUG", .when(configuration: .release)), +] + +let package = Package( + name: "react-native-pager-view", + platforms: [.iOS("15.1"), .visionOS(.v1)], + products: [ + .library(name: "react-native-pager-view", targets: ["react-native-pager-view", "RNCPagerViewSwift"]), + ], + dependencies: [ + .package(name: "ReactNative", path: "../../../../xcframeworks"), + .package(name: "React-GeneratedCode", path: "../../../ios"), + .package(url: "https://github.com/siteline/swiftui-introspect", from: "1.0.0"), + ], + targets: [ + // SwiftPM can't mix Swift and ObjC++ in one target, so the Swift + // sources build separately. ObjC++ can't import Swift (no C++ modules), + // so the dependency runs the other way: Swift implements the ObjC API + // in RNCPagerViewProviding.h, and both targets ship in the product. + .target( + name: "RNCPagerViewSwift", + dependencies: [ + "react-native-pager-view", + .product(name: "SwiftUIIntrospect", package: "swiftui-introspect"), + ] + reactNativeHeaders, + path: ".", + sources: [ + "Extensions.swift", + "PagerScrollDelegate.swift", + "PagerView.swift", + "PagerViewIOS15Compat.swift", + "PagerViewProps.swift", + "PagerViewProvider.swift", + ], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + .target( + name: "react-native-pager-view", + dependencies: reactNativeHeaders, + path: ".", + sources: [ + "RCTOnPageScrollEvent.m", + "RNCPagerViewComponentView.mm", + ], + publicHeadersPath: ".", + cxxSettings: reactCxxDefines, + linkerSettings: [ + .linkedFramework("UIKit"), + .linkedFramework("SwiftUI"), + ] + ), + ], + cxxLanguageStandard: .cxx20 +) diff --git a/ios/PagerScrollDelegate.swift b/ios/PagerScrollDelegate.swift index 5b09e032..57e10f91 100644 --- a/ios/PagerScrollDelegate.swift +++ b/ios/PagerScrollDelegate.swift @@ -1,4 +1,7 @@ import UIKit +#if SWIFT_PACKAGE +import react_native_pager_view +#endif /** Scroll delegate used to control underlying TabView's collection view. @@ -29,8 +32,7 @@ class PagerScrollDelegate: NSObject, UIScrollViewDelegate, UICollectionViewDeleg let offset = contentOffset.truncatingRemainder(dividingBy: pageSize) / pageSize let position = round(contentOffset / pageSize - offset) - let eventData = OnPageScrollEventData(position: position, offset: offset) - delegate?.onPageScroll(data: eventData) + delegate?.onPageScroll(position: position, offset: offset) originalDelegate?.scrollViewDidScroll?(scrollView) } @@ -43,8 +45,7 @@ class PagerScrollDelegate: NSObject, UIScrollViewDelegate, UICollectionViewDeleg if pageSize > 0 { let page = Int(round(contentOffset / pageSize)) - let eventData = OnPageScrollEventData(position: Double(page), offset: 0) - delegate?.onPageScroll(data: eventData) + delegate?.onPageScroll(position: Double(page), offset: 0) } delegate?.onPageScrollStateChanged(state: .idle) diff --git a/ios/PagerView.swift b/ios/PagerView.swift index e0283a35..4b6a45f5 100644 --- a/ios/PagerView.swift +++ b/ios/PagerView.swift @@ -3,6 +3,9 @@ import SwiftUI @_spi(Advanced) import SwiftUIIntrospect +#if SWIFT_PACKAGE +import react_native_pager_view +#endif struct PagerView: View { @ObservedObject var props: PagerViewProps diff --git a/ios/PagerViewProps.swift b/ios/PagerViewProps.swift index fbc2c9ac..10995c91 100644 --- a/ios/PagerViewProps.swift +++ b/ios/PagerViewProps.swift @@ -1,5 +1,8 @@ import SwiftUI import UIKit +#if SWIFT_PACKAGE +import react_native_pager_view +#endif struct IdentifiablePlatformView: Identifiable, Equatable { let id = UUID() @@ -10,10 +13,7 @@ struct IdentifiablePlatformView: Identifiable, Equatable { } } -@objc public enum PagerLayoutDirection: Int { - case ltr - case rtl - +extension PagerLayoutDirection { var converted: LayoutDirection { switch self { case .ltr: diff --git a/ios/PagerViewProvider.swift b/ios/PagerViewProvider.swift index 82cf6f1e..802da979 100644 --- a/ios/PagerViewProvider.swift +++ b/ios/PagerViewProvider.swift @@ -1,123 +1,109 @@ import SwiftUI import UIKit - -@objc public enum PageScrollState: Int { - case idle - case dragging - case settling -} - -@objcMembers public class OnPageScrollEventData: NSObject { - public let position: Double - public let offset: Double - - init(position: Double, offset: Double) { - self.position = position - self.offset = offset - super.init() - } -} - -@objc public protocol PagerViewProviderDelegate { - func onPageScroll(data: OnPageScrollEventData) - func onPageScrollStateChanged(state: PageScrollState) - func onPageSelected(position: Int) +#if SWIFT_PACKAGE +// CocoaPods exposes React's UIView+React category and RNCPagerViewProviding.h +// implicitly (same module); SwiftPM needs the imports. +import React +import react_native_pager_view +#endif + +@_cdecl("RNCPagerViewCreateProvider") +public func createPagerViewProvider(delegate: PagerViewProviderDelegate) -> UIView & RNCPagerViewProviding { + PagerViewProvider(delegate: delegate) } -@objc public class PagerViewProvider: UIView { +final class PagerViewProvider: UIView, RNCPagerViewProviding { private weak var delegate: PagerViewProviderDelegate? private var hostingController: UIHostingController? private var props = PagerViewProps() - @objc public var scrollEnabled: Bool = true { + var scrollEnabled: Bool = true { didSet { props.scrollEnabled = scrollEnabled } } - @objc public var overdrag: Bool = false { + var overdrag: Bool = false { didSet { props.overdrag = overdrag } } - @objc public var currentPage: Int = -1 { + var currentPage: Int = -1 { didSet { props.currentPage = currentPage } } - @objc public var keyboardDismissMode: UIScrollView.KeyboardDismissMode = .none { + var keyboardDismissMode: UIScrollView.KeyboardDismissMode = .none { didSet { props.keyboardDismissMode = keyboardDismissMode } } - @objc public var layoutDirection: PagerLayoutDirection = .ltr { + var layoutDirection: PagerLayoutDirection = .ltr { didSet { props.layoutDirection = layoutDirection } } - @objc public var orientation: UICollectionView.ScrollDirection = .horizontal { + var orientation: UICollectionView.ScrollDirection = .horizontal { didSet { props.orientation = orientation } } - @objc public convenience init(delegate: PagerViewProviderDelegate) { + convenience init(delegate: PagerViewProviderDelegate) { self.init() self.delegate = delegate } - @objc(insertChild:atIndex:) - public func insertChild(_ child: UIView, at index: Int) { + func insertChild(_ child: UIView, at index: Int) { guard index >= 0 && index <= props.children.count else { return } props.children.insert(IdentifiablePlatformView(child), at: index) } - @objc(removeChildAtIndex:) - public func removeChild(at index: Int) { + func removeChild(at index: Int) { guard index >= 0 && index < props.children.count else { return } props.children.remove(at: index) } - override public func didMoveToWindow() { + override func didMoveToWindow() { super.didMoveToWindow() if window != nil { setupView() } } - override public func willMove(toWindow newWindow: UIWindow?) { + override func willMove(toWindow newWindow: UIWindow?) { super.willMove(toWindow: newWindow) if newWindow != nil { syncParentViewController() } } - override public func willMove(toSuperview newSuperview: UIView?) { + override func willMove(toSuperview newSuperview: UIView?) { if let parentViewController = newSuperview?.reactViewController() { syncParentViewController(to: parentViewController) } super.willMove(toSuperview: newSuperview) } - override public func didMoveToSuperview() { + override func didMoveToSuperview() { super.didMoveToSuperview() syncParentViewController() } - override public func layoutSubviews() { + override func layoutSubviews() { super.layoutSubviews() if window != nil { setupView() } } - override public func safeAreaInsetsDidChange() { + override func safeAreaInsetsDidChange() { super.safeAreaInsetsDidChange() // The hosting view is forced to `.zero`, so UIKit will not forward this // change into `PageChildViewController`. Relayout so it can re-read our @@ -125,7 +111,7 @@ import UIKit hostingController?.view.setNeedsLayout() } - @objc public func goTo(index: Int, animated: Bool) { + func goTo(index: Int, animated: Bool) { if animated && hasPresentedViewController() { // A native-stack modal can begin its dismissal in the same JavaScript // callback as `setPage`. Starting a SwiftUI TabView animation in that diff --git a/ios/RNCPagerViewComponentView.mm b/ios/RNCPagerViewComponentView.mm index eeeecde6..a846fe67 100644 --- a/ios/RNCPagerViewComponentView.mm +++ b/ios/RNCPagerViewComponentView.mm @@ -5,16 +5,11 @@ #import #import -#import "RCTFabricComponentsPlugins.h" +#import #import "React/RCTConversions.h" #import "RCTOnPageScrollEvent.h" - -#if __has_include("react_native_pager_view/react_native_pager_view-Swift.h") -#import "react_native_pager_view/react_native_pager_view-Swift.h" -#else -#import "react_native_pager_view-Swift.h" -#endif +#import "RNCPagerViewProviding.h" using namespace facebook::react; @@ -22,7 +17,7 @@ @interface RNCPagerViewComponentView () *_pagerViewProvider; } // Needed because of this: https://github.com/facebook/react-native/pull/37274 @@ -37,7 +32,7 @@ - (instancetype)initWithFrame:(CGRect)frame if (self = [super initWithFrame:frame]) { static const auto defaultProps = std::make_shared(); _props = defaultProps; - _pagerViewProvider = [[PagerViewProvider alloc] initWithDelegate:self]; + _pagerViewProvider = RNCPagerViewCreateProvider(self); self.contentView = _pagerViewProvider; } @@ -101,9 +96,8 @@ - (void)updateProps:(const facebook::react::Props::Shared &)props oldProps:(cons #pragma mark - PagerViewProviderDelegate -- (void)onPageScrollWithData:(OnPageScrollEventData *)data { - const auto eventEmitter = [self pagerEventEmitter]; - [self sendScrollEventsForPosition:data.position offset:data.offset]; +- (void)onPageScrollWithPosition:(double)position offset:(double)offset { + [self sendScrollEventsForPosition:position offset:offset]; } - (void)onPageSelectedWithPosition:(NSInteger)position { @@ -111,7 +105,7 @@ - (void)onPageSelectedWithPosition:(NSInteger)position { eventEmitter->onPageSelected(RNCViewPagerEventEmitter::OnPageSelected{.position = static_cast(position)}); } -- (void)onPageScrollStateChangedWithState:(enum PageScrollState)state { +- (void)onPageScrollStateChangedWithState:(PageScrollState)state { const auto eventEmitter = [self pagerEventEmitter]; RNCViewPagerEventEmitter::OnPageScrollStateChangedPageScrollState scrollState; diff --git a/ios/RNCPagerViewProviding.h b/ios/RNCPagerViewProviding.h new file mode 100644 index 00000000..60354a2a --- /dev/null +++ b/ios/RNCPagerViewProviding.h @@ -0,0 +1,42 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +// The pager's native API: declared here in ObjC, implemented in Swift +// (PagerViewProvider.swift). RNCPagerViewComponentView.mm only needs this +// header, never the generated Swift one, which ObjC++ can't import under +// Swift Package Manager (the Swift sources are a separate module there). + +typedef NS_CLOSED_ENUM(NSInteger, PageScrollState) { + PageScrollStateIdle, + PageScrollStateDragging, + PageScrollStateSettling, +}; + +typedef NS_CLOSED_ENUM(NSInteger, PagerLayoutDirection) { + PagerLayoutDirectionLtr, + PagerLayoutDirectionRtl, +}; + +@protocol PagerViewProviderDelegate +- (void)onPageScrollWithPosition:(double)position offset:(double)offset NS_SWIFT_NAME(onPageScroll(position:offset:)); +- (void)onPageScrollStateChangedWithState:(PageScrollState)state NS_SWIFT_NAME(onPageScrollStateChanged(state:)); +- (void)onPageSelectedWithPosition:(NSInteger)position NS_SWIFT_NAME(onPageSelected(position:)); +@end + +@protocol RNCPagerViewProviding +@property (nonatomic) BOOL scrollEnabled; +@property (nonatomic) BOOL overdrag; +@property (nonatomic) NSInteger currentPage; +@property (nonatomic) UIScrollViewKeyboardDismissMode keyboardDismissMode; +@property (nonatomic) PagerLayoutDirection layoutDirection; +@property (nonatomic) UICollectionViewScrollDirection orientation; +- (void)insertChild:(UIView *)child atIndex:(NSInteger)index NS_SWIFT_NAME(insertChild(_:at:)); +- (void)removeChildAtIndex:(NSInteger)index NS_SWIFT_NAME(removeChild(at:)); +- (void)goToIndex:(NSInteger)index animated:(BOOL)animated NS_SWIFT_NAME(goTo(index:animated:)); +@end + +// Defined in PagerViewProvider.swift via @_cdecl. +FOUNDATION_EXTERN UIView *RNCPagerViewCreateProvider(id delegate); + +NS_ASSUME_NONNULL_END diff --git a/package.json b/package.json index e0aebaf0..3ded993f 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,9 @@ "publishConfig": { "registry": "https://registry.npmjs.org/" }, + "swiftpmConfig": { + "name": "react-native-pager-view" + }, "devDependencies": { "@react-native/babel-preset": "0.88.0", "@react-native/eslint-config": "0.88.0", diff --git a/react-native-pager-view.podspec b/react-native-pager-view.podspec index d55795ac..99c841cc 100644 --- a/react-native-pager-view.podspec +++ b/react-native-pager-view.podspec @@ -14,6 +14,7 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/callstack/react-native-pager-view.git", :tag => "#{s.version}" } s.source_files = "ios/**/*.{h,m,mm,swift}" + s.exclude_files = "ios/Package.swift" s.dependency "SwiftUIIntrospect", '~> 1.0'