From 977198265a9a17f3b36eb7bfe054c480220fcd7d Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Fri, 29 May 2026 13:22:58 +0200 Subject: [PATCH 1/4] modernize --- .github/workflows/ci.yml | 26 +++ .gitignore | 36 +--- .swift-version | 1 + README.md | 103 ++++++++++- .../project.pbxproj | 62 ++++--- .../xcshareddata/swiftpm/Package.resolved | 61 ++++--- .../Animations/Animation+Fade.swift | 1 + .../Animations/Animation+Modal.swift | 1 + .../Animations/Animation+Navigation.swift | 2 + .../Animations/Animation+Scale.swift | 3 +- .../Animations/Animation+Swirl.swift | 4 +- XCoordinator-Example/Common/AppDelegate.swift | 12 +- .../Common/DeepLinkParser.swift | 38 ++++ .../Common/SceneDelegate.swift | 51 ++++++ .../Common/UITestIdentifiers.swift | 23 +++ .../Coordinators/AboutCoordinator.swift | 16 +- .../Coordinators/AppCoordinator.swift | 53 +++++- .../Coordinators/HomePageCoordinator.swift | 11 +- .../Coordinators/HomeSplitCoordinator.swift | 3 + .../Coordinators/HomeTabCoordinator.swift | 7 + .../Coordinators/NewsCoordinator.swift | 14 +- .../Coordinators/UserCoordinator.swift | 10 ++ .../Coordinators/UserListCoordinator.swift | 19 +- .../TransitionAnimation+Defaults.swift | 4 + .../Extensions/Transitions.swift | 39 +++++ XCoordinator-Example/Info.plist | 32 +++- XCoordinator-Example/PrivacyInfo.xcprivacy | 14 ++ .../Scenes/Home/HomeViewController.swift | 3 +- .../Scenes/Home/HomeViewModel.swift | 14 +- .../Scenes/Home/HomeViewModelImpl.swift | 6 - .../Scenes/Login/LoginViewController.swift | 1 + .../Scenes/User/UserViewController.swift | 2 +- .../Scenes/UserList/UsersViewController.swift | 2 +- .../Services/NewsService.swift | 5 +- .../Services/UserService.swift | 2 + XCoordinator-Example/Utils/BindableType.swift | 5 + .../Utils/NibIdentifiable.swift | 3 + .../XCoordinator_ExampleUITests.swift | 162 ++++++++++++++++-- 38 files changed, 685 insertions(+), 166 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .swift-version create mode 100644 XCoordinator-Example/Common/DeepLinkParser.swift create mode 100644 XCoordinator-Example/Common/SceneDelegate.swift create mode 100644 XCoordinator-Example/Common/UITestIdentifiers.swift create mode 100644 XCoordinator-Example/PrivacyInfo.xcprivacy diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dc11242 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +jobs: + build-and-test: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Show Xcode version + run: xcodebuild -version + + - name: Build and test + run: | + xcodebuild \ + -project XCoordinator-Example.xcodeproj \ + -scheme XCoordinator-Example \ + -testPlan XCoordinator-Example \ + -destination 'platform=iOS Simulator,OS=latest,name=iPhone 16' \ + CODE_SIGNING_ALLOWED=NO \ + test diff --git a/.gitignore b/.gitignore index 312d1f6..3290c5e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,4 @@ # Xcode -# -# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore ## Build generated build/ @@ -16,6 +14,10 @@ DerivedData/ *.perspectivev3 !default.perspectivev3 xcuserdata/ +*.xcworkspace/xcuserdata/ + +## macOS +.DS_Store ## Other *.moved-aside @@ -32,37 +34,11 @@ xcuserdata/ timeline.xctimeline playground.xcworkspace -# Swift Package Manager +## Swift Package Manager # # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. # Packages/ # Package.pins # Package.resolved .build/ - -# CocoaPods -# -# We recommend against adding the Pods directory to your .gitignore. However -# you should judge for yourself, the pros and cons are mentioned at: -# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# -# Pods/ - -# Carthage -# -# Add this line if you want to avoid checking in source code from Carthage dependencies. -# Carthage/Checkouts - -Carthage/Build - -# fastlane -# -# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the -# screenshots whenever they are needed. -# For more information about the recommended setup visit: -# https://docs.fastlane.tools/best-practices/source-control/#source-control - -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots/**/*.png -fastlane/test_output +.swiftpm/ diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..95ee81a --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +5.9 diff --git a/README.md b/README.md index 9a9d509..9d3c7ad 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,112 @@ -

