Skip to content
Merged
7 changes: 3 additions & 4 deletions Examples/SignUpDemo/Sources/Home/HomeScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

import NanoViewControllerController

/// `SceneController` glue for the Home screen. See `SignUpScene` for why the
/// body is empty.
public final class HomeScene: Scene<HomeView> {
public static let title = "Home"
/// `NanoViewController` glue for the Home screen.
public final class HomeScene: NanoViewController<HomeView>, ControllerConfigProviding {
public static let config = ControllerConfig(title: "Home")
}
6 changes: 3 additions & 3 deletions Examples/SignUpDemo/Sources/Home/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ extension HomeView: ViewModelled {
InputFromView(logoutTrigger: logoutButton.tapPublisher)
}

public func populate(with output: ViewModel.Output) -> [AnyCancellable] {
output.greeting --> greetingLabel
output.email --> emailLabel
public func populate(with publishers: ViewModel.Publishers) -> [AnyCancellable] {
publishers.greeting --> greetingLabel
publishers.email --> emailLabel
}
}
33 changes: 19 additions & 14 deletions Examples/SignUpDemo/Sources/Home/HomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,19 @@
import Combine
import NanoViewControllerController
import NanoViewControllerCore
import NanoViewControllerNavigation

/// User outcomes the Home scene can emit.
public enum HomeUserAction: Sendable {
case logout
}

/// Drives `HomeView`: produces a static "Welcome, <name>" greeting + forwards
/// the logout-button tap to the coordinator.
public final class HomeViewModel: BaseViewModel<
HomeUserAction,
/// the logout-button tap to the coordinator via the navigation publisher.
public final class HomeViewModel: AbstractViewModel<
HomeViewModel.InputFromView,
HomeViewModel.Output
HomeViewModel.Publishers,
HomeUserAction
> {
private let user: SignedUpUser

Expand All @@ -23,17 +24,21 @@ public final class HomeViewModel: BaseViewModel<
super.init()
}

override public func transform(input: Input) -> Output {
input.fromView.logoutTrigger
.sink { [weak navigator] in navigator?.next(.logout) }
.store(in: &cancellables)
override public func transform(input: Input) -> Output<Publishers, HomeUserAction> {
let navigator = Navigator<HomeUserAction>()

// Greeting is a one-shot publisher — no upstream state changes after
// the user lands here, so `Just` is the simplest fit.
return Output(
greeting: Just("Welcome, \(user.name)!").eraseToAnyPublisher(),
email: Just(user.email).eraseToAnyPublisher()
)
publishers: Publishers(
// Greeting is a one-shot publisher — no upstream state changes
// after the user lands here, so `Just` is the simplest fit.
greeting: Just("Welcome, \(user.name)!").eraseToAnyPublisher(),
email: Just(user.email).eraseToAnyPublisher()
),
navigation: navigator.navigation
) {
input.fromView.logoutTrigger
.sink { [navigator] in navigator.next(.logout) }
}
}
}

Expand All @@ -48,7 +53,7 @@ public extension HomeViewModel {
}

/// Reactive bindings the view installs.
struct Output {
struct Publishers {
public let greeting: AnyPublisher<String, Never>
public let email: AnyPublisher<String, Never>
}
Expand Down
8 changes: 3 additions & 5 deletions Examples/SignUpDemo/Sources/Onboarding/SignUpScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@

import NanoViewControllerController

/// `SceneController` glue for the SignUp screen. The empty body is intentional:
/// `SceneController<SignUpView>` already wires the View ↔ ViewModel pipeline;
/// `TitledScene` bolts on the navigation-bar title.
public final class SignUpScene: Scene<SignUpView> {
public static let title = "Sign Up"
/// `NanoViewController` glue for the SignUp screen.
public final class SignUpScene: NanoViewController<SignUpView>, ControllerConfigProviding {
public static let config = ControllerConfig(title: "Sign Up")
}
8 changes: 4 additions & 4 deletions Examples/SignUpDemo/Sources/Onboarding/SignUpView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ extension SignUpView: ViewModelled {
)
}

public func populate(with output: ViewModel.Output) -> [AnyCancellable] {
output.isSubmitEnabled --> submitButton.isEnabledBinder
output.loadingText --> submitButton.titleBinder(for: .normal)
output.isLoading --> spinner.isAnimatingBinder
public func populate(with publishers: ViewModel.Publishers) -> [AnyCancellable] {
publishers.isSubmitEnabled --> submitButton.isEnabledBinder
publishers.loadingText --> submitButton.titleBinder(for: .normal)
publishers.isLoading --> spinner.isAnimatingBinder
}
}
58 changes: 32 additions & 26 deletions Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Foundation
import NanoViewControllerCombine
import NanoViewControllerController
import NanoViewControllerCore
import NanoViewControllerNavigation

/// User outcomes the SignUp scene can emit. The coordinator subscribes and
/// decides what happens next (here: `signedUp(_:)` advances to Home).
Expand Down Expand Up @@ -32,10 +33,10 @@ public extension SignUpViewModel {
}
}

