Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 21 additions & 30 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ concurrency:
jobs:
build-test:
name: Build & Test (iOS Simulator)
runs-on: macos-14
runs-on: macos-15
steps:
- uses: actions/checkout@v4

- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_15.4.app
- name: Select latest Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable

- name: Show toolchain
run: |
Expand All @@ -29,48 +31,37 @@ jobs:
run: swift package resolve

- name: Build (iOS Simulator)
run: |
xcodebuild build \
-scheme XCoordinator \
-destination 'generic/platform=iOS Simulator' \
-skipPackagePluginValidation \
CODE_SIGNING_ALLOWED=NO
run: Scripts/build.sh

- name: Test
run: |
xcodebuild test \
-scheme XCoordinator \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-skipPackagePluginValidation \
CODE_SIGNING_ALLOWED=NO
run: Scripts/test.sh

pod-lint:
name: CocoaPods lint
runs-on: macos-14
runs-on: macos-15
steps:
- uses: actions/checkout@v4

- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_15.4.app
- name: Select latest Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable

- name: Lint podspec
run: pod lib lint --allow-warnings --fail-fast
# iOS only: recent Xcode does not ship the tvOS simulator runtime, and the rest
# of CI (build/test/docs) is iOS-only as well.
run: pod lib lint --allow-warnings --fail-fast --platforms=ios

docs:
name: DocC build
runs-on: macos-14
runs-on: macos-15
steps:
- uses: actions/checkout@v4

- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_15.4.app
- name: Select latest Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable

- name: Build documentation
run: |
swift package \
--allow-writing-to-directory ./Documentation \
generate-documentation \
--target XCoordinator \
--output-path ./Documentation \
--transform-for-static-hosting \
--warnings-as-errors
run: Scripts/docs.sh
18 changes: 6 additions & 12 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,17 @@ concurrency:

jobs:
build:
runs-on: macos-14
runs-on: macos-15
steps:
- uses: actions/checkout@v4

- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_15.4.app
- name: Select latest Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable

- name: Build documentation
run: |
swift package \
--allow-writing-to-directory ./Documentation \
generate-documentation \
--target XCoordinator \
--output-path ./Documentation \
--transform-for-static-hosting \
--hosting-base-path XCoordinator \
--warnings-as-errors
run: Scripts/docs.sh XCoordinator

- name: Upload artifact
uses: actions/upload-pages-artifact@v3
Expand Down
5 changes: 1 addition & 4 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import PackageDescription