# XCoordinator-Example -XCoordinator-Example is a MVVM-C example app for [XCoordinator](https://github.com/quickbirdstudios/XCoordinator). For a MVC example app, have a look at [a workshop](https://github.com/quickbirdstudios/Mobile-HackNight-XCoordinator) we did with a previous version of XCoordinator. +A sample iOS app showing **MVVM-C with [XCoordinator](https://github.com/quickbirdstudios/XCoordinator) v2** — built specifically to demonstrate the library's container coordinators side by side and to give you a worked example of MVVM-C scene wiring with RxSwift. + + + +## What this example teaches + +A login → home → detail flow built end-to-end with XCoordinator v2 and MVVM-C. Use it as a reference for: + +- **Four coordinator container types in one app** — `NavigationCoordinator`, `TabBarCoordinator`, `SplitCoordinator`, and `PageCoordinator`. The Home screen lets you pick which one drives the same `HomeRoute`, so you can compare them side-by-side. +- **MVVM-C scene wiring** via the `BindableType` protocol — each scene is a `ViewController` + `ViewModel` protocol triplet + `ViewModelImpl`, bound with [RxSwift](https://github.com/ReactiveX/RxSwift) and [Action](https://github.com/RxSwiftCommunity/Action). +- **Custom transition animations** — a swirl push, fade modals, and shared defaults under `Animations/`. +- **Deep linking** through nested coordinators with `Transition.multiple(.dismissAll(), .popToRoot(), deepLink(...))`, simulating an incoming push notification. +- **Child coordinators without a transition** — the About screen is attached via `addChild` + `.none()` instead of being presented. +- **Custom transition extensions** — `presentFullScreen` and `dismissAll` in `Extensions/Transitions.swift`. + +## The three-home-coordinator picker + +When you reach the Home screen, the app asks you to pick between `HomeTabCoordinator`, `HomeSplitCoordinator`, `HomePageCoordinator`, or a random one. This picker is the point of the example: all three coordinators expose the **same** `HomeRoute { case news; case userList }`, and `AppCoordinator` wraps whichever one you pick into `AppRoute.home(StrongRouter)`. + +The takeaway: routes describe *what* should happen, coordinators decide *how*. Swapping the container coordinator changes the entire navigation chrome of your app without touching a single view model or route definition. Open [`AppCoordinator.swift`](XCoordinator-Example/Coordinators/AppCoordinator.swift) to see the wrapping, then compare [`HomeTabCoordinator.swift`](XCoordinator-Example/Coordinators/HomeTabCoordinator.swift), [`HomeSplitCoordinator.swift`](XCoordinator-Example/Coordinators/HomeSplitCoordinator.swift), and [`HomePageCoordinator.swift`](XCoordinator-Example/Coordinators/HomePageCoordinator.swift) to see three different `prepareTransition(for:)` implementations handling identical routes. + +## Deep-link demo + +The app registers the `xcoordinator-example://` URL scheme. Trigger it from a booted simulator: + +```bash +xcrun simctl openurl booted "xcoordinator-example://news/3" +xcrun simctl openurl booted "xcoordinator-example://users/Paul" +``` + +`SceneDelegate.scene(_:openURLContexts:)` (and the matching cold-launch path in `willConnectTo`) parses the URL via [`DeepLinkParser`](XCoordinator-Example/Common/DeepLinkParser.swift) into an `AppRoute`, then triggers it on the app's `StrongRouter`. For news, `AppRoute.newsDetail` resolves to + +```swift +.multiple( + .dismissAll(), + .popToRoot(), + deepLink(AppRoute.home(...), HomeRoute.news, NewsRoute.newsDetail(news)) +) +``` + +`.multiple` chains the transitions sequentially, and `deepLink(...)` walks the coordinator hierarchy by triggering successive routes, so the app lands on the article from *any* current navigation state (modal stacks included). The users path (`AppRoute.userDetail`) takes the same shape and ends in a modal present. + +One gotcha worth knowing if you deep-link through a `PageCoordinator`: `deepLink` chains the next route inside each transition's completion handler, but `UIPageViewController.setViewControllers(…, animated: true)` silently skips its completion when the requested page is *already* on-screen. A deep link whose page step targets the already-visible page would stall there. This example works around it with `Transition.setReliably(_:direction:)` in [`Extensions/Transitions.swift`](XCoordinator-Example/Extensions/Transitions.swift), a drop-in replacement for `.set` that always fires the completion — see [`HomePageCoordinator`](XCoordinator-Example/Coordinators/HomePageCoordinator.swift). + +## Requirements + +- Xcode (current stable) +- Swift 5.9 +- iOS 16+ (iPhone and iPad) +- Swift Package Manager — `XCoordinator` 2.2.1, `RxSwift` 6.10.2, `Action` 5.0.0 + +No CocoaPods, no Carthage; dependencies resolve automatically when you open the project. + +## Run it + +```bash +git clone https://github.com/quickbirdstudios/XCoordinator-Example.git +cd XCoordinator-Example +open XCoordinator-Example.xcodeproj +``` + +Build and run on any iOS 16+ simulator. Log in with any credentials, then pick a Home coordinator from the alert to start exploring. + +## Project structure + +``` +XCoordinator-Example/ +├── Common/ AppDelegate, SceneDelegate, asset catalog, launch screen +├── Coordinators/ AppCoordinator + four home coordinators + News/User/UserList/About +├── Scenes// +│ ├── ViewController.swift (xib-based, conforms to BindableType) +│ ├── ViewModel.swift (Input / Output / composite protocols) +│ └── ViewModelImpl.swift (RxSwift + Action implementation) +├── Animations/ Custom Animation instances (.fade, .scale, .swirl, .modal, .navigation) +├── Extensions/ Transitions.swift (presentFullScreen, dismissAll), Rx helpers +├── Services/ MockNewsService, MockUserService (no real backend) +├── Utils/ BindableType, NibIdentifiable +└── Models/ News, User +``` + +Every scene under `Scenes//` is the same triplet pattern — read [`HomeViewModel.swift`](XCoordinator-Example/Scenes/Home/HomeViewModel.swift) once to see how Input/Output/composite protocols compose into a single `ViewModelImpl`. + +## Learn more + +- [XCoordinator](https://github.com/quickbirdstudios/XCoordinator) — the library this example demonstrates. +- [Mobile-HackNight-XCoordinator](https://github.com/quickbirdstudios/Mobile-HackNight-XCoordinator) — a workshop using an earlier version of XCoordinator with MVC. +- [Coordinators Redux](https://khanlou.com/2015/10/coordinators-redux/) by Soroush Khanlou — background reading on the coordinator pattern. ## 👤 Author + This example app is created with ❤️ by [QuickBird Studios](https://quickbirdstudios.com). ## ❤️ Contributing -Open an issue if you need help, if you found a bug, or if you want to discuss a feature request. If you feel like having a chat about XCoordinator or this example app with the developers and other users, join our [Slack Workspace](https://join.slack.com/t/xcoordinator/shared_invite/enQtNDg4NDAxNTk1ODQ1LTRhMjY0OTAwNWMyYmQ5ZWI5Mzk3ODU1NGJmMWZlZDY3Y2Q0NTZjOWNkMjgyNmQwYjY4MzZmYTRhN2EzMzczNTM). - -Open a PR if you want to make changes to the XCoordinator example app. +Open an issue if you need help, if you found a bug, or if you want to discuss a feature request. Open a PR if you want to make changes to the XCoordinator example app. ## 📃 License -XCoordinator-Example is released under an MIT license. See [License.md](https://github.com/quickbirdstudios/XCoordinator-Example/blob/master/LICENSE) for more information. +XCoordinator-Example is released under an MIT license. See [LICENSE](LICENSE) for more information. diff --git a/XCoordinator-Example.xcodeproj/project.pbxproj b/XCoordinator-Example.xcodeproj/project.pbxproj index 10a4ee7..0efd903 100644 --- a/XCoordinator-Example.xcodeproj/project.pbxproj +++ b/XCoordinator-Example.xcodeproj/project.pbxproj @@ -21,6 +21,10 @@ 9B5657B02315FB3500F4F4F7 /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9B5657742315FB3500F4F4F7 /* LaunchScreen.xib */; }; 9B5657B12315FB3500F4F4F7 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9B5657762315FB3500F4F4F7 /* Images.xcassets */; }; 9B5657B22315FB3500F4F4F7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B5657772315FB3500F4F4F7 /* AppDelegate.swift */; }; + AABBCCDDEEFF000000000002 /* UITestIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCCDDEEFF000000000001 /* UITestIdentifiers.swift */; }; + AABBCCDDEEFF000000000004 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCCDDEEFF000000000003 /* SceneDelegate.swift */; }; + AABBCCDDEEFF000000000006 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AABBCCDDEEFF000000000005 /* PrivacyInfo.xcprivacy */; }; + AABBCCDDEEFF000000000008 /* DeepLinkParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCCDDEEFF000000000007 /* DeepLinkParser.swift */; }; 9B5657B32315FB3500F4F4F7 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B56577A2315FB3500F4F4F7 /* HomeViewModel.swift */; }; 9B5657B42315FB3500F4F4F7 /* HomeViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9B56577B2315FB3500F4F4F7 /* HomeViewController.xib */; }; 9B5657B52315FB3500F4F4F7 /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B56577C2315FB3500F4F4F7 /* HomeViewController.swift */; }; @@ -110,6 +114,10 @@ 9B5657752315FB3500F4F4F7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; }; 9B5657762315FB3500F4F4F7 /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; 9B5657772315FB3500F4F4F7 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + AABBCCDDEEFF000000000001 /* UITestIdentifiers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UITestIdentifiers.swift; sourceTree = ""; }; + AABBCCDDEEFF000000000003 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + AABBCCDDEEFF000000000005 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + AABBCCDDEEFF000000000007 /* DeepLinkParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeepLinkParser.swift; sourceTree = ""; }; 9B56577A2315FB3500F4F4F7 /* HomeViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; 9B56577B2315FB3500F4F4F7 /* HomeViewController.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = HomeViewController.xib; sourceTree = ""; }; 9B56577C2315FB3500F4F4F7 /* HomeViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeViewController.swift; sourceTree = ""; }; @@ -230,6 +238,7 @@ 9B5657972315FB3500F4F4F7 /* Utils */, 9B56579A2315FB3500F4F4F7 /* Views */, 9B5657462315FA7E00F4F4F7 /* Info.plist */, + AABBCCDDEEFF000000000005 /* PrivacyInfo.xcprivacy */, ); path = "XCoordinator-Example"; sourceTree = ""; @@ -286,6 +295,9 @@ isa = PBXGroup; children = ( 9B5657772315FB3500F4F4F7 /* AppDelegate.swift */, + AABBCCDDEEFF000000000003 /* SceneDelegate.swift */, + AABBCCDDEEFF000000000007 /* DeepLinkParser.swift */, + AABBCCDDEEFF000000000001 /* UITestIdentifiers.swift */, 9B5657742315FB3500F4F4F7 /* LaunchScreen.xib */, 9B5657762315FB3500F4F4F7 /* Images.xcassets */, ); @@ -498,8 +510,8 @@ 9B56572D2315FA7B00F4F4F7 /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1100; - LastUpgradeCheck = 1100; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; ORGANIZATIONNAME = "QuickBird Studios"; TargetAttributes = { 9B5657342315FA7B00F4F4F7 = { @@ -553,6 +565,7 @@ 9B5657B42315FB3500F4F4F7 /* HomeViewController.xib in Resources */, 9B5657B02315FB3500F4F4F7 /* LaunchScreen.xib in Resources */, 9B5657C32315FB3500F4F4F7 /* NewsViewController.xib in Resources */, + AABBCCDDEEFF000000000006 /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -612,6 +625,9 @@ 9BD3EC102330F372005861BF /* AboutViewModel.swift in Sources */, 9B5657D12315FB3500F4F4F7 /* Animation+Fade.swift in Sources */, 9B5657B22315FB3500F4F4F7 /* AppDelegate.swift in Sources */, + AABBCCDDEEFF000000000004 /* SceneDelegate.swift in Sources */, + AABBCCDDEEFF000000000008 /* DeepLinkParser.swift in Sources */, + AABBCCDDEEFF000000000002 /* UITestIdentifiers.swift in Sources */, 9B5657BB2315FB3500F4F4F7 /* UserViewModel.swift in Sources */, 9B5657BF2315FB3500F4F4F7 /* UsersViewModelImpl.swift in Sources */, 9B5657AA2315FB3500F4F4F7 /* UserCoordinator.swift in Sources */, @@ -724,7 +740,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -778,7 +794,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -793,7 +809,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 77E79NGPCV; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "XCoordinator-Example/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -801,7 +817,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "com.quickbirdstudios.XCoordinator-Example"; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -811,7 +827,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 77E79NGPCV; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "XCoordinator-Example/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -819,7 +835,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "com.quickbirdstudios.XCoordinator-Example"; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -827,10 +843,10 @@ 9B5657632315FA7E00F4F4F7 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 77E79NGPCV; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "XCoordinator-ExampleTests/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -839,7 +855,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "com.quickbirdstudios.XCoordinator-ExampleTests"; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/XCoordinator-Example.app/XCoordinator-Example"; }; @@ -848,10 +864,10 @@ 9B5657642315FA7E00F4F4F7 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 77E79NGPCV; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "XCoordinator-ExampleTests/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -860,7 +876,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "com.quickbirdstudios.XCoordinator-ExampleTests"; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/XCoordinator-Example.app/XCoordinator-Example"; }; @@ -869,9 +885,9 @@ 9B5657662315FA7E00F4F4F7 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 77E79NGPCV; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "XCoordinator-ExampleUITests/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -880,7 +896,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "com.quickbirdstudios.XCoordinator-ExampleUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = "XCoordinator-Example"; }; @@ -889,9 +905,9 @@ 9B5657672315FA7E00F4F4F7 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 77E79NGPCV; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "XCoordinator-ExampleUITests/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -900,7 +916,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "com.quickbirdstudios.XCoordinator-ExampleUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = "XCoordinator-Example"; }; @@ -953,7 +969,7 @@ repositoryURL = "https://github.com/RxSwiftCommunity/Action.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 4.0.1; + minimumVersion = 5.0.0; }; }; 9B5657DA2315FC1000F4F4F7 /* XCRemoteSwiftPackageReference "RxSwift" */ = { @@ -961,7 +977,7 @@ repositoryURL = "https://github.com/ReactiveX/RxSwift.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 5.0.1; + minimumVersion = 6.0.0; }; }; 9B5657DD2315FC5C00F4F4F7 /* XCRemoteSwiftPackageReference "XCoordinator" */ = { @@ -969,7 +985,7 @@ repositoryURL = "https://github.com/quickbirdstudios/XCoordinator.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.0.2; + minimumVersion = 2.2.1; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/XCoordinator-Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/XCoordinator-Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2f97f0c..71fb7c6 100644 --- a/XCoordinator-Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/XCoordinator-Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,34 +1,33 @@ { - "object": { - "pins": [ - { - "package": "Action", - "repositoryURL": "https://github.com/RxSwiftCommunity/Action.git", - "state": { - "branch": null, - "revision": "cdade63f7bbe1f5e1eff7779e5858a796dc2c001", - "version": "4.0.1" - } - }, - { - "package": "RxSwift", - "repositoryURL": "https://github.com/ReactiveX/RxSwift.git", - "state": { - "branch": null, - "revision": "b3e888b4972d9bc76495dd74d30a8c7fad4b9395", - "version": "5.0.1" - } - }, - { - "package": "XCoordinator", - "repositoryURL": "https://github.com/quickbirdstudios/XCoordinator.git", - "state": { - "branch": null, - "revision": "0c16cc7061f93d278279137277efb13385e960a6", - "version": "2.0.5" - } + "originHash" : "5d5a6fcf63f9ca094c24fbe84c948b9dd4403adf50e1cd46835046ac2743aa5c", + "pins" : [ + { + "identity" : "action", + "kind" : "remoteSourceControl", + "location" : "https://github.com/RxSwiftCommunity/Action.git", + "state" : { + "revision" : "55f5da064fd66cb09acde22cbbb744fe7f6355aa", + "version" : "5.0.0" } - ] - }, - "version": 1 + }, + { + "identity" : "rxswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveX/RxSwift.git", + "state" : { + "revision" : "132aea4f236ccadc51590b38af0357a331d51fa2", + "version" : "6.10.2" + } + }, + { + "identity" : "xcoordinator", + "kind" : "remoteSourceControl", + "location" : "https://github.com/quickbirdstudios/XCoordinator.git", + "state" : { + "revision" : "00b066139ed0bf2f68549a7d87434ed36dfa6dea", + "version" : "2.2.1" + } + } + ], + "version" : 3 } diff --git a/XCoordinator-Example/Animations/Animation+Fade.swift b/XCoordinator-Example/Animations/Animation+Fade.swift index 0b01238..891ce06 100644 --- a/XCoordinator-Example/Animations/Animation+Fade.swift +++ b/XCoordinator-Example/Animations/Animation+Fade.swift @@ -10,6 +10,7 @@ import UIKit import XCoordinator extension Animation { + /// Cross-fade between view controllers. Used for the login → home presentation. static let fade = Animation( presentation: InteractiveTransitionAnimation.fade, dismissal: InteractiveTransitionAnimation.fade diff --git a/XCoordinator-Example/Animations/Animation+Modal.swift b/XCoordinator-Example/Animations/Animation+Modal.swift index 6693bb8..ae265c1 100644 --- a/XCoordinator-Example/Animations/Animation+Modal.swift +++ b/XCoordinator-Example/Animations/Animation+Modal.swift @@ -10,6 +10,7 @@ import UIKit import XCoordinator extension Animation { + /// Bottom-sheet style: incoming view slides up from below, dismissal slides back down. static let modal = Animation(presentation: InteractiveTransitionAnimation.modalPresentation, dismissal: InteractiveTransitionAnimation.modalDismissal) } diff --git a/XCoordinator-Example/Animations/Animation+Navigation.swift b/XCoordinator-Example/Animations/Animation+Navigation.swift index 1c6eff1..876736f 100644 --- a/XCoordinator-Example/Animations/Animation+Navigation.swift +++ b/XCoordinator-Example/Animations/Animation+Navigation.swift @@ -12,6 +12,8 @@ import XCoordinator // swiftlint:disable force_unwrapping extension Animation { + /// Custom navigation push/pop: slides horizontally with a parallax-style 30%-width offset on the + /// outgoing view, mimicking the default `UINavigationController` push. static let navigation = Animation(presentation: InteractiveTransitionAnimation.push, dismissal: InteractiveTransitionAnimation.pop) } diff --git a/XCoordinator-Example/Animations/Animation+Scale.swift b/XCoordinator-Example/Animations/Animation+Scale.swift index 3a15cc1..5c6aa1b 100644 --- a/XCoordinator-Example/Animations/Animation+Scale.swift +++ b/XCoordinator-Example/Animations/Animation+Scale.swift @@ -10,6 +10,8 @@ import UIKit import XCoordinator extension Animation { + /// Scale-and-fade transition: the incoming view grows from near-zero to full size while the outgoing + /// view fades out. Used as the iOS 9 fallback for `.swirl` in `NewsCoordinator`. static let scale = Animation( presentation: InteractiveTransitionAnimation.scalePresentation, dismissal: InteractiveTransitionAnimation.scaleDismissal @@ -51,7 +53,6 @@ extension InteractiveTransitionAnimation { toView.alpha = 0 fromView.layer.masksToBounds = true - let cornerRadius = max(fromView.frame.height, fromView.frame.width) UIView.animate(withDuration: defaultAnimationDuration, animations: { fromView.transform.scale(by: .verySmall) diff --git a/XCoordinator-Example/Animations/Animation+Swirl.swift b/XCoordinator-Example/Animations/Animation+Swirl.swift index 5193e44..8c3a3d7 100644 --- a/XCoordinator-Example/Animations/Animation+Swirl.swift +++ b/XCoordinator-Example/Animations/Animation+Swirl.swift @@ -11,12 +11,12 @@ import XCoordinator extension Animation { - @available(iOS 10.0, *) + /// Spin-and-grow transition built on `UIViewPropertyAnimator` (hence interruptible). Used for the + /// news-list → news-detail push. static let swirl = Animation(presentation: InterruptibleTransitionAnimation.swirlPresentation, dismissal: InterruptibleTransitionAnimation.swirlDismissal) } -@available(iOS 10.0, *) extension InterruptibleTransitionAnimation { fileprivate static let swirlPresentation = InterruptibleTransitionAnimation(duration: defaultAnimationDuration) { transitionContext in let containerView = transitionContext.containerView diff --git a/XCoordinator-Example/Common/AppDelegate.swift b/XCoordinator-Example/Common/AppDelegate.swift index 0db0077..fe4fda0 100644 --- a/XCoordinator-Example/Common/AppDelegate.swift +++ b/XCoordinator-Example/Common/AppDelegate.swift @@ -11,20 +11,20 @@ import UIKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { - // MARK: Stored properties - - private lazy var mainWindow = UIWindow() - private let router = AppCoordinator().strongRouter - // MARK: UIApplicationDelegate func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { configureUI() - router.setRoot(for: mainWindow) return true } + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + // MARK: Helpers private func configureUI() { diff --git a/XCoordinator-Example/Common/DeepLinkParser.swift b/XCoordinator-Example/Common/DeepLinkParser.swift new file mode 100644 index 0000000..b7c4bf4 --- /dev/null +++ b/XCoordinator-Example/Common/DeepLinkParser.swift @@ -0,0 +1,38 @@ +// +// DeepLinkParser.swift +// XCoordinator-Example +// +// Copyright © 2026 QuickBird Studios. All rights reserved. +// + +import Foundation + +/// Maps incoming URLs to `AppRoute` values that the existing deep-link transition chain can handle. +/// +/// Recognised schemes: +/// - `xcoordinator-example://news/` — open a specific article (id is the article's index) +/// - `xcoordinator-example://users/` — open a specific user's detail screen +/// +/// Anything else returns `nil`; callers ignore unknown URLs rather than crashing. +enum DeepLinkParser { + + static let scheme = "xcoordinator-example" + + static func parse(_ url: URL) -> AppRoute? { + guard url.scheme == scheme else { return nil } + switch url.host { + case "news": + guard let idString = url.pathComponents.dropFirst().first, + let id = Int(idString) else { return nil } + let articles = MockNewsService().mostRecentNews().articles + guard articles.indices.contains(id) else { return nil } + return .newsDetail(articles[id]) + case "users": + guard let username = url.pathComponents.dropFirst().first, + !username.isEmpty else { return nil } + return .userDetail(username: username) + default: + return nil + } + } +} diff --git a/XCoordinator-Example/Common/SceneDelegate.swift b/XCoordinator-Example/Common/SceneDelegate.swift new file mode 100644 index 0000000..06c6155 --- /dev/null +++ b/XCoordinator-Example/Common/SceneDelegate.swift @@ -0,0 +1,51 @@ +// +// SceneDelegate.swift +// XCoordinator-Example +// +// Copyright © 2026 QuickBird Studios. All rights reserved. +// + +import UIKit + +/// Owns the app window per scene and bootstraps the routing tree. +/// `AppCoordinator().strongRouter` holds the entire coordinator graph for this scene's lifetime; +/// `setRoot(for:)` installs `AppCoordinator.rootViewController` as the window's `rootViewController`. +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + private let router = AppCoordinator().strongRouter + + func scene(_ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + let window = UIWindow(windowScene: windowScene) + self.window = window + router.setRoot(for: window) + + // Cold-launch deep links arrive via `connectionOptions.urlContexts`. UI tests inject one through + // the `XCOORDINATOR_DEEP_LINK` env var instead since `launchEnvironment` is the only handle XCUI + // gives us into the launch. + // + // The handler is dispatched to the next runloop so the AppCoordinator's initial `.login` route + // has finished pushing before we trigger the deep-link chain. + let coldLaunchURL = ProcessInfo.processInfo.environment["XCOORDINATOR_DEEP_LINK"].flatMap(URL.init(string:)) + ?? connectionOptions.urlContexts.first?.url + if let url = coldLaunchURL { + DispatchQueue.main.async { [weak self] in + self?.handle(url: url) + } + } + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + if let context = URLContexts.first { + handle(url: context.url) + } + } + + private func handle(url: URL) { + guard let route = DeepLinkParser.parse(url) else { return } + router.trigger(route) + } +} diff --git a/XCoordinator-Example/Common/UITestIdentifiers.swift b/XCoordinator-Example/Common/UITestIdentifiers.swift new file mode 100644 index 0000000..31f07bd --- /dev/null +++ b/XCoordinator-Example/Common/UITestIdentifiers.swift @@ -0,0 +1,23 @@ +// +// UITestIdentifiers.swift +// XCoordinator-Example +// +// Accessibility identifiers used by the UI test target. If you change a string here, update the +// matching literal in XCoordinator-ExampleUITests/XCoordinator_ExampleUITests.swift. +// + +import Foundation + +enum UITestIdentifiers { + static let loginButton = "login.button" + static let homeContainerTab = "home-container.tab" + static let homeContainerSplit = "home-container.split" + static let homeContainerPage = "home-container.page" + static let usersButton = "home.users-button" +} + +enum UITestLaunchArguments { + /// `--random-picker-index N` makes the Random picker action deterministic in UI tests: + /// the AppCoordinator picks `routers[N]` instead of calling `randomElement()`. Absent → real random. + static let randomPickerIndex = "--random-picker-index" +} diff --git a/XCoordinator-Example/Coordinators/AboutCoordinator.swift b/XCoordinator-Example/Coordinators/AboutCoordinator.swift index 2a39280..e5e6aaf 100644 --- a/XCoordinator-Example/Coordinators/AboutCoordinator.swift +++ b/XCoordinator-Example/Coordinators/AboutCoordinator.swift @@ -9,11 +9,17 @@ import UIKit import XCoordinator +/// Routes for the About flow. enum AboutRoute: Route { + /// Push the About screen. Used as the initial route. case home + /// Open the QuickBird Studios website in the system browser (no view-controller transition). case website } +/// About flow attached as a child to `UserListCoordinator`. Reuses the parent's navigation controller +/// (passed via `init(rootViewController:)`) — see `UserListCoordinator.prepareTransition` for the +/// `addChild + .none()` pattern that makes this work. class AboutCoordinator: NavigationCoordinator { // MARK: Initialization @@ -33,6 +39,9 @@ class AboutCoordinator: NavigationCoordinator { viewController.bind(to: viewModel) return .push(viewController) case .website: + // Custom side-effecting `Transition`: there is no view controller to present, but the + // route still needs to flow through the coordinator. Passing `presentables: []` and a + // closure that opens the URL externally lets the routing pipeline drive arbitrary work. let url = URL(string: "https://quickbirdstudios.com/")! return Transition(presentables: [], animationInUse: nil) { _, _, completion in UIApplication.shared.open(url) @@ -41,11 +50,4 @@ class AboutCoordinator: NavigationCoordinator { } } - // MARK: Actions - - @objc - private func openWebsite() { - trigger(.website) - } - } diff --git a/XCoordinator-Example/Coordinators/AppCoordinator.swift b/XCoordinator-Example/Coordinators/AppCoordinator.swift index a1b356c..05e163a 100644 --- a/XCoordinator-Example/Coordinators/AppCoordinator.swift +++ b/XCoordinator-Example/Coordinators/AppCoordinator.swift @@ -9,12 +9,21 @@ import UIKit import XCoordinator +/// The top-level routes of the app: login, the chosen home flow, and deep-link entries. enum AppRoute: Route { + /// Push the login screen. Used as the initial route. case login + /// Present the home flow. Pass `nil` to show the picker that lets the user choose one of `HomeTabCoordinator`, + /// `HomeSplitCoordinator`, or `HomePageCoordinator`; pass a concrete router to skip the picker. case home(StrongRouter?) + /// Deep-link into a specific article, tearing down any modal stack and resetting navigation first. case newsDetail(News) + /// Deep-link into a specific user, tearing down any modal stack and resetting navigation first. + case userDetail(username: String) } +/// Owns the top-level navigation stack. Entry point from `SceneDelegate`/`AppDelegate`; spawns the chosen +/// home-flow coordinator and handles the simulated deep link into a news article. class AppCoordinator: NavigationCoordinator { // MARK: Initialization @@ -36,6 +45,10 @@ class AppCoordinator: NavigationCoordinator { if let router = router { return .presentFullScreen(router, animation: .fade) } + // Teaching device: when no router is supplied, the route itself resolves to a `UIAlertController` + // that lets the user pick one of the three home-flow coordinators. The chosen coordinator's + // router is then re-triggered through `.home(...)`, demonstrating that a `Transition` can be + // anything you can `.present`, including ad-hoc decision UI. let alert = UIAlertController( title: "How would you like to login?", message: "Please choose the type of coordinator used for the `Home` scene.", @@ -60,14 +73,23 @@ class AppCoordinator: NavigationCoordinator { let routers: [() -> StrongRouter] = [ { HomeTabCoordinator().strongRouter }, { HomeSplitCoordinator().strongRouter }, - { HomeTabCoordinator().strongRouter } + { HomePageCoordinator().strongRouter } ] - let router = routers.randomElement().map { $0() } - self.trigger(.home(router)) + let factory: (() -> StrongRouter)? + if let index = Self.testRandomPickerIndex, routers.indices.contains(index) { + factory = routers[index] + } else { + factory = routers.randomElement() + } + self.trigger(.home(factory?())) } ) return .present(alert) case .newsDetail(let news): + // Deep-link demo: `.multiple` chains transitions in sequence, and `deepLink(...)` walks down the + // coordinator hierarchy by triggering successive routes (AppRoute → HomeRoute → NewsRoute). + // The leading `.dismissAll()` + `.popToRoot()` guarantee a clean slate regardless of where in + // the navigation tree the user happens to be when the link fires. return .multiple( .dismissAll(), .popToRoot(), @@ -75,16 +97,33 @@ class AppCoordinator: NavigationCoordinator { HomeRoute.news, NewsRoute.newsDetail(news)) ) + case let .userDetail(username): + // Same deep-link shape as `.newsDetail`, ending in a modal present of the user detail. + // Note this targets `HomeRoute.userList`, which is `HomePageCoordinator`'s initial page — + // it works because `HomePageCoordinator` uses `setReliably` (see Extensions/Transitions.swift), + // which fires the transition completion even when the page is already on-screen, so the chain + // continues to `UserListRoute.user` instead of stalling. + return .multiple( + .dismissAll(), + .popToRoot(), + deepLink(AppRoute.home(HomePageCoordinator().strongRouter), + HomeRoute.userList, + UserListRoute.user(username)) + ) } } // MARK: Methods - func notificationReceived() { - guard let news = MockNewsService().mostRecentNews().articles.randomElement() else { - return + /// Returns the index value from `--random-picker-index N` launch argument, if present and parseable. + /// Used by UI tests to make the Random picker deterministic; nil in normal runs. + private static var testRandomPickerIndex: Int? { + let args = ProcessInfo.processInfo.arguments + guard let position = args.firstIndex(of: UITestLaunchArguments.randomPickerIndex), + position + 1 < args.count else { + return nil } - self.trigger(.newsDetail(news)) + return Int(args[position + 1]) } } diff --git a/XCoordinator-Example/Coordinators/HomePageCoordinator.swift b/XCoordinator-Example/Coordinators/HomePageCoordinator.swift index da623b5..62a131f 100644 --- a/XCoordinator-Example/Coordinators/HomePageCoordinator.swift +++ b/XCoordinator-Example/Coordinators/HomePageCoordinator.swift @@ -8,6 +8,8 @@ import XCoordinator +/// Home flow rendered as a horizontally-scrolling `UIPageViewController`. Demonstrates `PageCoordinator` +/// driving `HomeRoute` — same routes as `HomeTabCoordinator`, different container. class HomePageCoordinator: PageCoordinator { // MARK: Stored properties @@ -21,7 +23,7 @@ class HomePageCoordinator: PageCoordinator { userListRouter: StrongRouter = UserListCoordinator().strongRouter) { self.newsRouter = newsRouter self.userListRouter = userListRouter - + super.init( rootViewController: .init(transitionStyle: .scroll, navigationOrientation: .horizontal, @@ -29,16 +31,19 @@ class HomePageCoordinator: PageCoordinator { pages: [userListRouter, newsRouter], loop: false, set: userListRouter, direction: .forward ) + rootViewController.view.accessibilityIdentifier = UITestIdentifiers.homeContainerPage } // MARK: Overrides override func prepareTransition(for route: HomeRoute) -> PageTransition { + // `setReliably` instead of the stock `.set` so that deep links chaining through this coordinator + // don't stall when the target page is already on-screen — see Extensions/Transitions.swift. switch route { case .news: - return .set(newsRouter, direction: .forward) + return .setReliably(newsRouter, direction: .forward) case .userList: - return .set(userListRouter, direction: .reverse) + return .setReliably(userListRouter, direction: .reverse) } } diff --git a/XCoordinator-Example/Coordinators/HomeSplitCoordinator.swift b/XCoordinator-Example/Coordinators/HomeSplitCoordinator.swift index 290620f..b344625 100644 --- a/XCoordinator-Example/Coordinators/HomeSplitCoordinator.swift +++ b/XCoordinator-Example/Coordinators/HomeSplitCoordinator.swift @@ -8,6 +8,8 @@ import XCoordinator +/// Home flow rendered as a `UISplitViewController`. Demonstrates `SplitCoordinator` driving `HomeRoute` +/// — same routes as `HomeTabCoordinator`, different container. class HomeSplitCoordinator: SplitCoordinator { // MARK: Stored properties @@ -23,6 +25,7 @@ class HomeSplitCoordinator: SplitCoordinator { self.userListRouter = userListRouter super.init(master: userListRouter, detail: newsRouter) + rootViewController.view.accessibilityIdentifier = UITestIdentifiers.homeContainerSplit } // MARK: Overrides diff --git a/XCoordinator-Example/Coordinators/HomeTabCoordinator.swift b/XCoordinator-Example/Coordinators/HomeTabCoordinator.swift index 377fb98..72d772d 100644 --- a/XCoordinator-Example/Coordinators/HomeTabCoordinator.swift +++ b/XCoordinator-Example/Coordinators/HomeTabCoordinator.swift @@ -9,11 +9,17 @@ import UIKit import XCoordinator +/// Routes available within the home flow. Shared by all three home coordinators +/// (`HomeTabCoordinator`, `HomeSplitCoordinator`, `HomePageCoordinator`) — the same route enum drives +/// three different container types, which is the central teaching device of this example. enum HomeRoute: Route { + /// Surface the news flow (selects the news tab / detail column / page). case news + /// Surface the user-list flow (selects the user-list tab / master column / page). case userList } +/// Home flow rendered as a `UITabBarController`. Demonstrates `TabBarCoordinator` driving `HomeRoute`. class HomeTabCoordinator: TabBarCoordinator { // MARK: Stored properties @@ -40,6 +46,7 @@ class HomeTabCoordinator: TabBarCoordinator { self.userListRouter = userListRouter super.init(tabs: [newsRouter, userListRouter], select: userListRouter) + rootViewController.view.accessibilityIdentifier = UITestIdentifiers.homeContainerTab } // MARK: Overrides diff --git a/XCoordinator-Example/Coordinators/NewsCoordinator.swift b/XCoordinator-Example/Coordinators/NewsCoordinator.swift index 8429d61..4941562 100644 --- a/XCoordinator-Example/Coordinators/NewsCoordinator.swift +++ b/XCoordinator-Example/Coordinators/NewsCoordinator.swift @@ -8,12 +8,18 @@ import XCoordinator +/// Routes for the news flow: the news list, a specific article, and a "close everything" command +/// that returns to the flow's root. enum NewsRoute: Route { + /// Push the news list. Used as the initial route. case news + /// Push a detail screen for a specific article. case newsDetail(News) + /// Dismiss everything pushed by this coordinator back to its root. case close } +/// News flow inside a `UINavigationController`. Owns the news list and the article-detail push. class NewsCoordinator: NavigationCoordinator { // MARK: Initialization @@ -36,13 +42,7 @@ class NewsCoordinator: NavigationCoordinator { let viewController = NewsDetailViewController.instantiateFromNib() let viewModel = NewsDetailViewModelImpl(news: news) viewController.bind(to: viewModel) - let animation: Animation - if #available(iOS 10.0, *) { - animation = .swirl - } else { - animation = .scale - } - return .push(viewController, animation: animation) + return .push(viewController, animation: .swirl) case .close: return .dismissToRoot() } diff --git a/XCoordinator-Example/Coordinators/UserCoordinator.swift b/XCoordinator-Example/Coordinators/UserCoordinator.swift index d6f6947..393c672 100644 --- a/XCoordinator-Example/Coordinators/UserCoordinator.swift +++ b/XCoordinator-Example/Coordinators/UserCoordinator.swift @@ -9,13 +9,20 @@ import UIKit import XCoordinator +/// Routes for the user-detail modal flow. enum UserRoute: Route { + /// Push the user-detail screen for the named user. Used as the initial route. case user(String) + /// Present a `UIAlertController` with the given title and message. case alert(title: String, message: String) + /// Dismiss the modal user flow back to the user list. case users + /// Push a screen with a random background colour — the target of the interactive edge-pan gesture. case randomColor } +/// Modal user-detail flow. Demonstrates an interactive (gesture-driven) transition: the edge-pan +/// recognizer registered in `presented(from:)` interactively pushes `.randomColor`. class UserCoordinator: NavigationCoordinator { // MARK: Initialization @@ -59,6 +66,9 @@ class UserCoordinator: NavigationCoordinator { gestureRecognizer.edges = .right view?.addGestureRecognizer(gestureRecognizer) + // Interactive-transition wiring: pan progress drives the `.randomColor` push frame-by-frame via + // XCoordinator's `registerInteractiveTransition`. `progress` reports the gesture's position as a + // fraction in [0, 1]; `shouldFinish` decides on touch-up whether to complete or cancel. registerInteractiveTransition( for: .randomColor, triggeredBy: gestureRecognizer, diff --git a/XCoordinator-Example/Coordinators/UserListCoordinator.swift b/XCoordinator-Example/Coordinators/UserListCoordinator.swift index 6d44a92..0e66a95 100644 --- a/XCoordinator-Example/Coordinators/UserListCoordinator.swift +++ b/XCoordinator-Example/Coordinators/UserListCoordinator.swift @@ -8,15 +8,23 @@ import XCoordinator +/// Routes for the user-list flow: the home screen, the users list, a single user's detail, +/// logout, and the About screen (which is attached as a child coordinator, not presented). enum UserListRoute: Route { + /// Push the home screen. Used as the initial route. case home + /// Push the users list. case users + /// Present a modal detail screen for the named user, owned by a child `UserCoordinator`. case user(String) - case registerUsersPeek(from: Container) + /// Dismiss this flow back to the login screen. case logout + /// Attach an `AboutCoordinator` that pushes into the existing navigation stack. case about } +/// User-list flow inside a `UINavigationController`. Owns the home screen, the users list, and +/// the about-coordinator attachment; presents `UserCoordinator` modally for user detail. class UserListCoordinator: NavigationCoordinator { // MARK: Initialization @@ -42,15 +50,12 @@ class UserListCoordinator: NavigationCoordinator { case .user(let username): let coordinator = UserCoordinator(user: username) return .present(coordinator, animation: .default) - case .registerUsersPeek(let source): - if #available(iOS 13.0, *) { - return .none() - } else { - return registerPeek(for: source, route: .users) - } case .logout: return .dismiss() case .about: + // Child-coordinator idiom: `AboutCoordinator` reuses *this* coordinator's `UINavigationController` + // (passed as `rootViewController`), so its pushes happen inside the same stack. Nothing needs to + // be presented or pushed at this level — hence `.none()` paired with `addChild(...)`. addChild(AboutCoordinator(rootViewController: rootViewController)) return .none() } diff --git a/XCoordinator-Example/Extensions/TransitionAnimation+Defaults.swift b/XCoordinator-Example/Extensions/TransitionAnimation+Defaults.swift index 7ed4807..e74a684 100644 --- a/XCoordinator-Example/Extensions/TransitionAnimation+Defaults.swift +++ b/XCoordinator-Example/Extensions/TransitionAnimation+Defaults.swift @@ -9,8 +9,12 @@ import UIKit import XCoordinator +/// Shared default used by every custom `Animation` in `Animations/`. let defaultAnimationDuration: TimeInterval = 0.35 extension CGFloat { + /// Near-zero value used as a scale factor in transforms. Scaling by exactly `0` produces a non-invertible + /// matrix and breaks `UIView.animate` interpolation; a small positive number avoids that without being + /// visually distinguishable from zero. static let verySmall: CGFloat = 0.0001 } diff --git a/XCoordinator-Example/Extensions/Transitions.swift b/XCoordinator-Example/Extensions/Transitions.swift index e4f48ed..70586e9 100644 --- a/XCoordinator-Example/Extensions/Transitions.swift +++ b/XCoordinator-Example/Extensions/Transitions.swift @@ -11,11 +11,16 @@ import XCoordinator extension Transition { + /// Wraps `.present` but forces `modalPresentationStyle = .fullScreen` first. Avoids the iOS 13+ sheet + /// presentation default for cases (like login → home) where the parent should be fully covered. static func presentFullScreen(_ presentable: Presentable, animation: Animation? = nil) -> Transition { presentable.viewController?.modalPresentationStyle = .fullScreen return .present(presentable, animation: animation) } + /// Walks the entire modal-presentation chain rooted at `rootViewController` and dismisses each + /// presented controller in order. Terminates when `rootViewController.presentedViewController` is `nil` + /// — each dismissal removes one level from the chain, so the recursion is bounded by the modal depth. static func dismissAll() -> Transition { return Transition(presentables: [], animationInUse: nil) { rootViewController, options, completion in guard let presentedViewController = rootViewController.presentedViewController else { @@ -30,3 +35,37 @@ extension Transition { } } + +extension Transition where RootViewController: UIPageViewController { + + /// A drop-in replacement for the stock `PageTransition.set(_:direction:)` that **always** calls its + /// completion handler. + /// + /// `UIPageViewController.setViewControllers(_:direction:animated:completion:)` silently skips its + /// completion block when the requested page is already the one on-screen (a long-standing UIKit quirk). + /// `deepLink` chains the next route *inside* a transition's completion, so a deep link whose page step + /// targets the already-visible page would stall forever. This variant short-circuits the no-op case and + /// invokes the completion directly, so deep links (and any chained `.multiple`) keep flowing. + static func setReliably(_ presentable: Presentable, + direction: UIPageViewController.NavigationDirection) -> Transition { + Transition(presentables: [presentable], animationInUse: nil) { rootViewController, options, completion in + guard let target = presentable.viewController else { + completion?() + return + } + let isAlreadyVisible = rootViewController.viewControllers?.count == 1 + && rootViewController.viewControllers?.first === target + guard !isAlreadyVisible else { + // The page is already displayed — UIKit would not call the completion, so do it ourselves. + // `presented(from:)` was already invoked when this page was first set, so it is not repeated. + completion?() + return + } + rootViewController.setViewControllers([target], direction: direction, animated: options.animated) { _ in + presentable.presented(from: rootViewController) + completion?() + } + } + } + +} diff --git a/XCoordinator-Example/Info.plist b/XCoordinator-Example/Info.plist index 5a63475..35dab7d 100644 --- a/XCoordinator-Example/Info.plist +++ b/XCoordinator-Example/Info.plist @@ -16,16 +16,40 @@ $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString 1.0 + CFBundleURLTypes + + + CFBundleURLName + com.quickbirdstudios.XCoordinator-Example + CFBundleURLSchemes + + xcoordinator-example + + + CFBundleVersion 1 LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + UILaunchStoryboardName LaunchScreen - UIRequiredDeviceCapabilities - - armv7 - UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/XCoordinator-Example/PrivacyInfo.xcprivacy b/XCoordinator-Example/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..6af1641 --- /dev/null +++ b/XCoordinator-Example/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTracking + + NSPrivacyCollectedDataTypes + + NSPrivacyTrackingDomains + + NSPrivacyAccessedAPITypes + + + diff --git a/XCoordinator-Example/Scenes/Home/HomeViewController.swift b/XCoordinator-Example/Scenes/Home/HomeViewController.swift index db73dd9..13ec301 100644 --- a/XCoordinator-Example/Scenes/Home/HomeViewController.swift +++ b/XCoordinator-Example/Scenes/Home/HomeViewController.swift @@ -29,6 +29,7 @@ class HomeViewController: UIViewController, BindableType { super.viewDidLoad() title = "Home" + usersButton.accessibilityIdentifier = UITestIdentifiers.usersButton } // MARK: BindableType @@ -45,8 +46,6 @@ class HomeViewController: UIViewController, BindableType { aboutButton.rx.tap .bind(to: viewModel.input.aboutTrigger) .disposed(by: disposeBag) - - viewModel.registerPeek(for: usersButton) } } diff --git a/XCoordinator-Example/Scenes/Home/HomeViewModel.swift b/XCoordinator-Example/Scenes/Home/HomeViewModel.swift index d145f22..d208239 100644 --- a/XCoordinator-Example/Scenes/Home/HomeViewModel.swift +++ b/XCoordinator-Example/Scenes/Home/HomeViewModel.swift @@ -10,6 +10,18 @@ import Action import RxSwift import XCoordinator +// MARK: - The Input/Output/composite ViewModel pattern (used by every scene in this app) +// +// Each scene defines three protocols: +// * `ViewModelInput` — RxSwift `AnyObserver` triggers the view controller pushes events into. +// * `ViewModelOutput` — observables the view controller binds to UI. +// * `ViewModel` — exposes `input` and `output` so view controllers don't see the impl. +// +// A single concrete `ViewModelImpl` adopts all three. The `where Self: Input & Output` extension +// below lets that impl satisfy the composite protocol by returning `self` from `input` and `output`, +// so there's no boilerplate forwarding. This pattern is repeated identically across every scene; the +// comment lives here so it's documented once. + protocol HomeViewModelInput { var logoutTrigger: AnyObserver { get } var usersTrigger: AnyObserver { get } @@ -21,8 +33,6 @@ protocol HomeViewModelOutput {} protocol HomeViewModel { var input: HomeViewModelInput { get } var output: HomeViewModelOutput { get } - - func registerPeek(for sourceView: Container) } extension HomeViewModel where Self: HomeViewModelInput & HomeViewModelOutput { diff --git a/XCoordinator-Example/Scenes/Home/HomeViewModelImpl.swift b/XCoordinator-Example/Scenes/Home/HomeViewModelImpl.swift index 13759fa..b216690 100644 --- a/XCoordinator-Example/Scenes/Home/HomeViewModelImpl.swift +++ b/XCoordinator-Example/Scenes/Home/HomeViewModelImpl.swift @@ -42,10 +42,4 @@ class HomeViewModelImpl: HomeViewModel, HomeViewModelInput, HomeViewModelOutput self.router = router } - // MARK: Methods - - func registerPeek(for sourceView: Container) { - router.trigger(.registerUsersPeek(from: sourceView)) - } - } diff --git a/XCoordinator-Example/Scenes/Login/LoginViewController.swift b/XCoordinator-Example/Scenes/Login/LoginViewController.swift index b454058..8b93799 100644 --- a/XCoordinator-Example/Scenes/Login/LoginViewController.swift +++ b/XCoordinator-Example/Scenes/Login/LoginViewController.swift @@ -27,6 +27,7 @@ class LoginViewController: UIViewController, BindableType { super.viewDidLoad() title = "Login" + loginButton.accessibilityIdentifier = UITestIdentifiers.loginButton } // MARK: BindableType diff --git a/XCoordinator-Example/Scenes/User/UserViewController.swift b/XCoordinator-Example/Scenes/User/UserViewController.swift index 1b701d1..724fac9 100644 --- a/XCoordinator-Example/Scenes/User/UserViewController.swift +++ b/XCoordinator-Example/Scenes/User/UserViewController.swift @@ -23,7 +23,7 @@ class UserViewController: UIViewController, BindableType { private let disposeBag = DisposeBag() - // MARK: Initialization + // MARK: Overrides override func viewDidLoad() { super.viewDidLoad() diff --git a/XCoordinator-Example/Scenes/UserList/UsersViewController.swift b/XCoordinator-Example/Scenes/UserList/UsersViewController.swift index 983250d..49c09b3 100644 --- a/XCoordinator-Example/Scenes/UserList/UsersViewController.swift +++ b/XCoordinator-Example/Scenes/UserList/UsersViewController.swift @@ -22,7 +22,7 @@ class UsersViewController: UIViewController, BindableType { private let disposeBag = DisposeBag() private let cellIdentifier = String(describing: DetailTableViewCell.self) - // MARK: Initialization + // MARK: Overrides override func viewDidLoad() { super.viewDidLoad() diff --git a/XCoordinator-Example/Services/NewsService.swift b/XCoordinator-Example/Services/NewsService.swift index a5b4cc1..3a1238d 100644 --- a/XCoordinator-Example/Services/NewsService.swift +++ b/XCoordinator-Example/Services/NewsService.swift @@ -9,8 +9,8 @@ import UIKit import RxSwift -// swiftlint:disable line_length - +/// Source of news content. The sample app ships a mock-only implementation (`MockNewsService`) — there is +/// no real backend; this protocol exists to demonstrate the MVVM-C dependency-injection seam. protocol NewsService { func mostRecentNews() -> (title: String, articles: [News]) } @@ -48,6 +48,7 @@ extension UIImage { } +// swiftlint:disable:next line_length let loremIpsum = """ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus iaculis, augue ac consectetur volutpat, dui est malesuada tellus, et elementum odio urna quis odio. Mauris mollis at libero in elementum. Mauris enim dui, tincidunt id blandit vitae, condimentum a tellus. Donec ut diam in nisl interdum ultrices. Vivamus id magna nisi. Duis molestie libero velit, vel consequat mi viverra pellentesque. Nulla at tellus eget risus fringilla ornare id a quam. Pellentesque arcu neque, interdum nec enim eu, tincidunt volutpat mauris. Vivamus ultricies tortor at lacus vehicula, vitae laoreet tellus tincidunt. Etiam sollicitudin nisl scelerisque odio malesuada consectetur. Nunc non tempor felis. Sed dolor ipsum, scelerisque vitae dolor in, porttitor facilisis lorem. Nam id dolor sagittis, fermentum nulla eu, ornare neque. diff --git a/XCoordinator-Example/Services/UserService.swift b/XCoordinator-Example/Services/UserService.swift index 93ae664..b883e4d 100644 --- a/XCoordinator-Example/Services/UserService.swift +++ b/XCoordinator-Example/Services/UserService.swift @@ -6,6 +6,8 @@ // Copyright © 2019 QuickBird Studios. All rights reserved. // +/// Source of user data. The sample app ships a mock-only implementation (`MockUserService`) — there is +/// no real backend; this protocol exists to demonstrate the MVVM-C dependency-injection seam. protocol UserService { func allUsers() -> [User] } diff --git a/XCoordinator-Example/Utils/BindableType.swift b/XCoordinator-Example/Utils/BindableType.swift index d73bd85..3fb3b09 100644 --- a/XCoordinator-Example/Utils/BindableType.swift +++ b/XCoordinator-Example/Utils/BindableType.swift @@ -9,11 +9,16 @@ import Foundation import UIKit +/// The MVVM-C binding contract used by every scene in this app. A coordinator instantiates the view +/// controller (typically via `instantiateFromNib()`), then calls `bind(to:)` on it — that assigns the +/// view-model, forces the view hierarchy to load, and invokes `bindViewModel()` so the controller can +/// wire its `IBOutlet`s to the model's `input`/`output` streams via RxSwift. protocol BindableType: AnyObject { associatedtype ViewModelType var viewModel: ViewModelType! { get set } + /// Override to wire the view controller's controls to `viewModel.input` and its outputs to UI elements. func bindViewModel() } diff --git a/XCoordinator-Example/Utils/NibIdentifiable.swift b/XCoordinator-Example/Utils/NibIdentifiable.swift index 8cb6e2a..090c5d9 100644 --- a/XCoordinator-Example/Utils/NibIdentifiable.swift +++ b/XCoordinator-Example/Utils/NibIdentifiable.swift @@ -9,6 +9,9 @@ import Foundation import UIKit +/// Convention for view classes whose name matches their `.xib` file. Provides `instantiateFromNib()` on +/// `UIViewController` and `UIView`, which every coordinator in this app uses to construct screens +/// (e.g. `NewsViewController.instantiateFromNib()`). protocol NibIdentifiable { static var nibIdentifier: String { get } } diff --git a/XCoordinator-ExampleUITests/XCoordinator_ExampleUITests.swift b/XCoordinator-ExampleUITests/XCoordinator_ExampleUITests.swift index ea503fe..ad9878d 100644 --- a/XCoordinator-ExampleUITests/XCoordinator_ExampleUITests.swift +++ b/XCoordinator-ExampleUITests/XCoordinator_ExampleUITests.swift @@ -8,36 +8,162 @@ import XCTest -class XCoordinator_ExampleUITests: XCTestCase { +// String literals below must match those in XCoordinator-Example/Common/UITestIdentifiers.swift. +// The UI-test target is a separate process and can't import the app target, so the constants are +// duplicated; keep them in sync by hand. +private enum ID { + static let loginButton = "login.button" + static let homeContainerTab = "home-container.tab" + static let homeContainerSplit = "home-container.split" + static let homeContainerPage = "home-container.page" + static let usersButton = "home.users-button" + static let randomPickerIndexArg = "--random-picker-index" +} - override func setUp() { - // Put setup code here. This method is called before the invocation of each test method in the class. +private enum PickerLabel { + static let tab = "HomeTabCoordinator" + static let split = "HomeSplitCoordinator" + static let page = "HomePageCoordinator" + static let random = "Random" +} - // In UI tests it is usually best to stop immediately when a failure occurs. +final class XCoordinator_ExampleUITests: XCTestCase { + + override func setUp() { + super.setUp() continueAfterFailure = false + } + + // MARK: - Helpers + + private func launch(arguments: [String] = []) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments += arguments + app.launch() + return app + } - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + @discardableResult + private func tapLoginAndWaitForPicker(_ app: XCUIApplication) -> XCUIElement { + let loginButton = app.buttons[ID.loginButton] + XCTAssertTrue(loginButton.waitForExistence(timeout: 5), "Login button never appeared") + loginButton.tap() + let alert = app.alerts.firstMatch + XCTAssertTrue(alert.waitForExistence(timeout: 5), "Picker alert never appeared") + return alert } - override func tearDown() { - // Put teardown code here. This method is called after the invocation of each test method in the class. + private func assertContainerVisible(_ identifier: String, in app: XCUIApplication, + file: StaticString = #file, line: UInt = #line) { + let container = app.descendants(matching: .any).matching(identifier: identifier).firstMatch + XCTAssertTrue(container.waitForExistence(timeout: 5), + "Expected container '\(identifier)' to appear", file: file, line: line) } - func testExample() { - // UI tests must launch the application that they test. + // MARK: - Scene delegate / launch + + func testAppLaunchesUnderSceneDelegate() { + let app = launch() + XCTAssertTrue(app.buttons[ID.loginButton].waitForExistence(timeout: 5), + "App did not launch — SceneDelegate may not be wired up correctly.") + } + + // MARK: - Picker structure + + func testLoginFlowReachesPicker() { + let app = launch() + let alert = tapLoginAndWaitForPicker(app) + XCTAssertTrue(alert.buttons[PickerLabel.tab].exists) + XCTAssertTrue(alert.buttons[PickerLabel.split].exists) + XCTAssertTrue(alert.buttons[PickerLabel.page].exists) + XCTAssertTrue(alert.buttons[PickerLabel.random].exists) + } + + // MARK: - Explicit picker choices land on the right container + + func testTabPickerLandsOnTabBar() { + let app = launch() + tapLoginAndWaitForPicker(app).buttons[PickerLabel.tab].tap() + assertContainerVisible(ID.homeContainerTab, in: app) + } + + func testSplitPickerLandsOnSplit() { + let app = launch() + tapLoginAndWaitForPicker(app).buttons[PickerLabel.split].tap() + assertContainerVisible(ID.homeContainerSplit, in: app) + } + + func testPagePickerLandsOnPage() { + let app = launch() + tapLoginAndWaitForPicker(app).buttons[PickerLabel.page].tap() + assertContainerVisible(ID.homeContainerPage, in: app) + } + + // MARK: - Random picker exercises all three containers (regression for the duplicate-entry bug) + + func testRandomPickerIndex0LandsOnTab() { + let app = launch(arguments: [ID.randomPickerIndexArg, "0"]) + tapLoginAndWaitForPicker(app).buttons[PickerLabel.random].tap() + assertContainerVisible(ID.homeContainerTab, in: app) + } + + func testRandomPickerIndex1LandsOnSplit() { + let app = launch(arguments: [ID.randomPickerIndexArg, "1"]) + tapLoginAndWaitForPicker(app).buttons[PickerLabel.random].tap() + assertContainerVisible(ID.homeContainerSplit, in: app) + } + + func testRandomPickerIndex2LandsOnPage() { + let app = launch(arguments: [ID.randomPickerIndexArg, "2"]) + tapLoginAndWaitForPicker(app).buttons[PickerLabel.random].tap() + assertContainerVisible(ID.homeContainerPage, in: app) + } + + // MARK: - URL deep linking + + private func launchWithDeepLink(_ url: String) -> XCUIApplication { let app = XCUIApplication() + app.launchEnvironment["XCOORDINATOR_DEEP_LINK"] = url app.launch() + return app + } + + func testNewsDeepLinkFromColdLaunch() { + let app = launchWithDeepLink("xcoordinator-example://news/0") + // Title label is "Article 0\nStefan" (title + newline + subtitle), so match by prefix. + let predicate = NSPredicate(format: "label BEGINSWITH 'Article 0'") + let title = app.staticTexts.matching(predicate).firstMatch + XCTAssertTrue(title.waitForExistence(timeout: 8), + "Expected news detail for article 0 to appear via deep link.") + } + + func testUsersDeepLink() { + let app = launchWithDeepLink("xcoordinator-example://users/Paul") + XCTAssertTrue(app.staticTexts["Paul"].waitForExistence(timeout: 10), + "Expected user detail screen for 'Paul' to appear via deep link.") + } - // Use recording to get started writing UI tests. - // Use XCTAssert and related functions to verify your tests produce the correct results. + func testUnknownURLIsIgnored() { + let app = launchWithDeepLink("xcoordinator-example://nonsense/whatever") + XCTAssertTrue(app.buttons[ID.loginButton].waitForExistence(timeout: 5), + "App should fall back to the login screen for unrecognised URLs.") } - func testLaunchPerformance() { - if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { - // This measures how long it takes to launch your application. - measure(metrics: [XCTOSSignpostMetric.applicationLaunch]) { - XCUIApplication().launch() - } - } + // MARK: - Peek/pop removal: long-press is now a no-op (used to register a 3D-Touch peek source) + + func testLongPressOnUserListDoesNotCrash() { + let app = launch() + tapLoginAndWaitForPicker(app).buttons[PickerLabel.tab].tap() + assertContainerVisible(ID.homeContainerTab, in: app) + + let users = app.buttons[ID.usersButton] + XCTAssertTrue(users.waitForExistence(timeout: 5), "Users button never appeared") + users.tap() + + let firstCell = app.cells.firstMatch + XCTAssertTrue(firstCell.waitForExistence(timeout: 5), "User-list cell never appeared") + firstCell.press(forDuration: 1.2) + + XCTAssertTrue(app.cells.firstMatch.exists, "App appears to have crashed during long-press") } } From 8f24213690fee0e7ad3468a24186a89bca4c9c86 Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Mon, 1 Jun 2026 16:34:32 +0200 Subject: [PATCH 2/4] update --- CLAUDE.md | 73 +++++++++++++++++++ .../Common/SceneDelegate.swift | 9 ++- .../XCoordinator_ExampleUITests.swift | 13 +++- 3 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2611b8e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,73 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +A MVVM-C iOS sample app that demonstrates how to use [XCoordinator](https://github.com/quickbirdstudios/XCoordinator) v2 for navigation. There is no app logic of real-world value here — the scenes (Login → Home → News/Users → Detail/About) exist purely to exercise the coordinator types (`NavigationCoordinator`, `TabBarCoordinator`, `PageCoordinator`, `SplitCoordinator`) and the transition/animation APIs. When adding examples or fixing bugs, preserve coverage of these coordinator variants rather than collapsing them. + +- Swift 5, iOS 13.0+, universal (iPhone + iPad). +- Dependencies are Swift Package Manager only — no Podfile / Cartfile. Resolved versions are pinned in `XCoordinator-Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved`: `XCoordinator` 2.0.5, `RxSwift` 5.0.1, `Action` 4.0.1. +- Open `XCoordinator-Example.xcodeproj` directly (there is no `.xcworkspace`). + +## Build / test commands + +```bash +# Build for the simulator +xcodebuild -project XCoordinator-Example.xcodeproj \ + -scheme XCoordinator-Example \ + -destination 'platform=iOS Simulator,name=iPhone 15' \ + build + +# Run the full test plan (unit + UI tests) +xcodebuild -project XCoordinator-Example.xcodeproj \ + -scheme XCoordinator-Example \ + -testPlan XCoordinator-Example \ + -destination 'platform=iOS Simulator,name=iPhone 15' \ + test + +# Run a single test (Xcode test identifier: Target/Class/method) +xcodebuild ... test \ + -only-testing:XCoordinator-ExampleTests/AnimationTests/testPageCoordinator +``` + +The test plan at `XCoordinator-ExampleTests/XCoordinator-Example.xctestplan` includes both `XCoordinator-ExampleTests` (unit) and `XCoordinator-ExampleUITests` (UI) targets. Code coverage is off by default in the plan. + +## Architecture + +### Coordinator graph + +`AppDelegate` instantiates `AppCoordinator().strongRouter` and calls `setRoot(for: window)`. From there everything flows through XCoordinator `Route` enums and `prepareTransition(for:)` overrides — no view controller is allocated outside a coordinator. + +The top-level structure is: + +- **`AppCoordinator`** (`NavigationCoordinator`) — starts on `.login`. On `.home`, it presents a `UIAlertController` that lets the user pick between `HomeTabCoordinator`, `HomeSplitCoordinator`, `HomePageCoordinator`, or a Random one of those. The chosen home coordinator's `StrongRouter` is wrapped back into `AppRoute.home(...)` and presented full-screen. This picker is the whole point of the example — it shows the same `HomeRoute` driving three different container coordinators. Keep the three variants interchangeable from `HomeRoute`'s perspective when modifying them. +- **Home container coordinators** all expose `HomeRoute { case news; case userList }` and delegate to: + - `NewsCoordinator` (`NavigationCoordinator`) — news list → detail (uses a custom `.swirl` animation on iOS 10+, falls back to `.scale`). + - `UserListCoordinator` (`NavigationCoordinator`) — user list → user detail (`UserCoordinator`), plus `.about` which is implemented via `addChild(AboutCoordinator(rootViewController: ...))` + `.none()` rather than a transition. `AppRoute.newsDetail` deep-links through `HomePageCoordinator → HomeRoute.news → NewsRoute.newsDetail` via `Transition.multiple(.dismissAll(), .popToRoot(), deepLink(...))`. + +### MVVM-C scene wiring + +Every scene under `Scenes//` is three files: + +- `ViewController.swift` — `.xib`-based, conforms to `BindableType` (`Utils/BindableType.swift`). Coordinators call `VC.instantiateFromNib()` then `vc.bind(to: viewModel)`, which assigns the model, loads the view, and invokes `bindViewModel()`. +- `ViewModel.swift` — protocol triplet: `ViewModelInput`, `ViewModelOutput`, and `ViewModel { var input; var output }`. A `where Self: Input & Output` extension lets a single impl class satisfy all three by returning `self`. +- `ViewModelImpl.swift` — concrete RxSwift implementation. Triggers are `CocoaAction`s that call `router.rx.trigger(.someRoute)` (via `XCoordinatorRx` + `Action`). Routers are held as `UnownedRouter<…>` to avoid retain cycles. Follow this pattern verbatim when adding a scene. + +### Models, services, common + +- `Models/` — plain Swift structs (`News`, `User`). +- `Services/` — `MockNewsService`, `MockUserService`. These return hardcoded data; there is no networking. `AppCoordinator.notificationReceived()` uses `MockNewsService().mostRecentNews().articles.randomElement()` to fake an incoming push that deep-links to a news detail. +- `Common/` — `AppDelegate`, `Main.storyboard` placeholder (in `Base.lproj`), asset catalog. The app does not use the storyboard for routing — only as the launch storyboard reference. + +### Animations and transitions + +- `Animations/Animation+*.swift` — custom XCoordinator `Animation` instances (`.fade`, `.scale`, `.swirl`, `.modal`, `.navigation`) built from `StaticTransitionAnimation` / `TransitionAnimation`. `AnimationTests` instantiates each coordinator type and asserts that pushing/popping triggers the configured animation's `performAnimation` blocks — when you add a new custom animation, mirror the existing test pattern. +- `Extensions/Transitions.swift` — defines two app-specific transition factories: `Transition.presentFullScreen(_:animation:)` (sets `modalPresentationStyle = .fullScreen` before `.present`) and `Transition.dismissAll()` (recursively dismisses presented VCs). Prefer these over inline `modalPresentationStyle` mutation. +- `Extensions/TransitionAnimation+Defaults.swift` — shared defaults reused across the custom animations. + +### Conventions worth keeping + +- Routes are always enums conforming to `Route`. Adding a new screen means: add a case to the relevant `…Route`, handle it in the matching coordinator's `prepareTransition`, and add a scene triplet under `Scenes/`. +- Coordinators expose themselves to view models as `unownedRouter` (or `strongRouter` when handed across coordinator boundaries, as in `AppRoute.home`). Do not pass coordinators directly into view models. +- Child coordinators that share a navigation stack with their parent (see `AboutCoordinator` in `UserListCoordinator`) are attached with `addChild(...)` + a `.none()` transition rather than a presentation. diff --git a/XCoordinator-Example/Common/SceneDelegate.swift b/XCoordinator-Example/Common/SceneDelegate.swift index 06c6155..bd64306 100644 --- a/XCoordinator-Example/Common/SceneDelegate.swift +++ b/XCoordinator-Example/Common/SceneDelegate.swift @@ -29,8 +29,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // // The handler is dispatched to the next runloop so the AppCoordinator's initial `.login` route // has finished pushing before we trigger the deep-link chain. - let coldLaunchURL = ProcessInfo.processInfo.environment["XCOORDINATOR_DEEP_LINK"].flatMap(URL.init(string:)) - ?? connectionOptions.urlContexts.first?.url + var coldLaunchURL = connectionOptions.urlContexts.first?.url + #if DEBUG + // Test-only hook: UI tests can't reach `connectionOptions`, so they inject a cold-launch deep + // link via this env var. Gated to DEBUG so it never ships in release builds. + coldLaunchURL = ProcessInfo.processInfo.environment["XCOORDINATOR_DEEP_LINK"].flatMap(URL.init(string:)) + ?? coldLaunchURL + #endif if let url = coldLaunchURL { DispatchQueue.main.async { [weak self] in self?.handle(url: url) diff --git a/XCoordinator-ExampleUITests/XCoordinator_ExampleUITests.swift b/XCoordinator-ExampleUITests/XCoordinator_ExampleUITests.swift index ad9878d..c7cbf43 100644 --- a/XCoordinator-ExampleUITests/XCoordinator_ExampleUITests.swift +++ b/XCoordinator-ExampleUITests/XCoordinator_ExampleUITests.swift @@ -130,8 +130,11 @@ final class XCoordinator_ExampleUITests: XCTestCase { func testNewsDeepLinkFromColdLaunch() { let app = launchWithDeepLink("xcoordinator-example://news/0") - // Title label is "Article 0\nStefan" (title + newline + subtitle), so match by prefix. - let predicate = NSPredicate(format: "label BEGINSWITH 'Article 0'") + // The detail screen's single title label joins the title and subtitle into one string + // ("Article 0" + "Stefan"). The news *list* renders the title and subtitle as two separate + // cell labels and never joins them, so a single label containing BOTH is detail-only — + // asserting the bare "Article 0" would pass even if the final newsDetail push never happened. + let predicate = NSPredicate(format: "label CONTAINS %@ AND label CONTAINS %@", "Article 0", "Stefan") let title = app.staticTexts.matching(predicate).firstMatch XCTAssertTrue(title.waitForExistence(timeout: 8), "Expected news detail for article 0 to appear via deep link.") @@ -139,7 +142,11 @@ final class XCoordinator_ExampleUITests: XCTestCase { func testUsersDeepLink() { let app = launchWithDeepLink("xcoordinator-example://users/Paul") - XCTAssertTrue(app.staticTexts["Paul"].waitForExistence(timeout: 10), + // Assert on the "Close" bar button, which only exists on the modal UserViewController detail + // screen. The user *list* renders "Paul" as a cell label too, so asserting `staticTexts["Paul"]` + // would pass even if the final UserListRoute.user push silently failed — match a detail-only + // element instead so the test actually proves navigation reached the detail screen. + XCTAssertTrue(app.buttons["Close"].waitForExistence(timeout: 10), "Expected user detail screen for 'Paul' to appear via deep link.") } From fab1cfbe929d0a1c171a917d8bc586fc4dc24e8e Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Wed, 1 Jul 2026 10:48:26 +0200 Subject: [PATCH 3/4] Update to XCoordinator 3.0.0 --- CLAUDE.md | 39 +++-- .../project.pbxproj | 38 ++--- .../xcshareddata/swiftpm/Package.resolved | 7 +- .../Animations/Animation+Fade.swift | 9 + .../Animations/Animation+Modal.swift | 15 +- .../Animations/Animation+Navigation.swift | 8 +- .../Animations/Animation+Scale.swift | 12 ++ .../Animations/Animation+Swirl.swift | 12 ++ .../Common/SceneDelegate.swift | 6 +- .../Coordinators/AboutCoordinator.swift | 34 ++-- .../Coordinators/AppCoordinator.swift | 156 ++++++++++-------- .../Coordinators/HomePageCoordinator.swift | 21 ++- .../Coordinators/HomeSplitCoordinator.swift | 18 +- .../Coordinators/HomeSwiftUICoordinator.swift | 68 ++++++++ .../Coordinators/HomeTabCoordinator.swift | 16 +- .../Coordinators/NewsCoordinator.swift | 31 ++-- .../Coordinators/UserCoordinator.swift | 36 ++-- .../Coordinators/UserListCoordinator.swift | 46 ++++-- .../Extensions/Presentable+Rx.swift | 1 + .../Extensions/Transitions.swift | 34 ---- .../Scenes/About/AboutViewModelImpl.swift | 6 +- .../Scenes/Home/HomeSwiftUIView.swift | 54 ++++++ .../Scenes/Home/HomeViewModelImpl.swift | 5 +- .../Scenes/Login/LoginViewModelImpl.swift | 6 +- .../Scenes/News/NewsViewModelImpl.swift | 6 +- .../Scenes/User/UserViewModelImpl.swift | 6 +- .../Scenes/UserList/UsersViewModelImpl.swift | 6 +- .../AnimationTests.swift | 153 ----------------- XCoordinator-ExampleTests/TestAnimation.swift | 42 ----- XCoordinator-ExampleTests/TestRoute.swift | 13 -- .../TransitionTests.swift | 102 ------------ .../XCTestManifests.swift | 10 -- XCoordinator-ExampleTests/XCText+Extras.swift | 22 --- .../XCoordinator-Example.xctestplan | 7 - .../XCoordinator_ExampleUITests.swift | 30 ++++ 35 files changed, 485 insertions(+), 590 deletions(-) create mode 100644 XCoordinator-Example/Coordinators/HomeSwiftUICoordinator.swift create mode 100644 XCoordinator-Example/Scenes/Home/HomeSwiftUIView.swift delete mode 100644 XCoordinator-ExampleTests/AnimationTests.swift delete mode 100644 XCoordinator-ExampleTests/TestAnimation.swift delete mode 100644 XCoordinator-ExampleTests/TestRoute.swift delete mode 100644 XCoordinator-ExampleTests/TransitionTests.swift delete mode 100644 XCoordinator-ExampleTests/XCTestManifests.swift delete mode 100644 XCoordinator-ExampleTests/XCText+Extras.swift diff --git a/CLAUDE.md b/CLAUDE.md index 2611b8e..13612be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,10 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Overview -A MVVM-C iOS sample app that demonstrates how to use [XCoordinator](https://github.com/quickbirdstudios/XCoordinator) v2 for navigation. There is no app logic of real-world value here — the scenes (Login → Home → News/Users → Detail/About) exist purely to exercise the coordinator types (`NavigationCoordinator`, `TabBarCoordinator`, `PageCoordinator`, `SplitCoordinator`) and the transition/animation APIs. When adding examples or fixing bugs, preserve coverage of these coordinator variants rather than collapsing them. +A MVVM-C iOS sample app that demonstrates how to use [XCoordinator](https://github.com/QuickBirdEng/XCoordinator) v3 for navigation. There is no app logic of real-world value here — the scenes (Login → Home → News/Users → Detail/About) exist purely to exercise the coordinator types (`NavigationCoordinator`, `TabBarCoordinator`, `PageCoordinator`, `SplitCoordinator`) and the transition/animation APIs. When adding examples or fixing bugs, preserve coverage of these coordinator variants rather than collapsing them. -- Swift 5, iOS 13.0+, universal (iPhone + iPad). -- Dependencies are Swift Package Manager only — no Podfile / Cartfile. Resolved versions are pinned in `XCoordinator-Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved`: `XCoordinator` 2.0.5, `RxSwift` 5.0.1, `Action` 4.0.1. +- Swift 5.9, iOS 16.0+, universal (iPhone + iPad). (XCoordinator 3 requires iOS 16 / Swift 5.9.) +- Dependencies are Swift Package Manager only — no Podfile / Cartfile. Resolved versions are pinned in `XCoordinator-Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved`: `RxSwift` 6.x, `Action` 5.x, and `XCoordinator` pinned to a **commit revision** on the `feature/3.0.0` branch (`QuickBirdEng/XCoordinator`) because 3.0.0 is not yet tagged — see the `XCRemoteSwiftPackageReference "XCoordinator"` block in `project.pbxproj`. - Open `XCoordinator-Example.xcodeproj` directly (there is no `.xcworkspace`). ## Build / test commands @@ -16,43 +16,52 @@ A MVVM-C iOS sample app that demonstrates how to use [XCoordinator](https://gith # Build for the simulator xcodebuild -project XCoordinator-Example.xcodeproj \ -scheme XCoordinator-Example \ - -destination 'platform=iOS Simulator,name=iPhone 15' \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ build -# Run the full test plan (unit + UI tests) +# Run the full test plan (UI tests) xcodebuild -project XCoordinator-Example.xcodeproj \ -scheme XCoordinator-Example \ -testPlan XCoordinator-Example \ - -destination 'platform=iOS Simulator,name=iPhone 15' \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ test # Run a single test (Xcode test identifier: Target/Class/method) xcodebuild ... test \ - -only-testing:XCoordinator-ExampleTests/AnimationTests/testPageCoordinator + -only-testing:XCoordinator-ExampleUITests/XCoordinator_ExampleUITests/testTabPickerLandsOnTabBar ``` -The test plan at `XCoordinator-ExampleTests/XCoordinator-Example.xctestplan` includes both `XCoordinator-ExampleTests` (unit) and `XCoordinator-ExampleUITests` (UI) targets. Code coverage is off by default in the plan. +The test plan at `XCoordinator-ExampleTests/XCoordinator-Example.xctestplan` now contains only the `XCoordinator-ExampleUITests` (UI) target, which drives the actual app. The `XCoordinator-ExampleTests` unit target's contents were copies of XCoordinator's own `Tests/XCoordinatorTests` suite (transition/animation mechanics of the library, not the example app); since the library now owns and maintains those, the duplicates were removed and the empty unit target dropped from the plan. Code coverage is off by default in the plan. ## Architecture ### Coordinator graph -`AppDelegate` instantiates `AppCoordinator().strongRouter` and calls `setRoot(for: window)`. From there everything flows through XCoordinator `Route` enums and `prepareTransition(for:)` overrides — no view controller is allocated outside a coordinator. +`SceneDelegate` holds an `AppCoordinator()` typed as `any Router` and calls `setRoot(for: window)`. From there everything flows through XCoordinator `Route` enums and `prepareTransition(for:)` overrides — no view controller is allocated outside a coordinator. The top-level structure is: -- **`AppCoordinator`** (`NavigationCoordinator`) — starts on `.login`. On `.home`, it presents a `UIAlertController` that lets the user pick between `HomeTabCoordinator`, `HomeSplitCoordinator`, `HomePageCoordinator`, or a Random one of those. The chosen home coordinator's `StrongRouter` is wrapped back into `AppRoute.home(...)` and presented full-screen. This picker is the whole point of the example — it shows the same `HomeRoute` driving three different container coordinators. Keep the three variants interchangeable from `HomeRoute`'s perspective when modifying them. +- **`AppCoordinator`** (`NavigationCoordinator`) — starts on `.login`. On `.home`, it presents a `UIAlertController` that lets the user pick between `HomeTabCoordinator`, `HomeSplitCoordinator`, `HomePageCoordinator`, `HomeSwiftUICoordinator`, or a Random one of those. The chosen home coordinator (an `any Router` — the coordinator instance itself) is wrapped back into `AppRoute.home(...)` and presented full-screen. This picker is the whole point of the example — it shows the same `HomeRoute` driving four different container coordinators. Keep the variants interchangeable from `HomeRoute`'s perspective when modifying them. (The `Random` action appends `HomeSwiftUICoordinator` **last**, so the UI tests that pass `--random-picker-index 0/1/2` keep landing on Tab/Split/Page.) - **Home container coordinators** all expose `HomeRoute { case news; case userList }` and delegate to: - - `NewsCoordinator` (`NavigationCoordinator`) — news list → detail (uses a custom `.swirl` animation on iOS 10+, falls back to `.scale`). + - `NewsCoordinator` (`NavigationCoordinator`) — news list → detail (uses a custom `.swirl` animation). - `UserListCoordinator` (`NavigationCoordinator`) — user list → user detail (`UserCoordinator`), plus `.about` which is implemented via `addChild(AboutCoordinator(rootViewController: ...))` + `.none()` rather than a transition. `AppRoute.newsDetail` deep-links through `HomePageCoordinator → HomeRoute.news → NewsRoute.newsDetail` via `Transition.multiple(.dismissAll(), .popToRoot(), deepLink(...))`. +### SwiftUI interop (the `HomeSwiftUICoordinator` container) + +`HomeSwiftUICoordinator` is a **SwiftUI** home container (the fourth picker variant) that demonstrates XCoordinator 3's SwiftUI bridge in **both directions** — it is the reference for how to mix SwiftUI and UIKit coordinators here: + +- **UIKit → SwiftUI (forward):** it subclasses `ViewCoordinator` and calls `super.init(body: { HomeSwiftUIView(state:) })`, which hosts the SwiftUI view in a `RoutingController` and auto-registers the coordinator, so `@Routing` inside the view resolves to it. +- **SwiftUI → UIKit (backward):** `HomeSwiftUIView` is a `TabView` whose tabs embed the existing UIKit `UserListCoordinator` and `NewsCoordinator` via `WrappedRouter { … }`. +- **Route-driven SwiftUI state:** tab selection is a `Binding` whose setter calls `await router.trigger(.news/.userList)`; `HomeSwiftUICoordinator.prepareTransition` handles those with `Transition.withAnimation { state.selection = … }` (a no-UIKit-transition route that animates SwiftUI state). +- The `@Routing`/`Transition.withAnimation` etc. live in `Sources/XCoordinator/SwiftUI/` of the library. Deferred (not yet shown here): `redirect(to:map:)`, the `.triggerOnAppear`/`.trigger(when:)` modifiers. + ### MVVM-C scene wiring Every scene under `Scenes//` is three files: - `ViewController.swift` — `.xib`-based, conforms to `BindableType` (`Utils/BindableType.swift`). Coordinators call `VC.instantiateFromNib()` then `vc.bind(to: viewModel)`, which assigns the model, loads the view, and invokes `bindViewModel()`. - `ViewModel.swift` — protocol triplet: `ViewModelInput`, `ViewModelOutput`, and `ViewModel { var input; var output }`. A `where Self: Input & Output` extension lets a single impl class satisfy all three by returning `self`. -- `ViewModelImpl.swift` — concrete RxSwift implementation. Triggers are `CocoaAction`s that call `router.rx.trigger(.someRoute)` (via `XCoordinatorRx` + `Action`). Routers are held as `UnownedRouter<…>` to avoid retain cycles. Follow this pattern verbatim when adding a scene. +- `ViewModelImpl.swift` — concrete RxSwift implementation, annotated `@MainActor` (XCoordinator 3's API is `@MainActor`, so the router-triggering closures must be too). Triggers are `CocoaAction`s that call `router.rx.trigger(.someRoute)` (via `XCoordinatorRx` + `Action`; `import XCoordinatorRx` is required for `.rx`). Routers are held as `unowned let router: any Router<…>` to avoid retain cycles. Follow this pattern verbatim when adding a scene. ### Models, services, common @@ -62,12 +71,14 @@ Every scene under `Scenes//` is three files: ### Animations and transitions -- `Animations/Animation+*.swift` — custom XCoordinator `Animation` instances (`.fade`, `.scale`, `.swirl`, `.modal`, `.navigation`) built from `StaticTransitionAnimation` / `TransitionAnimation`. `AnimationTests` instantiates each coordinator type and asserts that pushing/popping triggers the configured animation's `performAnimation` blocks — when you add a new custom animation, mirror the existing test pattern. +- `Animations/Animation+*.swift` — custom XCoordinator `Animation` instances (`.fade`, `.scale`, `.swirl`, `.modal`, `.navigation`) built from `StaticTransitionAnimation` / `TransitionAnimation`. The library's own `Tests/XCoordinatorTests` (e.g. `AnimationTests`) covers that pushing/popping triggers a configured animation's blocks; this example no longer duplicates that. The example's `XCoordinator-ExampleUITests` instead verify the real app flows end-to-end (login → picker → each home container, deep links). + - **`UIHostingController` gotcha:** a custom animator must give the incoming view a real frame. All five animations set `toView.frame = transitionContext.finalFrame(for:)` (+ `autoresizingMask`) before animating — without it, UIKit does not pre-size the presented view for a custom animator and a `UIHostingController`'s SwiftUI view lays out to zero and renders **blank/black** (this is exactly what happened to `HomeSwiftUICoordinator` under `.fade`). Keep this pattern in any new custom animator. - `Extensions/Transitions.swift` — defines two app-specific transition factories: `Transition.presentFullScreen(_:animation:)` (sets `modalPresentationStyle = .fullScreen` before `.present`) and `Transition.dismissAll()` (recursively dismisses presented VCs). Prefer these over inline `modalPresentationStyle` mutation. - `Extensions/TransitionAnimation+Defaults.swift` — shared defaults reused across the custom animations. ### Conventions worth keeping - Routes are always enums conforming to `Route`. Adding a new screen means: add a case to the relevant `…Route`, handle it in the matching coordinator's `prepareTransition`, and add a scene triplet under `Scenes/`. -- Coordinators expose themselves to view models as `unownedRouter` (or `strongRouter` when handed across coordinator boundaries, as in `AppRoute.home`). Do not pass coordinators directly into view models. +- `prepareTransition(for:)` overrides use XCoordinator 3's `@TransitionBuilder` (inherited from `BaseCoordinator`): each `switch` case is a single trailing `Transition` expression with no `return`. The builder transforms reliably only when each case is **one** expression — a case body with `void` statements, local `let`s, `if/else`, or several listed transitions falls back to "missing return". So view-controller construction (instantiate + `bind(to:)`), `addChild`, control flow, and transition chains (`.multiple(...)`) live in `private func` helpers that each return a `Transition`, keeping every case a single expression. Keep this shape when adding scenes. +- Coordinators pass themselves to view models as `self` (a coordinator is an `any Router<…>`); the view model stores it `unowned`. When a router is handed across coordinator boundaries (as in `AppRoute.home`) the coordinator instance is held strongly as `any Router<…>`. Do not pass coordinators into view models by concrete type. - Child coordinators that share a navigation stack with their parent (see `AboutCoordinator` in `UserListCoordinator`) are attached with `addChild(...)` + a `.none()` transition rather than a presentation. diff --git a/XCoordinator-Example.xcodeproj/project.pbxproj b/XCoordinator-Example.xcodeproj/project.pbxproj index 0efd903..2df89f7 100644 --- a/XCoordinator-Example.xcodeproj/project.pbxproj +++ b/XCoordinator-Example.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 9B0A753A2316C1810092CA3A /* XCoordinatorRx in Frameworks */ = {isa = PBXBuildFile; productRef = 9B0A75392316C1810092CA3A /* XCoordinatorRx */; }; 9B56575B2315FA7E00F4F4F7 /* XCoordinator_ExampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B56575A2315FA7E00F4F4F7 /* XCoordinator_ExampleUITests.swift */; }; 9B5657A72315FB3500F4F4F7 /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B5657692315FB3500F4F4F7 /* AppCoordinator.swift */; }; + 9BA0000000000000000000B1 /* HomeSwiftUICoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BA0000000000000000000C1 /* HomeSwiftUICoordinator.swift */; }; + 9BA0000000000000000000B2 /* HomeSwiftUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BA0000000000000000000C2 /* HomeSwiftUIView.swift */; }; 9B5657A82315FB3500F4F4F7 /* UserListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B56576A2315FB3500F4F4F7 /* UserListCoordinator.swift */; }; 9B5657A92315FB3500F4F4F7 /* HomeSplitCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B56576B2315FB3500F4F4F7 /* HomeSplitCoordinator.swift */; }; 9B5657AA2315FB3500F4F4F7 /* UserCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B56576C2315FB3500F4F4F7 /* UserCoordinator.swift */; }; @@ -63,12 +65,6 @@ 9B5657D92315FB9700F4F4F7 /* Action in Frameworks */ = {isa = PBXBuildFile; productRef = 9B5657D82315FB9700F4F4F7 /* Action */; }; 9B5657DC2315FC1000F4F4F7 /* RxCocoa in Frameworks */ = {isa = PBXBuildFile; productRef = 9B5657DB2315FC1000F4F4F7 /* RxCocoa */; }; 9BD3EBF42330CE46005861BF /* Transitions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD3EBF32330CE46005861BF /* Transitions.swift */; }; - 9BD3EBFD2330D84F005861BF /* XCText+Extras.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD3EBF62330D84E005861BF /* XCText+Extras.swift */; }; - 9BD3EBFE2330D84F005861BF /* XCTestManifests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD3EBF72330D84F005861BF /* XCTestManifests.swift */; }; - 9BD3EBFF2330D84F005861BF /* TestRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD3EBF82330D84F005861BF /* TestRoute.swift */; }; - 9BD3EC002330D84F005861BF /* TransitionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD3EBF92330D84F005861BF /* TransitionTests.swift */; }; - 9BD3EC012330D84F005861BF /* AnimationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD3EBFA2330D84F005861BF /* AnimationTests.swift */; }; - 9BD3EC022330D84F005861BF /* TestAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD3EBFB2330D84F005861BF /* TestAnimation.swift */; }; 9BD3EC042330DA3A005861BF /* UserService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD3EC032330DA3A005861BF /* UserService.swift */; }; 9BD3EC072330DA74005861BF /* News.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD3EC062330DA74005861BF /* News.swift */; }; 9BD3EC092330EFDF005861BF /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD3EC082330EFDF005861BF /* User.swift */; }; @@ -103,6 +99,8 @@ 9B56575A2315FA7E00F4F4F7 /* XCoordinator_ExampleUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCoordinator_ExampleUITests.swift; sourceTree = ""; }; 9B56575C2315FA7E00F4F4F7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 9B5657692315FB3500F4F4F7 /* AppCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; + 9BA0000000000000000000C1 /* HomeSwiftUICoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeSwiftUICoordinator.swift; sourceTree = ""; }; + 9BA0000000000000000000C2 /* HomeSwiftUIView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeSwiftUIView.swift; sourceTree = ""; }; 9B56576A2315FB3500F4F4F7 /* UserListCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserListCoordinator.swift; sourceTree = ""; }; 9B56576B2315FB3500F4F4F7 /* HomeSplitCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeSplitCoordinator.swift; sourceTree = ""; }; 9B56576C2315FB3500F4F4F7 /* UserCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserCoordinator.swift; sourceTree = ""; }; @@ -154,12 +152,6 @@ 9B5657A42315FB3500F4F4F7 /* TransitionAnimation+Defaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TransitionAnimation+Defaults.swift"; sourceTree = ""; }; 9B5657A62315FB3500F4F4F7 /* CGAffineTransform+InPlace.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CGAffineTransform+InPlace.swift"; sourceTree = ""; }; 9BD3EBF32330CE46005861BF /* Transitions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transitions.swift; sourceTree = ""; }; - 9BD3EBF62330D84E005861BF /* XCText+Extras.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "XCText+Extras.swift"; sourceTree = ""; }; - 9BD3EBF72330D84F005861BF /* XCTestManifests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XCTestManifests.swift; sourceTree = ""; }; - 9BD3EBF82330D84F005861BF /* TestRoute.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestRoute.swift; sourceTree = ""; }; - 9BD3EBF92330D84F005861BF /* TransitionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransitionTests.swift; sourceTree = ""; }; - 9BD3EBFA2330D84F005861BF /* AnimationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnimationTests.swift; sourceTree = ""; }; - 9BD3EBFB2330D84F005861BF /* TestAnimation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestAnimation.swift; sourceTree = ""; }; 9BD3EC032330DA3A005861BF /* UserService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserService.swift; sourceTree = ""; }; 9BD3EC062330DA74005861BF /* News.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = News.swift; sourceTree = ""; }; 9BD3EC082330EFDF005861BF /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; @@ -247,12 +239,6 @@ isa = PBXGroup; children = ( 9BD5395523315E150014DF01 /* XCoordinator-Example.xctestplan */, - 9BD3EBFA2330D84F005861BF /* AnimationTests.swift */, - 9BD3EBFB2330D84F005861BF /* TestAnimation.swift */, - 9BD3EBF82330D84F005861BF /* TestRoute.swift */, - 9BD3EBF92330D84F005861BF /* TransitionTests.swift */, - 9BD3EBF72330D84F005861BF /* XCTestManifests.swift */, - 9BD3EBF62330D84E005861BF /* XCText+Extras.swift */, 9B5657512315FA7E00F4F4F7 /* Info.plist */, ); path = "XCoordinator-ExampleTests"; @@ -278,6 +264,7 @@ 9B56576E2315FB3500F4F4F7 /* HomeTabCoordinator.swift */, 9B56576F2315FB3500F4F4F7 /* HomePageCoordinator.swift */, 9B5657702315FB3500F4F4F7 /* AboutCoordinator.swift */, + 9BA0000000000000000000C1 /* HomeSwiftUICoordinator.swift */, ); path = Coordinators; sourceTree = ""; @@ -325,6 +312,7 @@ 9B56577B2315FB3500F4F4F7 /* HomeViewController.xib */, 9B56577C2315FB3500F4F4F7 /* HomeViewController.swift */, 9B56577D2315FB3500F4F4F7 /* HomeViewModelImpl.swift */, + 9BA0000000000000000000C2 /* HomeSwiftUIView.swift */, ); path = Home; sourceTree = ""; @@ -602,6 +590,8 @@ 9B5657D22315FB3500F4F4F7 /* Animation+Scale.swift in Sources */, 9B5657B32315FB3500F4F4F7 /* HomeViewModel.swift in Sources */, 9B5657A72315FB3500F4F4F7 /* AppCoordinator.swift in Sources */, + 9BA0000000000000000000B1 /* HomeSwiftUICoordinator.swift in Sources */, + 9BA0000000000000000000B2 /* HomeSwiftUIView.swift in Sources */, 9B5657AD2315FB3500F4F4F7 /* HomePageCoordinator.swift in Sources */, 9BD3EC0F2330F372005861BF /* AboutViewModelImpl.swift in Sources */, 9B5657C82315FB3500F4F4F7 /* LoginViewModel.swift in Sources */, @@ -646,12 +636,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 9BD3EC022330D84F005861BF /* TestAnimation.swift in Sources */, - 9BD3EC012330D84F005861BF /* AnimationTests.swift in Sources */, - 9BD3EBFD2330D84F005861BF /* XCText+Extras.swift in Sources */, - 9BD3EBFF2330D84F005861BF /* TestRoute.swift in Sources */, - 9BD3EBFE2330D84F005861BF /* XCTestManifests.swift in Sources */, - 9BD3EC002330D84F005861BF /* TransitionTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -982,10 +966,10 @@ }; 9B5657DD2315FC5C00F4F4F7 /* XCRemoteSwiftPackageReference "XCoordinator" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/quickbirdstudios/XCoordinator.git"; + repositoryURL = "https://github.com/QuickBirdEng/XCoordinator.git"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.2.1; + kind = revision; + revision = 9496ecaa81a82de7a20715afd8c14ce78bdb300f; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/XCoordinator-Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/XCoordinator-Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 71fb7c6..692c1e6 100644 --- a/XCoordinator-Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/XCoordinator-Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "5d5a6fcf63f9ca094c24fbe84c948b9dd4403adf50e1cd46835046ac2743aa5c", + "originHash" : "9c1864d231eb59d38d87aa55b3dfc4457908bde0f6050dc7bd59267a70bfaa2d", "pins" : [ { "identity" : "action", @@ -22,10 +22,9 @@ { "identity" : "xcoordinator", "kind" : "remoteSourceControl", - "location" : "https://github.com/quickbirdstudios/XCoordinator.git", + "location" : "https://github.com/QuickBirdEng/XCoordinator.git", "state" : { - "revision" : "00b066139ed0bf2f68549a7d87434ed36dfa6dea", - "version" : "2.2.1" + "revision" : "9496ecaa81a82de7a20715afd8c14ce78bdb300f" } } ], diff --git a/XCoordinator-Example/Animations/Animation+Fade.swift b/XCoordinator-Example/Animations/Animation+Fade.swift index 891ce06..df15fdc 100644 --- a/XCoordinator-Example/Animations/Animation+Fade.swift +++ b/XCoordinator-Example/Animations/Animation+Fade.swift @@ -22,6 +22,15 @@ extension InteractiveTransitionAnimation { let containerView = transitionContext.containerView let toView = transitionContext.view(forKey: .to)! + // Give the incoming view its final frame before animating. UIKit does not pre-size the + // presented view when a custom animator drives the transition, and a `UIHostingController` + // whose view has no frame lays out to zero size and renders blank. Setting the final frame + // (plus autoresizing) keeps the fade working for UIKit *and* SwiftUI-hosted view controllers. + if let toViewController = transitionContext.viewController(forKey: .to) { + toView.frame = transitionContext.finalFrame(for: toViewController) + } + toView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + toView.alpha = 0.0 containerView.addSubview(toView) diff --git a/XCoordinator-Example/Animations/Animation+Modal.swift b/XCoordinator-Example/Animations/Animation+Modal.swift index ae265c1..7278b68 100644 --- a/XCoordinator-Example/Animations/Animation+Modal.swift +++ b/XCoordinator-Example/Animations/Animation+Modal.swift @@ -22,14 +22,18 @@ extension InteractiveTransitionAnimation { let toView: UIView = context.view(forKey: .to)! let fromView: UIView = context.view(forKey: .from)! - var startToFrame = fromView.frame + // Drive the incoming view from its final frame (a custom animator owns layout; a + // `UIHostingController` without a frame renders blank — see Animation+Fade). + let finalFrame = context.viewController(forKey: .to).map(context.finalFrame(for:)) ?? fromView.frame + var startToFrame = finalFrame startToFrame.origin.y += startToFrame.height + toView.autoresizingMask = [.flexibleWidth, .flexibleHeight] context.containerView.addSubview(toView) context.containerView.bringSubviewToFront(toView) toView.frame = startToFrame UIView.animate(withDuration: duration, animations: { - toView.frame = fromView.frame + toView.frame = finalFrame }, completion: { _ in context.completeTransition(!context.transitionWasCancelled) }) @@ -39,10 +43,13 @@ extension InteractiveTransitionAnimation { let toView: UIView = context.view(forKey: .to)! let fromView: UIView = context.view(forKey: .from)! + let finalFrame = context.viewController(forKey: .to).map(context.finalFrame(for:)) ?? toView.frame + toView.frame = finalFrame + toView.autoresizingMask = [.flexibleWidth, .flexibleHeight] context.containerView.addSubview(toView) context.containerView.sendSubviewToBack(toView) - var newFromFrame = toView.frame - newFromFrame.origin.y += toView.frame.height + var newFromFrame = finalFrame + newFromFrame.origin.y += finalFrame.height UIView.animate(withDuration: duration, animations: { fromView.frame = newFromFrame diff --git a/XCoordinator-Example/Animations/Animation+Navigation.swift b/XCoordinator-Example/Animations/Animation+Navigation.swift index 876736f..42fc54b 100644 --- a/XCoordinator-Example/Animations/Animation+Navigation.swift +++ b/XCoordinator-Example/Animations/Animation+Navigation.swift @@ -25,7 +25,9 @@ extension InteractiveTransitionAnimation { let toView = context.view(forKey: .to)! let fromView = context.view(forKey: .from)! - let middleFrame = fromView.frame + // The on-screen frame is the incoming view's final frame (a custom animator owns layout; a + // `UIHostingController` without a frame renders blank — see Animation+Fade). + let middleFrame = context.viewController(forKey: .to).map(context.finalFrame(for:)) ?? fromView.frame var leftFrame = middleFrame leftFrame.origin.x -= middleFrame.width * 0.3 @@ -33,6 +35,7 @@ extension InteractiveTransitionAnimation { var rightFrame = middleFrame rightFrame.origin.x += middleFrame.width + toView.autoresizingMask = [.flexibleWidth, .flexibleHeight] context.containerView.addSubview(toView) context.containerView.bringSubviewToFront(toView) toView.frame = rightFrame @@ -49,7 +52,7 @@ extension InteractiveTransitionAnimation { let toView = context.view(forKey: .to)! let fromView = context.view(forKey: .from)! - let middleFrame = fromView.frame + let middleFrame = context.viewController(forKey: .to).map(context.finalFrame(for:)) ?? fromView.frame var leftFrame = middleFrame leftFrame.origin.x -= middleFrame.width * 0.3 @@ -57,6 +60,7 @@ extension InteractiveTransitionAnimation { var rightFrame = middleFrame rightFrame.origin.x += middleFrame.width + toView.autoresizingMask = [.flexibleWidth, .flexibleHeight] context.containerView.addSubview(toView) context.containerView.sendSubviewToBack(toView) toView.frame = leftFrame diff --git a/XCoordinator-Example/Animations/Animation+Scale.swift b/XCoordinator-Example/Animations/Animation+Scale.swift index 5c6aa1b..b872974 100644 --- a/XCoordinator-Example/Animations/Animation+Scale.swift +++ b/XCoordinator-Example/Animations/Animation+Scale.swift @@ -24,6 +24,13 @@ extension InteractiveTransitionAnimation { let toView = transitionContext.view(forKey: .to)! let fromView = transitionContext.view(forKey: .from)! + // Set the incoming view's final frame before applying any transform. A custom animator owns + // layout, and a `UIHostingController`'s view with no frame renders blank. (See Animation+Fade.) + if let toViewController = transitionContext.viewController(forKey: .to) { + toView.frame = transitionContext.finalFrame(for: toViewController) + } + toView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + containerView.backgroundColor = .white toView.transform = CGAffineTransform(scaleX: .verySmall, y: .verySmall) toView.alpha = 0 @@ -47,6 +54,11 @@ extension InteractiveTransitionAnimation { let toView: UIView = transitionContext.view(forKey: .to)! let fromView: UIView = transitionContext.view(forKey: .from)! + if let toViewController = transitionContext.viewController(forKey: .to) { + toView.frame = transitionContext.finalFrame(for: toViewController) + } + toView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + containerView.backgroundColor = .white containerView.addSubview(toView) containerView.sendSubviewToBack(toView) diff --git a/XCoordinator-Example/Animations/Animation+Swirl.swift b/XCoordinator-Example/Animations/Animation+Swirl.swift index 8c3a3d7..cbe819d 100644 --- a/XCoordinator-Example/Animations/Animation+Swirl.swift +++ b/XCoordinator-Example/Animations/Animation+Swirl.swift @@ -23,6 +23,13 @@ extension InterruptibleTransitionAnimation { let toView = transitionContext.view(forKey: .to)! let fromView = transitionContext.view(forKey: .from)! + // Set the incoming view's final frame before applying any transform. A custom animator owns + // layout, and a `UIHostingController`'s view with no frame renders blank. (See Animation+Fade.) + if let toViewController = transitionContext.viewController(forKey: .to) { + toView.frame = transitionContext.finalFrame(for: toViewController) + } + toView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + containerView.backgroundColor = .white toView.transform = CGAffineTransform(scaleX: .verySmall, y: .verySmall) toView.alpha = 0 @@ -52,6 +59,11 @@ extension InterruptibleTransitionAnimation { let toView: UIView = transitionContext.view(forKey: .to)! let fromView: UIView = transitionContext.view(forKey: .from)! + if let toViewController = transitionContext.viewController(forKey: .to) { + toView.frame = transitionContext.finalFrame(for: toViewController) + } + toView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + containerView.backgroundColor = .white containerView.addSubview(toView) containerView.sendSubviewToBack(toView) diff --git a/XCoordinator-Example/Common/SceneDelegate.swift b/XCoordinator-Example/Common/SceneDelegate.swift index bd64306..1f0eaf5 100644 --- a/XCoordinator-Example/Common/SceneDelegate.swift +++ b/XCoordinator-Example/Common/SceneDelegate.swift @@ -6,14 +6,16 @@ // import UIKit +import XCoordinator /// Owns the app window per scene and bootstraps the routing tree. -/// `AppCoordinator().strongRouter` holds the entire coordinator graph for this scene's lifetime; +/// The `AppCoordinator` holds the entire coordinator graph for this scene's lifetime; /// `setRoot(for:)` installs `AppCoordinator.rootViewController` as the window's `rootViewController`. +/// Typed as `any Router` so this layer only knows it as a router, not the concrete coordinator. class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - private let router = AppCoordinator().strongRouter + private let router: any Router = AppCoordinator() func scene(_ scene: UIScene, willConnectTo session: UISceneSession, diff --git a/XCoordinator-Example/Coordinators/AboutCoordinator.swift b/XCoordinator-Example/Coordinators/AboutCoordinator.swift index e5e6aaf..4a8125b 100644 --- a/XCoordinator-Example/Coordinators/AboutCoordinator.swift +++ b/XCoordinator-Example/Coordinators/AboutCoordinator.swift @@ -34,19 +34,29 @@ class AboutCoordinator: NavigationCoordinator { override func prepareTransition(for route: AboutRoute) -> NavigationTransition { switch route { case .home: - let viewController = AboutViewController() - let viewModel = AboutViewModelImpl(router: unownedRouter) - viewController.bind(to: viewModel) - return .push(viewController) + Transition.push(makeAboutViewController()) case .website: - // Custom side-effecting `Transition`: there is no view controller to present, but the - // route still needs to flow through the coordinator. Passing `presentables: []` and a - // closure that opens the URL externally lets the routing pipeline drive arbitrary work. - let url = URL(string: "https://quickbirdstudios.com/")! - return Transition(presentables: [], animationInUse: nil) { _, _, completion in - UIApplication.shared.open(url) - completion?() - } + openWebsiteTransition() + } + } + + // MARK: Helpers + + private func makeAboutViewController() -> UIViewController { + let viewController = AboutViewController() + let viewModel = AboutViewModelImpl(router: self) + viewController.bind(to: viewModel) + return viewController + } + + /// Custom side-effecting `Transition`: there is no view controller to present, but the route still + /// needs to flow through the coordinator. Passing `presentables: []` and a closure that opens the URL + /// externally lets the routing pipeline drive arbitrary work. + private func openWebsiteTransition() -> NavigationTransition { + let url = URL(string: "https://quickbirdstudios.com/")! + return Transition(presentables: [], animationInUse: nil) { _, _, completion in + UIApplication.shared.open(url) + completion?() } } diff --git a/XCoordinator-Example/Coordinators/AppCoordinator.swift b/XCoordinator-Example/Coordinators/AppCoordinator.swift index 05e163a..6bfa2d2 100644 --- a/XCoordinator-Example/Coordinators/AppCoordinator.swift +++ b/XCoordinator-Example/Coordinators/AppCoordinator.swift @@ -15,7 +15,7 @@ enum AppRoute: Route { case login /// Present the home flow. Pass `nil` to show the picker that lets the user choose one of `HomeTabCoordinator`, /// `HomeSplitCoordinator`, or `HomePageCoordinator`; pass a concrete router to skip the picker. - case home(StrongRouter?) + case home((any Router)?) /// Deep-link into a specific article, tearing down any modal stack and resetting navigation first. case newsDetail(News) /// Deep-link into a specific user, tearing down any modal stack and resetting navigation first. @@ -34,87 +34,103 @@ class AppCoordinator: NavigationCoordinator { // MARK: Overrides + // `@TransitionBuilder` (inherited from `BaseCoordinator.prepareTransition`) lets each case read as a + // single declarative `Transition` expression with no `return`. View-controller construction is + // extracted into the helpers below so each case stays a plain expression. override func prepareTransition(for route: AppRoute) -> NavigationTransition { switch route { case .login: - let viewController = LoginViewController.instantiateFromNib() - let viewModel = LoginViewModelImpl(router: unownedRouter) - viewController.bind(to: viewModel) - return .push(viewController) + Transition.push(makeLoginViewController()) case let .home(router): - if let router = router { - return .presentFullScreen(router, animation: .fade) - } - // Teaching device: when no router is supplied, the route itself resolves to a `UIAlertController` - // that lets the user pick one of the three home-flow coordinators. The chosen coordinator's - // router is then re-triggered through `.home(...)`, demonstrating that a `Transition` can be - // anything you can `.present`, including ad-hoc decision UI. - let alert = UIAlertController( - title: "How would you like to login?", - message: "Please choose the type of coordinator used for the `Home` scene.", - preferredStyle: .alert) - alert.addAction( - .init(title: "\(HomeTabCoordinator.self)", style: .default) { [unowned self] _ in - self.trigger(.home(HomeTabCoordinator().strongRouter)) - } - ) - alert.addAction( - .init(title: "\(HomeSplitCoordinator.self)", style: .default) { [unowned self] _ in - self.trigger(.home(HomeSplitCoordinator().strongRouter)) - } - ) - alert.addAction( - .init(title: "\(HomePageCoordinator.self)", style: .default) { [unowned self] _ in - self.trigger(.home(HomePageCoordinator().strongRouter)) - } - ) - alert.addAction( - .init(title: "Random", style: .default) { [unowned self] _ in - let routers: [() -> StrongRouter] = [ - { HomeTabCoordinator().strongRouter }, - { HomeSplitCoordinator().strongRouter }, - { HomePageCoordinator().strongRouter } - ] - let factory: (() -> StrongRouter)? - if let index = Self.testRandomPickerIndex, routers.indices.contains(index) { - factory = routers[index] - } else { - factory = routers.randomElement() - } - self.trigger(.home(factory?())) - } - ) - return .present(alert) + homeTransition(for: router) case .newsDetail(let news): - // Deep-link demo: `.multiple` chains transitions in sequence, and `deepLink(...)` walks down the - // coordinator hierarchy by triggering successive routes (AppRoute → HomeRoute → NewsRoute). - // The leading `.dismissAll()` + `.popToRoot()` guarantee a clean slate regardless of where in - // the navigation tree the user happens to be when the link fires. - return .multiple( - .dismissAll(), - .popToRoot(), - deepLink(AppRoute.home(HomePageCoordinator().strongRouter), - HomeRoute.news, - NewsRoute.newsDetail(news)) - ) + // Deep-link demo: `.multiple` runs the transitions in sequence, and `deepLink(...)` walks down + // the coordinator hierarchy (AppRoute → HomeRoute → NewsRoute). The leading `.dismissAll()` + + // `.popToRoot()` guarantee a clean slate wherever the link fires. + Transition.multiple(.dismissAll(), .popToRoot(), + deepLink(AppRoute.home(HomePageCoordinator()), + HomeRoute.news, + NewsRoute.newsDetail(news))) case let .userDetail(username): // Same deep-link shape as `.newsDetail`, ending in a modal present of the user detail. - // Note this targets `HomeRoute.userList`, which is `HomePageCoordinator`'s initial page — - // it works because `HomePageCoordinator` uses `setReliably` (see Extensions/Transitions.swift), - // which fires the transition completion even when the page is already on-screen, so the chain - // continues to `UserListRoute.user` instead of stalling. - return .multiple( - .dismissAll(), - .popToRoot(), - deepLink(AppRoute.home(HomePageCoordinator().strongRouter), - HomeRoute.userList, - UserListRoute.user(username)) - ) + // Targets `HomeRoute.userList`, `HomePageCoordinator`'s initial page — it works because + // XCoordinator 3's `.set` fires its completion even when the page is already on-screen, so the + // chain continues instead of stalling. + Transition.multiple(.dismissAll(), .popToRoot(), + deepLink(AppRoute.home(HomePageCoordinator()), + HomeRoute.userList, + UserListRoute.user(username))) } } // MARK: Methods + /// When a concrete home router is supplied, present it full-screen; otherwise present the picker alert. + private func homeTransition(for router: (any Router)?) -> NavigationTransition { + if let router { + return .presentFullScreen(router, animation: .fade) + } else { + // No router supplied → present an ad-hoc `UIAlertController` that lets the user pick one of the + // three home-flow coordinators; the chosen coordinator is re-triggered through `.home(...)`, + // demonstrating that a `Transition` can present arbitrary decision UI. + return .present(makeHomePickerAlert()) + } + } + + private func makeLoginViewController() -> UIViewController { + let viewController = LoginViewController.instantiateFromNib() + let viewModel = LoginViewModelImpl(router: self) + viewController.bind(to: viewModel) + return viewController + } + + private func makeHomePickerAlert() -> UIAlertController { + let alert = UIAlertController( + title: "How would you like to login?", + message: "Please choose the type of coordinator used for the `Home` scene.", + preferredStyle: .alert) + alert.addAction( + .init(title: "\(HomeTabCoordinator.self)", style: .default) { [unowned self] _ in + self.trigger(.home(HomeTabCoordinator())) + } + ) + alert.addAction( + .init(title: "\(HomeSplitCoordinator.self)", style: .default) { [unowned self] _ in + self.trigger(.home(HomeSplitCoordinator())) + } + ) + alert.addAction( + .init(title: "\(HomePageCoordinator.self)", style: .default) { [unowned self] _ in + self.trigger(.home(HomePageCoordinator())) + } + ) + alert.addAction( + .init(title: "\(HomeSwiftUICoordinator.self)", style: .default) { [unowned self] _ in + self.trigger(.home(HomeSwiftUICoordinator())) + } + ) + alert.addAction( + .init(title: "Random", style: .default) { [unowned self] _ in + // `HomeSwiftUICoordinator` is appended last so the UI tests that pass + // `--random-picker-index 0/1/2` still resolve to Tab/Split/Page respectively. + let routers: [() -> any Router] = [ + { HomeTabCoordinator() }, + { HomeSplitCoordinator() }, + { HomePageCoordinator() }, + { HomeSwiftUICoordinator() } + ] + let factory: (() -> any Router)? + if let index = Self.testRandomPickerIndex, routers.indices.contains(index) { + factory = routers[index] + } else { + factory = routers.randomElement() + } + self.trigger(.home(factory?())) + } + ) + return alert + } + /// Returns the index value from `--random-picker-index N` launch argument, if present and parseable. /// Used by UI tests to make the Random picker deterministic; nil in normal runs. private static var testRandomPickerIndex: Int? { diff --git a/XCoordinator-Example/Coordinators/HomePageCoordinator.swift b/XCoordinator-Example/Coordinators/HomePageCoordinator.swift index 62a131f..e16da8e 100644 --- a/XCoordinator-Example/Coordinators/HomePageCoordinator.swift +++ b/XCoordinator-Example/Coordinators/HomePageCoordinator.swift @@ -14,13 +14,17 @@ class HomePageCoordinator: PageCoordinator { // MARK: Stored properties - private let newsRouter: StrongRouter - private let userListRouter: StrongRouter + private let newsRouter: any Router + private let userListRouter: any Router // MARK: Initialization - init(newsRouter: StrongRouter = NewsCoordinator().strongRouter, - userListRouter: StrongRouter = UserListCoordinator().strongRouter) { + convenience init() { + self.init(newsRouter: NewsCoordinator(), userListRouter: UserListCoordinator()) + } + + init(newsRouter: any Router, + userListRouter: any Router) { self.newsRouter = newsRouter self.userListRouter = userListRouter @@ -37,13 +41,14 @@ class HomePageCoordinator: PageCoordinator { // MARK: Overrides override func prepareTransition(for route: HomeRoute) -> PageTransition { - // `setReliably` instead of the stock `.set` so that deep links chaining through this coordinator - // don't stall when the target page is already on-screen — see Extensions/Transitions.swift. + // XCoordinator 3's stock `.set` already calls its completion even when the target page is already + // on-screen, so deep links chaining through this coordinator no longer stall (this used to require a + // custom `.setReliably`, since removed). switch route { case .news: - return .setReliably(newsRouter, direction: .forward) + .set(newsRouter, direction: .forward) case .userList: - return .setReliably(userListRouter, direction: .reverse) + .set(userListRouter, direction: .reverse) } } diff --git a/XCoordinator-Example/Coordinators/HomeSplitCoordinator.swift b/XCoordinator-Example/Coordinators/HomeSplitCoordinator.swift index b344625..93fd2ad 100644 --- a/XCoordinator-Example/Coordinators/HomeSplitCoordinator.swift +++ b/XCoordinator-Example/Coordinators/HomeSplitCoordinator.swift @@ -14,17 +14,21 @@ class HomeSplitCoordinator: SplitCoordinator { // MARK: Stored properties - private let newsRouter: StrongRouter - private let userListRouter: StrongRouter + private let newsRouter: any Router + private let userListRouter: any Router // MARK: Initialization - init(newsRouter: StrongRouter = NewsCoordinator().strongRouter, - userListRouter: StrongRouter = UserListCoordinator().strongRouter) { + convenience init() { + self.init(newsRouter: NewsCoordinator(), userListRouter: UserListCoordinator()) + } + + init(newsRouter: any Router, + userListRouter: any Router) { self.newsRouter = newsRouter self.userListRouter = userListRouter - super.init(master: userListRouter, detail: newsRouter) + super.init(primary: userListRouter, secondary: newsRouter) rootViewController.view.accessibilityIdentifier = UITestIdentifiers.homeContainerSplit } @@ -33,9 +37,9 @@ class HomeSplitCoordinator: SplitCoordinator { override func prepareTransition(for route: HomeRoute) -> SplitTransition { switch route { case .news: - return .showDetail(newsRouter) + .showDetail(newsRouter) case .userList: - return .show(userListRouter) + .show(userListRouter) } } diff --git a/XCoordinator-Example/Coordinators/HomeSwiftUICoordinator.swift b/XCoordinator-Example/Coordinators/HomeSwiftUICoordinator.swift new file mode 100644 index 0000000..53d835b --- /dev/null +++ b/XCoordinator-Example/Coordinators/HomeSwiftUICoordinator.swift @@ -0,0 +1,68 @@ +// +// HomeSwiftUICoordinator.swift +// XCoordinator-Example +// +// Copyright © 2026 QuickBird Studios. All rights reserved. +// + +import SwiftUI +import XCoordinator + +/// Observable state shared between `HomeSwiftUICoordinator` and `HomeSwiftUIView`. +/// +/// The selected tab lives here (not in `HomeRoute`, which has no `Hashable` requirement) so the +/// coordinator can mutate it from `prepareTransition` and the SwiftUI `TabView` can bind to it. +@MainActor +final class HomeSwiftUIState: ObservableObject { + + /// Local, `Hashable` tag for the `TabView` selection. Mirrors `HomeRoute`'s two flows. + enum Tab: Hashable { + case news + case userList + } + + @Published var selection: Tab = .userList +} + +/// Home flow rendered as a **SwiftUI** container — the fourth variant alongside `HomeTabCoordinator`, +/// `HomeSplitCoordinator`, and `HomePageCoordinator`, driving the same `HomeRoute`. +/// +/// It demonstrates XCoordinator 3's SwiftUI interop in *both* directions: +/// - **UIKit → SwiftUI**: `ViewCoordinator(body:)` hosts `HomeSwiftUIView` in a `RoutingController` +/// and registers `self`, so a `@Routing` inside the view resolves to this coordinator. +/// - **SwiftUI → UIKit**: `HomeSwiftUIView` embeds `NewsCoordinator` / `UserListCoordinator` via +/// `WrappedRouter` (see that file). +/// +/// `HomeRoute` is handled with `Transition.withAnimation`, mutating the SwiftUI `selection` binding +/// instead of performing a UIKit transition — so triggering `.news` / `.userList` animates the tab. +final class HomeSwiftUICoordinator: ViewCoordinator { + + // MARK: Stored properties + + private let state: HomeSwiftUIState + + // MARK: Initialization + + init() { + // Stored properties must be initialized before `super.init`, and the body closure must not + // capture `self` (not yet initialized) — so build the state locally and capture that. + let state = HomeSwiftUIState() + self.state = state + super.init(body: { HomeSwiftUIView(state: state) }) + } + + // MARK: Overrides + + override func prepareTransition(for route: HomeRoute) -> ViewTransition { + // Binding-update route: instead of a UIKit transition, drive the SwiftUI `TabView` selection. + // `Transition.withAnimation` runs the mutation inside `SwiftUI.withAnimation` and respects the + // transition's `animated` flag. + switch route { + case .news: + Transition.withAnimation { [state] in state.selection = .news } + case .userList: + Transition.withAnimation { [state] in state.selection = .userList } + } + } + +} diff --git a/XCoordinator-Example/Coordinators/HomeTabCoordinator.swift b/XCoordinator-Example/Coordinators/HomeTabCoordinator.swift index 72d772d..4d540a1 100644 --- a/XCoordinator-Example/Coordinators/HomeTabCoordinator.swift +++ b/XCoordinator-Example/Coordinators/HomeTabCoordinator.swift @@ -24,8 +24,8 @@ class HomeTabCoordinator: TabBarCoordinator { // MARK: Stored properties - private let newsRouter: StrongRouter - private let userListRouter: StrongRouter + private let newsRouter: any Router + private let userListRouter: any Router // MARK: Initialization @@ -36,12 +36,12 @@ class HomeTabCoordinator: TabBarCoordinator { let userListCoordinator = UserListCoordinator() userListCoordinator.rootViewController.tabBarItem = UITabBarItem(tabBarSystemItem: .more, tag: 1) - self.init(newsRouter: newsCoordinator.strongRouter, - userListRouter: userListCoordinator.strongRouter) + self.init(newsRouter: newsCoordinator, + userListRouter: userListCoordinator) } - init(newsRouter: StrongRouter, - userListRouter: StrongRouter) { + init(newsRouter: any Router, + userListRouter: any Router) { self.newsRouter = newsRouter self.userListRouter = userListRouter @@ -54,9 +54,9 @@ class HomeTabCoordinator: TabBarCoordinator { override func prepareTransition(for route: HomeRoute) -> TabBarTransition { switch route { case .news: - return .select(newsRouter) + .select(newsRouter) case .userList: - return .select(userListRouter) + .select(userListRouter) } } diff --git a/XCoordinator-Example/Coordinators/NewsCoordinator.swift b/XCoordinator-Example/Coordinators/NewsCoordinator.swift index 4941562..e453eb0 100644 --- a/XCoordinator-Example/Coordinators/NewsCoordinator.swift +++ b/XCoordinator-Example/Coordinators/NewsCoordinator.swift @@ -6,6 +6,7 @@ // Copyright © 2018 QuickBird Studios. All rights reserved. // +import UIKit import XCoordinator /// Routes for the news flow: the news list, a specific article, and a "close everything" command @@ -33,19 +34,29 @@ class NewsCoordinator: NavigationCoordinator { override func prepareTransition(for route: NewsRoute) -> NavigationTransition { switch route { case .news: - let viewController = NewsViewController.instantiateFromNib() - let service = MockNewsService() - let viewModel = NewsViewModelImpl(newsService: service, router: unownedRouter) - viewController.bind(to: viewModel) - return .push(viewController) + Transition.push(makeNewsViewController()) case .newsDetail(let news): - let viewController = NewsDetailViewController.instantiateFromNib() - let viewModel = NewsDetailViewModelImpl(news: news) - viewController.bind(to: viewModel) - return .push(viewController, animation: .swirl) + Transition.push(makeNewsDetailViewController(news: news), animation: .swirl) case .close: - return .dismissToRoot() + Transition.dismissToRoot() } } + // MARK: Factories + + private func makeNewsViewController() -> UIViewController { + let viewController = NewsViewController.instantiateFromNib() + let service = MockNewsService() + let viewModel = NewsViewModelImpl(newsService: service, router: self) + viewController.bind(to: viewModel) + return viewController + } + + private func makeNewsDetailViewController(news: News) -> UIViewController { + let viewController = NewsDetailViewController.instantiateFromNib() + let viewModel = NewsDetailViewModelImpl(news: news) + viewController.bind(to: viewModel) + return viewController + } + } diff --git a/XCoordinator-Example/Coordinators/UserCoordinator.swift b/XCoordinator-Example/Coordinators/UserCoordinator.swift index 393c672..790027f 100644 --- a/XCoordinator-Example/Coordinators/UserCoordinator.swift +++ b/XCoordinator-Example/Coordinators/UserCoordinator.swift @@ -36,23 +36,37 @@ class UserCoordinator: NavigationCoordinator { override func prepareTransition(for route: UserRoute) -> NavigationTransition { switch route { case .randomColor: - let viewController = UIViewController() - viewController.view.backgroundColor = .random() - return .push(viewController, animation: .fade) + Transition.push(makeRandomColorViewController(), animation: .fade) case let .user(username): - let viewController = UserViewController.instantiateFromNib() - let viewModel = UserViewModelImpl(router: unownedRouter, username: username) - viewController.bind(to: viewModel) - return .push(viewController) + Transition.push(makeUserViewController(username: username)) case let .alert(title, message): - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil)) - return .present(alert) + Transition.present(makeAlert(title: title, message: message)) case .users: - return .dismiss() + Transition.dismiss() } } + // MARK: Factories + + private func makeRandomColorViewController() -> UIViewController { + let viewController = UIViewController() + viewController.view.backgroundColor = .random() + return viewController + } + + private func makeUserViewController(username: String) -> UIViewController { + let viewController = UserViewController.instantiateFromNib() + let viewModel = UserViewModelImpl(router: self, username: username) + viewController.bind(to: viewModel) + return viewController + } + + private func makeAlert(title: String, message: String) -> UIAlertController { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil)) + return alert + } + override func presented(from presentable: Presentable?) { super.presented(from: presentable) addPushGestureRecognizer(to: rootViewController) diff --git a/XCoordinator-Example/Coordinators/UserListCoordinator.swift b/XCoordinator-Example/Coordinators/UserListCoordinator.swift index 0e66a95..1f51ccd 100644 --- a/XCoordinator-Example/Coordinators/UserListCoordinator.swift +++ b/XCoordinator-Example/Coordinators/UserListCoordinator.swift @@ -6,6 +6,7 @@ // Copyright © 2018 QuickBird Studios. All rights reserved. // +import UIKit import XCoordinator /// Routes for the user-list flow: the home screen, the users list, a single user's detail, @@ -38,27 +39,40 @@ class UserListCoordinator: NavigationCoordinator { override func prepareTransition(for route: UserListRoute) -> NavigationTransition { switch route { case .home: - let viewController = HomeViewController.instantiateFromNib() - let viewModel = HomeViewModelImpl(router: unownedRouter) - viewController.bind(to: viewModel) - return .push(viewController) + Transition.push(makeHomeViewController()) case .users: - let viewController = UsersViewController.instantiateFromNib() - let viewModel = UsersViewModelImpl(userService: MockUserService(), router: unownedRouter) - viewController.bind(to: viewModel) - return .push(viewController, animation: .fade) + Transition.push(makeUsersViewController(), animation: .fade) case .user(let username): - let coordinator = UserCoordinator(user: username) - return .present(coordinator, animation: .default) + Transition.present(UserCoordinator(user: username), animation: .default) case .logout: - return .dismiss() + Transition.dismiss() case .about: - // Child-coordinator idiom: `AboutCoordinator` reuses *this* coordinator's `UINavigationController` - // (passed as `rootViewController`), so its pushes happen inside the same stack. Nothing needs to - // be presented or pushed at this level — hence `.none()` paired with `addChild(...)`. - addChild(AboutCoordinator(rootViewController: rootViewController)) - return .none() + attachAboutCoordinator() } } + // MARK: Helpers + + private func makeHomeViewController() -> UIViewController { + let viewController = HomeViewController.instantiateFromNib() + let viewModel = HomeViewModelImpl(router: self) + viewController.bind(to: viewModel) + return viewController + } + + private func makeUsersViewController() -> UIViewController { + let viewController = UsersViewController.instantiateFromNib() + let viewModel = UsersViewModelImpl(userService: MockUserService(), router: self) + viewController.bind(to: viewModel) + return viewController + } + + private func attachAboutCoordinator() -> NavigationTransition { + // Child-coordinator idiom: `AboutCoordinator` reuses *this* coordinator's `UINavigationController` + // (passed as `rootViewController`), so its pushes happen inside the same stack. Nothing needs to + // be presented or pushed at this level — hence `.none()` paired with `addChild(...)`. + addChild(AboutCoordinator(rootViewController: rootViewController)) + return .none() + } + } diff --git a/XCoordinator-Example/Extensions/Presentable+Rx.swift b/XCoordinator-Example/Extensions/Presentable+Rx.swift index 1874f1a..ab0393f 100644 --- a/XCoordinator-Example/Extensions/Presentable+Rx.swift +++ b/XCoordinator-Example/Extensions/Presentable+Rx.swift @@ -15,6 +15,7 @@ import XCoordinator extension Reactive where Base: Presentable { + @MainActor public var dismissal: Observable! { guard let viewController = base.viewController else { return nil diff --git a/XCoordinator-Example/Extensions/Transitions.swift b/XCoordinator-Example/Extensions/Transitions.swift index 70586e9..c1d4cec 100644 --- a/XCoordinator-Example/Extensions/Transitions.swift +++ b/XCoordinator-Example/Extensions/Transitions.swift @@ -35,37 +35,3 @@ extension Transition { } } - -extension Transition where RootViewController: UIPageViewController { - - /// A drop-in replacement for the stock `PageTransition.set(_:direction:)` that **always** calls its - /// completion handler. - /// - /// `UIPageViewController.setViewControllers(_:direction:animated:completion:)` silently skips its - /// completion block when the requested page is already the one on-screen (a long-standing UIKit quirk). - /// `deepLink` chains the next route *inside* a transition's completion, so a deep link whose page step - /// targets the already-visible page would stall forever. This variant short-circuits the no-op case and - /// invokes the completion directly, so deep links (and any chained `.multiple`) keep flowing. - static func setReliably(_ presentable: Presentable, - direction: UIPageViewController.NavigationDirection) -> Transition { - Transition(presentables: [presentable], animationInUse: nil) { rootViewController, options, completion in - guard let target = presentable.viewController else { - completion?() - return - } - let isAlreadyVisible = rootViewController.viewControllers?.count == 1 - && rootViewController.viewControllers?.first === target - guard !isAlreadyVisible else { - // The page is already displayed — UIKit would not call the completion, so do it ourselves. - // `presented(from:)` was already invoked when this page was first set, so it is not repeated. - completion?() - return - } - rootViewController.setViewControllers([target], direction: direction, animated: options.animated) { _ in - presentable.presented(from: rootViewController) - completion?() - } - } - } - -} diff --git a/XCoordinator-Example/Scenes/About/AboutViewModelImpl.swift b/XCoordinator-Example/Scenes/About/AboutViewModelImpl.swift index 1b74f7f..210e3c2 100644 --- a/XCoordinator-Example/Scenes/About/AboutViewModelImpl.swift +++ b/XCoordinator-Example/Scenes/About/AboutViewModelImpl.swift @@ -10,7 +10,9 @@ import Foundation import RxSwift import Action import XCoordinator +import XCoordinatorRx +@MainActor class AboutViewModelImpl: AboutViewModel, AboutViewModelInput, AboutViewModelOutput { // MARK: Inputs @@ -29,11 +31,11 @@ class AboutViewModelImpl: AboutViewModel, AboutViewModelInput, AboutViewModelOut // MARK: Stored properties - private let router: UnownedRouter + private unowned let router: any Router // MARK: Initialization - init(router: UnownedRouter) { + init(router: any Router) { self.router = router } diff --git a/XCoordinator-Example/Scenes/Home/HomeSwiftUIView.swift b/XCoordinator-Example/Scenes/Home/HomeSwiftUIView.swift new file mode 100644 index 0000000..3ee6ded --- /dev/null +++ b/XCoordinator-Example/Scenes/Home/HomeSwiftUIView.swift @@ -0,0 +1,54 @@ +// +// HomeSwiftUIView.swift +// XCoordinator-Example +// +// Copyright © 2026 QuickBird Studios. All rights reserved. +// + +import SwiftUI +import XCoordinator + +/// The SwiftUI body of `HomeSwiftUICoordinator`. Demonstrates the **SwiftUI → UIKit** direction: +/// each tab hosts a UIKit coordinator sub-flow via `WrappedRouter`. Tab selection is routed through +/// `@Routing` (the **forward**, in-SwiftUI routing direction), which the coordinator turns +/// into an animated `selection` change via `Transition.withAnimation`. +struct HomeSwiftUIView: View { + + // MARK: Properties + + @ObservedObject var state: HomeSwiftUIState + + /// Resolves to the hosting `HomeSwiftUICoordinator` (registered by `ViewCoordinator(body:)`). + @Routing private var router + + // MARK: Body + + var body: some View { + TabView(selection: routerDrivenSelection) { + WrappedRouter { UserListCoordinator() } + .ignoresSafeArea() + .tabItem { Label("Users", systemImage: "person.2.fill") } + .tag(HomeSwiftUIState.Tab.userList) + + WrappedRouter { NewsCoordinator() } + .ignoresSafeArea() + .tabItem { Label("News", systemImage: "newspaper.fill") } + .tag(HomeSwiftUIState.Tab.news) + } + } + + // MARK: Helpers + + /// A binding whose setter routes through the coordinator instead of mutating state directly: + /// tapping a tab calls `@Routing` → `router.trigger(...)` → + /// `HomeSwiftUICoordinator.prepareTransition` → `Transition.withAnimation { state.selection = … }`. + private var routerDrivenSelection: Binding { + Binding( + get: { state.selection }, + set: { tab in + router.trigger(tab == .news ? .news : .userList) + } + ) + } + +} diff --git a/XCoordinator-Example/Scenes/Home/HomeViewModelImpl.swift b/XCoordinator-Example/Scenes/Home/HomeViewModelImpl.swift index b216690..ccb92c4 100644 --- a/XCoordinator-Example/Scenes/Home/HomeViewModelImpl.swift +++ b/XCoordinator-Example/Scenes/Home/HomeViewModelImpl.swift @@ -11,6 +11,7 @@ import RxSwift import XCoordinator import XCoordinatorRx +@MainActor class HomeViewModelImpl: HomeViewModel, HomeViewModelInput, HomeViewModelOutput { // MARK: Inputs @@ -34,11 +35,11 @@ class HomeViewModelImpl: HomeViewModel, HomeViewModelInput, HomeViewModelOutput } // MARK: Stored properties - private let router: UnownedRouter + private unowned let router: any Router // MARK: Initialization - init(router: UnownedRouter) { + init(router: any Router) { self.router = router } diff --git a/XCoordinator-Example/Scenes/Login/LoginViewModelImpl.swift b/XCoordinator-Example/Scenes/Login/LoginViewModelImpl.swift index 14360a1..4106d8f 100644 --- a/XCoordinator-Example/Scenes/Login/LoginViewModelImpl.swift +++ b/XCoordinator-Example/Scenes/Login/LoginViewModelImpl.swift @@ -9,7 +9,9 @@ import Action import RxSwift import XCoordinator +import XCoordinatorRx +@MainActor class LoginViewModelImpl: LoginViewModel, LoginViewModelInput, LoginViewModelOutput { // MARK: Inputs @@ -24,11 +26,11 @@ class LoginViewModelImpl: LoginViewModel, LoginViewModelInput, LoginViewModelOut // MARK: Stored properties - private let router: UnownedRouter + private unowned let router: any Router // MARK: Initialization - init(router: UnownedRouter) { + init(router: any Router) { self.router = router } } diff --git a/XCoordinator-Example/Scenes/News/NewsViewModelImpl.swift b/XCoordinator-Example/Scenes/News/NewsViewModelImpl.swift index d329ab8..c7d27fd 100644 --- a/XCoordinator-Example/Scenes/News/NewsViewModelImpl.swift +++ b/XCoordinator-Example/Scenes/News/NewsViewModelImpl.swift @@ -9,7 +9,9 @@ import Action import RxSwift import XCoordinator +import XCoordinatorRx +@MainActor class NewsViewModelImpl: NewsViewModel, NewsViewModelInput, NewsViewModelOutput { // MARK: Inputs @@ -32,11 +34,11 @@ class NewsViewModelImpl: NewsViewModel, NewsViewModelInput, NewsViewModelOutput // MARK: Stored properties private let newsService: NewsService - private let router: UnownedRouter + private unowned let router: any Router // MARK: Initialization - init(newsService: NewsService, router: UnownedRouter) { + init(newsService: NewsService, router: any Router) { self.newsService = newsService self.newsObservable = .just(newsService.mostRecentNews()) self.router = router diff --git a/XCoordinator-Example/Scenes/User/UserViewModelImpl.swift b/XCoordinator-Example/Scenes/User/UserViewModelImpl.swift index ea1b96b..c284223 100644 --- a/XCoordinator-Example/Scenes/User/UserViewModelImpl.swift +++ b/XCoordinator-Example/Scenes/User/UserViewModelImpl.swift @@ -9,7 +9,9 @@ import Action import RxSwift import XCoordinator +import XCoordinatorRx +@MainActor class UserViewModelImpl: UserViewModel, UserViewModelInput, UserViewModelOutput { // MARK: Inputs @@ -33,11 +35,11 @@ class UserViewModelImpl: UserViewModel, UserViewModelInput, UserViewModelOutput // MARK: Stored properties - private let router: UnownedRouter + private unowned let router: any Router // MARK: Initialization - init(router: UnownedRouter, username: String) { + init(router: any Router, username: String) { self.router = router self.username = .just(username) } diff --git a/XCoordinator-Example/Scenes/UserList/UsersViewModelImpl.swift b/XCoordinator-Example/Scenes/UserList/UsersViewModelImpl.swift index c787695..1ff3729 100644 --- a/XCoordinator-Example/Scenes/UserList/UsersViewModelImpl.swift +++ b/XCoordinator-Example/Scenes/UserList/UsersViewModelImpl.swift @@ -9,7 +9,9 @@ import Action import RxSwift import XCoordinator +import XCoordinatorRx +@MainActor class UsersViewModelImpl: UsersViewModel, UsersViewModelInput, UsersViewModelOutput { // MARK: Inputs @@ -29,11 +31,11 @@ class UsersViewModelImpl: UsersViewModel, UsersViewModelInput, UsersViewModelOut // MARK: Stored properties private let userService: UserService - private let router: UnownedRouter + private unowned let router: any Router // MARK: Initialization - init(userService: UserService, router: UnownedRouter) { + init(userService: UserService, router: any Router) { self.userService = userService self.router = router } diff --git a/XCoordinator-ExampleTests/AnimationTests.swift b/XCoordinator-ExampleTests/AnimationTests.swift deleted file mode 100644 index c3f9c5b..0000000 --- a/XCoordinator-ExampleTests/AnimationTests.swift +++ /dev/null @@ -1,153 +0,0 @@ -// -// AnimationTests.swift -// XCoordinator_Example -// -// Created by Paul Kraft on 16.09.18. -// Copyright © 2018 QuickBird Studios. All rights reserved. -// - -import UIKit -import XCoordinator -import XCTest - -class AnimationTests: XCTestCase { - - // MARK: Static properties - - static let allTests = [ - ("testPageCoordinator", testPageCoordinator), - ("testSplitCoordinator", testSplitCoordinator), - ("testTabBarCoordinator", testTabBarCoordinator), - ("testViewCoordinator", testViewCoordinator), - ("testNavigationCoordinator", testNavigationCoordinator), - ] - - // MARK: Stored properties - - private lazy var window = UIWindow() - - // MARK: Tests - - func testViewCoordinator() { - let coordinator = ViewCoordinator(rootViewController: .init()) - coordinator.setRoot(for: window) - testStandardAnimationsCalled(on: coordinator) - } - - func testSplitCoordinator() { - let coordinator = SplitCoordinator(master: UIViewController(), detail: UIViewController()) - coordinator.setRoot(for: window) - testStandardAnimationsCalled(on: coordinator) - } - - func testPageCoordinator() { - let coordinator = PageCoordinator(pages: [UIViewController()]) - coordinator.setRoot(for: window) - testStandardAnimationsCalled(on: coordinator) - } - - func testTabBarCoordinator() { - let tabs = [UIViewController(), UIViewController(), UIViewController()] - let coordinator = TabBarCoordinator(tabs: tabs) - coordinator.setRoot(for: window) - testStandardAnimationsCalled(on: coordinator) - - testStaticAnimationCalled(on: coordinator, transition: { .select(tabs[1], animation: $0) }) - testInteractiveAnimationCalled(on: coordinator, transition: { .select(tabs[2], animation: $0) }) - - testStaticAnimationCalled(on: coordinator, transition: { .select(index: 1, animation: $0) }) - testInteractiveAnimationCalled(on: coordinator, transition: { .select(index: 2, animation: $0) }) - - testStaticAnimationCalled( - on: coordinator, - transition: { .set([UIViewController(), UIViewController()], animation: $0) } - ) - testInteractiveAnimationCalled( - on: coordinator, - transition: { .set([UIViewController(), UIViewController()], animation: $0) } - ) - } - - func testNavigationCoordinator() { - let coordinator = NavigationCoordinator(root: UIViewController()) - coordinator.setRoot(for: window) - testStandardAnimationsCalled(on: coordinator) - - testStaticAnimationCalled(on: coordinator, transition: { .push(UIViewController(), animation: $0) }) - testStaticAnimationCalled(on: coordinator, transition: { .pop(animation: $0) }) - - testInteractiveAnimationCalled(on: coordinator, transition: { .push(UIViewController(), animation: $0) }) - testInteractiveAnimationCalled(on: coordinator, transition: { .pop(animation: $0) }) - - testStaticAnimationCalled(on: coordinator, transition: { .push(UIViewController(), animation: $0) }) - testStaticAnimationCalled(on: coordinator, transition: { .push(UIViewController(), animation: $0) }) - testStaticAnimationCalled(on: coordinator, transition: { .popToRoot(animation: $0) }) - - testInteractiveAnimationCalled(on: coordinator, transition: { .push(UIViewController(), animation: $0) }) - testInteractiveAnimationCalled(on: coordinator, transition: { .push(UIViewController(), animation: $0) }) - testInteractiveAnimationCalled(on: coordinator, transition: { .popToRoot(animation: $0) }) - - let staticViewControllers = [UIViewController(), UIViewController()] - testStaticAnimationCalled(on: coordinator, transition: { .set(staticViewControllers, animation: $0) }) - testStaticAnimationCalled(on: coordinator, transition: { .pop(to: staticViewControllers[0], animation: $0) }) - - let interactiveViewControllers = [UIViewController(), UIViewController()] - testInteractiveAnimationCalled(on: coordinator, transition: { .set(interactiveViewControllers, animation: $0) }) - testInteractiveAnimationCalled( - on: coordinator, - transition: { .pop(to: interactiveViewControllers[0], animation: $0) } - ) - } - - // MARK: Helpers - - private func testStandardAnimationsCalled(on coordinator: C) where C.TransitionType == Transition { - testStaticAnimationCalled(on: coordinator, transition: { .present(UIViewController(), animation: $0) }) - testStaticAnimationCalled(on: coordinator, transition: { .dismiss(animation: $0) }) - testStaticAnimationCalled( - on: coordinator, - transition: { .multiple(.present(UIViewController(), animation: nil), .dismiss(animation: $0)) } - ) - testStaticAnimationCalled( - on: coordinator, - transition: { .multiple(.present(UIViewController(), animation: $0), .dismiss(animation: .default)) } - ) - - testInteractiveAnimationCalled(on: coordinator, transition: { .present(UIViewController(), animation: $0) }) - testInteractiveAnimationCalled(on: coordinator, transition: { .dismiss(animation: $0) }) - testInteractiveAnimationCalled( - on: coordinator, - transition: { .multiple(.present(UIViewController(), animation: $0), .dismiss(animation: .default)) } - ) - } - - private func testStaticAnimationCalled(on coordinator: C, - transition: (Animation) -> C.TransitionType) { - let animationExpectation = expectation(description: "Animation \(Date().timeIntervalSince1970)") - let completionExpectation = expectation(description: "Completion \(Date().timeIntervalSince1970)") - let testAnimation = TestAnimation.static(presentation: animationExpectation, dismissal: animationExpectation) - let t = transition(testAnimation) - coordinator.performTransition(t, with: TransitionOptions(animated: true)) { - completionExpectation.fulfill() - } - wait(for: [animationExpectation, completionExpectation], timeout: 3, enforceOrder: true) - asyncWait(for: 0.1) - } - - private func testInteractiveAnimationCalled(on coordinator: C, - transition: (Animation) -> C.TransitionType) { - let animationExpectation = expectation(description: "Animation \(Date().timeIntervalSince1970)") - let completionExpectation = expectation(description: "Completion \(Date().timeIntervalSince1970)") - let testAnimation = TestAnimation.interactive( - presentation: animationExpectation, - dismissal: animationExpectation - ) - let t = transition(testAnimation) - coordinator.performTransition(t, with: TransitionOptions(animated: true)) { - completionExpectation.fulfill() - _ = testAnimation - } - wait(for: [animationExpectation, completionExpectation], timeout: 3, enforceOrder: true) - asyncWait(for: 0.1) - } -} diff --git a/XCoordinator-ExampleTests/TestAnimation.swift b/XCoordinator-ExampleTests/TestAnimation.swift deleted file mode 100644 index 81ba42c..0000000 --- a/XCoordinator-ExampleTests/TestAnimation.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// TestAnimation.swift -// XCoordinator_Tests -// -// Created by Paul Kraft on 16.09.18. -// Copyright © 2018 QuickBird Studios. All rights reserved. -// - -import XCoordinator -import XCTest - -class TestAnimation: Animation { - - static func `static`(presentation: XCTestExpectation, dismissal: XCTestExpectation) -> TestAnimation { - return TestAnimation( - presentation: TestAnimation.staticTransitionAnimation(for: presentation), - dismissal: TestAnimation.staticTransitionAnimation(for: dismissal) - ) - } - - static func interactive(presentation: XCTestExpectation, dismissal: XCTestExpectation) -> TestAnimation { - return TestAnimation( - presentation: TestAnimation.interactiveTransitionAnimation(for: presentation), - dismissal: TestAnimation.interactiveTransitionAnimation(for: dismissal) - ) - } - - private static func interactiveTransitionAnimation(for expectation: XCTestExpectation?) -> TransitionAnimation { - return InteractiveTransitionAnimation(duration: 0.1) { - expectation?.fulfill() - $0.completeTransition(true) - } - } - - private static func staticTransitionAnimation(for expectation: XCTestExpectation?) -> TransitionAnimation { - return StaticTransitionAnimation(duration: 0.1) { - expectation?.fulfill() - $0.completeTransition(true) - } - } - -} diff --git a/XCoordinator-ExampleTests/TestRoute.swift b/XCoordinator-ExampleTests/TestRoute.swift deleted file mode 100644 index ef2453c..0000000 --- a/XCoordinator-ExampleTests/TestRoute.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// TestRoute.swift -// XCoordinatorTests -// -// Created by Paul Kraft on 16.09.18. -// Copyright © 2018 QuickBird Studios. All rights reserved. -// - -import XCoordinator - -enum TestRoute: Route { - case home -} diff --git a/XCoordinator-ExampleTests/TransitionTests.swift b/XCoordinator-ExampleTests/TransitionTests.swift deleted file mode 100644 index 82f7850..0000000 --- a/XCoordinator-ExampleTests/TransitionTests.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// TransitionTests.swift -// XCoordinatorTests -// -// Created by Paul Kraft on 16.09.18. -// Copyright © 2018 QuickBird Studios. All rights reserved. -// - -import UIKit -import XCoordinator -import XCTest - -class TransitionTests: XCTestCase { - - // MARK: Static properties - - static let allTests = [ - ("testPageCoordinator", testPageCoordinator), - ("testSplitCoordinator", testSplitCoordinator), - ("testTabBarCoordinator", testTabBarCoordinator), - ("testViewCoordinator", testViewCoordinator), - ("testNavigationCoordinator", testNavigationCoordinator), - ] - - // MARK: Stored properties - - private lazy var window = UIWindow() - - // MARK: Tests - - func testPageCoordinator() { - let pages = [UIViewController(), UIViewController(), UIViewController()] - let coordinator = PageCoordinator(pages: pages) - coordinator.setRoot(for: window) - testStandardTransitions(on: coordinator) - testCompletionCalled(on: coordinator, transition: .set(pages[0], direction: .forward)) - coordinator.rootViewController.isDoubleSided = true - testCompletionCalled(on: coordinator, transition: .set(pages[1], pages[2], direction: .forward)) - } - - func testSplitCoordinator() { - let coordinator = SplitCoordinator(master: UIViewController(), detail: UIViewController()) - coordinator.setRoot(for: window) - testStandardTransitions(on: coordinator) - testCompletionCalled( - on: coordinator, - transition: .multiple(.show(UIViewController()), .showDetail(UIViewController())) - ) - } - - func testTabBarCoordinator() { - let tabs0 = [UIViewController(), UIViewController()] - let coordinator = TabBarCoordinator(tabs: tabs0) - coordinator.setRoot(for: window) - testStandardTransitions(on: coordinator) - let tabs1 = [UIViewController(), UIViewController()] - testCompletionCalled(on: coordinator, transition: .multiple(.set(tabs1), .select(tabs1[1]))) - testCompletionCalled(on: coordinator, transition: .multiple(.set(tabs0), .select(index: 1))) - } - - func testViewCoordinator() { - let coordinator = ViewCoordinator(rootViewController: .init()) - coordinator.setRoot(for: window) - testStandardTransitions(on: coordinator) - } - - func testNavigationCoordinator() { - let coordinator = NavigationCoordinator(root: UIViewController()) - coordinator.setRoot(for: window) - testStandardTransitions(on: coordinator) - testCompletionCalled(on: coordinator, transition: .push(UIViewController())) - testCompletionCalled(on: coordinator, transition: .pop()) - testCompletionCalled(on: coordinator, transition: .push(UIViewController())) - testCompletionCalled(on: coordinator, transition: .popToRoot()) - - let viewControllers = [UIViewController(), UIViewController()] - testCompletionCalled(on: coordinator, transition: .set(viewControllers)) - testCompletionCalled(on: coordinator, transition: .pop(to: viewControllers[0])) - } - - // MARK: Helpers - - private func testStandardTransitions(on coordinator: C) where C.TransitionType == Transition { - testCompletionCalled(on: coordinator, transition: .none()) - testCompletionCalled(on: coordinator, transition: .present(UIViewController())) - testCompletionCalled(on: coordinator, transition: .dismiss()) - testCompletionCalled(on: coordinator, transition: .embed(UIViewController(), in: UIViewController())) - testCompletionCalled(on: coordinator, transition: .multiple(.none())) - testCompletionCalled(on: coordinator, transition: .multiple()) - } - - private func testCompletionCalled(on coordinator: C, transition: C.TransitionType) { - let exp = expectation(description: Date().timeIntervalSince1970.description) - DispatchQueue.main.async { - coordinator.performTransition(transition, with: .init(animated: true)) { - exp.fulfill() - } - } - wait(for: [exp], timeout: 3) - } - -} diff --git a/XCoordinator-ExampleTests/XCTestManifests.swift b/XCoordinator-ExampleTests/XCTestManifests.swift deleted file mode 100644 index 7eddaab..0000000 --- a/XCoordinator-ExampleTests/XCTestManifests.swift +++ /dev/null @@ -1,10 +0,0 @@ -import XCTest - -#if !canImport(ObjectiveC) -public func allTests() -> [XCTestCaseEntry] { - return [ - testCase(AnimationTests.allTests), - testCase(TransitionTests.allTests) - ] -} -#endif diff --git a/XCoordinator-ExampleTests/XCText+Extras.swift b/XCoordinator-ExampleTests/XCText+Extras.swift deleted file mode 100644 index ddab2fa..0000000 --- a/XCoordinator-ExampleTests/XCText+Extras.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// XCText+Extras.swift -// XCoordinator_Tests -// -// Created by Paul Kraft on 20.11.18. -// Copyright © 2018 QuickBird Studios. All rights reserved. -// - -import Foundation -import XCTest - -extension XCTestCase { - - func asyncWait(for timeInterval: TimeInterval) { - let waitExpectation = self.expectation(description: "WAIT \(Date().timeIntervalSince1970)") - DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + timeInterval) { - waitExpectation.fulfill() - } - wait(for: [waitExpectation], timeout: max(timeInterval * 2, 1)) - } - -} diff --git a/XCoordinator-ExampleTests/XCoordinator-Example.xctestplan b/XCoordinator-ExampleTests/XCoordinator-Example.xctestplan index 29dfdfd..a669da3 100644 --- a/XCoordinator-ExampleTests/XCoordinator-Example.xctestplan +++ b/XCoordinator-ExampleTests/XCoordinator-Example.xctestplan @@ -17,13 +17,6 @@ } }, "testTargets" : [ - { - "target" : { - "containerPath" : "container:XCoordinator-Example.xcodeproj", - "identifier" : "9B56574A2315FA7E00F4F4F7", - "name" : "XCoordinator-ExampleTests" - } - }, { "target" : { "containerPath" : "container:XCoordinator-Example.xcodeproj", diff --git a/XCoordinator-ExampleUITests/XCoordinator_ExampleUITests.swift b/XCoordinator-ExampleUITests/XCoordinator_ExampleUITests.swift index c7cbf43..4dad3a7 100644 --- a/XCoordinator-ExampleUITests/XCoordinator_ExampleUITests.swift +++ b/XCoordinator-ExampleUITests/XCoordinator_ExampleUITests.swift @@ -24,6 +24,7 @@ private enum PickerLabel { static let tab = "HomeTabCoordinator" static let split = "HomeSplitCoordinator" static let page = "HomePageCoordinator" + static let swiftUI = "HomeSwiftUICoordinator" static let random = "Random" } @@ -119,6 +120,35 @@ final class XCoordinator_ExampleUITests: XCTestCase { assertContainerVisible(ID.homeContainerPage, in: app) } + // MARK: - SwiftUI container hosts the UIKit sub-flows (both interop directions) + + /// Picking the SwiftUI container must: + /// 1. show the SwiftUI host (forward: `ViewCoordinator(body:)`/`RoutingController`), and + /// 2. embed the UIKit `UserListCoordinator` and `NewsCoordinator` via `WrappedRouter` + /// (backward). Switching the segmented control routes through `@Routing` and the + /// coordinator's `Transition.withAnimation`, so the News sub-flow becomes interactive. + func testSwiftUIContainerHostsBothFlows() { + let app = launch() + tapLoginAndWaitForPicker(app).buttons[PickerLabel.swiftUI].tap() + + // The SwiftUI host rendered iff its embedded UIKit sub-flow is present. The default tab is the + // UserList flow, whose (UIKit) Home screen exposes the users button — proving the forward host + // (RoutingController) AND the backward embed (WrappedRouter { UserListCoordinator() }). + let users = app.buttons[ID.usersButton] + XCTAssertTrue(users.waitForExistence(timeout: 8), "UserList sub-flow (WrappedRouter) never appeared") + XCTAssertTrue(users.isHittable, "UserList sub-flow should be the active tab by default") + + // The tab bar drives selection through @Routing + Transition.withAnimation. + let newsTab = app.tabBars.buttons["News"] + XCTAssertTrue(newsTab.waitForExistence(timeout: 5), "News tab (@Routing) never appeared") + newsTab.tap() + + // Routing to the News tab must activate the other embedded UIKit flow (news list). + let newsCell = app.cells.firstMatch + XCTAssertTrue(newsCell.waitForExistence(timeout: 5), "News sub-flow (WrappedRouter) never appeared") + XCTAssertTrue(newsCell.isHittable, "News list should be interactive after routing to the News tab") + } + // MARK: - URL deep linking private func launchWithDeepLink(_ url: String) -> XCUIApplication { From 7307162832fa6fdf0cd1ede709dd4a5c783e4de3 Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Wed, 1 Jul 2026 12:25:37 +0200 Subject: [PATCH 4/4] Fix CI simulator selection and address PR review comments - ci.yml: pick an available iPhone simulator dynamically instead of the hardcoded `iPhone 16` (the macOS runner image no longer ships it, which failed destination resolution with exit 70). - README: update to XCoordinator 3 (versions, `any Router`, four home containers incl. the SwiftUI one, removed the obsolete `setReliably` note). - AppCoordinator: doc/comment now mention all four home coordinators. - UI tests: match the picker alert by its title instead of `firstMatch`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 10 ++++++++- README.md | 21 ++++++++++--------- .../Coordinators/AppCoordinator.swift | 7 ++++--- .../XCoordinator_ExampleUITests.swift | 4 +++- 4 files changed, 27 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc11242..72c5522 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,10 +17,18 @@ jobs: - name: Build and test run: | + # Pick whatever iPhone simulator the runner image ships with, rather than hardcoding a + # model name (which breaks whenever GitHub bumps the Xcode/runtime — e.g. iPhone 16 → 17). + UDID=$(xcrun simctl list devices available --json \ + | jq -r '[.devices[][] | select(.name | startswith("iPhone"))][0].udid') + if [ -z "$UDID" ] || [ "$UDID" = "null" ]; then + echo "No available iPhone simulator found"; xcrun simctl list devices available; exit 1 + fi + echo "Using simulator $UDID" xcodebuild \ -project XCoordinator-Example.xcodeproj \ -scheme XCoordinator-Example \ -testPlan XCoordinator-Example \ - -destination 'platform=iOS Simulator,OS=latest,name=iPhone 16' \ + -destination "id=$UDID" \ CODE_SIGNING_ALLOWED=NO \ test diff --git a/README.md b/README.md index 9d3c7ad..2eeab8a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # XCoordinator-Example -A sample iOS app showing **MVVM-C with [XCoordinator](https://github.com/quickbirdstudios/XCoordinator) v2** — built specifically to demonstrate the library's container coordinators side by side and to give you a worked example of MVVM-C scene wiring with RxSwift. +A sample iOS app showing **MVVM-C with [XCoordinator](https://github.com/quickbirdstudios/XCoordinator) 3** — built specifically to demonstrate the library's container coordinators side by side (including the new SwiftUI interop) and to give you a worked example of MVVM-C scene wiring with RxSwift.