// MARK: Output
// MARK: Publishers
public extension SignUpViewModel {
/// Reactive bindings the view installs.
struct Output {
struct Publishers {
/// Drives the Sign Up button's `isEnabled` (`isFormValid && !isLoading`).
public let isSubmitEnabled: AnyPublisher<Bool, Never>

Expand All @@ -45,19 +46,20 @@ public extension SignUpViewModel {
public let isLoading: AnyPublisher<Bool, Never>
}
}
public extension SignUpViewModel.Output {
public extension SignUpViewModel.Publishers {
var loadingText: AnyPublisher<String, Never> {
isLoading.map { $0 ? "" : "Sign Up" }.eraseToAnyPublisher()
}
}

/// Drives `SignUpView`: validates the (very loose) name + email rules,
/// gates the submit button, and on tap calls the injected service. The
/// returned user is forwarded as `.signedUp` to the parent coordinator.
public final class SignUpViewModel: BaseViewModel<
SignUpUserAction,
/// returned user is forwarded as `.signedUp` via the navigation publisher
/// exposed in `Output`, which the coordinator subscribes to.
public final class SignUpViewModel: AbstractViewModel<
SignUpViewModel.InputFromView,
SignUpViewModel.Output
SignUpViewModel.Publishers,
SignUpUserAction
> {
private let service: SignUpServicing

Expand All @@ -66,8 +68,10 @@ public final class SignUpViewModel: BaseViewModel<
super.init()
}

// MARK: BaseViewModel Overrides
override public func transform(input: Input) -> Output {
// MARK: AbstractViewModel Overrides
override public func transform(input: Input) -> Output<Publishers, SignUpUserAction> {
let navigator = Navigator<SignUpUserAction>()

// Track the in-flight state of the sign-up call so the view can show
// a spinner and disable the submit button while waiting.
let activity = ActivityIndicator()
Expand All @@ -90,23 +94,25 @@ public final class SignUpViewModel: BaseViewModel<
.map { valid, loading in valid && !loading }
.eraseToAnyPublisher()

// On submit-tap: snapshot the latest (name, email), call the service
// (tracking activity), forward the resulting user as `.signedUp`.
input.fromView.submitTrigger
.withLatestFrom(input.fromView.name.combineLatest(input.fromView.email))
.map { [service] name, email in
service.signUp(name: name, email: email)
.trackActivity(activity)
}
.switchToLatest()
.sink { [weak navigator] user in
navigator?.next(.signedUp(user))
}
.store(in: &cancellables)

return Output(
isSubmitEnabled: isSubmitEnabled,
isLoading: isLoading
)
publishers: Publishers(
isSubmitEnabled: isSubmitEnabled,
isLoading: isLoading
),
navigation: navigator.navigation
) {
// On submit-tap: snapshot the latest (name, email), call the service
// (tracking activity), forward the resulting user as `.signedUp`.
input.fromView.submitTrigger
.withLatestFrom(input.fromView.name.combineLatest(input.fromView.email))
.map { [service] name, email in
service.signUp(name: name, email: email)
.trackActivity(activity)
}
.switchToLatest()
.sink { [navigator] user in
navigator.next(.signedUp(user))
}
}
}
}
7 changes: 6 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// NanoViewControllerCore value types only; no UIKit
// NanoViewControllerCombine Combine helpers + Binder + --> operator
// NanoViewControllerNavigation Coordinator pattern + Navigator
// NanoViewControllerController SceneController, BarButton plumbing, nav-bar layout
// NanoViewControllerController NanoViewController, BarButton plumbing, nav-bar layout
// NanoViewControllerSceneViews AbstractSceneView + SingleCellTypeTableView
// NanoViewControllerDIPrimitives protocol-only DI (Clock, MainScheduler, …)
//
Expand Down Expand Up @@ -90,6 +90,11 @@ let package = Package(
dependencies: ["NanoViewControllerCore"],
swiftSettings: swift6Mode
),
.testTarget(
name: "NanoViewControllerControllerTests",
dependencies: ["NanoViewControllerController"],
swiftSettings: swift6Mode
),
.testTarget(
name: "NanoViewControllerCombineTests",
dependencies: ["NanoViewControllerCombine"],
Expand Down
77 changes: 39 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ A quick inline taste of the architecture — for a runnable toy example and a re

```swift
// MARK: NanoViewController
public final class SignUpScene: Scene<SignUpView> { // 🤯 3 lines VC!
public static let title = "Sign Up"
public final class SignUpScene: NanoViewController<SignUpView>, ControllerConfigProviding { // tiny VC
public static let config = ControllerConfig(title: "Sign Up")
}

// MARK: View
Expand All @@ -31,10 +31,10 @@ extension SignUpView: ViewModelled {
)
}

public func populate(with output: ViewModel.Output) -> [AnyCancellable] {
output.isSubmitEnabled --> submitButton.isEnabledBinder
output.loadingText --> submitButton.titleBinder(for: .normal)
output.isLoading --> spinner.isAnimatingBinder
public func populate(with publishers: ViewModel.Publishers) -> [AnyCancellable] {
publishers.isSubmitEnabled --> submitButton.isEnabledBinder
publishers.loadingText --> submitButton.titleBinder(for: .normal)
publishers.isLoading --> spinner.isAnimatingBinder
}
}

Expand All @@ -47,27 +47,26 @@ public extension SignUpViewModel {
}
}

// MARK: ViewModel.Output
// MARK: ViewModel.Publishers
public extension SignUpViewModel {
struct Output {
struct Publishers {
public let isSubmitEnabled: AnyPublisher<Bool, Never>
public let isLoading: AnyPublisher<Bool, Never>
}
}

// MARK: ViewModel.InputFromView
public final class SignUpViewModel: BaseViewModel<
SignUpUserAction, // NavigationStep
// MARK: SignUpViewModel
public final class SignUpViewModel: AbstractViewModel<
SignUpViewModel.InputFromView,
SignUpViewModel.Output
SignUpViewModel.Publishers,
SignUpUserAction // NavigationStep
> {
private let service: SignUpServicing
/* BaseViewModel declared `public let navigator = Navigator<NavigationStep>()` */
/* BaseViewModel declared `public var cancellables = Set<AnyCancellable>()` */

// MARK: BaseViewModel Overrides
override public func transform(input: Input) -> Output {
let activity = ActivityIndicator()
// MARK: AbstractViewModel Overrides
override public func transform(input: Input) -> Output<Publishers, SignUpUserAction> {
let navigator = Navigator<SignUpUserAction>()
let activity = ActivityIndicator()

// Name + email both non-empty → form is valid.
let isFormValid: AnyPublisher<Bool, Never> = input.fromView.name
Expand All @@ -87,24 +86,26 @@ public final class SignUpViewModel: BaseViewModel<
.map { valid, loading in valid && !loading }
.eraseToAnyPublisher()

// On submit-tap: snapshot the latest (name, email), call the service
// (tracking activity), forward the resulting user as `.signedUp`.
input.fromView.submitTrigger
.withLatestFrom(input.fromView.name.combineLatest(input.fromView.email))
.map { [service] name, email in
service.signUp(name: name, email: email)
.trackActivity(activity)
}
.switchToLatest()
.sink { [weak navigator] user in
navigator?.next(.signedUp(user))
}
.store(in: &cancellables)

return Output(
isSubmitEnabled: isSubmitEnabled,
isLoading: isLoading
)
publishers: Publishers(
isSubmitEnabled: isSubmitEnabled,
isLoading: isLoading
),
navigation: navigator.navigation
) {
// On submit-tap: snapshot the latest (name, email), call the service
// (tracking activity), forward the resulting user as `.signedUp`.
input.fromView.submitTrigger
.withLatestFrom(input.fromView.name.combineLatest(input.fromView.email))
.map { [service] name, email in
service.signUp(name: name, email: email)
.trackActivity(activity)
}
.switchToLatest()
.sink { [navigator] user in
navigator.next(.signedUp(user))
}
}
}
}

Expand All @@ -119,10 +120,10 @@ The package ships six independent SPM library targets so consumers can pick exac

| Product | Layer | Notes |
|---|---|---|
| `NanoViewControllerCore` | value types | `ViewModelType`, `InputType`, `EmptyInitializable`, `AbstractViewModel`, `AbstractTarget`, `ActivityIndicator`, `ErrorTracker` |
| `NanoViewControllerCore` | value types | `Output`, `EmptyInitializable`, `AbstractTarget`, `ActivityIndicator`, `ErrorTracker`, `BindingsBuilder` |
| `NanoViewControllerCombine` | reactive | `Binder`, the `-->` operator, `Publisher+Extras`, `UIControl`/`UITextField`/`UIView` publisher extensions |
| `NanoViewControllerNavigation` | coordinators | `Coordinating`, `BaseCoordinator`, `Navigator`, `Stepper` |
| `NanoViewControllerController` | UIKit glue | `SceneController<View>`, `BarButtonContent`, `InputFromController`, `ViewModelled`, `NavigationBarLayoutingNavigationController`, `Toast` |
| `NanoViewControllerController` | UIKit glue | `NanoViewController<View>`, `AbstractViewModel`, `ViewModelType`, `InputType`, `ControllerConfig`, `BarButtonContent`, `InputFromController`, `ViewModelled`, `NavigationBarLayoutingNavigationController`, `Toast` |
| `NanoViewControllerSceneViews` | UIKit views | `AbstractSceneView`, `BaseScrollableStackViewOwner`, `BaseTableViewOwner`, `SingleCellTypeTableView`, `CellConfigurable`, pull-to-refresh / class-identifiable / footer plumbing |
| `NanoViewControllerDIPrimitives` | DI protocols | `Clock`, `MainScheduler`, `DateProvider`, `HapticFeedback`, `Pasteboard`, `UrlOpener` |

Expand Down Expand Up @@ -154,15 +155,15 @@ The `pre-commit` hook installed by `just bootstrap` enforces: typos, shellcheck,

## SignUpDemo (toy, in this repo)

[`Examples/SignUpDemo/`](./Examples/SignUpDemo/) is a small UIKit iOS app that walks through every load-bearing piece of the package: a `SceneController`-backed sign-up screen, a `Coordinator` swap on success, and a logout button on the home screen that re-runs the onboarding flow. It uses a stub `SignUpServicing` (instant-success) so it runs out of the box on the simulator.
[`Examples/SignUpDemo/`](./Examples/SignUpDemo/) is a small UIKit iOS app that walks through every load-bearing piece of the package: a `NanoViewController`-backed sign-up screen, a `Coordinator` swap on success, and a logout button on the home screen that re-runs the onboarding flow. It uses a stub `SignUpServicing` (instant-success) so it runs out of the box on the simulator.

```sh
just example-gen # generate Examples/SignUpDemo/SignUpDemo.xcodeproj from project.yml
just example-build # xcodebuild for iPhone 17 simulator
open Examples/SignUpDemo/SignUpDemo.xcodeproj # then ⌘R in Xcode
```

The example shows the canonical wiring: scene = `SceneController<View>`, view-model subclasses the package's `BaseViewModel<NavigationStep, InputFromView, Output>` (which fixes `FromController` to `InputFromController` and provides a `Navigator<Step>`), coordinator subscribes to that navigator and routes the user-actions to push / pop / present transitions.
The example shows the canonical wiring: controller = `NanoViewController<View>`, view-model subclasses the package's `AbstractViewModel<InputFromView, Publishers, NavigationStep>`, declares a local `Navigator<Step>` inside `transform` and surfaces it on the returned `Output<Publishers, Step>`. The coordinator subscribes to that publisher (via the hosting controller's `.navigation`) and routes the user-actions to push / pop / present transitions.

## Zhip (real-world iOS wallet)

Expand Down
10 changes: 4 additions & 6 deletions Sources/NanoViewControllerCombine/Binder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,10 @@ import Foundation
/// }
///
/// // Usage in a populate(with:) implementation:
/// func populate(with output: ViewModel.OutputVM) -> [AnyCancellable] {
/// [
/// output.elevation --> card.elevationBinder,
/// output.title --> titleLabel, // -- string overload
/// output.isEnabled --> primaryButton.isEnabledBinder, // -- bool binder
/// ]
/// func populate(with publishers: ViewModel.Publishers) -> [AnyCancellable] {
/// publishers.elevation --> card.elevationBinder
/// publishers.title --> titleLabel // -- string overload
/// publishers.isEnabled --> primaryButton.isEnabledBinder // -- bool binder
/// }
/// ```
///
Expand Down
Loading
Loading