Skip to content

Commit 8f24213

Browse files
update
1 parent 9771982 commit 8f24213

3 files changed

Lines changed: 90 additions & 5 deletions

File tree

CLAUDE.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Overview
6+
7+
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.
8+
9+
- Swift 5, iOS 13.0+, universal (iPhone + iPad).
10+
- 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.
11+
- Open `XCoordinator-Example.xcodeproj` directly (there is no `.xcworkspace`).
12+
13+
## Build / test commands
14+
15+
```bash
16+
# Build for the simulator
17+
xcodebuild -project XCoordinator-Example.xcodeproj \
18+
-scheme XCoordinator-Example \
19+
-destination 'platform=iOS Simulator,name=iPhone 15' \
20+
build
21+
22+
# Run the full test plan (unit + UI tests)
23+
xcodebuild -project XCoordinator-Example.xcodeproj \
24+
-scheme XCoordinator-Example \
25+
-testPlan XCoordinator-Example \
26+
-destination 'platform=iOS Simulator,name=iPhone 15' \
27+
test
28+
29+
# Run a single test (Xcode test identifier: Target/Class/method)
30+
xcodebuild ... test \
31+
-only-testing:XCoordinator-ExampleTests/AnimationTests/testPageCoordinator
32+
```
33+
34+
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.
35+
36+
## Architecture
37+
38+
### Coordinator graph
39+
40+
`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.
41+
42+
The top-level structure is:
43+
44+
- **`AppCoordinator`** (`NavigationCoordinator<AppRoute>`) — 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<HomeRoute>` 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.
45+
- **Home container coordinators** all expose `HomeRoute { case news; case userList }` and delegate to:
46+
- `NewsCoordinator` (`NavigationCoordinator<NewsRoute>`) — news list → detail (uses a custom `.swirl` animation on iOS 10+, falls back to `.scale`).
47+
- `UserListCoordinator` (`NavigationCoordinator<UserListRoute>`) — 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(...))`.
48+
49+
### MVVM-C scene wiring
50+
51+
Every scene under `Scenes/<Feature>/` is three files:
52+
53+
- `<Feature>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()`.
54+
- `<Feature>ViewModel.swift` — protocol triplet: `<Feature>ViewModelInput`, `<Feature>ViewModelOutput`, and `<Feature>ViewModel { var input; var output }`. A `where Self: Input & Output` extension lets a single impl class satisfy all three by returning `self`.
55+
- `<Feature>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.
56+
57+
### Models, services, common
58+
59+
- `Models/` — plain Swift structs (`News`, `User`).
60+
- `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.
61+
- `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.
62+
63+
### Animations and transitions
64+
65+
- `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.
66+
- `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.
67+
- `Extensions/TransitionAnimation+Defaults.swift` — shared defaults reused across the custom animations.
68+
69+
### Conventions worth keeping
70+
71+
- 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/`.
72+
- 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.
73+
- 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.

XCoordinator-Example/Common/SceneDelegate.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
2929
//
3030
// The handler is dispatched to the next runloop so the AppCoordinator's initial `.login` route
3131
// has finished pushing before we trigger the deep-link chain.
32-
let coldLaunchURL = ProcessInfo.processInfo.environment["XCOORDINATOR_DEEP_LINK"].flatMap(URL.init(string:))
33-
?? connectionOptions.urlContexts.first?.url
32+
var coldLaunchURL = connectionOptions.urlContexts.first?.url
33+
#if DEBUG
34+
// Test-only hook: UI tests can't reach `connectionOptions`, so they inject a cold-launch deep
35+
// link via this env var. Gated to DEBUG so it never ships in release builds.
36+
coldLaunchURL = ProcessInfo.processInfo.environment["XCOORDINATOR_DEEP_LINK"].flatMap(URL.init(string:))
37+
?? coldLaunchURL
38+
#endif
3439
if let url = coldLaunchURL {
3540
DispatchQueue.main.async { [weak self] in
3641
self?.handle(url: url)

XCoordinator-ExampleUITests/XCoordinator_ExampleUITests.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,16 +130,23 @@ final class XCoordinator_ExampleUITests: XCTestCase {
130130

131131
func testNewsDeepLinkFromColdLaunch() {
132132
let app = launchWithDeepLink("xcoordinator-example://news/0")
133-
// Title label is "Article 0\nStefan" (title + newline + subtitle), so match by prefix.
134-
let predicate = NSPredicate(format: "label BEGINSWITH 'Article 0'")
133+
// The detail screen's single title label joins the title and subtitle into one string
134+
// ("Article 0" + "Stefan"). The news *list* renders the title and subtitle as two separate
135+
// cell labels and never joins them, so a single label containing BOTH is detail-only —
136+
// asserting the bare "Article 0" would pass even if the final newsDetail push never happened.
137+
let predicate = NSPredicate(format: "label CONTAINS %@ AND label CONTAINS %@", "Article 0", "Stefan")
135138
let title = app.staticTexts.matching(predicate).firstMatch
136139
XCTAssertTrue(title.waitForExistence(timeout: 8),
137140
"Expected news detail for article 0 to appear via deep link.")
138141
}
139142

140143
func testUsersDeepLink() {
141144
let app = launchWithDeepLink("xcoordinator-example://users/Paul")
142-
XCTAssertTrue(app.staticTexts["Paul"].waitForExistence(timeout: 10),
145+
// Assert on the "Close" bar button, which only exists on the modal UserViewController detail
146+
// screen. The user *list* renders "Paul" as a cell label too, so asserting `staticTexts["Paul"]`
147+
// would pass even if the final UserListRoute.user push silently failed — match a detail-only
148+
// element instead so the test actually proves navigation reached the detail screen.
149+
XCTAssertTrue(app.buttons["Close"].waitForExistence(timeout: 10),
143150
"Expected user detail screen for 'Paul' to appear via deep link.")
144151
}
145152

0 commit comments

Comments
 (0)