let package = Package(
name: "XCoordinator",
platforms: [.iOS(.v14), .tvOS(.v14)],
platforms: [.iOS(.v16), .tvOS(.v16)],
products: [
.library(
name: "XCoordinator",
Expand All @@ -24,8 +24,5 @@ let package = Package(
.target(
name: "XCoordinatorRx",
dependencies: ["XCoordinator", "RxSwift"]),
.testTarget(
name: "XCoordinatorTests",
dependencies: ["XCoordinator", "XCoordinatorRx"]),
]
)
38 changes: 33 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ XCoordinator decouples navigation from view controllers and view models: you des

## 🏃‍♂️ Getting started

Define a `Route` enum and a `Coordinator` that prepares a transition for each case:
Define a `Route` enum and a `Coordinator` that prepares a transition for each case. Since 3.0 you can
describe transitions with the **transition builder** ✨ — opt in by annotating your override with
`@TransitionBuilder<RootViewController>`:

```swift
enum UserListRoute: Route {
Expand All @@ -63,19 +65,45 @@ class UserListCoordinator: NavigationCoordinator<UserListRoute> {
super.init(initialRoute: .home)
}

@TransitionBuilder<UINavigationController>
override func prepareTransition(for route: UserListRoute) -> NavigationTransition {
switch route {
case .home:
return .push(HomeViewController())
Transition.push(HomeViewController())
case .user(let name):
return .present(UserCoordinator(user: name), animation: .default)
Transition.present(UserCoordinator(user: name), animation: .default)
case .logout:
return .dismiss()
Transition.dismiss()
}
}
}
```

> ✨ **New in 3.0 — the transition builder.** A `@resultBuilder` DSL lets you compose transitions
> declaratively by listing the `Transition.…` factories (`.push`, `.present`, `.select`, …) — list
> several in one block to chain them (like `.multiple`). It's opt-in and additive.

<details>
<summary><strong>Classic style (still supported, non-breaking)</strong></summary>

The original style — a plain `prepareTransition(for:)` returning `Transition.…` factories — keeps working
exactly as before. Just omit the `@TransitionBuilder` annotation:

```swift
class UserListCoordinator: NavigationCoordinator<UserListRoute> {
override func prepareTransition(for route: UserListRoute) -> NavigationTransition {
switch route {
case .home: .push(HomeViewController())
case .user(let name): .present(UserCoordinator(user: name), animation: .default)
case .logout: .dismiss()
}
}
}
```

The builder is a modern convenience, not a requirement — you opt into it per override by adding the attribute.
</details>

Trigger routes from a view model that holds a typed router reference:

```swift
Expand Down Expand Up @@ -154,7 +182,7 @@ struct ChildView: View {
}
```

**Drive SwiftUI state changes from `prepareTransition`** without performing a UIKit transition — use `Transition.withAnimation` or `Transition.withTransaction`:
**Drive SwiftUI state changes from `prepareTransition(for:)`** without performing a UIKit transition — use `Transition.withAnimation` or `Transition.withTransaction`:

```swift
class HomeCoordinator: TabBarCoordinator<HomeRoute> {
Expand Down
47 changes: 39 additions & 8 deletions Scripts/docs.sh
Original file line number Diff line number Diff line change
@@ -1,16 +1,47 @@
#!/bin/sh

# Generates static-hosting DocC output into ./Documentation.
# Uses the iOS Simulator SDK and lets the toolchain pick the matching target triple,
# so it runs unchanged on Apple Silicon and Intel Macs and on whatever Xcode is current.
#
# Builds the DocC archive against the iOS SDK via `xcodebuild docbuild` (the
# package depends on UIKit, so a plain SwiftPM host build fails with
# "no such module 'UIKit'"), then transforms it for static hosting.
#
# Pass an optional hosting base path as the first argument (used when publishing
# to GitHub Pages):
#
# Scripts/docs.sh # local / CI validation build
# Scripts/docs.sh XCoordinator # Pages build served under /XCoordinator

set -e -o pipefail

cd "$(dirname "$0")/.."

swift package \
--allow-writing-to-directory Documentation \
generate-documentation \
--target XCoordinator \
--output-path Documentation \
--transform-for-static-hosting
DERIVED_DATA=".build/docs-derived-data"

HOSTING_BASE_PATH_ARG=""
if [ -n "$1" ]; then
HOSTING_BASE_PATH_ARG="--hosting-base-path $1"
fi

rm -rf "$DERIVED_DATA"

xcodebuild docbuild \
-scheme XCoordinator \
-destination 'generic/platform=iOS' \
-derivedDataPath "$DERIVED_DATA" \
ONLY_ACTIVE_ARCH=YES \
CODE_SIGNING_ALLOWED=NO \
OTHER_DOCC_FLAGS="--warnings-as-errors" \
-quiet

ARCHIVE=$(find "$DERIVED_DATA" -type d -name 'XCoordinator.doccarchive' | head -n 1)
if [ -z "$ARCHIVE" ]; then
echo "error: could not find XCoordinator.doccarchive under $DERIVED_DATA" >&2
exit 1
fi

rm -rf ./Documentation

"$(xcrun --find docc)" process-archive transform-for-static-hosting "$ARCHIVE" \
--output-path ./Documentation \
$HOSTING_BASE_PATH_ARG
31 changes: 27 additions & 4 deletions Scripts/docs_preview.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,30 @@ set -e -o pipefail

cd "$(dirname "$0")/.."

swift package \
--disable-sandbox \
preview-documentation \
--product XCoordinator
echo "1. Building documentation archive for iOS..."
(./Scripts/docs.sh)

# Locate the generated archive
DOCC_ARCHIVE=$(find .build/Documentation -type d -name "XCoordinator.doccarchive" | head -n 1)

if [ -z "$DOCC_ARCHIVE" ]; then
echo "Error: Could not find the generated XCoordinator.doccarchive artifact."
exit 1
fi

echo "2. Transforming archive for local static web hosting..."
STATIC_OUT=".build/Documentation/static"
rm -rf "$STATIC_OUT"

xcrun docc process-archive transform-for-static-hosting "$DOCC_ARCHIVE" \
--output-path "$STATIC_OUT"

DOCC_URL=http://localhost:8000/documentation/xcoordinator

echo "--------------------------------------------------------"
echo "Documentation server running!"
echo "$DOCC_URL"
echo "--------------------------------------------------------"

# 3. Serve the interactive documentation site
python3 -m http.server --directory "$STATIC_OUT" 8000
38 changes: 38 additions & 0 deletions Scripts/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/bin/sh

# Runs the XCoordinator tests on an iOS Simulator.
#
# The tests exercise real UIKit view-controller transitions, which only work when a
# UIWindowScene exists — something a bare SwiftPM test bundle does not provide. They
# therefore run through the TestHost application (TestHost/XCoordinatorTestHost.xcodeproj),
# which references this package and hosts the tests in Tests/XCoordinatorTests.
#
# An available iPhone simulator is resolved at runtime so this works across Xcode
# versions (which ship different device names) on both local machines and CI.

set -e -o pipefail

cd "$(dirname "$0")/.."

DEVICE_ID=$(xcrun simctl list devices available -j | python3 -c '
import json, sys
devices = json.load(sys.stdin)["devices"]
candidates = [
dev["udid"]
for runtime, devs in devices.items() if "iOS" in runtime
for dev in devs if "iPhone" in dev["name"]
]
print(candidates[0] if candidates else "")
')

if [ -z "$DEVICE_ID" ]; then
echo "error: no available iPhone simulator found" >&2
exit 1
fi

xcodebuild test \
-project TestHost/XCoordinatorTestHost.xcodeproj \
-scheme XCoordinatorTestHost \
-destination "id=$DEVICE_ID" \
-skipPackagePluginValidation \
CODE_SIGNING_ALLOWED=NO
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// XCoordinator
//
// Created by Paul Kraft on 19.12.18.
// Copyright © 2018 QuickBird Studios. All rights reserved.
//

import UIKit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// XCoordinator
//
// Created by Paul Kraft on 24.12.18.
// Copyright © 2018 QuickBird Studios. All rights reserved.
//

import UIKit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,8 @@ open class StaticTransitionAnimation: NSObject, TransitionAnimation {
///
/// - Parameters:
/// - duration: The total duration of the animation.
/// - performAnimation: A closure performing the animation.
/// - context:
/// From the context, you can access source and destination views and
/// viewControllers and the containerView.
/// - performAnimation: A closure performing the animation. From the closure's `context`,
/// you can access source and destination views and viewControllers and the containerView.
///
public init(duration: TimeInterval, performAnimation: @escaping (_ context: UIViewControllerContextTransitioning) -> Void) {
self.duration = duration
Expand Down
4 changes: 2 additions & 2 deletions Sources/XCoordinator/Combine/Router+Combine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ extension Router {
public func contextTriggerPublisher(
_ route: RouteType,
with options: TransitionOptions = .init(animated: true)
) -> Future<any TransitionProtocol, Never> {
) -> Future<any TransitionContext, Never> {
Future { completion in
self.contextTrigger(route, with: options) {
completion(.success($0))
Expand Down Expand Up @@ -101,7 +101,7 @@ extension PublisherExtension where Base: Router {
public func contextTrigger(
_ route: Base.RouteType,
with options: TransitionOptions = .init(animated: true)
) -> Future<any TransitionProtocol, Never> {
) -> Future<any TransitionContext, Never> {
base.contextTriggerPublisher(route, with: options)
}

Expand Down
Loading
Loading