diff --git a/Examples/SignUpDemo/Sources/Home/HomeScene.swift b/Examples/SignUpDemo/Sources/Home/HomeScene.swift index a519c5c..9c568d8 100644 --- a/Examples/SignUpDemo/Sources/Home/HomeScene.swift +++ b/Examples/SignUpDemo/Sources/Home/HomeScene.swift @@ -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 { - public static let title = "Home" +/// `NanoViewController` glue for the Home screen. +public final class HomeScene: NanoViewController, ControllerConfigProviding { + public static let config = ControllerConfig(title: "Home") } diff --git a/Examples/SignUpDemo/Sources/Home/HomeView.swift b/Examples/SignUpDemo/Sources/Home/HomeView.swift index 176de7d..448a5e9 100644 --- a/Examples/SignUpDemo/Sources/Home/HomeView.swift +++ b/Examples/SignUpDemo/Sources/Home/HomeView.swift @@ -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 } } diff --git a/Examples/SignUpDemo/Sources/Home/HomeViewModel.swift b/Examples/SignUpDemo/Sources/Home/HomeViewModel.swift index 118a07e..3a5e91c 100644 --- a/Examples/SignUpDemo/Sources/Home/HomeViewModel.swift +++ b/Examples/SignUpDemo/Sources/Home/HomeViewModel.swift @@ -3,6 +3,7 @@ import Combine import NanoViewControllerController import NanoViewControllerCore +import NanoViewControllerNavigation /// User outcomes the Home scene can emit. public enum HomeUserAction: Sendable { @@ -10,11 +11,11 @@ public enum HomeUserAction: Sendable { } /// Drives `HomeView`: produces a static "Welcome, " 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 @@ -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 { + let navigator = Navigator() - // 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) } + } } } @@ -48,7 +53,7 @@ public extension HomeViewModel { } /// Reactive bindings the view installs. - struct Output { + struct Publishers { public let greeting: AnyPublisher public let email: AnyPublisher } diff --git a/Examples/SignUpDemo/Sources/Onboarding/SignUpScene.swift b/Examples/SignUpDemo/Sources/Onboarding/SignUpScene.swift index 3e59219..8592034 100644 --- a/Examples/SignUpDemo/Sources/Onboarding/SignUpScene.swift +++ b/Examples/SignUpDemo/Sources/Onboarding/SignUpScene.swift @@ -2,9 +2,7 @@ import NanoViewControllerController -/// `SceneController` glue for the SignUp screen. The empty body is intentional: -/// `SceneController` already wires the View ↔ ViewModel pipeline; -/// `TitledScene` bolts on the navigation-bar title. -public final class SignUpScene: Scene { - public static let title = "Sign Up" +/// `NanoViewController` glue for the SignUp screen. +public final class SignUpScene: NanoViewController, ControllerConfigProviding { + public static let config = ControllerConfig(title: "Sign Up") } diff --git a/Examples/SignUpDemo/Sources/Onboarding/SignUpView.swift b/Examples/SignUpDemo/Sources/Onboarding/SignUpView.swift index 83aa531..b20cc39 100644 --- a/Examples/SignUpDemo/Sources/Onboarding/SignUpView.swift +++ b/Examples/SignUpDemo/Sources/Onboarding/SignUpView.swift @@ -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 } } diff --git a/Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift b/Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift index d5fef85..3a93450 100644 --- a/Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift +++ b/Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift @@ -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). @@ -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 @@ -45,7 +46,7 @@ public extension SignUpViewModel { public let isLoading: AnyPublisher } } -public extension SignUpViewModel.Output { +public extension SignUpViewModel.Publishers { var loadingText: AnyPublisher { isLoading.map { $0 ? "" : "Sign Up" }.eraseToAnyPublisher() } @@ -53,11 +54,12 @@ public extension SignUpViewModel.Output { /// 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 @@ -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 { + let navigator = Navigator() + // 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() @@ -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)) + } + } } } diff --git a/Package.swift b/Package.swift index 720dc71..c1bf19d 100644 --- a/Package.swift +++ b/Package.swift @@ -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, …) // @@ -90,6 +90,11 @@ let package = Package( dependencies: ["NanoViewControllerCore"], swiftSettings: swift6Mode ), + .testTarget( + name: "NanoViewControllerControllerTests", + dependencies: ["NanoViewControllerController"], + swiftSettings: swift6Mode + ), .testTarget( name: "NanoViewControllerCombineTests", dependencies: ["NanoViewControllerCombine"], diff --git a/README.md b/README.md index e24deeb..99bd6b4 100644 --- a/README.md +++ b/README.md @@ -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 { // 🤯 3 lines VC! - public static let title = "Sign Up" +public final class SignUpScene: NanoViewController, ControllerConfigProviding { // tiny VC + public static let config = ControllerConfig(title: "Sign Up") } // MARK: View @@ -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 } } @@ -47,27 +47,26 @@ public extension SignUpViewModel { } } -// MARK: ViewModel.Output +// MARK: ViewModel.Publishers public extension SignUpViewModel { - struct Output { + struct Publishers { public let isSubmitEnabled: AnyPublisher public let isLoading: AnyPublisher } } -// 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()` */ - /* BaseViewModel declared `public var cancellables = Set()` */ - // MARK: BaseViewModel Overrides - override public func transform(input: Input) -> Output { - let activity = ActivityIndicator() + // MARK: AbstractViewModel Overrides + override public func transform(input: Input) -> Output { + let navigator = Navigator() + let activity = ActivityIndicator() // Name + email both non-empty → form is valid. let isFormValid: AnyPublisher = input.fromView.name @@ -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)) + } + } } } @@ -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`, `BarButtonContent`, `InputFromController`, `ViewModelled`, `NavigationBarLayoutingNavigationController`, `Toast` | +| `NanoViewControllerController` | UIKit glue | `NanoViewController`, `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` | @@ -154,7 +155,7 @@ 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 @@ -162,7 +163,7 @@ 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-model subclasses the package's `BaseViewModel` (which fixes `FromController` to `InputFromController` and provides a `Navigator`), coordinator subscribes to that navigator and routes the user-actions to push / pop / present transitions. +The example shows the canonical wiring: controller = `NanoViewController`, view-model subclasses the package's `AbstractViewModel`, declares a local `Navigator` inside `transform` and surfaces it on the returned `Output`. 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) diff --git a/Sources/NanoViewControllerCombine/Binder.swift b/Sources/NanoViewControllerCombine/Binder.swift index ac4b640..e73b3e2 100644 --- a/Sources/NanoViewControllerCombine/Binder.swift +++ b/Sources/NanoViewControllerCombine/Binder.swift @@ -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 /// } /// ``` /// diff --git a/Sources/NanoViewControllerController/AbstractController.swift b/Sources/NanoViewControllerController/AbstractController.swift index 5db8f43..6f81b50 100644 --- a/Sources/NanoViewControllerController/AbstractController.swift +++ b/Sources/NanoViewControllerController/AbstractController.swift @@ -1,102 +1,6 @@ // MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) -import Combine -import NanoViewControllerCore import UIKit -/// Common ancestor of every screen-level `UIViewController` in apps using -/// the package. -/// -/// Owns the bar-button-tap pipelines that ``SceneController`` exposes to -/// view-models through ``InputFromController``. Concretely: -/// -/// * ``rightBarButtonSubject`` / ``leftBarButtonSubject`` — the publisher -/// side, fired when the bar button is pressed. -/// * ``rightBarButtonAbstractTarget`` / ``leftBarButtonAbstractTarget`` — -/// the `@objc` target/action bridge UIKit can call as a selector and that -/// internally pushes `()` into the matching subject. -/// -/// Splitting the two halves like this lets us hand UIKit a real -/// `target/action` pair (which it requires) while presenting the ViewModel -/// layer with a clean Combine publisher. -/// -/// ## Example — referencing the bar-button targets from a custom subclass -/// -/// You normally don't subclass `AbstractController` directly — you subclass -/// ``SceneController`` (or use it through the ``Scene`` typealias). In the -/// rare case you need a custom controller, here's the wiring: -/// -/// ```swift -/// final class CustomScene: AbstractController { -/// override func viewDidLoad() { -/// super.viewDidLoad() -/// // Hook the right bar button into UIKit's target/action. -/// navigationItem.rightBarButtonItem = UIBarButtonItem( -/// title: "Save", -/// style: .done, -/// target: rightBarButtonAbstractTarget, // <- AbstractTarget -/// action: #selector(AbstractTarget.pressed) // <- pushes Void -/// ) -/// // Subscribe to taps as a Combine publisher. -/// rightBarButtonSubject -/// .sink { print("save tapped") } -/// .store(in: &cancellables) -/// } -/// -/// private var cancellables = Set() -/// } -/// ``` -open class AbstractController: UIViewController { - /// Subject fired every time the navigation-item *right* bar button is - /// pressed. Forwarded to the ViewModel as - /// ``InputFromController/rightBarButtonTrigger``. - public let rightBarButtonSubject = PassthroughSubject() - - /// Subject fired every time the navigation-item *left* bar button is - /// pressed. Forwarded to the ViewModel as - /// ``InputFromController/leftBarButtonTrigger``. - public let leftBarButtonSubject = PassthroughSubject() - - /// `@objc` target object UIKit invokes for the right bar button's action - /// selector. - /// - /// Lazy because it captures ``rightBarButtonSubject``, which must be - /// initialised first. - public lazy var rightBarButtonAbstractTarget = AbstractTarget(triggerSubject: rightBarButtonSubject) - - /// `@objc` target object UIKit invokes for the left bar button's action - /// selector. - /// - /// Lazy because it captures ``leftBarButtonSubject``, which must be - /// initialised first. - public lazy var leftBarButtonAbstractTarget = AbstractTarget(triggerSubject: leftBarButtonSubject) - - /// Default initializer forwards to `UIViewController` with the standard - /// programmatic-only `(nibName: nil, bundle: nil)` pair. - public init() { - super.init(nibName: nil, bundle: nil) - } - - /// Designated `nibName/bundle` initializer kept available for subclasses - /// that want to forward storyboard/Xib paths through. The package itself - /// never uses this path. - override public init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { - super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) - } - - /// Unavailable — Interface Builder is not supported. Traps to enforce the - /// programmatic-only invariant. - @available(*, unavailable) - public required init?(coder _: NSCoder) { - interfaceBuilderSucks - } -} - -extension AbstractController { - /// Default `description` is the runtime class name — handy in logs to - /// identify the concrete `SceneController<…>` specialisation without an - /// inheritance dance. - override open var description: String { - "\(type(of: self))" - } -} +@available(*, unavailable, message: "Use NanoViewController for MVVM scenes or NanoViewControllerWithoutVM for static scenes.") +public typealias AbstractController = UIViewController diff --git a/Sources/NanoViewControllerController/AbstractViewModel.swift b/Sources/NanoViewControllerController/AbstractViewModel.swift new file mode 100644 index 0000000..f06ece1 --- /dev/null +++ b/Sources/NanoViewControllerController/AbstractViewModel.swift @@ -0,0 +1,143 @@ +// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) + +import Combine +import Foundation +import NanoViewControllerCore + +/// Abstract base class supplying the boilerplate every concrete scene-bound +/// ViewModel needs. +/// +/// `AbstractViewModel` provides: +/// +/// * a synthesised nested ``Input`` struct conforming to ``InputType`` whose +/// controller channel is pinned to ``InputFromController``, and +/// * an open `transform(input:)` method that traps if not overridden — so +/// forgetting to override surfaces immediately at runtime. +/// +/// Subscriptions started inside `transform`, plus the navigation publisher, +/// are returned in the resulting ``Output`` and consumed by +/// ``NanoViewController`` for the lifetime of the scene — `AbstractViewModel` +/// does **not** carry a `cancellables` bag or a stored `navigator`. +/// +/// The class is generic over three slots: +/// +/// * `FromView` — the view-driven publisher struct the View exposes. +/// * `Publishers` — the publisher bag returned to the view. +/// * `NavigationStep` — the enum of navigation transitions; use `Never` +/// for scenes that emit none. +/// +/// The controller channel is pinned to ``InputFromController`` (by the +/// ``InputType`` protocol itself) — every scene-bound view-model uses it. +/// View-models that don't run on a ``NanoViewController`` (embedded sub-views, +/// for example) should conform to ``ViewModelType`` directly without +/// subclassing `AbstractViewModel`. +/// +/// ## Example — a sign-up ViewModel with navigation +/// +/// ```swift +/// import Combine +/// import NanoViewControllerController +/// import NanoViewControllerCore +/// import NanoViewControllerNavigation +/// +/// enum SignUpStep: Sendable { case signedUp(User) } +/// +/// struct SignUpInputFromView { +/// let username: AnyPublisher +/// let password: AnyPublisher +/// let signUpTapped: AnyPublisher +/// } +/// +/// final class SignUpViewModel: AbstractViewModel< +/// SignUpInputFromView, +/// SignUpViewModel.Publishers, +/// SignUpStep +/// > { +/// private let service: SignUpServicing +/// init(service: SignUpServicing) { self.service = service; super.init() } +/// } +/// +/// extension SignUpViewModel { +/// struct Publishers { +/// let isSignUpEnabled: AnyPublisher +/// let isLoading: AnyPublisher +/// } +/// } +/// +/// extension SignUpViewModel { +/// override func transform(input: Input) -> Output { +/// let navigator = Navigator() +/// let activity = ActivityIndicator() +/// +/// let credentials = input.fromView.username.combineLatest(input.fromView.password) +/// let isValid = credentials.map { !$0.isEmpty && $1.count >= 8 } +/// +/// return Output( +/// publishers: Publishers( +/// isSignUpEnabled: isValid.eraseToAnyPublisher(), +/// isLoading: activity.asPublisher() +/// ), +/// navigation: navigator.navigation +/// ) { +/// input.fromView.signUpTapped +/// .withLatestFrom(credentials) +/// .map { [service] u, p in +/// service.signUp(username: u, password: p).trackActivity(activity) +/// } +/// .switchToLatest() +/// .sink { [navigator] user in navigator.next(.signedUp(user)) } +/// } +/// } +/// } +/// ``` +/// +/// `@MainActor` because it conforms to ``ViewModelType``, which is itself +/// `@MainActor`. View-models in this package are inherently main-thread — +/// they're owned by `NanoViewController` (a `UIViewController` subclass) and +/// their `transform(input:)` runs on the main actor. +@MainActor +open class AbstractViewModel< + FromView, + Publishers, + NavigationStep: Sendable +>: ViewModelType { + /// The concrete ``InputType`` Swift synthesizes for each + /// `AbstractViewModel` specialisation. + /// + /// `NanoViewController` constructs this struct by combining the View's + /// `inputFromView` with the lifecycle-derived ``InputFromController`` it + /// owns, and hands it to ``transform(input:)``. + public struct Input: InputType { + /// Controller-lifecycle + write-back subjects channel. + public let fromController: InputFromController + + /// User-driven publishers channel (taps, text, toggles). + public let fromView: FromView + + /// Designated initializer. + /// + /// `NanoViewController` calls this to stitch together the two input + /// channels before handing the struct to `transform`. Tests call it + /// directly when building synthetic input. + public init(fromView: FromView, fromController: InputFromController) { + self.fromView = fromView + self.fromController = fromController + } + } + + /// Designated initialiser — public so consumer subclasses can call `super.init()`. + public init() {} + + /// Runs the ViewModel's business logic — must be overridden by subclasses. + /// + /// The default implementation traps via the ``abstract`` helper to surface + /// a missing override at runtime instead of silently returning a default + /// value (which would break scene wiring further down the line). + /// + /// - Parameter input: The pre-stitched ``Input`` with both channels. + /// - Returns: An ``Output`` wrapping the publisher bag, the navigation + /// publisher, and the subscriptions started inside `transform`. + open func transform(input _: Input) -> Output { + abstract + } +} diff --git a/Sources/NanoViewControllerController/BarButtonContent.swift b/Sources/NanoViewControllerController/BarButtonContent.swift index 0746b86..64b132b 100644 --- a/Sources/NanoViewControllerController/BarButtonContent.swift +++ b/Sources/NanoViewControllerController/BarButtonContent.swift @@ -12,31 +12,34 @@ import UIKit /// ## Example — static bar-button content /// /// ```swift -/// final class SignUpScene: Scene, RightBarButtonContentMaking { -/// static var title: String { "Sign up" } -/// // A `Skip` button on the top-right. -/// static var makeRightContent: BarButtonContent { -/// BarButtonContent(title: "Skip", style: .plain) -/// } +/// final class SignUpScene: NanoViewController, ControllerConfigProviding { +/// static let config = ControllerConfig( +/// title: "Sign up", +/// rightBarButton: BarButtonContent(title: "Skip", style: .plain) +/// ) /// } /// ``` /// /// ## Example — dynamic bar-button content driven by a publisher /// /// ```swift -/// final class EditProfileViewModel: BaseViewModel<…> { -/// override func transform(input: Input) -> Output { +/// final class EditProfileViewModel: AbstractViewModel<…, EditProfileStep> { +/// override func transform(input: Input) -> Output { +/// let navigator = Navigator() /// let canSave = input.fromView.firstName.combineLatest(input.fromView.lastName) /// .map { !$0.isEmpty && !$1.isEmpty } /// -/// // Update the right bar button's enabled-ness via dynamic content. -/// canSave -/// .map { enabled in -/// BarButtonContent(title: "Save", style: enabled ? .done : .plain) -/// } -/// .sink { input.fromController.rightBarButtonContentSubject.send($0) } -/// .store(in: &cancellables) -/// // … +/// return Output( +/// publishers: Publishers(/* … */), +/// navigation: navigator.navigation +/// ) { +/// // Update the right bar button's enabled-ness via dynamic content. +/// canSave +/// .map { enabled in +/// BarButtonContent(title: "Save", style: enabled ? .done : .plain) +/// } +/// .sink { input.fromController.rightBarButtonContentSubject.send($0) } +/// } /// } /// } /// ``` @@ -124,10 +127,10 @@ public extension BarButtonContent { /// the raw `SystemItem` to the matching `UIBarButtonItem` initialiser, /// which already encodes its own visual style. /// - /// ``AbstractController/setRightBarButtonUsing(content:)`` and the left- + /// ``NanoViewController/setRightBarButtonUsing(content:)`` and the left- /// hand variant call this with the controller's - /// ``AbstractController/rightBarButtonAbstractTarget`` / - /// ``AbstractController/leftBarButtonAbstractTarget`` and + /// ``NanoViewController/rightBarButtonAbstractTarget`` / + /// ``NanoViewController/leftBarButtonAbstractTarget`` and /// `#selector(AbstractTarget.pressed)`. You rarely call this directly. /// /// - Parameters: diff --git a/Sources/NanoViewControllerController/BaseViewModel.swift b/Sources/NanoViewControllerController/BaseViewModel.swift deleted file mode 100644 index 0a3203f..0000000 --- a/Sources/NanoViewControllerController/BaseViewModel.swift +++ /dev/null @@ -1,104 +0,0 @@ -// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) - -import NanoViewControllerCore -import NanoViewControllerNavigation - -/// Concrete convenience over ``AbstractViewModel`` that pins `FromController` -/// to the package's standard ``InputFromController`` and adds a typed -/// ``Navigator``. -/// -/// This is the base class most concrete view-models in consuming apps should -/// subclass. Generic parameters: -/// -/// * `NavigationStep` — the per-scene navigation enum the coordinator -/// listens for (e.g. `enum SignUpUserAction { case signedUp(User) }`). -/// * `InputFromView` — the view-event channel struct nested inside the -/// subclass (taps, text changes, toggles). -/// * `Output` — the bag of publishers the view binds to UI controls. -/// -/// `AbstractViewModel` stays generic over `FromController` so consumers who -/// want a different controller-input shape can still use it directly. Use -/// this class otherwise — it's what 99% of scenes need. -/// -/// ## Example — sign-up screen ViewModel -/// -/// ```swift -/// import Combine -/// import NanoViewControllerController -/// import NanoViewControllerCore -/// import NanoViewControllerNavigation -/// -/// // 1. Define the navigation contract. -/// enum SignUpStep { -/// case signedUp(User) -/// case userPressedHaveAccount -/// case userPressedTermsOfService -/// } -/// -/// // 2. Define the view-event channel (struct of publishers). -/// struct SignUpInputFromView { -/// let username: AnyPublisher -/// let password: AnyPublisher -/// let userPressedSignUp: AnyPublisher -/// let userPressedHaveAccount: AnyPublisher -/// let userPressedTermsOfService: AnyPublisher -/// } -/// -/// // 3. Define the view-output channel (struct of publishers the view binds). -/// struct SignUpOutput { -/// let isSignUpEnabled: AnyPublisher -/// let isLoading: AnyPublisher -/// } -/// -/// // 4. The ViewModel itself — subclass BaseViewModel. -/// final class SignUpViewModel: BaseViewModel { -/// private let service: SignUpServicing -/// -/// init(service: SignUpServicing) { self.service = service } -/// -/// override func transform(input: Input) -> SignUpOutput { -/// let activity = ActivityIndicator() -/// -/// let credentials = input.fromView.username.combineLatest(input.fromView.password) -/// let isValid = credentials.map { !$0.isEmpty && $1.count >= 8 } -/// -/// input.fromView.userPressedSignUp -/// .withLatestFrom(credentials) -/// .map { [service] u, p in -/// service.signUp(username: u, password: p) -/// .trackActivity(activity) -/// .replaceErrorWithEmpty() -/// } -/// .switchToLatest() -/// .sink { [navigator] user in navigator.next(.signedUp(user)) } -/// .store(in: &cancellables) -/// -/// input.fromView.userPressedHaveAccount -/// .sink { [navigator] in navigator.next(.userPressedHaveAccount) } -/// .store(in: &cancellables) -/// -/// input.fromView.userPressedTermsOfService -/// .sink { [navigator] in navigator.next(.userPressedTermsOfService) } -/// .store(in: &cancellables) -/// -/// return SignUpOutput( -/// isSignUpEnabled: isValid.eraseToAnyPublisher(), -/// isLoading: activity.asPublisher() -/// ) -/// } -/// } -/// ``` -/// -/// The coordinator subscribes to `signUpVM.navigator.navigation` and routes -/// each `SignUpStep` to a push/present/finish — see ``BaseCoordinator`` for -/// that side of the wiring. -open class BaseViewModel: - AbstractViewModel, - Navigating -{ - /// Stepper the coordinator subscribes to. - /// - /// Subclasses call `navigator.next(.step)` to declare an intent; the - /// coordinator decides how to satisfy it (push, pop, present, finish). - public let navigator = Navigator() -} diff --git a/Sources/NanoViewControllerController/Scene.swift b/Sources/NanoViewControllerController/ContentView.swift similarity index 52% rename from Sources/NanoViewControllerController/Scene.swift rename to Sources/NanoViewControllerController/ContentView.swift index b3525fc..f122986 100644 --- a/Sources/NanoViewControllerController/Scene.swift +++ b/Sources/NanoViewControllerController/ContentView.swift @@ -8,9 +8,9 @@ import UIKit /// (``EmptyInitializable``) and how to bind to its associated ViewModel via /// `populate(with:)` and `inputFromView`. /// -/// ``SceneController`` is parameterised on this typealias so the same generic +/// ``NanoViewController`` is parameterised on this typealias so the same generic /// glue can host any `(UIView, ViewModelled)` pair without each scene having -/// to subclass `SceneController`. +/// to declare its view/model relationship repeatedly. /// /// ## Example — declaring a `ContentView` from scratch /// @@ -44,42 +44,12 @@ import UIKit /// ) /// } /// -/// func populate(with output: WelcomeViewModel.OutputVM) -> [AnyCancellable] { -/// [output.title --> titleLabel] +/// func populate(with publishers: WelcomeViewModel.Publishers) -> [AnyCancellable] { +/// publishers.title --> titleLabel /// } /// } /// /// // Now WelcomeView fits the ContentView typealias and can be hosted by -/// // SceneController directly. +/// // NanoViewController directly. /// ``` public typealias ContentView = UIView & ViewModelled - -/// The standard scene-controller "shape" used throughout coordinators. -/// -/// Equivalent to ``SceneController`` plus a static ``TitledScene`` title. The -/// `where` clause anchors the view-model's controller-side input shape to the -/// package-wide ``InputFromController`` struct, so coordinators can hand the -/// scene any `View` whose `ViewModel.Input.FromController` matches. -/// -/// Use this typealias when you don't need a subclass. If your screen requires -/// a subclass (custom lifecycle, extra UIKit hooks), inherit from -/// ``SceneController`` directly and conform to ``TitledScene`` yourself. -/// -/// ## Example — declaring a Scene typealias for a screen -/// -/// ```swift -/// import NanoViewControllerController -/// -/// final class WelcomeScene: Scene { -/// // The compiler synthesises the (TitledScene + SceneController) -/// // conformance via the typealias; we only need a static title. -/// static var title: String { "Welcome" } -/// } -/// -/// // In a coordinator: -/// push(scene: WelcomeScene.self, viewModel: WelcomeViewModel(api: api)) { step in -/// // … -/// } -/// ``` -public typealias Scene = SceneController & TitledScene - where View.ViewModel.Input.FromController == InputFromController diff --git a/Sources/NanoViewControllerController/ControllerConfig.swift b/Sources/NanoViewControllerController/ControllerConfig.swift new file mode 100644 index 0000000..d184510 --- /dev/null +++ b/Sources/NanoViewControllerController/ControllerConfig.swift @@ -0,0 +1,84 @@ +// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) + +import Foundation + +/// Static controller chrome configuration for a ``NanoViewController``. +/// +/// The default value is intentionally empty: no title, no static bar buttons, +/// no back-button override, and no navigation-bar layout override. Dynamic +/// bar-button changes still flow through ``InputFromController``. +public struct ControllerConfig: Sendable { + public static let `default` = ControllerConfig() + + /// Navigation title installed during `viewDidLoad`. Empty and `nil` both + /// mean "leave the title alone". + public let title: String? + + /// Whether to hide the system back button and disable interactive pop. + public let hidesBackButton: Bool + + /// Static left bar button installed during `viewDidLoad`. + public let leftBarButton: BarButtonContent? + + /// Static right bar button installed during `viewDidLoad`. + public let rightBarButton: BarButtonContent? + + /// Optional per-controller navigation-bar layout. + public let navigationBarLayout: NavigationBarLayout? + + public init( + title: String? = nil, + hidesBackButton: Bool = false, + leftBarButton: BarButtonContent? = nil, + rightBarButton: BarButtonContent? = nil, + navigationBarLayout: NavigationBarLayout? = nil + ) { + self.title = title + self.hidesBackButton = hidesBackButton + self.leftBarButton = leftBarButton + self.rightBarButton = rightBarButton + self.navigationBarLayout = navigationBarLayout + } + + /// Convenience initializer for localized navigation titles. + /// + /// This keeps static controller chrome call sites readable when titles + /// come from string-catalog generated ``LocalizedStringResource`` values: + /// + /// ```swift + /// static let config = ControllerConfig(titleKey: .CreateWallet.title) + /// ``` + public init( + titleKey: LocalizedStringResource, + hidesBackButton: Bool = false, + leftBarButton: BarButtonContent? = nil, + rightBarButton: BarButtonContent? = nil, + navigationBarLayout: NavigationBarLayout? = nil + ) { + self.init( + title: String(localized: titleKey), + hidesBackButton: hidesBackButton, + leftBarButton: leftBarButton, + rightBarButton: rightBarButton, + navigationBarLayout: navigationBarLayout + ) + } +} + +/// Optional static configuration hook for `NanoViewController` subclasses. +/// +/// Concrete final screens can use a `static let config` without overriding a +/// `class var`, which keeps the common declaration terse and SwiftLint-clean: +/// +/// ```swift +/// final class LoginScene: NanoViewController, ControllerConfigProviding { +/// static let config = ControllerConfig(title: "Login") +/// } +/// ``` +/// +/// Screens whose chrome depends on construction-time state should instead +/// override ``NanoViewController/controllerConfig``. +@MainActor +public protocol ControllerConfigProviding { + static var config: ControllerConfig { get } +} diff --git a/Sources/NanoViewControllerController/Coordinating+NanoViewController+NavigationHelpers.swift b/Sources/NanoViewControllerController/Coordinating+NanoViewController+NavigationHelpers.swift new file mode 100644 index 0000000..aad46fd --- /dev/null +++ b/Sources/NanoViewControllerController/Coordinating+NanoViewController+NavigationHelpers.swift @@ -0,0 +1,40 @@ +// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) + +import Combine +import NanoViewControllerCombine +import NanoViewControllerCore +import NanoViewControllerNavigation + +extension Coordinating { + /// Subscribes the coordinator to the controller's navigation publisher and + /// stores the resulting cancellable on the coordinator's bag. + /// + /// Used by the push-style hookup in + /// ``Coordinating/pushSceneInstance(_:animated:navigationPresentationCompletion:navigationHandler:)``. + func subscribeToNavigation, V: ContentView>( + of scene: S, + handler: @escaping (V.ViewModel.NavigationStep) -> Void + ) { + scene.navigation + .sinkOnMain { handler($0) } + .store(in: &cancellables) + } + + /// Subscribes the coordinator to the controller's navigation publisher and + /// hands the caller's handler a `DismissScene` callback that dismisses + /// the controller with optional animation. Used by the modal-style hookups in + /// ``Coordinating/modallyPresent(scene:animated:presentationCompletion:navigationHandler:)`` + /// and ``Coordinating/replaceAllScenes(with:animated:whenReplacingFinished:navigationHandler:)``. + func subscribeToModalNavigation, V: ContentView>( + of scene: S, + handler: @escaping ModalNavigationHandler + ) { + scene.navigation + .sinkOnMain { [weak scene] step in + handler(step) { animated, completion in + scene?.dismiss(animated: animated, completion: completion) + } + } + .store(in: &cancellables) + } +} diff --git a/Sources/NanoViewControllerController/Coordinating+Scene+Present.swift b/Sources/NanoViewControllerController/Coordinating+NanoViewController+Present.swift similarity index 63% rename from Sources/NanoViewControllerController/Coordinating+Scene+Present.swift rename to Sources/NanoViewControllerController/Coordinating+NanoViewController+Present.swift index 97439e3..717c108 100644 --- a/Sources/NanoViewControllerController/Coordinating+Scene+Present.swift +++ b/Sources/NanoViewControllerController/Coordinating+NanoViewController+Present.swift @@ -2,14 +2,15 @@ import Combine import NanoViewControllerCombine +import NanoViewControllerCore import NanoViewControllerNavigation import UIKit public extension Coordinating { - /// Convenience overload: builds the `Scene` from its type + view-model and + /// Convenience overload: builds the `NanoViewController` from its type + view-model and /// forwards to ``modallyPresent(scene:animated:presentationCompletion:navigationHandler:)``. /// - /// ## Example — present a modal "legal" scene + /// ## Example — present a modal legal controller /// /// ```swift /// modallyPresent( @@ -23,20 +24,20 @@ public extension Coordinating { /// ``` /// /// - Parameters: - /// - scene: The scene type. The function constructs an instance via + /// - scene: The controller type. The function constructs an instance via /// `S(viewModel: viewModel)`. /// - viewModel: The ViewModel to inject. /// - animated: Whether the modal presentation animates. Default `true`. /// - presentationCompletion: Optional callback fired after presentation. /// - navigationHandler: Step-handling closure. The trailing /// ``DismissScene`` is how the caller dismisses this modal. - func modallyPresent, V: ContentView>( + func modallyPresent, V: ContentView>( scene _: S.Type, viewModel: V.ViewModel, animated: Bool = true, presentationCompletion: Completion? = nil, - navigationHandler: @escaping NavigationHandlerModalScene - ) where V.ViewModel: Navigating { + navigationHandler: @escaping ModalNavigationHandler + ) { let scene = S(viewModel: viewModel) modallyPresent( scene: scene, @@ -49,38 +50,29 @@ public extension Coordinating { /// Wraps `scene` in its own ``NavigationBarLayoutingNavigationController`` /// and presents it modally on `self.navigationController`. /// - /// Subscribes to the scene's view-model navigator so coordinator-level + /// Subscribes to the controller's view-model navigator so coordinator-level /// handling can react to user actions and dismiss when appropriate. /// - /// Use the overload above unless you already have a `Scene` instance — + /// Use the overload above unless you already have a controller instance — /// the typed-form version saves a line at the call site. /// /// - Parameters: - /// - scene: A pre-built scene instance. + /// - scene: A pre-built controller instance. /// - animated: Animate presentation. /// - presentationCompletion: Fires after presentation completes. /// - navigationHandler: Step-handling closure. Use the trailing /// ``DismissScene`` to dismiss. - func modallyPresent( - scene: some Scene, + func modallyPresent, V: ContentView>( + scene: S, animated: Bool = true, presentationCompletion: Completion? = nil, - navigationHandler: @escaping NavigationHandlerModalScene - ) where V.ViewModel: Navigating { - let viewModel = scene.viewModel + navigationHandler: @escaping ModalNavigationHandler + ) { // Wrap in a nav controller so the modal sheet has its own navigation - // bar (and our shared layout owner machinery still works). + // bar (and our shared layout machinery still works). let viewControllerToPresent = NavigationBarLayoutingNavigationController(rootViewController: scene) navigationController.present(viewControllerToPresent, animated: animated, completion: presentationCompletion) - // Bridge the view-model's navigation pulses to the caller's handler, - // handing the handler a closure it can call to dismiss this modal. - viewModel.navigator.navigation - .sinkOnMain { [weak scene] step in - navigationHandler(step) { animated, navigationCompletion in - scene?.dismiss(animated: animated, completion: navigationCompletion) - } - } - .store(in: &cancellables) + subscribeToModalNavigation(of: scene, handler: navigationHandler) } } diff --git a/Sources/NanoViewControllerController/Coordinating+Scene+Push.swift b/Sources/NanoViewControllerController/Coordinating+NanoViewController+Push.swift similarity index 75% rename from Sources/NanoViewControllerController/Coordinating+Scene+Push.swift rename to Sources/NanoViewControllerController/Coordinating+NanoViewController+Push.swift index 8eb6c3c..c927351 100644 --- a/Sources/NanoViewControllerController/Coordinating+Scene+Push.swift +++ b/Sources/NanoViewControllerController/Coordinating+NanoViewController+Push.swift @@ -2,16 +2,17 @@ import Combine import NanoViewControllerCombine +import NanoViewControllerCore import NanoViewControllerNavigation import UIKit public extension Coordinating { - /// Convenience overload: builds the `Scene` from its type + view-model + /// Convenience overload: builds the `NanoViewController` from its type + view-model /// and forwards to ``pushSceneInstance(_:animated:navigationPresentationCompletion:navigationHandler:)``. /// /// This is the single line of code most coordinators use per screen. /// - /// ## Example — push a sign-up scene and route its steps + /// ## Example — push a sign-up controller and route its steps /// /// ```swift /// final class OnboardingCoordinator: BaseCoordinator { @@ -36,19 +37,19 @@ public extension Coordinating { /// ``` /// /// - Parameters: - /// - scene: The scene type. Constructed via `S(viewModel: viewModel)`. + /// - scene: The controller type. Constructed via `S(viewModel: viewModel)`. /// - viewModel: The ViewModel to inject. /// - animated: Animate the push. /// - navigationPresentationCompletion: Fires after the push transition. /// - navigationHandler: Pattern-match on `V.ViewModel.NavigationStep` /// and route each case. - func push, V: ContentView>( + func push, V: ContentView>( scene _: S.Type, viewModel: V.ViewModel, animated: Bool = true, navigationPresentationCompletion: Completion? = nil, navigationHandler: @escaping (_ step: V.ViewModel.NavigationStep) -> Void - ) where V.ViewModel: Navigating { + ) { let scene = S(viewModel: viewModel) pushSceneInstance( scene, @@ -59,37 +60,30 @@ public extension Coordinating { } /// Pushes `scene` onto the navigation stack (or sets it as the root if the - /// stack is empty) and subscribes to its view-model navigator so + /// stack is empty) and subscribes to its navigation publisher so /// coordinator logic can react to user actions and decide when to /// advance/pop. /// - /// Use the overload above unless you already have a scene instance. + /// Use the overload above unless you already have a controller instance. /// /// - Parameters: - /// - scene: A pre-built scene instance. + /// - scene: A pre-built controller instance. /// - animated: Animate the push. /// - navigationPresentationCompletion: Fires after the push transition. /// - navigationHandler: Pattern-match on `V.ViewModel.NavigationStep` /// and route each case. - func pushSceneInstance( - _ scene: some Scene, + func pushSceneInstance, V: ContentView>( + _ scene: S, animated: Bool = true, navigationPresentationCompletion: Completion? = nil, navigationHandler: @escaping (_ step: V.ViewModel.NavigationStep) -> Void - ) where V.ViewModel: Navigating { - let viewModel = scene.viewModel - + ) { navigationController.setRootViewControllerIfEmptyElsePush( viewController: scene, animated: animated, completion: navigationPresentationCompletion ) - // Forward navigation steps from the view-model to the caller's handler. - // The handler closes over coordinator state and decides whether to push - // another scene, present a modal, or finish the flow. - viewModel.navigator.navigation - .sinkOnMain { navigationHandler($0) } - .store(in: &cancellables) + subscribeToNavigation(of: scene, handler: navigationHandler) } } diff --git a/Sources/NanoViewControllerController/Coordinating+Scene+Replace.swift b/Sources/NanoViewControllerController/Coordinating+NanoViewController+Replace.swift similarity index 85% rename from Sources/NanoViewControllerController/Coordinating+Scene+Replace.swift rename to Sources/NanoViewControllerController/Coordinating+NanoViewController+Replace.swift index da51a80..a584aa0 100644 --- a/Sources/NanoViewControllerController/Coordinating+Scene+Replace.swift +++ b/Sources/NanoViewControllerController/Coordinating+NanoViewController+Replace.swift @@ -2,6 +2,7 @@ import Combine import NanoViewControllerCombine +import NanoViewControllerCore import NanoViewControllerNavigation import UIKit @@ -9,7 +10,7 @@ public extension Coordinating { /// Closure shape used by ``modallyPresent(scene:viewModel:animated:presentationCompletion:navigationHandler:)`` /// and ``replaceAllScenes(with:viewModel:animated:whenReplacingFinished:navigationHandler:)``: /// receives the next navigation step plus a ``DismissScene`` callback the - /// handler can invoke to dismiss the presenting scene with optional + /// handler can invoke to dismiss the presenting controller with optional /// animation. /// /// ## Example @@ -22,9 +23,9 @@ public extension Coordinating { /// } /// } /// ``` - typealias NavigationHandlerModalScene = (N.NavigationStep, @escaping DismissScene) -> Void + typealias ModalNavigationHandler = (VM.NavigationStep, @escaping DismissScene) -> Void - /// Replaces every scene in the current navigation stack with `scene`. + /// Replaces every controller in the current navigation stack with `scene`. /// /// Use when transitioning to a *fresh* root for the same nav controller /// (e.g. logout → login). The previous stack is dismissed in @@ -47,14 +48,14 @@ public extension Coordinating { /// } /// } /// ``` - func replaceAllScenes, V: ContentView>( + func replaceAllScenes, V: ContentView>( with _: S.Type, viewModel: V.ViewModel, animated: Bool = true, whenReplacingFinished: Completion? = nil, - navigationHandler: @escaping NavigationHandlerModalScene - ) where V.ViewModel: Navigating { - // Create a new instance of the `Scene`, injecting its ViewModel + navigationHandler: @escaping ModalNavigationHandler + ) { + // Create a new instance of the controller, injecting its ViewModel. let scene = S(viewModel: viewModel) replaceAllScenes( @@ -68,15 +69,13 @@ public extension Coordinating { /// Instance-level variant of /// ``replaceAllScenes(with:viewModel:animated:whenReplacingFinished:navigationHandler:)``. /// - /// Use when you already have a scene instance. - func replaceAllScenes( - with scene: some Scene, + /// Use when you already have a controller instance. + func replaceAllScenes, V: ContentView>( + with scene: S, animated: Bool = true, whenReplacingFinished: Completion? = nil, - navigationHandler: @escaping NavigationHandlerModalScene - ) where V.ViewModel: Navigating { - let viewModel = scene.viewModel - + navigationHandler: @escaping ModalNavigationHandler + ) { let oldVCs = navigationController.viewControllers navigationController.setRootViewControllerIfEmptyElsePush( @@ -88,13 +87,7 @@ public extension Coordinating { oldVCs.forEach { $0.dismiss(animated: false, completion: nil) } } - viewModel.navigator.navigation - .sinkOnMain { [weak scene] step in - navigationHandler(step) { animated, navigationCompletion in - scene?.dismiss(animated: animated, completion: navigationCompletion) - } - } - .store(in: &cancellables) + subscribeToModalNavigation(of: scene, handler: navigationHandler) } } @@ -108,7 +101,7 @@ public extension UINavigationController { /// ## Example /// /// ```swift - /// // First scene of a flow — sets root if empty, pushes otherwise. + /// // First controller of a flow — sets root if empty, pushes otherwise. /// navigationController.setRootViewControllerIfEmptyElsePush( /// viewController: SignUpScene(viewModel: vm) /// ) diff --git a/Sources/NanoViewControllerController/Coordinating+NavigationStack.swift b/Sources/NanoViewControllerController/Coordinating+NavigationStack.swift index 7c301c7..0c65032 100644 --- a/Sources/NanoViewControllerController/Coordinating+NavigationStack.swift +++ b/Sources/NanoViewControllerController/Coordinating+NavigationStack.swift @@ -5,7 +5,7 @@ import UIKit public extension Coordinating { /// Returns `true` iff the navigation stack's topmost view controller is - /// an instance of `Scene`. + /// an instance of the supplied view-controller type. /// /// Used by handlers that need to make sure they're reacting to navigation /// only when they are the active scene (e.g. avoiding double-pushes from @@ -22,10 +22,10 @@ public extension Coordinating { /// ``` /// /// - Parameter scene: The scene type to test for. Phantom-arg only — - /// only `Scene.self` is read. - /// - Returns: `true` if `navigationController.topViewController is Scene`. - func isTopmost(scene _: Scene.Type) -> Bool { - guard navigationController.topViewController is Scene else { return false } + /// only `ViewController.self` is read. + /// - Returns: `true` if `navigationController.topViewController is ViewController`. + func isTopmost(scene _: ViewController.Type) -> Bool { + guard navigationController.topViewController is ViewController else { return false } return true } } diff --git a/Sources/NanoViewControllerController/Coordinating+Stack.swift b/Sources/NanoViewControllerController/Coordinating+Stack.swift index 8458915..6a15c34 100644 --- a/Sources/NanoViewControllerController/Coordinating+Stack.swift +++ b/Sources/NanoViewControllerController/Coordinating+Stack.swift @@ -74,8 +74,8 @@ public extension Coordinating { return last.topMostCoordinator } - /// The ``AbstractController`` currently visible on screen, taking into - /// account modal presentations. + /// The `UIViewController` currently visible on screen, taking into account + /// modal presentations. /// /// Used by toast presentation so a toast is shown on top of any modal /// that's currently up. @@ -88,15 +88,15 @@ public extension Coordinating { /// Toast("Synced").present(using: scene, clock: MainQueueClock()) /// } /// ``` - var topMostScene: AbstractController? { + var topMostScene: UIViewController? { if let presentedController = topMostCoordinator.navigationController.presentedViewController { if let presentedNavigationController = presentedController as? UINavigationController { - return presentedNavigationController.topViewController as? AbstractController + return presentedNavigationController.topViewController } else { - return presentedController as? AbstractController + return presentedController } } else { - return topMostCoordinator.navigationController.topViewController as? AbstractController + return topMostCoordinator.navigationController.topViewController } } } diff --git a/Sources/NanoViewControllerController/InputFromController.swift b/Sources/NanoViewControllerController/InputFromController.swift index 6491b96..1075dd2 100644 --- a/Sources/NanoViewControllerController/InputFromController.swift +++ b/Sources/NanoViewControllerController/InputFromController.swift @@ -3,49 +3,60 @@ import Combine import Foundation -/// The controller-lifecycle + write-back surface every ``BaseViewModel`` -/// receives. +/// The controller-lifecycle + write-back surface every scene-bound ViewModel +/// receives through `Input.fromController`. /// /// Publishers (``viewDidLoad``, bar-button triggers) flow **from** the -/// ``SceneController`` **into** the ViewModel. Subjects (``titleSubject``, +/// ``NanoViewController`` **into** the ViewModel. Subjects (``titleSubject``, /// ``toastSubject``, etc.) flow the other direction: the ViewModel `send`s /// values to drive UI the controller owns. /// -/// ## Example — using all four directions in a single ViewModel +/// ## Example — using all four directions in a single ViewModel transform /// /// ```swift -/// final class HomeViewModel: BaseViewModel { -/// override func transform(input: Input) -> HomeOutput { +/// import Combine +/// import NanoViewControllerCombine +/// import NanoViewControllerController +/// import NanoViewControllerCore +/// import NanoViewControllerNavigation +/// +/// final class HomeViewModel: AbstractViewModel< +/// HomeInputFromView, +/// HomeViewModel.Publishers, +/// HomeStep +/// > { +/// override func transform(input: Input) -> Output { +/// let navigator = Navigator() +/// /// // 1. Lifecycle in: kick off an initial fetch on first appear. -/// input.fromController.viewWillAppear +/// let home = input.fromController.viewWillAppear /// .first() /// .flatMapLatest { [api] _ in api.fetchHome().replaceErrorWithEmpty() } -/// .sink { [weak self] home in self?.applyHome(home) } -/// .store(in: &cancellables) -/// -/// // 2. Title write-back: change the title each time the user picks a tab. -/// input.fromView.tabSelected -/// .map { tab in tab.localizedTitle } -/// .sink { input.fromController.titleSubject.send($0) } -/// .store(in: &cancellables) +/// .share() /// -/// // 3. Bar-button trigger in: the navigation-bar Edit button was tapped. -/// input.fromController.rightBarButtonTrigger -/// .sink { [navigator] in navigator.next(.userTappedEdit) } -/// .store(in: &cancellables) +/// return Output( +/// publishers: Publishers(home: home.eraseToAnyPublisher()), +/// navigation: navigator.navigation +/// ) { +/// // 2. Title write-back: change the title each time the user picks a tab. +/// input.fromView.tabSelected +/// .map { tab in tab.localizedTitle } +/// .sink { input.fromController.titleSubject.send($0) } /// -/// // 4. Toast write-back: tell the user when a sync completes. -/// input.fromView.userTappedSync -/// .flatMapLatest { [api] _ in api.sync().replaceErrorWithEmpty() } -/// .sink { input.fromController.toastSubject.send("Synced") } -/// .store(in: &cancellables) +/// // 3. Bar-button trigger in: the navigation-bar Edit button was tapped. +/// input.fromController.rightBarButtonTrigger +/// .sink { [navigator] in navigator.next(.userTappedEdit) } /// -/// return HomeOutput(/* … */) +/// // 4. Toast write-back: tell the user when a sync completes. +/// input.fromView.userTappedSync +/// .flatMapLatest { [api] _ in api.sync().replaceErrorWithEmpty() } +/// .sink { input.fromController.toastSubject.send("Synced") } +/// } /// } /// } /// ``` /// -/// The ``SceneController`` handles all the UIKit side-effects: it sets the +/// The ``NanoViewController`` handles all the UIKit side-effects: it sets the /// title on the navigation item when ``titleSubject`` fires, dispatches a /// `UIAlertController`-based toast when ``toastSubject`` fires, and so on. public struct InputFromController { @@ -74,7 +85,7 @@ public struct InputFromController { /// The ViewModel pushes left-bar-button content (icon / title / enabled /// state) here. Each emission is wired up via - /// ``AbstractController/setLeftBarButtonUsing(content:)``. + /// ``NanoViewController/setLeftBarButtonUsing(content:)``. public let leftBarButtonContentSubject: PassthroughSubject /// The ViewModel pushes right-bar-button content here. Same wiring as @@ -85,7 +96,7 @@ public struct InputFromController { /// to display. public let toastSubject: PassthroughSubject - /// Memberwise initialiser — public so ``SceneController`` (or test fakes) + /// Memberwise initialiser — public so ``NanoViewController`` (or test fakes) /// can build the struct from the right side of the package boundary. public init( viewDidLoad: AnyPublisher, diff --git a/Sources/NanoViewControllerCore/InputType.swift b/Sources/NanoViewControllerController/InputType.swift similarity index 71% rename from Sources/NanoViewControllerCore/InputType.swift rename to Sources/NanoViewControllerController/InputType.swift index 2e78399..26f3c3f 100644 --- a/Sources/NanoViewControllerCore/InputType.swift +++ b/Sources/NanoViewControllerController/InputType.swift @@ -11,7 +11,7 @@ import Foundation /// Owned by the View, exposed as `inputFromView`. /// * ``fromController`` — controller lifecycle events plus *write-back* /// subjects (title updates, toast dispatch, dynamic bar-button content). -/// Owned by ``SceneController``, exposed as ``InputFromController``. +/// Owned by ``NanoViewController``, exposed as ``InputFromController``. /// /// Splitting them this way keeps the View free of any UIKit-controller knowledge /// and lets the ViewModel react to lifecycle events (e.g. fetch on @@ -20,34 +20,43 @@ import Foundation /// You almost never implement this protocol directly — ``AbstractViewModel/Input`` /// is the synthesised conformance every concrete ViewModel inherits. /// -/// ## Example — synthesised Input on a `BaseViewModel` subclass +/// ## Example — synthesised Input on an `AbstractViewModel` subclass /// /// ```swift -/// final class HomeViewModel: BaseViewModel { +/// import Combine +/// import NanoViewControllerCombine +/// import NanoViewControllerController +/// import NanoViewControllerCore +/// +/// final class HomeViewModel: AbstractViewModel< +/// HomeInputFromView, +/// HomeViewModel.Publishers, +/// HomeStep +/// > { /// // `Input` here is `AbstractViewModel.Input`, with /// // FromView = HomeInputFromView -/// // FromController = InputFromController (fixed by BaseViewModel) -/// override func transform(input: Input) -> HomeOutput { +/// // fromController = InputFromController (pinned by the protocol) +/// override func transform(input: Input) -> Output { /// // Trigger an initial fetch the first time the controller appears. -/// let onAppear = input.fromController.viewWillAppear.first() -/// -/// let initialLoad = onAppear +/// let initialLoad = input.fromController.viewWillAppear +/// .first() /// .flatMapLatest { [api] _ in api.fetchHome().replaceErrorWithEmpty() } /// /// // The user-driven channel. /// let userPullToRefresh = input.fromView.pullToRefresh /// .flatMapLatest { [api] _ in api.fetchHome().replaceErrorWithEmpty() } /// -/// let items = Publishers.Merge(initialLoad, userPullToRefresh) -/// .map { $0.items } -/// -/// // Push the title back through the controller channel. -/// input.fromController.viewDidLoad -/// .map { "Home" } -/// .sink { input.fromController.titleSubject.send($0) } -/// .store(in: &cancellables) -/// -/// return HomeOutput(items: items.eraseToAnyPublisher()) +/// let items = Publishers.Merge(initialLoad, userPullToRefresh).map { $0.items } +/// +/// return Output( +/// publishers: Publishers(items: items.eraseToAnyPublisher()), +/// navigation: Empty().eraseToAnyPublisher() +/// ) { +/// // Push the title back through the controller channel. +/// input.fromController.viewDidLoad +/// .map { "Home" } +/// .sink { input.fromController.titleSubject.send($0) } +/// } /// } /// } /// ``` @@ -55,6 +64,9 @@ import Foundation /// ## Example — building an `Input` in a unit test /// /// ```swift +/// import Combine +/// import NanoViewControllerController +/// /// // Stand up a synthetic Input so we can drive the ViewModel from a test. /// let usernameSubject = CurrentValueSubject("") /// let passwordSubject = CurrentValueSubject("") @@ -95,7 +107,7 @@ import Foundation /// ``` /// /// `@MainActor` because the input is constructed and consumed inside -/// `SceneController` (a `UIViewController` subclass), so the whole +/// `NanoViewController` (a `UIViewController` subclass), so the whole /// view-model pipeline lives on the main actor. @MainActor public protocol InputType { @@ -103,22 +115,18 @@ public protocol InputType { /// `struct` nested inside the concrete View type. associatedtype FromView - /// The controller-driven publishers — `viewDidLoad`, navigation-bar taps, - /// plus the write-back subjects the ViewModel uses to push title / toast - /// updates. The package's standard concrete type is ``InputFromController``. - associatedtype FromController - /// The view channel. var fromView: FromView { get } - /// The controller channel. - var fromController: FromController { get } + /// The controller channel — pinned to ``InputFromController``, the + /// concrete write-back surface every ``NanoViewController`` builds. + var fromController: InputFromController { get } /// Designated initializer. /// - /// ``SceneController`` constructs this struct on the ViewModel's behalf by + /// ``NanoViewController`` constructs this struct on the ViewModel's behalf by /// combining the `View.inputFromView` property with the lifecycle-derived /// ``InputFromController`` it builds itself. Tests can call this directly /// when wiring a synthetic `Input`. - init(fromView: FromView, fromController: FromController) + init(fromView: FromView, fromController: InputFromController) } diff --git a/Sources/NanoViewControllerController/LeftBarButtonContentMaking.swift b/Sources/NanoViewControllerController/LeftBarButtonContentMaking.swift deleted file mode 100644 index 51452b0..0000000 --- a/Sources/NanoViewControllerController/LeftBarButtonContentMaking.swift +++ /dev/null @@ -1,42 +0,0 @@ -// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) - -import UIKit - -/// Low-level opt-in for a screen that wants a custom left bar button. -/// -/// Conformers supply a fully-formed ``BarButtonContent`` directly. Apps with a -/// predefined bar-button library typically declare a refinement (see -/// `LeftBarButtonMaking` in the original Zhip codebase) that pre-fills -/// ``makeLeftContent`` from a typed enum case. -/// -/// ``SceneController/viewDidLoad()`` runtime-casts `self as? -/// LeftBarButtonContentMaking`; conformance is the entire opt-in, no method -/// override is required. -/// -/// ## Example — static "Cancel" button on a modally-presented scene -/// -/// ```swift -/// final class EditProfileScene: Scene, LeftBarButtonContentMaking { -/// static var title: String { "Edit profile" } -/// static var makeLeftContent: BarButtonContent { BarButtonContent(system: .cancel) } -/// } -/// -/// // The user can now tap Cancel; the tap arrives at -/// // editProfileVM.input.fromController.leftBarButtonTrigger. -/// ``` -@MainActor -public protocol LeftBarButtonContentMaking { - /// The content to install as the left bar button on `viewDidLoad`. - static var makeLeftContent: BarButtonContent { get } -} - -public extension LeftBarButtonContentMaking { - /// Convenience used by ``SceneController/viewDidLoad()`` to install the - /// left bar button on the supplied controller without exposing the static - /// indirection at every call site. - /// - /// - Parameter viewController: The controller to install the button on. - func setLeftBarButton(for viewController: AbstractController) { - viewController.setLeftBarButtonUsing(content: Self.makeLeftContent) - } -} diff --git a/Sources/NanoViewControllerController/AbstractController+BarButtonContent.swift b/Sources/NanoViewControllerController/NanoViewController+BarButtonContent.swift similarity index 87% rename from Sources/NanoViewControllerController/AbstractController+BarButtonContent.swift rename to Sources/NanoViewControllerController/NanoViewController+BarButtonContent.swift index 0667c70..f949730 100644 --- a/Sources/NanoViewControllerController/AbstractController+BarButtonContent.swift +++ b/Sources/NanoViewControllerController/NanoViewController+BarButtonContent.swift @@ -3,7 +3,7 @@ import NanoViewControllerCore import UIKit -public extension AbstractController { +public extension NanoViewController { /// Installs `barButtonContent` as the navigation item's *right* bar /// button, wiring its tap to the controller's /// ``rightBarButtonAbstractTarget`` (which in turn pushes to @@ -13,7 +13,7 @@ public extension AbstractController { /// Use directly when imperatively setting a bar button (rare). Most /// scenes either: /// - /// * conform to ``RightBarButtonContentMaking`` — a static one-shot + /// * provide ``ControllerConfig/rightBarButton`` for static one-shot /// installation in `viewDidLoad`, or /// * push to ``InputFromController/rightBarButtonContentSubject`` — for /// dynamic content that changes over time (e.g. enable/disable based @@ -22,8 +22,9 @@ public extension AbstractController { /// ## Example — imperative use from a custom subclass /// /// ```swift - /// final class CustomScene: SceneController, TitledScene { - /// static var title: String { "Custom" } + /// final class CustomScene: NanoViewController, ControllerConfigProviding { + /// static let config = ControllerConfig(title: "Custom") + /// /// override func viewDidLoad() { /// super.viewDidLoad() /// setRightBarButtonUsing(content: BarButtonContent(system: .add)) diff --git a/Sources/NanoViewControllerController/SceneController.swift b/Sources/NanoViewControllerController/NanoViewController.swift similarity index 66% rename from Sources/NanoViewControllerController/SceneController.swift rename to Sources/NanoViewControllerController/NanoViewController.swift index 4f7b331..b29f571 100644 --- a/Sources/NanoViewControllerController/SceneController.swift +++ b/Sources/NanoViewControllerController/NanoViewController.swift @@ -8,21 +8,21 @@ import UIKit /// The "Single-Line Controller" base class — generic scene glue that hosts /// any `(UIView, ViewModelled)` pair without per-scene controller code. /// -/// `SceneController`: +/// `NanoViewController`: /// /// 1. Instantiates `View` empty via ``EmptyInitializable``. /// 2. Builds an ``InputFromController`` from its own lifecycle, bar-button /// and write-back subjects. /// 3. Stitches the View's ``ViewModelled/inputFromView`` together with that /// controller-side input and calls ``ViewModelType/transform(input:)`` -/// on the ViewModel. -/// 4. Binds the resulting `OutputVM` back into the View via +/// on the ViewModel, receiving an ``Output``. +/// 4. Stores the cancellables carried in the ``Output``, then binds the +/// ``Output/publishers`` bag back into the View via /// ``ViewModelled/populate(with:)``. /// /// This is the load-bearing class of the package — coordinators push instances -/// of `SceneController<…>` directly through the ``Scene`` typealias, and you -/// almost never need to subclass it. The whole point is that *one line of -/// code* per screen ("push this scene with this view-model") is enough. +/// of `NanoViewController<…>` directly, and you almost never need to subclass +/// it beyond declaring a concrete screen type and optional ``ControllerConfig``. /// /// ## Example — coordinator pushing a scene /// @@ -37,9 +37,8 @@ import UIKit /// } /// ``` /// -/// `WelcomeScene` is just a `Scene` typealias — there is *no* -/// hand-written controller class for the welcome screen. `SceneController` -/// is doing all the work generically. +/// `WelcomeScene` can be an empty `NanoViewController` subclass. +/// `NanoViewController` is doing all the work generically. /// /// ## Subclassing — when (rarely) needed /// @@ -47,22 +46,41 @@ import UIKit /// /// * change ``rootBackgroundColor`` — your app's brand background, /// * substitute a test ``clock`` for synchronous toast auto-dismiss, -/// * conform to ``NavigationBarLayoutOwner`` to pin a per-scene nav-bar -/// layout (translucent / opaque / hidden). +/// * provide ``ControllerConfigProviding/config`` to set static title, bar +/// buttons, back-button behavior, and nav-bar layout. /// /// ```swift -/// final class BrandedWelcomeScene: SceneController, TitledScene, NavigationBarLayoutOwner { -/// static var title: String { "Welcome" } +/// final class BrandedWelcomeScene: NanoViewController, ControllerConfigProviding { +/// static let config = ControllerConfig( +/// title: "Welcome", +/// navigationBarLayout: .opaque(brand: .primary) +/// ) +/// /// override var rootBackgroundColor: UIColor { .brandBackground } -/// var navigationBarLayout: NavigationBarLayout { .opaque(brand: .primary) } /// } /// ``` -open class SceneController: AbstractController - where View.ViewModel.Input.FromController == InputFromController -{ +open class NanoViewController: UIViewController { /// Convenience alias for the view's ViewModel type. public typealias ViewModel = View.ViewModel + /// Subject fired every time the navigation-item *right* bar button is + /// pressed. Forwarded to the ViewModel as + /// ``InputFromController/rightBarButtonTrigger``. + public let rightBarButtonSubject = PassthroughSubject() + + /// Subject fired every time the navigation-item *left* bar button is + /// pressed. Forwarded to the ViewModel as + /// ``InputFromController/leftBarButtonTrigger``. + public let leftBarButtonSubject = PassthroughSubject() + + /// `@objc` target object UIKit invokes for the right bar button's action + /// selector. + public lazy var rightBarButtonAbstractTarget = AbstractTarget(triggerSubject: rightBarButtonSubject) + + /// `@objc` target object UIKit invokes for the left bar button's action + /// selector. + public lazy var leftBarButtonAbstractTarget = AbstractTarget(triggerSubject: leftBarButtonSubject) + /// Bag of Combine subscriptions owned by this controller (navigation-bar /// bindings, toasts, title updates, view ↔ view-model bindings). /// Survives for the lifetime of the controller. @@ -71,6 +89,16 @@ open class SceneController: AbstractController /// The ViewModel injected by the coordinator at construction time. public let viewModel: ViewModel + /// Backing subject the controller forwards `Output.navigation` into. + /// Exists from construction so coordinators can subscribe to + /// ``navigation`` without an ordering dance with `bindViewToViewModel`. + private let navigationSubject = PassthroughSubject() + + /// The navigation publisher the coordinator subscribes to. Use this from + /// coordinator hookup code instead of reaching into the ViewModel. + public lazy var navigation: AnyPublisher = + navigationSubject.eraseToAnyPublisher() + /// Clock used to auto-dismiss toasts emitted via /// ``InputFromController/toastSubject``. /// @@ -85,11 +113,20 @@ open class SceneController: AbstractController /// `view.backgroundColor` is set to in `viewDidLoad`. /// /// Defaults to `.systemBackground`. Subclasses (or app-level extensions - /// on `SceneController`) override this to apply a brand background. + /// on `NanoViewController`) override this to apply a brand background. open var rootBackgroundColor: UIColor { .systemBackground } + /// Instance-level chrome configuration. + /// + /// Override this when a controller's chrome depends on construction-time state. + /// Otherwise conform the concrete subclass to ``ControllerConfigProviding`` + /// and declare a `static let config`. + open var controllerConfig: ControllerConfig { + (type(of: self) as? ControllerConfigProviding.Type)?.config ?? .default + } + /// Fires when `viewDidLoad` runs. Piped into /// ``InputFromController/viewDidLoad``. private let viewDidLoadSubject = PassthroughSubject() @@ -115,14 +152,15 @@ open class SceneController: AbstractController /// Designated initializer. /// - /// Coordinators call this with a freshly-constructed ViewModel. ``setup()`` - /// runs eagerly so the View has live publishers before `viewDidLoad`. + /// Coordinators call this with a freshly-constructed ViewModel. + /// ``bindViewToViewModel()`` runs eagerly so the View has live publishers + /// before `viewDidLoad`. /// /// - Parameter viewModel: The ViewModel for this scene. Owned by the controller. public required init(viewModel: ViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) - setup() + bindViewToViewModel() } /// Unavailable — Interface Builder is not supported. Traps to enforce @@ -138,12 +176,11 @@ open class SceneController: AbstractController /// swipe-back), then fires the `viewDidLoad` lifecycle subject so the /// ViewModel's pipelines see it. /// - /// Each opt-in protocol (``TitledScene``, - /// ``RightBarButtonContentMaking``, ``LeftBarButtonContentMaking``, - /// ``BackButtonHiding``) is detected via runtime cast — there is no - /// required override in subclasses, and absence is the no-op default. + /// Static chrome is read from ``controllerConfig``. Dynamic bar-button + /// changes still flow through ``InputFromController`` subjects. override open func viewDidLoad() { super.viewDidLoad() + let config = controllerConfig // App-wide background colour goes on the controller's view (visible // behind the content view during animations); content view is @@ -159,33 +196,27 @@ open class SceneController: AbstractController rootContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) - // Auto-set the navigation title only if a non-empty `TitledScene.title` - // is provided. `case let sceneTitle = …` is just a destructuring - // binding — could be a plain `let`. - if let titled = self as? TitledScene, case let sceneTitle = titled.sceneTitle, !sceneTitle.isEmpty { + // Auto-set the navigation title only if a non-empty title is provided. + if let sceneTitle = config.title, !sceneTitle.isEmpty { title = sceneTitle } // Opt-in static bar-button installation. Dynamic per-screen changes go // through the `…BarButtonContentSubject` instead (see // `makeAndSubscribeToInputFromController`). - if let rightButtonMaker = self as? RightBarButtonContentMaking { - rightButtonMaker.setRightBarButton(for: self) + if let rightBarButton = config.rightBarButton { + setRightBarButtonUsing(content: rightBarButton) } - if let leftButtonMaker = self as? LeftBarButtonContentMaking { - leftButtonMaker.setLeftBarButton(for: self) + if let leftBarButton = config.leftBarButton { + setLeftBarButtonUsing(content: leftBarButton) } - // BackButtonHiding screens both hide the chevron AND disable - // interactive pop — typically used on flow-terminating screens like - // a successful-create confirmation, where backing up would re-enter - // an inconsistent state. - if self is BackButtonHiding { + if config.hidesBackButton { navigationItem.hidesBackButton = true } - navigationController?.interactivePopGestureRecognizer?.isEnabled = !(self is BackButtonHiding) + navigationController?.interactivePopGestureRecognizer?.isEnabled = !config.hidesBackButton // Last — fire the lifecycle pulse only after all chrome is in place, // so any view-model handler observing `viewDidLoad` can safely assume @@ -197,7 +228,7 @@ open class SceneController: AbstractController /// previous scene) and forwards the lifecycle event. override open func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - applyLayoutIfNeeded() + applyLayoutIfNeeded(controllerConfig.navigationBarLayout) viewWillAppearSubject.send(()) } @@ -206,19 +237,18 @@ open class SceneController: AbstractController super.viewDidAppear(animated) viewDidAppearSubject.send(()) } + + /// Default `description` is the runtime class name — handy in logs to + /// identify the concrete `NanoViewController<…>` specialisation without an + /// inheritance dance. + override open var description: String { + "\(type(of: self))" + } } // MARK: Private -private extension SceneController { - /// Called from the designated initializer. - /// - /// Currently a thin wrapper so future setup steps can be added without - /// touching the init body. - func setup() { - bindViewToViewModel() - } - +private extension NanoViewController { /// Constructs the ViewModel-facing ``InputFromController``, eagerly /// subscribing the controller-side sinks (title text, toasts, dynamic /// bar-button updates) so the ViewModel can fire-and-forget those subjects. @@ -269,11 +299,12 @@ private extension SceneController { /// /// * View → InputFromView, /// * Controller → InputFromController, - /// * ViewModel.transform(_:) → Output, + /// * ViewModel.transform(_:) → Output, /// * View.populate(with:) → bindings. /// - /// Each cancellable returned by `populate` is stored so the bindings - /// live as long as this controller does. + /// Every cancellable carried in the ``Output`` from `transform`, plus + /// every cancellable returned by `populate`, is stored so all bindings + /// and side-effect subscriptions live as long as this controller does. func bindViewToViewModel() { let inputFromView = rootContentView.inputFromView let inputFromController = makeAndSubscribeToInputFromController() @@ -281,7 +312,16 @@ private extension SceneController { let input = ViewModel.Input(fromView: inputFromView, fromController: inputFromController) let output = viewModel.transform(input: input) - rootContentView.populate(with: output).forEach { $0.store(in: &cancellables) } + cancellables.formUnion(output.cancellables) + cancellables.formUnion(rootContentView.populate(with: output.publishers)) + + // Forward navigation through the controller's own subject so coordinators + // see a stable publisher regardless of when they subscribe. The upstream + // (typically a `Navigator` constructed inside `transform`) stays alive + // via the `[navigator]` captures inside `output.cancellables`. + output.navigation + .subscribe(navigationSubject) + .store(in: &cancellables) } /// Drives ``NavigationBarLayoutingNavigationController`` to apply the @@ -290,10 +330,11 @@ private extension SceneController { /// Logic ladder: /// 1. No nav controller? Nothing to do. /// 2. Nav controller is the wrong class? Programmer error — crash loudly. - /// 3. Scene doesn't own a layout? No-op (the previous layout stays). + /// 3. Controller doesn't own a layout? No-op (the previous layout stays). /// 4. Same layout as last applied? Skip the work (avoid pointless animations). /// 5. Otherwise apply the new layout. - func applyLayoutIfNeeded() { + func applyLayoutIfNeeded(_ layout: NavigationBarLayout?) { + guard let layout else { return } guard let navigationController else { return } guard let barLayoutingNavController = navigationController as? NavigationBarLayoutingNavigationController else { incorrectImplementation( @@ -301,16 +342,18 @@ private extension SceneController { ) } - guard let barLayoutOwner = self as? NavigationBarLayoutOwner else { - return - } - if let lastLayout = barLayoutingNavController.lastLayout { - let layout = barLayoutOwner.navigationBarLayout guard layout != lastLayout else { return } barLayoutingNavController.applyLayout(layout) } else { - barLayoutingNavController.applyLayout(barLayoutOwner.navigationBarLayout) + barLayoutingNavController.applyLayout(layout) } } } + +@MainActor +protocol ControllerConfigReadable: AnyObject { + var controllerConfig: ControllerConfig { get } +} + +extension NanoViewController: ControllerConfigReadable {} diff --git a/Sources/NanoViewControllerController/NanoViewControllerWithoutVM.swift b/Sources/NanoViewControllerController/NanoViewControllerWithoutVM.swift new file mode 100644 index 0000000..f17a2e1 --- /dev/null +++ b/Sources/NanoViewControllerController/NanoViewControllerWithoutVM.swift @@ -0,0 +1,73 @@ +// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) + +import NanoViewControllerCore +import UIKit + +/// A no-op view model used by ``NanoViewControllerWithoutVM`` to route static +/// UIKit views through the same hosting machinery as ordinary scenes. +@MainActor +public final class NanoViewControllerWithoutVMViewModel: AbstractViewModel< + NanoViewControllerWithoutVMViewModel.InputFromView, + NanoViewControllerWithoutVMViewModel.Publishers, + Never +> { + public struct InputFromView {} + public struct Publishers {} + + override public func transform(input _: Input) -> Output { + Output(publishers: Publishers()) + } +} + +/// Wraps a plain `UIView` so it satisfies ``ContentView`` without requiring +/// app code to write a dummy view model. +@MainActor +public final class NanoViewControllerWithoutVMContentView: UIView, ViewModelled { + public typealias ViewModel = NanoViewControllerWithoutVMViewModel + + public let contentView: Content + + public init() { + self.contentView = Content() + super.init(frame: .zero) + + addSubview(contentView) + contentView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + contentView.leadingAnchor.constraint(equalTo: leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: trailingAnchor), + contentView.topAnchor.constraint(equalTo: topAnchor), + contentView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + @available(*, unavailable) + public required init?(coder _: NSCoder) { + interfaceBuilderSucks + } + + public var inputFromView: InputFromView { + InputFromView() + } +} + +/// Hosts a static UIKit view with no app-facing view model. +/// +/// Use this for screens that still want the package's controller chrome +/// behavior but do not have user input, output bindings, or navigation events. +open class NanoViewControllerWithoutVM: + NanoViewController> +{ + public init() { + super.init(viewModel: NanoViewControllerWithoutVMViewModel()) + } + + public required init(viewModel: NanoViewControllerWithoutVMViewModel) { + super.init(viewModel: viewModel) + } + + @available(*, unavailable) + public required init?(coder _: NSCoder) { + interfaceBuilderSucks + } +} diff --git a/Sources/NanoViewControllerController/NavigationBarLayout.swift b/Sources/NanoViewControllerController/NavigationBarLayout.swift index d77fd18..e850548 100644 --- a/Sources/NanoViewControllerController/NavigationBarLayout.swift +++ b/Sources/NanoViewControllerController/NavigationBarLayout.swift @@ -2,44 +2,6 @@ import UIKit -/// Conformance signals that a `UIViewController` (typically a -/// ``SceneController``) wants its hosting navigation bar styled according to -/// a particular ``NavigationBarLayout``. -/// -/// ``NavigationBarLayoutingNavigationController/viewWillAppear(_:)`` checks -/// for this conformance and pushes the layout onto its `navigationBar` — so -/// per-screen styling (e.g. translucent vs opaque, hidden bar) lives on the -/// controller instance instead of in shared appearance proxies. -/// -/// ## Example -/// -/// ```swift -/// final class WelcomeScene: Scene, NavigationBarLayoutOwner { -/// static var title: String { "" } -/// -/// // Hide the nav bar entirely on the welcome screen. -/// var navigationBarLayout: NavigationBarLayout { -/// NavigationBarLayout( -/// barStyle: .default, -/// visibility: .hidden(animated: false), -/// isTranslucent: true, -/// barTintColor: .clear, -/// tintColor: .label, -/// backgroundColor: .clear, -/// backgroundImage: UIImage(), -/// shadowImage: UIImage(), -/// titleFont: .preferredFont(forTextStyle: .headline), -/// titleColor: .label -/// ) -/// } -/// } -/// ``` -@MainActor -public protocol NavigationBarLayoutOwner { - /// The styling the controller wants applied while it's on screen. - var navigationBarLayout: NavigationBarLayout { get } -} - public extension UINavigationBar { /// Mutates this navigation bar to match `layout`. /// @@ -127,8 +89,8 @@ public extension UINavigationBar { /// } /// /// // Consumer screen: -/// final class HomeScene: Scene, NavigationBarLayoutOwner { -/// var navigationBarLayout: NavigationBarLayout { .opaque(brand: .primary) } +/// final class HomeScene: NanoViewController, ControllerConfigProviding { +/// static let config = ControllerConfig(navigationBarLayout: .opaque(brand: .primary)) /// } /// ``` public struct NavigationBarLayout: Equatable, Sendable { diff --git a/Sources/NanoViewControllerController/NavigationBarLayoutingNavigationController.swift b/Sources/NanoViewControllerController/NavigationBarLayoutingNavigationController.swift index c985ee4..4de3962 100644 --- a/Sources/NanoViewControllerController/NavigationBarLayoutingNavigationController.swift +++ b/Sources/NanoViewControllerController/NavigationBarLayoutingNavigationController.swift @@ -31,14 +31,14 @@ import UIKit public final class NavigationBarLayoutingNavigationController: UINavigationController { /// The layout most recently applied to the nav bar. /// - /// ``SceneController/applyLayoutIfNeeded()`` reads this to skip + /// ``NanoViewController/applyLayoutIfNeeded(_:)`` reads this to skip /// re-applying an identical layout — avoids needless animation flickers /// when two consecutive scenes use the same layout. public var lastLayout: NavigationBarLayout? // MARK: - Overridden Methods - /// Re-applies the top scene's layout when the nav controller itself + /// Re-applies the top controller's layout when the nav controller itself /// reappears (e.g. after a modal dismissal), and installs `self` as the /// gesture-recognizer delegate so the swipe-back recognizer can coexist /// with custom gestures. @@ -48,7 +48,7 @@ public final class NavigationBarLayoutingNavigationController: UINavigationContr interactivePopGestureRecognizer?.delegate = self } - /// Applies the *destination* scene's layout *before* the push runs, so + /// Applies the *destination* controller's layout *before* the push runs, so /// the bar already has the right look when the animation starts (no /// mid-transition flicker). override public func pushViewController(_ viewController: UIViewController, animated: Bool) { @@ -101,17 +101,23 @@ public final class NavigationBarLayoutingNavigationController: UINavigationContr // MARK: - Public Methods public extension NavigationBarLayoutingNavigationController { - /// Reads the layout from a ``NavigationBarLayoutOwner`` (if the VC opts - /// in) and applies it. + /// Reads the layout from a ``NanoViewController``'s ``ControllerConfig`` + /// (if present) and applies it. /// - /// No-op if the VC doesn't own a layout — the previous layout stays. + /// No-op if the VC doesn't configure a layout — the previous layout stays. /// This is what lets a non-conforming controller "inherit" the previous - /// scene's bar styling: no override means no change. + /// controller's bar styling: no override means no change. /// /// - Parameter viewController: The candidate VC. May be nil after a pop. func applyLayoutToViewController(_ viewController: UIViewController?) { - guard let viewController, let barLayoutOwner = viewController as? NavigationBarLayoutOwner else { return } - applyLayout(barLayoutOwner.navigationBarLayout) + guard + let viewController = viewController as? ControllerConfigReadable, + let navigationBarLayout = viewController.controllerConfig.navigationBarLayout + else { + return + } + + applyLayout(navigationBarLayout) } /// Applies a ``NavigationBarLayout`` to the underlying `UINavigationBar`, diff --git a/Sources/NanoViewControllerController/RightBarButtonContentMaking.swift b/Sources/NanoViewControllerController/RightBarButtonContentMaking.swift deleted file mode 100644 index c1ab653..0000000 --- a/Sources/NanoViewControllerController/RightBarButtonContentMaking.swift +++ /dev/null @@ -1,63 +0,0 @@ -// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) - -import UIKit - -/// Low-level opt-in for a screen that wants a custom right bar button. -/// -/// Conformers supply a fully-formed ``BarButtonContent`` directly. Apps with a -/// predefined bar-button library typically declare a refinement (see -/// `RightBarButtonMaking` in the original Zhip codebase) that pre-fills -/// ``makeRightContent`` from a typed enum case. -/// -/// ## Example — static "Save" button -/// -/// ```swift -/// final class EditProfileScene: Scene, RightBarButtonContentMaking { -/// static var title: String { "Edit profile" } -/// static var makeRightContent: BarButtonContent { -/// BarButtonContent(title: "Save", style: .done) -/// } -/// } -/// -/// // The tap arrives at editProfileVM.input.fromController.rightBarButtonTrigger. -/// ``` -/// -/// For *dynamic* bar-button content (e.g. an enabled/disabled state that -/// depends on form validity), don't use this protocol — push values into -/// ``InputFromController/rightBarButtonContentSubject`` from the ViewModel -/// instead. -@MainActor -public protocol RightBarButtonContentMaking { - /// The content to install as the right bar button on `viewDidLoad`. - static var makeRightContent: BarButtonContent { get } -} - -public extension RightBarButtonContentMaking { - /// Convenience used by ``SceneController/viewDidLoad()`` to install the - /// right bar button on the supplied controller. - /// - /// - Parameter viewController: The controller to install the button on. - func setRightBarButton(for viewController: AbstractController) { - viewController.setRightBarButtonUsing(content: Self.makeRightContent) - } -} - -/// Marker protocol — when a ``SceneController`` conforms, the system back -/// chevron is hidden AND the swipe-back gesture is disabled. -/// -/// Use on flow-terminating screens (e.g. "wallet created" confirmation) where -/// backing up would re-enter an inconsistent state. The conformance is its -/// own opt-in signal — no method requirements. -/// -/// ## Example -/// -/// ```swift -/// final class WalletCreatedScene: Scene, BackButtonHiding { -/// static var title: String { "" } -/// } -/// -/// // After this scene appears, the user CANNOT swipe back into the (now-stale) -/// // create-wallet flow — they have to use the explicit "Continue" CTA. -/// ``` -@MainActor -public protocol BackButtonHiding {} diff --git a/Sources/NanoViewControllerController/TitledScene.swift b/Sources/NanoViewControllerController/TitledScene.swift deleted file mode 100644 index 61c29f6..0000000 --- a/Sources/NanoViewControllerController/TitledScene.swift +++ /dev/null @@ -1,53 +0,0 @@ -// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) - -import Foundation - -/// Marker-with-payload protocol opt-into by ``SceneController`` subclasses -/// that want the navigation bar title set automatically. -/// -/// ``SceneController/viewDidLoad()`` checks `self as? TitledScene` and, if -/// present, assigns ``sceneTitle`` to `UIViewController.title`. Conformers -/// only need to override `static var title` — the instance-side -/// ``sceneTitle`` is provided by the default extension. -/// -/// ## Example -/// -/// ```swift -/// // Using the typealias — Scene<…> already includes TitledScene. -/// final class WelcomeScene: Scene { -/// static var title: String { "Welcome" } -/// } -/// -/// // Or as an explicit subclass. -/// final class CustomScene: SceneController, TitledScene { -/// static var title: String { "Custom Screen" } -/// } -/// -/// // Skip the title entirely — the protocol's default is "" which the -/// // SceneController treats as "no title to set". -/// final class HiddenTitleScene: Scene { -/// // no override — title remains "" -/// } -/// ``` -@MainActor -public protocol TitledScene { - /// The string to display in the navigation bar for this scene type. - /// Defaults to the empty string (no title) via the protocol extension. - static var title: String { get } -} - -public extension TitledScene { - /// Default — no title. Override on a per-scene basis when one is needed. - static var title: String { - "" - } - - /// Instance-side accessor that simply forwards to the static ``title``. - /// - /// Exists so call sites can read the title without knowing the concrete - /// metatype, and so ``SceneController/viewDidLoad()`` can do - /// `(self as? TitledScene)?.sceneTitle` cleanly. - var sceneTitle: String { - Self.title - } -} diff --git a/Sources/NanoViewControllerController/Toast.swift b/Sources/NanoViewControllerController/Toast.swift index 64dae0a..d64e411 100644 --- a/Sources/NanoViewControllerController/Toast.swift +++ b/Sources/NanoViewControllerController/Toast.swift @@ -8,8 +8,8 @@ import UIKit /// named after the Android equivalent. /// /// ViewModels `send(Toast(...))` into ``InputFromController/toastSubject`` to -/// request a display; the ``SceneController`` presents it on the active view -/// controller, using a ``Clock`` from ``SceneController/clock`` to schedule +/// request a display; the ``NanoViewController`` presents it on the active view +/// controller, using a ``Clock`` from ``NanoViewController/clock`` to schedule /// the auto-dismiss. /// /// ## Example — auto-dismissing toast on success diff --git a/Sources/NanoViewControllerController/ViewModelType.swift b/Sources/NanoViewControllerController/ViewModelType.swift new file mode 100644 index 0000000..effb199 --- /dev/null +++ b/Sources/NanoViewControllerController/ViewModelType.swift @@ -0,0 +1,142 @@ +// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) + +import Foundation +import NanoViewControllerCore + +/// The central contract every ViewModel conforms to. +/// +/// A ViewModel in NanoViewController is a *pure* +/// `Input → Output` transformation: it holds no +/// mutable state, produces all of its outputs as Combine publishers, and +/// carries its navigation channel as part of the return value rather than as a +/// stored property. The flow is: +/// +/// 1. ``NanoViewController`` collects `inputFromView` from the root content view. +/// 2. ``NanoViewController`` builds ``InputFromController`` from its own lifecycle. +/// 3. ``NanoViewController`` stitches the two into a single `Input` value and calls +/// ``transform(input:)``. +/// 4. The ViewModel returns an ``Output`` carrying: +/// * `publishers` — the bag the view binds in `populate(with:)`, +/// * `navigation` — the stream the coordinator subscribes to, +/// * `cancellables` — every subscription started inside `transform`. +/// 5. ``NanoViewController`` retains the cancellables, exposes `navigation` for +/// the coordinator, and forwards `publishers` to the view's `populate`. +/// +/// ## Why "pure" transform? +/// +/// All stateful side effects (timers, network calls, navigation pulses) are +/// constructed *inside* `transform`. The ViewModel itself has no mutable bag, +/// no stored `navigator`, no lifecycle methods to mock — drive it in tests by +/// constructing an `Input` from `PassthroughSubject`s and asserting on the +/// publishers in the returned ``Output``. +/// +/// ## Example — minimal sign-up ViewModel +/// +/// ```swift +/// import Combine +/// import NanoViewControllerController +/// import NanoViewControllerCore +/// import NanoViewControllerNavigation // for Navigator +/// +/// /// What the user can do on the sign-up screen. +/// struct SignUpInputFromView { +/// let username: AnyPublisher +/// let password: AnyPublisher +/// let signUpTapped: AnyPublisher +/// } +/// +/// /// Where the coordinator listens for "what should happen next". +/// enum SignUpStep: Sendable { case signedUp(User) } +/// +/// final class SignUpViewModel: AbstractViewModel< +/// SignUpInputFromView, +/// SignUpViewModel.Publishers, +/// SignUpStep +/// > { +/// private let service: SignUpServicing +/// init(service: SignUpServicing) { self.service = service; super.init() } +/// } +/// +/// extension SignUpViewModel { +/// /// What the view binds to its UI. +/// struct Publishers { +/// let isSignUpEnabled: AnyPublisher +/// let isLoading: AnyPublisher +/// } +/// } +/// +/// extension SignUpViewModel { +/// override func transform(input: Input) -> Output { +/// let navigator = Navigator() +/// let activity = ActivityIndicator() +/// +/// let credentials = input.fromView.username.combineLatest(input.fromView.password) +/// let isValid = credentials.map { !$0.isEmpty && $1.count >= 8 } +/// +/// return Output( +/// publishers: Publishers( +/// isSignUpEnabled: isValid.eraseToAnyPublisher(), +/// isLoading: activity.asPublisher() +/// ), +/// navigation: navigator.navigation +/// ) { +/// input.fromView.signUpTapped +/// .withLatestFrom(credentials) +/// .map { [service] u, p in +/// service.signUp(username: u, password: p).trackActivity(activity) +/// } +/// .switchToLatest() +/// .sink { [navigator] user in navigator.next(.signedUp(user)) } +/// } +/// } +/// } +/// ``` +/// +/// The view binds the publishers in `populate(with:)` (see ``ViewModelled``). +/// The coordinator subscribes to the navigation publisher exposed by the +/// hosting ``NanoViewController`` and routes `.signedUp(user)` to whatever +/// transition makes sense. +/// +/// `@MainActor` because every concrete ViewModel is constructed by, observed +/// from, and torn down with a `NanoViewController` (a `UIViewController` +/// subclass), all of which run on the main actor. +@MainActor +public protocol ViewModelType { + /// The combined user-action + controller-lifecycle input the ViewModel consumes. + /// + /// Use ``AbstractViewModel/Input`` (the synthesised nested type on every + /// ``AbstractViewModel`` subclass) — you almost never declare an `Input` + /// type from scratch. + associatedtype Input: InputType + + /// The publisher bag the view binds to UI controls. + /// + /// Conventionally a `struct` named `Publishers` nested inside the + /// concrete ViewModel, with one publisher per UI control the view drives. + associatedtype Publishers + + /// The enum of navigation steps this scene emits. `Never` for scenes + /// that don't emit navigation (e.g. fully self-contained leaf views). + associatedtype NavigationStep: Sendable + + /// Runs the ViewModel's business logic. + /// + /// Called exactly once per instance, typically by ``NanoViewController`` + /// during scene construction. Implementations: + /// + /// * Wire `input.fromView` and `input.fromController` publishers into + /// business-logic publishers. + /// * Construct a local `Navigator` (or + /// `PassthroughSubject`) for the navigation + /// channel and pass its publisher into the returned ``Output``. + /// * Return an ``Output`` whose `publishers:` field holds the + /// ``Publishers`` value, whose `navigation:` field holds the + /// navigation publisher, and whose `subscriptions:` builder block + /// contains every side-effect `.sink { … }` the view-model starts. + /// + /// - Parameter input: A pre-stitched `Input` containing both the + /// user-driven publishers and the controller-driven publishers. + /// - Returns: An ``Output`` wrapping the publisher bag, the navigation + /// publisher, and every subscription started inside `transform`. + func transform(input: Input) -> Output +} diff --git a/Sources/NanoViewControllerController/ViewModelled.swift b/Sources/NanoViewControllerController/ViewModelled.swift index c23004e..07bb8be 100644 --- a/Sources/NanoViewControllerController/ViewModelled.swift +++ b/Sources/NanoViewControllerController/ViewModelled.swift @@ -4,13 +4,13 @@ import Combine import NanoViewControllerCombine import NanoViewControllerCore -/// The contract every scene's root `UIView` implements to participate in the +/// The contract every controller's root `UIView` implements to participate in the /// reactive MVVM pipeline. /// /// A `ViewModelled` view exposes its user-driven publishers as -/// ``inputFromView`` (read by ``SceneController``), and binds the ViewModel's -/// `OutputVM` back into UI controls via ``populate(with:)`` — returning the -/// `AnyCancellable`s so the controller can retain them for the view's +/// ``inputFromView`` (read by ``NanoViewController``), and binds the ViewModel's +/// `Publishers` bag back into UI controls via ``populate(with:)`` — returning +/// the `AnyCancellable`s so the controller can retain them for the view's /// lifetime. /// /// ## Example — a minimal ViewModelled view @@ -55,18 +55,17 @@ import NanoViewControllerCore /// } /// /// // MARK: - ViewModelled — bind the VM's output to UI controls. -/// func populate(with output: WelcomeViewModel.OutputVM) -> [AnyCancellable] { -/// [ -/// output.headline --> headlineLabel, -/// output.signUpTitle --> signUpButton.titleBinder(for: .normal), -/// ] +/// func populate(with publishers: WelcomeViewModel.Publishers) -> [AnyCancellable] { +/// publishers.headline --> headlineLabel +/// publishers.signUpTitle --> signUpButton.titleBinder(for: .normal) /// } /// } /// ``` /// -/// `SceneController` then handles the rest — building the +/// `NanoViewController` then handles the rest — building the /// `Input`, calling `viewModel.transform(input:)`, and storing every -/// cancellable returned by `populate(with:)` on its own `cancellables` bag. +/// cancellable returned by `populate(with:)` *and* every cancellable carried +/// in the `Output` from `transform` on its own `cancellables` bag. /// /// `@MainActor` because every conformer is a UIView subclass — main-thread /// in the iOS 26 SDK. Inherits the isolation from ``EmptyInitializable``, @@ -87,14 +86,14 @@ public protocol ViewModelled: EmptyInitializable { /// Binds the ViewModel's output publishers to UI controls. /// /// Called exactly once after `transform`. The returned cancellables are - /// retained by ``SceneController`` for the lifetime of the scene. + /// retained by ``NanoViewController`` for the lifetime of the scene. /// - /// - Parameter viewModel: The output bag returned from - /// ``ViewModelType/transform(input:)``. + /// - Parameter publishers: The publisher bag carried in the ``Output`` + /// returned from ``ViewModelType/transform(input:)``. /// - Returns: Every `AnyCancellable` produced by the bindings; the /// controller stores them so they outlive the call. @BindingsBuilder - func populate(with viewModel: ViewModel.OutputVM) -> [AnyCancellable] + func populate(with publishers: ViewModel.Publishers) -> [AnyCancellable] } public extension ViewModelled { @@ -110,46 +109,7 @@ public extension ViewModelled { /// } /// ``` @BindingsBuilder - func populate(with _: ViewModel.OutputVM) -> [AnyCancellable] { + func populate(with _: ViewModel.Publishers) -> [AnyCancellable] { [] } } - -/// Sentinel `FromController` type used by views that don't need any -/// controller-lifecycle input. -/// -/// Keeps the ViewModel generic parameter non-optional without forcing every -/// view to accept an unused ``InputFromController``. Use this when you have a -/// reusable embedded view (e.g. a counter cell) that runs without being -/// hosted by a ``SceneController``. -/// -/// ## Example -/// -/// ```swift -/// final class CounterViewModel: AbstractViewModel< -/// CounterInputFromView, NoControllerInput, CounterOutput -/// > { -/// // FromController is NoControllerInput — we ignore the lifecycle channel. -/// } -/// -/// final class CounterView: UIView, ViewModelled { -/// typealias ViewModel = CounterViewModel -/// // … inputFromView, populate(with:) -/// } -/// -/// // Embedding code can build the input without any controller plumbing: -/// let input = counterView.input // <- convenience below -/// let output = counterVM.transform(input: input) -/// counterView.populate(with: output).forEach { $0.store(in: &cancellables) } -/// ``` -public struct NoControllerInput { - public init() {} -} - -public extension ViewModelled where ViewModel.Input.FromController == NoControllerInput { - /// Convenience: builds the ViewModel input struct with an empty - /// controller channel for views that don't care about lifecycle events. - var input: ViewModel.Input { - ViewModel.Input(fromView: inputFromView, fromController: NoControllerInput()) - } -} diff --git a/Sources/NanoViewControllerCore/AbstractTarget.swift b/Sources/NanoViewControllerCore/AbstractTarget.swift index c71c978..9395c59 100644 --- a/Sources/NanoViewControllerCore/AbstractTarget.swift +++ b/Sources/NanoViewControllerCore/AbstractTarget.swift @@ -12,9 +12,10 @@ import Foundation /// call, and it forwards each invocation through a ``Combine/PassthroughSubject`` /// the rest of the app subscribes to. /// -/// ``AbstractController`` keeps two long-lived `AbstractTarget` instances — -/// one each for the left and right navigation bar buttons — and exposes the -/// matching publishers to ViewModels via ``InputFromController``. +/// ``NanoViewControllerController/NanoViewController`` keeps two long-lived +/// `AbstractTarget` instances — one each for the left and right navigation bar +/// buttons — and exposes the matching publishers to ViewModels via +/// ``NanoViewControllerController/InputFromController``. /// /// ## Why is this its own class instead of a closure? /// @@ -58,10 +59,9 @@ import Foundation /// ``` /// /// In normal app code you'll never need to build one of these directly — -/// ``AbstractController`` already exposes -/// ``AbstractController/leftBarButtonAbstractTarget`` and -/// ``AbstractController/rightBarButtonAbstractTarget`` for navigation bar -/// buttons, and pure Combine extensions on `UIControl` (see +/// ``NanoViewControllerController/NanoViewController`` already exposes +/// `leftBarButtonAbstractTarget` and `rightBarButtonAbstractTarget` for +/// navigation bar buttons, and pure Combine extensions on `UIControl` (see /// `UIControl+Publisher.swift`) cover regular controls. /// /// `@MainActor` because UIKit dispatches target/action selectors on the main @@ -80,7 +80,7 @@ public class AbstractTarget { /// Designated initialiser — captures the subject this target forwards into. /// /// - Parameter triggerSubject: The subject every selector call should push - /// a `()` value into. Typically owned by an `AbstractController`. + /// a `()` value into. Typically owned by a `NanoViewController`. public init(triggerSubject: PassthroughSubject) { self.triggerSubject = triggerSubject } diff --git a/Sources/NanoViewControllerCore/AbstractViewModel.swift b/Sources/NanoViewControllerCore/AbstractViewModel.swift deleted file mode 100644 index 0c2d7c2..0000000 --- a/Sources/NanoViewControllerCore/AbstractViewModel.swift +++ /dev/null @@ -1,138 +0,0 @@ -// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) - -import Combine -import Foundation - -/// Abstract base class supplying the boilerplate every concrete ViewModel needs. -/// -/// `AbstractViewModel` provides: -/// -/// * a ``cancellables`` `Set` for `transform` implementations -/// to store subscriptions in, -/// * a synthesised nested ``Input`` struct conforming to ``InputType``, and -/// * an open `transform(input:)` method that traps if not overridden — so -/// forgetting to override surfaces immediately at runtime. -/// -/// The class is generic over three slots: -/// -/// * `FromView` — the view-driven publisher struct the View exposes. -/// * `FromController` — the controller-driven channel; usually -/// ``InputFromController`` (and that's what ``BaseViewModel`` pins). -/// * `OutputFromViewModel` — the bag of publishers returned by `transform`. -/// -/// Most consumers should subclass ``BaseViewModel`` instead — that variant -/// fixes `FromController` to ``InputFromController`` and adds a typed -/// `Navigator`, which is what 99% of scenes need. Subclass -/// `AbstractViewModel` directly only when you need a non-standard -/// `FromController` (e.g. a view that doesn't run on a ``SceneController`` at -/// all and uses ``NoControllerInput``). -/// -/// ## Example — a screen-less ViewModel for a self-contained view -/// -/// ```swift -/// import Combine -/// import NanoViewControllerCore -/// -/// /// View-driven channel for a tiny standalone counter view. -/// struct CounterInputFromView { -/// let increment: AnyPublisher -/// let decrement: AnyPublisher -/// } -/// -/// /// Bindings back to UILabel/UIButton in the view. -/// struct CounterOutput { -/// let countText: AnyPublisher -/// } -/// -/// /// No SceneController in play — this view is embedded inside another screen. -/// /// We use NoControllerInput as the controller channel. -/// final class CounterViewModel: AbstractViewModel< -/// CounterInputFromView, -/// NoControllerInput, -/// CounterOutput -/// > { -/// override func transform(input: Input) -> CounterOutput { -/// let count = Publishers.Merge( -/// input.fromView.increment.map { +1 }, -/// input.fromView.decrement.map { -1 } -/// ) -/// .scan(0, +) -/// .prepend(0) -/// -/// return CounterOutput( -/// countText: count.map { String($0) }.eraseToAnyPublisher() -/// ) -/// } -/// } -/// ``` -/// -/// In tests you can drive the ViewModel without any UIKit: -/// -/// ```swift -/// let inc = PassthroughSubject() -/// let dec = PassthroughSubject() -/// let vm = CounterViewModel() -/// let out = vm.transform(input: CounterViewModel.Input( -/// fromView: CounterInputFromView(increment: inc.eraseToAnyPublisher(), -/// decrement: dec.eraseToAnyPublisher()), -/// fromController: NoControllerInput() -/// )) -/// var collected: [String] = [] -/// out.countText.sink { collected.append($0) }.store(in: &vm.cancellables) -/// -/// inc.send(()); inc.send(()); dec.send(()) -/// XCTAssertEqual(collected, ["0", "1", "2", "1"]) -/// ``` -/// -/// `@MainActor` because it conforms to ``ViewModelType``, which is itself -/// `@MainActor`. View-models in this package are inherently main-thread — -/// they're owned by `SceneController` (a `UIViewController` subclass) and -/// their `transform(input:)` runs on the main actor. -@MainActor -open class AbstractViewModel: ViewModelType { - /// Bag of Combine subscriptions owned by this ViewModel. - /// - /// `transform` implementations call `.store(in: &cancellables)` on every - /// subscription they create so the subscriptions outlive the `transform` - /// call and are deinitialized together with the ViewModel itself. - public var cancellables = Set() - - /// The concrete ``InputType`` Swift synthesizes for each `AbstractViewModel` - /// specialisation. - /// - /// `SceneController` constructs this struct by combining the View's - /// `inputFromView` with the lifecycle-derived ``InputFromController`` it - /// owns, and hands it to ``transform(input:)``. - public struct Input: InputType { - /// Controller-lifecycle + write-back subjects channel. - public let fromController: FromController - - /// User-driven publishers channel (taps, text, toggles). - public let fromView: FromView - - /// Designated initializer. - /// - /// `SceneController` calls this to stitch together the two input - /// channels before handing the struct to `transform`. Tests call it - /// directly when building synthetic input. - public init(fromView: FromView, fromController: FromController) { - self.fromView = fromView - self.fromController = fromController - } - } - - /// Designated initialiser — public so consumer subclasses can call `super.init()`. - public init() {} - - /// Runs the ViewModel's business logic — must be overridden by subclasses. - /// - /// The default implementation traps via the ``abstract`` helper to surface - /// a missing override at runtime instead of silently returning a default - /// value (which would break scene wiring further down the line). - /// - /// - Parameter input: The pre-stitched ``Input`` with both channels. - /// - Returns: A bag of publishers the view binds to UI controls. - open func transform(input _: Input) -> OutputFromViewModel { - abstract - } -} diff --git a/Sources/NanoViewControllerCore/ActivityIndicator.swift b/Sources/NanoViewControllerCore/ActivityIndicator.swift index 0f7a0ce..febd432 100644 --- a/Sources/NanoViewControllerCore/ActivityIndicator.swift +++ b/Sources/NanoViewControllerCore/ActivityIndicator.swift @@ -20,34 +20,44 @@ import Foundation /// /// ```swift /// import Combine +/// import NanoViewControllerCombine +/// import NanoViewControllerController /// import NanoViewControllerCore +/// import NanoViewControllerNavigation /// -/// final class SignUpViewModel: BaseViewModel { -/// override func transform(input: Input) -> SignUpOutput { +/// final class SignUpViewModel: AbstractViewModel< +/// SignUpInputFromView, +/// SignUpViewModel.Publishers, +/// SignUpStep +/// > { +/// override func transform(input: Input) -> Output { +/// let navigator = Navigator() /// let activity = ActivityIndicator() /// -/// // Each tap triggers a network call. trackActivity marks the -/// // indicator true while the call is in flight, then flips back to -/// // false when the call completes (or fails, or is cancelled). -/// input.fromView.signUpTapped -/// .flatMapLatest { [service] _ in -/// service.signUp() -/// .trackActivity(activity) -/// .replaceErrorWithEmpty() -/// } -/// .sink { [navigator] user in navigator.next(.signedUp(user)) } -/// .store(in: &cancellables) -/// -/// return SignUpOutput( -/// // The view binds this Bool to a spinner / disabled state. -/// isLoading: activity.asPublisher() -/// ) +/// return Output( +/// publishers: Publishers( +/// // The view binds this Bool to a spinner / disabled state. +/// isLoading: activity.asPublisher() +/// ), +/// navigation: navigator.navigation +/// ) { +/// // Each tap triggers a network call. trackActivity marks the +/// // indicator true while the call is in flight, then flips back to +/// // false when the call completes (or fails, or is cancelled). +/// input.fromView.signUpTapped +/// .flatMapLatest { [service] _ in +/// service.signUp() +/// .trackActivity(activity) +/// .replaceErrorWithEmpty() +/// } +/// .sink { [navigator] user in navigator.next(.signedUp(user)) } +/// } /// } /// } /// /// // In the view's populate(with:): -/// output.isLoading --> primaryButton.isLoadingBinder -/// output.isLoading.map { !$0 } --> primaryButton.isEnabledBinder +/// publishers.isLoading --> primaryButton.isLoadingBinder +/// publishers.isLoading.map { !$0 } --> primaryButton.isEnabledBinder /// ``` /// /// ## Concurrency diff --git a/Sources/NanoViewControllerCombine/BindingsBuilder.swift b/Sources/NanoViewControllerCore/BindingsBuilder.swift similarity index 68% rename from Sources/NanoViewControllerCombine/BindingsBuilder.swift rename to Sources/NanoViewControllerCore/BindingsBuilder.swift index 0a68483..8c36d1c 100644 --- a/Sources/NanoViewControllerCombine/BindingsBuilder.swift +++ b/Sources/NanoViewControllerCore/BindingsBuilder.swift @@ -3,26 +3,27 @@ import Combine /// `@resultBuilder` that turns a block of binding statements into the -/// `[AnyCancellable]` shape ``ViewModelled/populate(with:)`` returns. +/// `[AnyCancellable]` shape +/// ``NanoViewControllerController/ViewModelled/populate(with:)`` returns. /// /// Lets you write: /// /// ```swift -/// 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 /// } /// ``` /// /// instead of the array-literal form: /// /// ```swift -/// public func populate(with output: ViewModel.Output) -> [AnyCancellable] { +/// public func populate(with publishers: ViewModel.Publishers) -> [AnyCancellable] { /// [ -/// output.isSubmitEnabled --> submitButton.isEnabledBinder, -/// output.loadingText --> submitButton.titleBinder(for: .normal), -/// output.isLoading --> spinner.isAnimatingBinder, +/// publishers.isSubmitEnabled --> submitButton.isEnabledBinder, +/// publishers.loadingText --> submitButton.titleBinder(for: .normal), +/// publishers.isLoading --> spinner.isAnimatingBinder, /// ] /// } /// ``` @@ -41,10 +42,10 @@ import Combine /// ## Example — conditional bindings /// /// ```swift -/// 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 /// /// // Debug-only: wire the loading state into a label so it shows up /// // in screenshots / UI tests. The builder handles `if` natively; @@ -52,18 +53,18 @@ import Combine /// if FeatureFlags.showDebugLabels { /// // The `-->` overloads accept any `Publisher<…, Never>`, so the /// // chained `.map { … }` drops in directly — no `.eraseToAnyPublisher()`. -/// output.isLoading.map(String.init) --> debugLabel.textBinder +/// publishers.isLoading.map(String.init) --> debugLabel.textBinder /// } /// /// // Forward several bindings from a sub-component as one expression. -/// headerView.populate(with: output.header) // returns [AnyCancellable] +/// headerView.populate(with: publishers.header) // returns [AnyCancellable] /// } /// ``` /// -/// ``ViewModelled/populate(with:)`` is annotated with this builder, so any -/// conformer's implementation gets the transformation automatically — no need -/// to repeat the attribute, and the array-literal form keeps working -/// (`buildExpression(_:)` accepts `[AnyCancellable]` directly). +/// ``NanoViewControllerController/ViewModelled/populate(with:)`` is annotated +/// with this builder, so any conformer's implementation gets the transformation +/// automatically — no need to repeat the attribute, and the array-literal form +/// keeps working (`buildExpression(_:)` accepts `[AnyCancellable]` directly). @resultBuilder public enum BindingsBuilder { /// The partial-result type carried through every builder phase. diff --git a/Sources/NanoViewControllerCore/EmptyInitializable.swift b/Sources/NanoViewControllerCore/EmptyInitializable.swift index 19fae11..e8e7fc7 100644 --- a/Sources/NanoViewControllerCore/EmptyInitializable.swift +++ b/Sources/NanoViewControllerCore/EmptyInitializable.swift @@ -4,10 +4,10 @@ import Foundation /// Marker protocol asserting "this type can be constructed with no arguments". /// -/// ``SceneController`` instantiates the root content view via -/// `(View.self as EmptyInitializable.Type).init()`. Declaring conformance is -/// effectively free for any type whose `init()` is non-failable — usually a -/// one-line: +/// ``NanoViewControllerController/NanoViewController`` instantiates the root +/// content view via `(View.self as EmptyInitializable.Type).init()`. Declaring +/// conformance is effectively free for any type whose `init()` is non-failable +/// — usually a one-line: /// /// ```swift /// extension MyContentView: EmptyInitializable {} @@ -29,16 +29,18 @@ import Foundation /// `init(frame:)`, *not* `init()`, so generic code can't simply write /// `View()`. Declaring `EmptyInitializable` lets the type system carry the /// "yes, this view promises a no-arg initialiser" guarantee through generic -/// constraints like `View: ContentView` (see ``ContentView``). +/// constraints like `View: ContentView` (see +/// ``NanoViewControllerController/ContentView``). /// /// ## Example — a minimal `ContentView` conformer /// /// ```swift /// import NanoViewControllerCore +/// import NanoViewControllerController /// import NanoViewControllerSceneViews /// import UIKit /// -/// /// Scene root. EmptyInitializable so SceneController can build it. +/// /// Scene root. EmptyInitializable so NanoViewController can build it. /// final class WelcomeView: BaseScrollableStackViewOwner, ContentViewProvider { /// // BaseScrollableStackViewOwner already provides `required init()` and /// // declares EmptyInitializable conformance, so this subclass inherits it. @@ -55,7 +57,7 @@ import Foundation /// // ... ViewModelled conformance fills in the rest /// } /// -/// // SceneController can now construct the view via +/// // NanoViewController can now construct the view via /// // (WelcomeView.self as EmptyInitializable.Type).init(). /// ``` /// diff --git a/Sources/NanoViewControllerCore/ErrorTracker.swift b/Sources/NanoViewControllerCore/ErrorTracker.swift index 94912ab..2eea8f3 100644 --- a/Sources/NanoViewControllerCore/ErrorTracker.swift +++ b/Sources/NanoViewControllerCore/ErrorTracker.swift @@ -22,8 +22,17 @@ import Foundation /// ## Example — global error toast plus per-chain typed handling /// /// ```swift -/// final class HomeViewModel: BaseViewModel { -/// override func transform(input: Input) -> HomeOutput { +/// import Combine +/// import NanoViewControllerCombine +/// import NanoViewControllerController +/// import NanoViewControllerCore +/// +/// final class HomeViewModel: AbstractViewModel< +/// HomeInputFromView, +/// HomeViewModel.Publishers, +/// Never +/// > { +/// override func transform(input: Input) -> Output { /// let activity = ActivityIndicator() /// let errors = ErrorTracker() /// @@ -43,20 +52,21 @@ import Foundation /// .replaceErrorWithEmpty() /// } /// -/// // Every captured error becomes a toast. -/// errors.asPublisher() -/// .map { Toast($0.localizedDescription) } -/// .sink { input.fromController.toastSubject.send($0) } -/// .store(in: &cancellables) -/// /// // Or use the typed projection for a typed-error case. /// let validationMessage = errors.compactMap { ($0 as? ValidationError)?.message } /// -/// return HomeOutput( -/// items: Publishers.Merge(initialFetch, userRefresh).map(\.items).eraseToAnyPublisher(), -/// isLoading: activity.asPublisher(), -/// validationMessage: validationMessage -/// ) +/// return Output( +/// publishers: Publishers( +/// items: Combine.Publishers.Merge(initialFetch, userRefresh).map(\.items).eraseToAnyPublisher(), +/// isLoading: activity.asPublisher(), +/// validationMessage: validationMessage +/// ) +/// ) { +/// // Every captured error becomes a toast. +/// errors.asPublisher() +/// .map { Toast($0.localizedDescription) } +/// .sink { input.fromController.toastSubject.send($0) } +/// } /// } /// } /// ``` @@ -148,12 +158,13 @@ public extension Publisher { /// ## Example /// /// ```swift - /// api.uploadAvatar(image) - /// .trackActivity(activity) - /// .trackError(errors) // shared tracker - /// .replaceErrorWithEmpty() // local chain stays Never-failing - /// .sink { [navigator] in navigator.next(.uploaded) } - /// .store(in: &cancellables) + /// return Output(publishers: Publishers(), navigation: navigator.navigation) { + /// api.uploadAvatar(image) + /// .trackActivity(activity) + /// .trackError(errors) // shared tracker + /// .replaceErrorWithEmpty() // local chain stays Never-failing + /// .sink { [navigator] in navigator.next(.uploaded) } + /// } /// ``` /// /// - Parameter tracker: The `ErrorTracker` to forward failures into. diff --git a/Sources/NanoViewControllerCore/Never+Helpers.swift b/Sources/NanoViewControllerCore/Never+Helpers.swift index 61863a0..6080e80 100644 --- a/Sources/NanoViewControllerCore/Never+Helpers.swift +++ b/Sources/NanoViewControllerCore/Never+Helpers.swift @@ -56,8 +56,9 @@ public func incorrectImplementation(_ message: CustomStringConvertible) -> Never /// expected to override. /// /// Used as the body of a base-class method whose default implementation makes -/// no sense — for example, ``AbstractViewModel/transform(input:)`` and -/// ``BaseCoordinator/start(didStart:)``. +/// no sense — for example, +/// ``NanoViewControllerController/AbstractViewModel/transform(input:)`` and +/// ``NanoViewControllerNavigation/BaseCoordinator/start(didStart:)``. /// /// ## Example /// diff --git a/Sources/NanoViewControllerCore/Output.swift b/Sources/NanoViewControllerCore/Output.swift new file mode 100644 index 0000000..c388534 --- /dev/null +++ b/Sources/NanoViewControllerCore/Output.swift @@ -0,0 +1,130 @@ +// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) + +import Combine + +/// What every ViewModel's +/// ``NanoViewControllerController/ViewModelType/transform(input:)`` returns: +/// the `Publishers` bag the view binds to UI controls, the navigation publisher +/// the coordinator subscribes to, and the `[AnyCancellable]` the controller +/// retains for the lifetime of the scene. +/// +/// `Output` is the *wrapper*; the generic `Publishers` parameter is the +/// ViewModel-specific publisher-bundle the view consumes in +/// ``NanoViewControllerController/ViewModelled/populate(with:)``, and +/// `NavigationStep` is the enum the coordinator pattern-matches on. +/// Folding all three into one value moves both subscription ownership and +/// navigation out of the ViewModel — the ViewModel itself carries no stored +/// state. +/// +/// ## Example — at the call site +/// +/// ```swift +/// import Combine +/// import NanoViewControllerCore +/// import NanoViewControllerNavigation +/// +/// public extension SignUpViewModel { +/// struct Publishers { +/// let isSubmitEnabled: AnyPublisher +/// let isLoading: AnyPublisher +/// } +/// } +/// +/// override func transform(input: Input) -> Output { +/// let navigator = Navigator() +/// let activity = ActivityIndicator() +/// let isLoading = activity.asPublisher() +/// let isSubmitEnabled = … +/// +/// return Output( +/// publishers: Publishers( +/// isSubmitEnabled: isSubmitEnabled, +/// isLoading: isLoading +/// ), +/// navigation: navigator.navigation +/// ) { +/// input.fromView.submitTrigger +/// .map { [service] in service.signUp(…).trackActivity(activity) } +/// .switchToLatest() +/// .sink { [navigator] user in navigator.next(.signedUp(user)) } +/// } +/// } +/// ``` +/// +/// The trailing closure is `@BindingsBuilder`-annotated, so each `.sink { … }` +/// is a statement, not an array element — no `.store(in: &cancellables)` and +/// no `[a, b, c]` collection. Helpers that return `[AnyCancellable]` plug in +/// directly. `if` / `switch` / `for` work naturally. +/// +/// ## Late navigation subscription +/// +/// The coordinator subscribes to ``navigation`` *after* `transform` returns +/// (it can't subscribe sooner — the publisher doesn't exist yet). The only +/// constraint: `transform` must not emit navigation **synchronously during +/// construction** (e.g. `Just(.x).sink { … }` — that step would be dropped). +/// Real flows emit on user input, which can't fire before the coordinator +/// finishes wiring. +/// +/// ## Naming note — `Publishers` collides with `Combine.Publishers` +/// +/// The consumer's nested struct is conventionally called `Publishers` (this +/// shape is plain "a bag of `AnyPublisher`s"). Combine exports a top-level +/// enum of the same name (`Combine.Publishers.Map`, `…CombineLatest`, …). +/// Bare unqualified `Publishers(…)` inside the ViewModel resolves to the +/// nested type — which is what consumers want at the construction site. Code +/// that explicitly reaches into Combine's namespace can qualify as +/// `Combine.Publishers.X`. +@MainActor +public struct Output { + /// The publisher bag the view binds to UI controls via + /// ``NanoViewControllerController/ViewModelled/populate(with:)``. + public let publishers: Publishers + + /// The navigation step stream the coordinator subscribes to. The + /// ViewModel emits via a locally-owned `PassthroughSubject` (or a + /// ``NanoViewControllerNavigation/Navigator``) — the coordinator + /// pattern-matches on the cases. + public let navigation: AnyPublisher + + /// Subscriptions started inside `transform` that must outlive the call. + /// ``NanoViewControllerController/NanoViewController`` stores these in its + /// own bag so they live as long as the scene. + public let cancellables: [AnyCancellable] + + /// Designated initializer. + /// + /// - Parameters: + /// - publishers: The publisher bag the view binds. + /// - navigation: The navigation step stream. Pass `Empty()…` (or use + /// the `NavigationStep == Never` overload) if the scene emits no + /// navigation. + /// - subscriptions: A `@BindingsBuilder` block whose statements are + /// each an `AnyCancellable` (`.sink { … }`, helpers, etc.). Defaults + /// to an empty block for ViewModels that need no side-effect + /// subscriptions. + public init( + publishers: Publishers, + navigation: AnyPublisher, + @BindingsBuilder subscriptions: () -> [AnyCancellable] = { [] } + ) { + self.publishers = publishers + self.navigation = navigation + self.cancellables = subscriptions() + } +} + +public extension Output where NavigationStep == Never { + /// Convenience initialiser for scenes that emit no navigation. The + /// navigation channel is wired to `Empty()` so the coordinator's + /// subscription is a no-op for the scene's lifetime. + init( + publishers: Publishers, + @BindingsBuilder subscriptions: () -> [AnyCancellable] = { [] } + ) { + self.init( + publishers: publishers, + navigation: Empty().eraseToAnyPublisher(), + subscriptions: subscriptions + ) + } +} diff --git a/Sources/NanoViewControllerCore/ViewModelType.swift b/Sources/NanoViewControllerCore/ViewModelType.swift deleted file mode 100644 index bf5bdd8..0000000 --- a/Sources/NanoViewControllerCore/ViewModelType.swift +++ /dev/null @@ -1,128 +0,0 @@ -// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) - -import Foundation - -/// The central contract every ViewModel conforms to. -/// -/// A ViewModel in NanoViewController is a *pure* `Input → Output` transformation: -/// it never holds mutable UI state beyond its `cancellables`, and it produces all -/// of its outputs as Combine publishers. The flow is: -/// -/// 1. ``SceneController`` collects `inputFromView` from the root content view. -/// 2. ``SceneController`` builds ``InputFromController`` from its own lifecycle. -/// 3. ``SceneController`` stitches the two into a single `Input` value and calls -/// ``transform(input:)``. -/// 4. The ViewModel returns an `OutputVM` (a struct full of publishers). -/// 5. The view's `populate(with:)` binds those publishers to UI controls. -/// -/// ## Why "pure" transform? -/// -/// Because all stateful side effects (timers, network calls, navigation pulses) -/// are launched *inside* `transform` and stored in the ViewModel's `cancellables` -/// bag, the ViewModel has no shape that requires mocking lifecycle methods. You -/// can drive it in tests by constructing an `Input` from `PassthroughSubject`s -/// and asserting on the publishers in the returned `OutputVM`. -/// -/// ## Example — minimal sign-up ViewModel -/// -/// ```swift -/// import Combine -/// import NanoViewControllerCore -/// import NanoViewControllerController // for InputFromController + BaseViewModel -/// import NanoViewControllerNavigation // for Navigator -/// -/// /// What the user can do on the sign-up screen. -/// struct SignUpInputFromView { -/// let username: AnyPublisher // text field text -/// let password: AnyPublisher // text field text -/// let signUpTapped: AnyPublisher // primary button tap -/// } -/// -/// /// What the view binds to its UI. -/// struct SignUpOutput { -/// let isSignUpEnabled: AnyPublisher -/// let isLoading: AnyPublisher -/// let errorMessage: AnyPublisher -/// } -/// -/// /// Where the coordinator listens for "what should happen next". -/// enum SignUpStep { case signedUp(User) } -/// -/// final class SignUpViewModel: BaseViewModel { -/// private let service: SignUpServicing -/// init(service: SignUpServicing) { self.service = service } -/// -/// override func transform(input: Input) -> SignUpOutput { -/// let activity = ActivityIndicator() -/// let errors = ErrorTracker() -/// -/// // Validity is just a function of the latest username + password. -/// let credentials = input.fromView.username -/// .combineLatest(input.fromView.password) -/// let isValid = credentials.map { !$0.isEmpty && $1.count >= 8 } -/// -/// // On every tap, fire a network call. .switchToLatest() cancels the -/// // previous request if the user double-taps. -/// input.fromView.signUpTapped -/// .withLatestFrom(credentials) -/// .map { [service] u, p in -/// service.signUp(username: u, password: p) -/// .trackActivity(activity) -/// .trackError(errors) -/// .replaceErrorWithEmpty() -/// } -/// .switchToLatest() -/// .sink { [navigator] user in navigator.next(.signedUp(user)) } -/// .store(in: &cancellables) -/// -/// return SignUpOutput( -/// isSignUpEnabled: isValid.eraseToAnyPublisher(), -/// isLoading: activity.asPublisher(), -/// errorMessage: errors.asPublisher().map(\.localizedDescription).eraseToAnyPublisher() -/// ) -/// } -/// } -/// ``` -/// -/// The view binds the three output publishers to its controls in -/// `populate(with:)` (see ``ViewModelled``). The coordinator subscribes to -/// `viewModel.navigator.navigation` and routes `.signedUp(user)` to whatever -/// transition makes sense (push the home screen, dismiss the modal, ...). -/// -/// `@MainActor` because in this UIKit-based architecture every concrete -/// ViewModel is constructed by, observed from, and torn down with a -/// `SceneController` (a `UIViewController` subclass), all of which run on the -/// main actor. Marking the protocol matches reality and removes the need to -/// scatter `Sendable` annotations across the value-model layer. -@MainActor -public protocol ViewModelType { - /// The combined user-action + controller-lifecycle input the ViewModel consumes. - /// - /// Use ``AbstractViewModel/Input`` (the synthesised nested type on every - /// ``AbstractViewModel`` subclass) — you almost never declare an `Input` - /// type from scratch. - associatedtype Input: InputType - - /// The bag of publishers the View binds to UI controls. - /// - /// Conventionally a `struct` named `Output` nested inside the concrete - /// ViewModel, with one publisher per UI control the view drives. - associatedtype OutputVM - - /// Runs the ViewModel's business logic. - /// - /// Called exactly once per instance, typically by ``SceneController`` during - /// scene construction. Implementations: - /// - /// * Wire `input.fromView` and `input.fromController` publishers into - /// business-logic publishers. - /// * Subscribe to side-effects (`.sink { … }.store(in: &cancellables)`) - /// to trigger navigation, fire toasts, copy to pasteboard, etc. - /// * Return an `OutputVM` populated with the publishers the View needs. - /// - /// - Parameter input: A pre-stitched `Input` containing both the - /// user-driven publishers and the controller-driven publishers. - /// - Returns: A bag of publishers the View binds to UI controls in - /// `populate(with:)`. - func transform(input: Input) -> OutputVM -} diff --git a/Sources/NanoViewControllerNavigation/Navigating.swift b/Sources/NanoViewControllerNavigation/Navigating.swift index 5281a52..2f79f4f 100644 --- a/Sources/NanoViewControllerNavigation/Navigating.swift +++ b/Sources/NanoViewControllerNavigation/Navigating.swift @@ -2,65 +2,51 @@ /// Type capable of navigating — declares which navigation steps it can emit. /// -/// Conformers expose a typed ``Navigator`` so subscribers (typically -/// coordinators) can react to the steps the conformer emits. The associated +/// Conformers expose a typed ``Navigator`` so subscribers (typically a parent +/// coordinator) can react to the steps the conformer emits. The associated /// ``NavigationStep`` is conventionally a nested `enum` with one case per -/// user-initiated transition the screen can request. +/// user-initiated transition. /// -/// Both ViewModels (via ``BaseViewModel``) and Coordinators (via -/// ``BaseCoordinator``) conform to `Navigating` — the former so a -/// ``SceneController``-backed scene can declare "what should happen next" in a -/// View-agnostic way, the latter so a parent coordinator can listen to a child -/// coordinator's flow-completion events. +/// Coordinators conform to `Navigating` (via ``BaseCoordinator``) so a parent +/// coordinator can listen to a child coordinator's flow-completion events. +/// ViewModels expose their navigation channel through the ``Output`` returned +/// from ``ViewModelType/transform(input:)`` instead — see ``NanoViewController`` +/// for how the coordinator subscribes. /// -/// ## Example — defining a screen's navigation contract +/// ## Example — child coordinator emitting flow-completion steps /// /// ```swift -/// import NanoViewControllerNavigation -/// -/// /// Three things the user can do on the SignUp screen. -/// enum SignUpUserAction { -/// case userPressedHaveAccount // → coordinator pops back to login -/// case userPressedTermsOfService // → coordinator presents legal modal -/// case signedUp(User) // → coordinator switches to home +/// enum OnboardingFlowStep: Sendable { +/// case finished(User) +/// case userTappedHaveAccount /// } /// -/// final class SignUpViewModel: -/// BaseViewModel -/// { -/// // BaseViewModel conforms to Navigating with NavigationStep == SignUpUserAction. -/// // It already has the `navigator: Navigator` property — +/// final class OnboardingCoordinator: BaseCoordinator { +/// // BaseCoordinator conforms to Navigating with NavigationStep == OnboardingFlowStep. +/// // It already owns the `navigator: Navigator` property — /// // we just call .next(...) on it. /// -/// override func transform(input: Input) -> SignUpOutput { -/// input.fromView.haveAccountTap -/// .sink { [navigator] in navigator.next(.userPressedHaveAccount) } -/// .store(in: &cancellables) -/// // … +/// override func start(didStart: Completion? = nil) { +/// showWelcome() /// } +/// +/// private func showWelcome() { /* … navigator.next(.finished(user)) on success … */ } /// } /// ``` /// -/// On the coordinator side, -/// ``Coordinating/push(scene:viewModel:animated:navigationPresentationCompletion:navigationHandler:)`` -/// (and the modal/replace overloads) take a `navigationHandler` closure that -/// receives every emitted ``NavigationStep`` so you can pattern-match on the -/// enum and route each case to the right transition. -/// -/// `@MainActor` because both ``Navigator`` and the conformers that hold one -/// (``BaseCoordinator``, ``BaseViewModel``) live on the main actor in this -/// UIKit-based architecture. +/// `@MainActor` because ``Navigator`` and ``BaseCoordinator`` live on the +/// main actor in this UIKit-based architecture. @MainActor public protocol Navigating { /// Enum of steps the conformer can emit. Conventionally nested as - /// `enum YourSceneStep` next to the conforming type. - /// `Sendable` because the navigation pulses flow through + /// `enum YourFlowStep` next to the conforming type. `Sendable` because + /// navigation pulses flow through /// ``Combine/Publisher/sinkOnMain(schedule:_:)`` which dispatches across /// a thread boundary; concrete enums of trivial cases are auto-Sendable. associatedtype NavigationStep: Sendable - /// The pulse stream of navigation requests. ViewModels call + /// The pulse stream of navigation requests. Conformers call /// `navigator.next(.someStep)` to request a transition; subscribers - /// (coordinators) react to those steps. + /// (parent coordinators) react to those steps. var navigator: Navigator { get } } diff --git a/Sources/NanoViewControllerNavigation/Navigator.swift b/Sources/NanoViewControllerNavigation/Navigator.swift index f039239..4ed7bac 100644 --- a/Sources/NanoViewControllerNavigation/Navigator.swift +++ b/Sources/NanoViewControllerNavigation/Navigator.swift @@ -25,17 +25,22 @@ import Foundation /// case userPressedHaveAccount /// } /// -/// final class SignUpViewModel: BaseViewModel { -/// override func transform(input: Input) -> SignUpOutput { -/// input.fromView.haveAccountTap -/// .sink { [navigator] in navigator.next(.userPressedHaveAccount) } -/// .store(in: &cancellables) +/// final class SignUpViewModel: AbstractViewModel< +/// SignUpInputFromView, SignUpViewModel.Publishers, SignUpStep +/// > { +/// override func transform(input: Input) -> Output { +/// let navigator = Navigator() +/// return Output( +/// publishers: Publishers(/* … */), +/// navigation: navigator.navigation +/// ) { +/// input.fromView.haveAccountTap +/// .sink { [navigator] in navigator.next(.userPressedHaveAccount) } /// -/// input.fromView.signUpTap -/// .flatMapLatest { [api] _ in api.signUp().replaceErrorWithEmpty() } -/// .sink { [navigator] user in navigator.next(.signedUp(user)) } -/// .store(in: &cancellables) -/// // … +/// input.fromView.signUpTap +/// .flatMapLatest { [api] _ in api.signUp().replaceErrorWithEmpty() } +/// .sink { [navigator] user in navigator.next(.signedUp(user)) } +/// } /// } /// } /// diff --git a/Sources/NanoViewControllerSceneViews/AbstractSceneView.swift b/Sources/NanoViewControllerSceneViews/AbstractSceneView.swift index 1000b18..bdaef3d 100644 --- a/Sources/NanoViewControllerSceneViews/AbstractSceneView.swift +++ b/Sources/NanoViewControllerSceneViews/AbstractSceneView.swift @@ -71,8 +71,8 @@ import UIKit /// var inputFromView: HomeInputFromView { /// HomeInputFromView(pullToRefresh: pullToRefreshTriggerPublisher) /// } -/// func populate(with output: HomeViewModel.OutputVM) -> [AnyCancellable] { -/// [output.isLoading --> isRefreshingBinder] +/// func populate(with publishers: HomeViewModel.Publishers) -> [AnyCancellable] { +/// publishers.isLoading --> isRefreshingBinder /// } /// } /// ``` diff --git a/Sources/NanoViewControllerSceneViews/BaseScrollableStackViewOwner.swift b/Sources/NanoViewControllerSceneViews/BaseScrollableStackViewOwner.swift index 9dc0eeb..6ca3166 100644 --- a/Sources/NanoViewControllerSceneViews/BaseScrollableStackViewOwner.swift +++ b/Sources/NanoViewControllerSceneViews/BaseScrollableStackViewOwner.swift @@ -11,7 +11,7 @@ import UIKit /// `UIStackView`. This is the most common scene-view base class, used for /// any "scroll a column of widgets" screen. /// -/// Conforms to ``EmptyInitializable`` so ``SceneController`` can construct it +/// Conforms to ``EmptyInitializable`` so ``NanoViewController`` can construct it /// from the `View` generic constraint without consumers having to write a /// custom factory. /// @@ -60,18 +60,18 @@ import UIKit /// ) /// } /// -/// func populate(with output: WelcomeViewModel.OutputVM) -> [AnyCancellable] { -/// [output.headline --> titleLabel] +/// func populate(with publishers: WelcomeViewModel.Publishers) -> [AnyCancellable] { +/// publishers.headline --> titleLabel /// } /// } /// -/// // SceneController can now host this view directly. -/// typealias WelcomeScene = Scene +/// // NanoViewController can now host this view directly. +/// final class WelcomeScene: NanoViewController {} /// ``` open class BaseScrollableStackViewOwner: AbstractSceneView, EmptyInitializable { // MARK: Initialization - /// ``EmptyInitializable`` entry point — ``SceneController`` constructs + /// ``EmptyInitializable`` entry point — ``NanoViewController`` constructs /// the scene view via `init()`. /// /// Passes a fresh empty `UIScrollView` to the abstract base, then runs diff --git a/Sources/NanoViewControllerSceneViews/BaseTableViewOwner.swift b/Sources/NanoViewControllerSceneViews/BaseTableViewOwner.swift index f1cc8ea..5e95809 100644 --- a/Sources/NanoViewControllerSceneViews/BaseTableViewOwner.swift +++ b/Sources/NanoViewControllerSceneViews/BaseTableViewOwner.swift @@ -26,8 +26,8 @@ import UIKit /// SettingsViewModel.InputFromView(selected: tableView.didSelectItem) /// } /// -/// func populate(with output: SettingsViewModel.OutputVM) -> [AnyCancellable] { -/// [output.sections --> tableView.sections] +/// func populate(with publishers: SettingsViewModel.Publishers) -> [AnyCancellable] { +/// publishers.sections --> tableView.sections /// } /// } /// ``` diff --git a/Sources/NanoViewControllerSceneViews/PullToRefreshCapable.swift b/Sources/NanoViewControllerSceneViews/PullToRefreshCapable.swift index 1a178ca..41786ea 100644 --- a/Sources/NanoViewControllerSceneViews/PullToRefreshCapable.swift +++ b/Sources/NanoViewControllerSceneViews/PullToRefreshCapable.swift @@ -26,8 +26,8 @@ import Foundation /// var inputFromView: HomeInputFromView { /// HomeInputFromView(pullToRefresh: pullToRefreshTriggerPublisher) /// } -/// func populate(with output: HomeViewModel.OutputVM) -> [AnyCancellable] { -/// [output.isLoading --> isRefreshingBinder] +/// func populate(with publishers: HomeViewModel.Publishers) -> [AnyCancellable] { +/// publishers.isLoading --> isRefreshingBinder /// } /// } /// ``` diff --git a/Sources/NanoViewControllerSceneViews/SingleCellTypeTableView.swift b/Sources/NanoViewControllerSceneViews/SingleCellTypeTableView.swift index 4476618..a8007f0 100644 --- a/Sources/NanoViewControllerSceneViews/SingleCellTypeTableView.swift +++ b/Sources/NanoViewControllerSceneViews/SingleCellTypeTableView.swift @@ -58,20 +58,18 @@ public typealias ListCell = CellConfigurable & UITableViewCell /// InputFromView(selected: tableView.didSelectItem) /// } /// -/// func populate(with output: WalletsViewModel.OutputVM) -> [AnyCancellable] { -/// [output.sections --> tableView.sections] +/// func populate(with publishers: WalletsViewModel.Publishers) -> [AnyCancellable] { +/// publishers.sections --> tableView.sections /// } /// } /// /// /// View-model — exposes `sections` and routes selections. -/// final class WalletsViewModel: BaseViewModel { -/// struct OutputVM { +/// final class WalletsViewModel: AbstractViewModel { +/// struct Publishers { /// let sections: AnyPublisher<[SectionModel], Never> /// } -/// override func transform(input: Input) -> OutputVM { -/// input.fromView.selected -/// .sink { [navigator] indexPath in navigator.next(.userTappedRow(at: indexPath)) } -/// .store(in: &cancellables) +/// override func transform(input: Input) -> Output { +/// let navigator = Navigator() /// let sections = api.fetchWallets() /// .replaceError(with: []) /// .map { wallets in [SectionModel( @@ -79,7 +77,13 @@ public typealias ListCell = CellConfigurable & UITableViewCell /// items: wallets.map { WalletRow(name: $0.name, balance: $0.balance) } /// )] } /// .eraseToAnyPublisher() -/// return OutputVM(sections: sections) +/// return Output( +/// publishers: Publishers(sections: sections), +/// navigation: navigator.navigation +/// ) { +/// input.fromView.selected +/// .sink { [navigator] indexPath in navigator.next(.userTappedRow(at: indexPath)) } +/// } /// } /// } /// ``` diff --git a/Tests/NanoViewControllerCombineTests/UIControlPublisherTests.swift b/Tests/NanoViewControllerCombineTests/UIControlPublisherTests.swift index cec3825..5be8360 100644 --- a/Tests/NanoViewControllerCombineTests/UIControlPublisherTests.swift +++ b/Tests/NanoViewControllerCombineTests/UIControlPublisherTests.swift @@ -16,39 +16,20 @@ import XCTest /// `subscriber = nil` from inside the same main-thread block as the /// `removeTarget` call so there's no read/write race. /// -/// We deliberately do **not** exercise the publisher's value-delivery path -/// here. `UIControl.sendActions(for:)` does not fire registered targets in -/// the iOS 26 simulator without actual touch tracking — that's a UIKit -/// test-environment quirk, not a publisher concern. End-to-end coverage -/// lives in the `SignUpDemo` example app. +/// `UIControl.sendActions(for:)` does not fire registered targets in the iOS +/// 26 simulator without actual touch tracking, so the value-delivery test +/// invokes the registered target/action directly. @MainActor final class UIControlPublisherTests: XCTestCase { - private var window: UIWindow! - - override func setUp() { - super.setUp() - window = UIWindow(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) - window.makeKeyAndVisible() - } - - override func tearDown() { - window?.isHidden = true - window = nil - super.tearDown() - } - - private func makeHostedButton() -> UIButton { - let button = UIButton(type: .system) - button.frame = CGRect(x: 0, y: 0, width: 50, height: 50) - window.addSubview(button) - return button + private func makeButton() -> UIButton { + UIButton(type: .system) } // MARK: - On-main subscribe + cancel (smoke) func test_subscribeAndCancelOnMain_doesNotTrap() { // ARRANGE - let button = makeHostedButton() + let button = makeButton() let cancellable = button.publisher(for: .touchUpInside).sink { _ in } // ACT @@ -61,7 +42,7 @@ final class UIControlPublisherTests: XCTestCase { func test_repeatedSubscribeCancelCycles_doNotLeak() { // ARRANGE - let button = makeHostedButton() + let button = makeButton() // ACT for _ in 0 ..< 50 { @@ -75,11 +56,29 @@ final class UIControlPublisherTests: XCTestCase { XCTAssertEqual(button.allTargets.count, 0) } + func test_tapPublisherForwardsRegisteredTargetAction() throws { + // ARRANGE + let button = makeButton() + var tapCount = 0 + let cancellable = button.tapPublisher.sink { tapCount += 1 } + + let target = try XCTUnwrap(button.allTargets.first as? NSObject) + let actions = try XCTUnwrap(button.actions(forTarget: target, forControlEvent: .touchUpInside)) + + // ACT + target.perform(NSSelectorFromString(actions[0])) + + // ASSERT + XCTAssertEqual(actions, ["fire"]) + XCTAssertEqual(tapCount, 1) + cancellable.cancel() + } + // MARK: - Off-main cancel (the AnyCancellable.deinit path) func test_cancelOffMain_doesNotTrap() async { // ARRANGE - let button = await MainActor.run { makeHostedButton() } + let button = await MainActor.run { makeButton() } // Build the subscription on main. Wrap the resulting cancellable in // an `@unchecked Sendable` box so we can hand it to `Task.detached` // without `AnyCancellable` itself needing to be `Sendable`. @@ -116,14 +115,12 @@ final class UIControlPublisherTests: XCTestCase { // ACT autoreleasepool { - let button = makeHostedButton() + let button = makeButton() weakButton = button // Subscribe and immediately cancel. After the autoreleasepool - // drains and the window releases the button, the publisher - // should not be the last strong reference. + // drains, the publisher should not be the last strong reference. let cancellable = button.publisher(for: .touchUpInside).sink { _ in } cancellable.cancel() - button.removeFromSuperview() } // ASSERT diff --git a/Tests/NanoViewControllerControllerTests/AbstractViewModelTests.swift b/Tests/NanoViewControllerControllerTests/AbstractViewModelTests.swift new file mode 100644 index 0000000..ea73517 --- /dev/null +++ b/Tests/NanoViewControllerControllerTests/AbstractViewModelTests.swift @@ -0,0 +1,144 @@ +// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) + +import Combine +@testable import NanoViewControllerController +@testable import NanoViewControllerCore +import XCTest + +/// Tests for `AbstractViewModel` — the generic base class every concrete +/// scene-bound ViewModel inherits from. Covers the synthesised `Input` struct, +/// and verifies that subclassing + overriding `transform` produces an +/// ``Output`` carrying the publisher bag, the navigation publisher, and the +/// subscriptions started inside `transform`. +/// +/// `AbstractViewModel` uses the standard ``InputFromController`` channel; +/// these tests construct a stub filled with `Empty()` publishers and +/// `PassthroughSubject`s where the wiring matters. +@MainActor +final class AbstractViewModelTests: XCTestCase { + private struct FromView { let tap: AnyPublisher } + private struct Publishers { let title: AnyPublisher } + + /// Builds an `InputFromController` whose lifecycle publishers come from + /// the supplied subjects (or `Empty()` by default). The write-back + /// subjects are real `PassthroughSubject`s the test can drive. + private static func makeStubInputFromController( + viewDidAppear: AnyPublisher = Empty().eraseToAnyPublisher() + ) -> InputFromController { + InputFromController( + viewDidLoad: Empty().eraseToAnyPublisher(), + viewWillAppear: Empty().eraseToAnyPublisher(), + viewDidAppear: viewDidAppear, + leftBarButtonTrigger: Empty().eraseToAnyPublisher(), + rightBarButtonTrigger: Empty().eraseToAnyPublisher(), + titleSubject: .init(), + leftBarButtonContentSubject: .init(), + rightBarButtonContentSubject: .init(), + toastSubject: .init() + ) + } + + private final class StubViewModel: AbstractViewModel { + private(set) var transformCalls = 0 + // Mutating side-effect counter that proves the subscription block ran. + private(set) var sideEffectCalls = 0 + override func transform(input: Input) -> Output { + transformCalls += 1 + // Use both channels so the synthesised stitching is genuinely + // exercised by the test, not just the storage. + let title = input.fromView.tap + .merge(with: input.fromController.viewDidAppear) + .map { _ in "tapped" } + .eraseToAnyPublisher() + return Output(publishers: Publishers(title: title)) { + // Side-effect subscription returned in the Output bag. + input.fromView.tap.sink { [weak self] in self?.sideEffectCalls += 1 } + } + } + } + + func test_subclass_transformReturnsPublishersAndCancellables() { + // ARRANGE + let vm = StubViewModel() + let tap = PassthroughSubject() + let appear = PassthroughSubject() + let input = StubViewModel.Input( + fromView: FromView(tap: tap.eraseToAnyPublisher()), + fromController: Self.makeStubInputFromController( + viewDidAppear: appear.eraseToAnyPublisher() + ) + ) + var bag: [AnyCancellable] = [] + var received: [String] = [] + + // ACT + let output = vm.transform(input: input) + // Output carries the publisher bag, the navigation publisher, and the + // subscriptions started inside transform — retain everything for the + // test's lifetime. + bag.append(contentsOf: output.cancellables) + output.publishers.title.sink { received.append($0) }.store(in: &bag) + tap.send(()) + appear.send(()) + + // ASSERT + XCTAssertEqual(vm.transformCalls, 1) + XCTAssertEqual(received, ["tapped", "tapped"]) + // The subscription returned in `output.cancellables` fired on the tap + // (but not on viewDidAppear, which doesn't feed it). + XCTAssertEqual(vm.sideEffectCalls, 1) + } + + func test_transform_withNoSubscriptions_returnsEmptyCancellables() { + // ARRANGE + final class NoSideEffectVM: AbstractViewModel { + override func transform(input: Input) -> Output { + Output(publishers: Publishers(title: input.fromView.tap.map { "x" }.eraseToAnyPublisher())) + } + } + let vm = NoSideEffectVM() + let input = NoSideEffectVM.Input( + fromView: FromView(tap: Empty().eraseToAnyPublisher()), + fromController: Self.makeStubInputFromController() + ) + + // ACT + let output = vm.transform(input: input) + + // ASSERT + XCTAssertTrue(output.cancellables.isEmpty) + } + + func test_transform_withNavigation_emitsThroughOutputChannel() { + // ARRANGE + enum Step: Sendable, Equatable { case finished } + final class NavigatingVM: AbstractViewModel { + override func transform(input: Input) -> Output { + let nav = PassthroughSubject() + return Output( + publishers: Publishers(title: Empty().eraseToAnyPublisher()), + navigation: nav.eraseToAnyPublisher() + ) { + input.fromView.tap.sink { nav.send(.finished) } + } + } + } + let vm = NavigatingVM() + let tap = PassthroughSubject() + let input = NavigatingVM.Input( + fromView: FromView(tap: tap.eraseToAnyPublisher()), + fromController: Self.makeStubInputFromController() + ) + var bag: [AnyCancellable] = [] + var steps: [Step] = [] + + // ACT + let output = vm.transform(input: input) + bag.append(contentsOf: output.cancellables) + output.navigation.sink { steps.append($0) }.store(in: &bag) + tap.send(()) + + // ASSERT + XCTAssertEqual(steps, [.finished]) + } +} diff --git a/Tests/NanoViewControllerControllerTests/CoordinatorHelperTests.swift b/Tests/NanoViewControllerControllerTests/CoordinatorHelperTests.swift new file mode 100644 index 0000000..1cb68c7 --- /dev/null +++ b/Tests/NanoViewControllerControllerTests/CoordinatorHelperTests.swift @@ -0,0 +1,339 @@ +// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) + +import Combine +@testable import NanoViewControllerController +import NanoViewControllerCore +import NanoViewControllerNavigation +import UIKit +import XCTest + +@MainActor +final class CoordinatorHelperTests: XCTestCase { + func test_startCoordinator_appendStartsChildAndRoutesNavigation() { + let parent = TestCoordinator(navigationController: UINavigationController()) + let child = TestCoordinator(navigationController: UINavigationController()) + var didStart = false + var routedSteps = [TestStep]() + + parent.start( + coordinator: child, + didStart: { didStart = true }, + navigationHandler: { routedSteps.append($0) } + ) + child.navigator.next(.first) + pumpMainRunLoop() + + XCTAssertEqual(parent.childCoordinators.count, 1) + XCTAssertTrue(parent.childCoordinators.first === child) + XCTAssertEqual(child.startCount, 1) + XCTAssertTrue(didStart) + XCTAssertEqual(routedSteps, [.first]) + } + + func test_startCoordinator_replaceClearsStackBeforeStartingChild() { + let nav = PresentedOverrideNavigationController() + nav.viewControllers = [UIViewController()] + let presented = DismissCapturingViewController() + nav.presentedViewControllerOverride = presented + let parent = TestCoordinator(navigationController: nav) + let child = TestCoordinator(navigationController: UINavigationController()) + var didStart = false + + parent.start( + coordinator: child, + transition: .replace, + didStart: { didStart = true }, + navigationHandler: { _ in } + ) + pumpMainRunLoop() + + XCTAssertTrue(presented.didDismiss) + XCTAssertEqual(nav.viewControllers.count, 0) + XCTAssertEqual(parent.childCoordinators.count, 1) + XCTAssertTrue(parent.childCoordinators.first === child) + XCTAssertEqual(child.startCount, 1) + XCTAssertTrue(didStart) + } + + func test_startCoordinator_replaceWithoutPresentedControllerClearsStackBeforeStartingChild() { + let nav = UINavigationController(rootViewController: UIViewController()) + let parent = TestCoordinator(navigationController: nav) + let child = TestCoordinator(navigationController: UINavigationController()) + var didStart = false + + parent.start( + coordinator: child, + transition: .replace, + didStart: { didStart = true }, + navigationHandler: { _ in } + ) + pumpMainRunLoop() + + XCTAssertTrue(nav.viewControllers.isEmpty) + XCTAssertEqual(parent.childCoordinators.count, 1) + XCTAssertTrue(parent.childCoordinators.first === child) + XCTAssertEqual(child.startCount, 1) + XCTAssertTrue(didStart) + } + + func test_presentModalCoordinatorStartsPresentsRoutesAndRemovesChild() { + let nav = PresentCapturingNavigationController() + let parent = TestCoordinator(navigationController: nav) + var child: TestCoordinator? + var didStart = false + var routedSteps = [TestStep]() + + parent.presentModalCoordinator( + makeCoordinator: { modalNavigationController in + let coordinator = TestCoordinator(navigationController: modalNavigationController) + child = coordinator + return coordinator + }, + didStart: { didStart = true }, + navigationHandler: { step, dismiss in + routedSteps.append(step) + dismiss(false) + } + ) + + XCTAssertEqual(parent.childCoordinators.count, 1) + XCTAssertTrue(nav.presentedViewControllerCapture is NavigationBarLayoutingNavigationController) + XCTAssertEqual(child?.startCount, 1) + XCTAssertTrue(didStart) + + child?.navigator.next(.second) + pumpMainRunLoop() + + XCTAssertEqual(routedSteps, [.second]) + XCTAssertTrue(parent.childCoordinators.isEmpty) + } + + func test_stackHelpersFindRemoveAndResolveTopMostCoordinatorAndScene() { + let topViewController = UIViewController() + let nav = UINavigationController(rootViewController: UIViewController()) + let grandchildNavigationController = UINavigationController(rootViewController: UIViewController()) + grandchildNavigationController.pushViewController(topViewController, animated: false) + let parent = TestCoordinator(navigationController: nav) + let child = TestCoordinator(navigationController: UINavigationController()) + let grandchild = TestCoordinator(navigationController: grandchildNavigationController) + parent.childCoordinators = [child] + child.childCoordinators = [grandchild] + + XCTAssertEqual(parent.firstIndexOf(child: child), 0) + XCTAssertTrue(parent.topMostCoordinator === grandchild) + XCTAssertTrue(parent.topMostScene === topViewController) + + parent.remove(childCoordinator: child) + + XCTAssertTrue(parent.childCoordinators.isEmpty) + XCTAssertTrue(parent.topMostCoordinator === parent) + } + + func test_topMostSceneHandlesPresentedPlainAndNavigationControllers() { + let nav = PresentedOverrideNavigationController(rootViewController: UIViewController()) + let coordinator = TestCoordinator(navigationController: nav) + let plainPresented = UIViewController() + let modalTop = UIViewController() + let modalNavigation = UINavigationController(rootViewController: modalTop) + + nav.presentedViewControllerOverride = plainPresented + XCTAssertTrue(coordinator.topMostScene === plainPresented) + + nav.presentedViewControllerOverride = modalNavigation + XCTAssertTrue(coordinator.topMostScene === modalTop) + } +} + +@MainActor +final class CoordinatorNavigationHelperTests: XCTestCase { + func test_navigationStackHelpers() { + let nav = UINavigationController() + let root = UIViewController() + let second = MarkerViewController() + let replacement = OtherViewController() + let coordinator = TestCoordinator(navigationController: nav) + var rootCompletion = false + var pushCompletion = false + var replacementCompletion = false + var popCompletion = false + + nav.setRootViewControllerIfEmptyElsePush(viewController: root, animated: true) { + rootCompletion = true + } + pumpMainRunLoop() + nav.setRootViewControllerIfEmptyElsePush(viewController: second, animated: false) { + pushCompletion = true + } + pumpMainRunLoop() + + XCTAssertEqual(nav.viewControllers, [root, second]) + XCTAssertTrue(rootCompletion) + XCTAssertTrue(pushCompletion) + XCTAssertTrue(coordinator.isTopmost(scene: MarkerViewController.self)) + XCTAssertFalse(coordinator.isTopmost(scene: OtherViewController.self)) + + nav.setRootViewControllerIfEmptyElsePush( + viewController: replacement, + animated: false, + forceReplaceAllVCsInsteadOfPush: true + ) { + replacementCompletion = true + } + pumpMainRunLoop() + + XCTAssertEqual(nav.viewControllers, [replacement]) + XCTAssertTrue(replacementCompletion) + XCTAssertTrue(coordinator.isTopmost(scene: OtherViewController.self)) + + nav.pushViewController(second, animated: false) + nav.popToRootViewController(animated: false) { + popCompletion = true + } + pumpMainRunLoop() + + XCTAssertEqual(nav.viewControllers, [replacement]) + XCTAssertTrue(popCompletion) + } + + func test_pushPresentAndReplaceSceneHelpersRouteSceneNavigation() { + let nav = PresentCapturingNavigationController() + let coordinator = TestCoordinator(navigationController: nav) + let pushedViewModel = RouteViewModel() + let modalViewModel = RouteViewModel() + let replacementViewModel = RouteViewModel() + var pushedSteps = [RouteStep]() + var modalSteps = [RouteStep]() + var replacementSteps = [RouteStep]() + + coordinator.push(scene: RouteScene.self, viewModel: pushedViewModel, animated: false) { + pushedSteps.append($0) + } + pushedViewModel.trigger.send(()) + pumpMainRunLoop() + + coordinator.modallyPresent(scene: RouteScene.self, viewModel: modalViewModel, animated: false) { step, dismiss in + modalSteps.append(step) + dismiss(false, nil) + } + modalViewModel.trigger.send(()) + pumpMainRunLoop() + + coordinator.replaceAllScenes(with: RouteScene.self, viewModel: replacementViewModel, animated: false) { step, dismiss in + replacementSteps.append(step) + dismiss(false, nil) + } + replacementViewModel.trigger.send(()) + pumpMainRunLoop() + + XCTAssertEqual(pushedSteps, [.triggered]) + XCTAssertEqual(modalSteps, [.triggered]) + XCTAssertEqual(replacementSteps, [.triggered]) + XCTAssertTrue(nav.presentedViewControllerCapture is NavigationBarLayoutingNavigationController) + XCTAssertTrue(nav.viewControllers.last is RouteScene) + } + + func test_navigatorCanEmitFromBackgroundThread() { + let navigator = Navigator() + let expectation = expectation(description: "navigation delivered") + var receivedSteps = [TestStep]() + let cancellable = navigator.navigation.sink { + receivedSteps.append($0) + expectation.fulfill() + } + + DispatchQueue.global().async { + navigator.next(.first) + } + + wait(for: [expectation], timeout: 1) + XCTAssertEqual(receivedSteps, [.first]) + _ = cancellable + } +} + +private enum TestStep: Sendable { + case first + case second +} + +private final class TestCoordinator: BaseCoordinator { + private(set) var startCount = 0 + + override func start(didStart: Completion? = nil) { + startCount += 1 + didStart?() + } +} + +private final class PresentCapturingNavigationController: UINavigationController { + private(set) var presentedViewControllerCapture: UIViewController? + + override func present( + _ viewControllerToPresent: UIViewController, + animated _: Bool, + completion: (() -> Void)? = nil + ) { + presentedViewControllerCapture = viewControllerToPresent + completion?() + } +} + +private final class PresentedOverrideNavigationController: UINavigationController { + var presentedViewControllerOverride: UIViewController? + + override var presentedViewController: UIViewController? { + presentedViewControllerOverride ?? super.presentedViewController + } +} + +private final class DismissCapturingViewController: UIViewController { + private(set) var didDismiss = false + + override func dismiss(animated _: Bool, completion: (() -> Void)? = nil) { + didDismiss = true + completion?() + } +} + +private final class MarkerViewController: UIViewController {} +private final class OtherViewController: UIViewController {} + +private enum RouteStep: Sendable { + case triggered +} + +private struct RouteInputFromView {} +private struct RoutePublishers {} + +private final class RouteViewModel: AbstractViewModel { + let trigger = PassthroughSubject() + + override func transform(input _: Input) -> Output { + let navigator = Navigator() + return Output( + publishers: RoutePublishers(), + navigation: navigator.navigation + ) { + trigger.sink { [navigator] in navigator.next(.triggered) } + } + } +} + +private final class RouteView: UIView, ViewModelled { + typealias ViewModel = RouteViewModel + + var inputFromView: RouteInputFromView { + RouteInputFromView() + } + + init() { + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + interfaceBuilderSucks + } +} + +private final class RouteScene: NanoViewController {} diff --git a/Tests/NanoViewControllerControllerTests/NanoViewControllerBehaviorTests.swift b/Tests/NanoViewControllerControllerTests/NanoViewControllerBehaviorTests.swift new file mode 100644 index 0000000..6d8bca2 --- /dev/null +++ b/Tests/NanoViewControllerControllerTests/NanoViewControllerBehaviorTests.swift @@ -0,0 +1,249 @@ +// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) + +import Combine +@testable import NanoViewControllerController +import NanoViewControllerCore +import NanoViewControllerNavigation +import UIKit +import XCTest + +@MainActor +final class NanoViewControllerBehaviorTests: XCTestCase { + func test_withoutVM_hostsStaticContentViewAndUsesNoOpViewModel() { + let scene = StaticScene() + let sceneWithExplicitViewModel = StaticScene(viewModel: NanoViewControllerWithoutVMViewModel()) + + scene.loadViewIfNeeded() + sceneWithExplicitViewModel.loadViewIfNeeded() + + let wrapper = try? XCTUnwrap( + scene.view.subviews.compactMap { $0 as? NanoViewControllerWithoutVMContentView }.first + ) + let explicitWrapper = try? XCTUnwrap( + sceneWithExplicitViewModel.view.subviews + .compactMap { $0 as? NanoViewControllerWithoutVMContentView } + .first + ) + XCTAssertNotNil(wrapper?.contentView) + XCTAssertNotNil(explicitWrapper?.contentView) + XCTAssertTrue(wrapper?.contentView.superview === wrapper) + XCTAssertEqual(wrapper?.contentView.translatesAutoresizingMaskIntoConstraints, false) + + let viewModel = NanoViewControllerWithoutVMViewModel() + let input = NanoViewControllerWithoutVMViewModel.Input( + fromView: NanoViewControllerWithoutVMViewModel.InputFromView(), + fromController: makeEmptyInputFromController() + ) + let output = viewModel.transform(input: input) + + XCTAssertTrue(output.cancellables.isEmpty) + } + + func test_controllerLifecycleWriteBacksAndNavigationAreWired() { + WiringContentView.populatedValues = [] + let viewModel = WiringViewModel() + let scene = WiringScene(viewModel: viewModel) + var navigationSteps = [WiringStep]() + let navigation = scene.navigation.sink { navigationSteps.append($0) } + + scene.loadViewIfNeeded() + pumpMainRunLoop() + scene.beginAppearanceTransition(true, animated: false) + scene.endAppearanceTransition() + scene.leftBarButtonAbstractTarget.pressed() + scene.rightBarButtonAbstractTarget.pressed() + pumpMainRunLoop() + + XCTAssertEqual(viewModel.events, [.viewDidLoad, .viewWillAppear, .viewDidAppear, .leftButton, .rightButton]) + XCTAssertEqual(WiringContentView.populatedValues, ["published"]) + XCTAssertEqual(scene.title, "Loaded") + XCTAssertNotNil(scene.navigationItem.leftBarButtonItem) + XCTAssertNotNil(scene.navigationItem.rightBarButtonItem) + XCTAssertEqual((scene.presentedToast as? UIAlertController)?.message, "Loaded toast") + XCTAssertEqual(navigationSteps, [.rightButton]) + _ = navigation + } + + func test_emptyTitleDoesNotOverwriteExistingTitleAndDescriptionUsesConcreteType() { + let scene = EmptyTitleScene(viewModel: WiringViewModel()) + scene.title = "Existing" + + scene.loadViewIfNeeded() + + XCTAssertEqual(scene.title, "Existing") + XCTAssertEqual(scene.description, "EmptyTitleScene") + } + + func test_layoutApplicationHandlesNoNavigationControllerNewSameAndChangedLayout() { + let standalone = LayoutWiringScene(viewModel: WiringViewModel()) + standalone.beginAppearanceTransition(true, animated: false) + standalone.endAppearanceTransition() + + let scene = LayoutWiringScene(viewModel: WiringViewModel()) + let nav = NavigationBarLayoutingNavigationController(rootViewController: scene) + + nav.lastLayout = nil + scene.beginAppearanceTransition(true, animated: false) + scene.endAppearanceTransition() + XCTAssertEqual(nav.lastLayout, LayoutWiringScene.config.navigationBarLayout) + + scene.beginAppearanceTransition(true, animated: false) + scene.endAppearanceTransition() + XCTAssertEqual(nav.lastLayout, LayoutWiringScene.config.navigationBarLayout) + + nav.lastLayout = .behaviorTest(visibility: .hidden(animated: false)) + scene.beginAppearanceTransition(true, animated: false) + scene.endAppearanceTransition() + XCTAssertEqual(nav.lastLayout, LayoutWiringScene.config.navigationBarLayout) + } +} + +private final class StaticContentView: UIView, EmptyInitializable { + init() { + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + interfaceBuilderSucks + } +} + +private final class StaticScene: NanoViewControllerWithoutVM {} + +private enum WiringStep: Sendable { + case rightButton +} + +private enum WiringEvent: Equatable { + case viewDidLoad + case viewWillAppear + case viewDidAppear + case leftButton + case rightButton +} + +private struct WiringInputFromView {} + +private struct WiringPublishers { + let value: AnyPublisher +} + +private final class WiringViewModel: AbstractViewModel { + private(set) var events = [WiringEvent]() + + override func transform(input: Input) -> Output { + let navigator = Navigator() + + return Output( + publishers: WiringPublishers(value: Just("published").eraseToAnyPublisher()), + navigation: navigator.navigation + ) { + input.fromController.viewDidLoad + .sink { [weak self] in + self?.events.append(.viewDidLoad) + input.fromController.titleSubject.send("Loaded") + input.fromController.leftBarButtonContentSubject.send(BarButtonContent(title: "Left")) + input.fromController.rightBarButtonContentSubject.send(BarButtonContent(system: .done)) + input.fromController.toastSubject.send(Toast("Loaded toast", dismissing: .manual(dismissButtonTitle: "OK"))) + } + + input.fromController.viewWillAppear + .sink { [weak self] in self?.events.append(.viewWillAppear) } + + input.fromController.viewDidAppear + .sink { [weak self] in self?.events.append(.viewDidAppear) } + + input.fromController.leftBarButtonTrigger + .sink { [weak self] in self?.events.append(.leftButton) } + + input.fromController.rightBarButtonTrigger + .sink { [weak self, navigator] in + self?.events.append(.rightButton) + navigator.next(.rightButton) + } + } + } +} + +private final class WiringContentView: UIView, ViewModelled { + typealias ViewModel = WiringViewModel + + static var populatedValues = [String]() + + var inputFromView: WiringInputFromView { + WiringInputFromView() + } + + init() { + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + interfaceBuilderSucks + } + + func populate(with publishers: WiringPublishers) -> [AnyCancellable] { + [ + publishers.value.sink { Self.populatedValues.append($0) }, + ] + } +} + +private final class WiringScene: NanoViewController, ControllerConfigProviding { + static let config = ControllerConfig( + leftBarButton: BarButtonContent(system: .cancel), + rightBarButton: BarButtonContent(system: .done) + ) + + private(set) var presentedToast: UIViewController? + + override func present( + _ viewControllerToPresent: UIViewController, + animated _: Bool, + completion: (() -> Void)? = nil + ) { + presentedToast = viewControllerToPresent + completion?() + } +} + +private final class EmptyTitleScene: NanoViewController, ControllerConfigProviding { + static let config = ControllerConfig(title: "") +} + +private final class LayoutWiringScene: NanoViewController, ControllerConfigProviding { + static let config = ControllerConfig(navigationBarLayout: .behaviorTest(visibility: .visible(animated: false))) +} + +private extension NavigationBarLayout { + static func behaviorTest(visibility: Visibility) -> NavigationBarLayout { + NavigationBarLayout( + barStyle: .default, + visibility: visibility, + isTranslucent: false, + barTintColor: .black, + tintColor: .white, + backgroundColor: .black, + backgroundImage: UIImage(), + shadowImage: UIImage(), + titleFont: .systemFont(ofSize: 17), + titleColor: .white + ) + } +} + +private func makeEmptyInputFromController() -> InputFromController { + InputFromController( + viewDidLoad: Empty().eraseToAnyPublisher(), + viewWillAppear: Empty().eraseToAnyPublisher(), + viewDidAppear: Empty().eraseToAnyPublisher(), + leftBarButtonTrigger: Empty().eraseToAnyPublisher(), + rightBarButtonTrigger: Empty().eraseToAnyPublisher(), + titleSubject: .init(), + leftBarButtonContentSubject: .init(), + rightBarButtonContentSubject: .init(), + toastSubject: .init() + ) +} diff --git a/Tests/NanoViewControllerControllerTests/NanoViewControllerConfigTests.swift b/Tests/NanoViewControllerControllerTests/NanoViewControllerConfigTests.swift new file mode 100644 index 0000000..9f5d0e3 --- /dev/null +++ b/Tests/NanoViewControllerControllerTests/NanoViewControllerConfigTests.swift @@ -0,0 +1,146 @@ +// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) + +import Combine +@testable import NanoViewControllerController +import NanoViewControllerCore +import UIKit +import XCTest + +@MainActor +final class NanoViewControllerConfigTests: XCTestCase { + func test_controllerConfig_localizedTitleInitializer_resolvesTitleKey() { + let config = ControllerConfig( + titleKey: "Localized Configured", + hidesBackButton: true, + leftBarButton: BarButtonContent(system: .cancel), + rightBarButton: BarButtonContent(system: .done), + navigationBarLayout: .testHidden + ) + + XCTAssertEqual(config.title, "Localized Configured") + XCTAssertTrue(config.hidesBackButton) + XCTAssertEqual(config.leftBarButton?.systemItem, .cancel) + XCTAssertEqual(config.rightBarButton?.systemItem, .done) + XCTAssertEqual(config.navigationBarLayout?.visibility, .hidden(animated: false)) + } + + func test_viewDidLoad_appliesStaticControllerConfig() { + let scene = ConfiguredScene(viewModel: ConfiguredViewModel()) + + scene.loadViewIfNeeded() + + XCTAssertEqual(scene.title, "Configured") + XCTAssertTrue(scene.navigationItem.hidesBackButton) + XCTAssertNotNil(scene.navigationItem.leftBarButtonItem) + XCTAssertNotNil(scene.navigationItem.rightBarButtonItem) + } + + func test_navigationController_appliesLayoutFromControllerConfig() { + let scene = ConfiguredScene(viewModel: ConfiguredViewModel()) + let nav = NavigationBarLayoutingNavigationController() + + nav.pushViewController(scene, animated: false) + + XCTAssertEqual(nav.lastLayout?.visibility, .hidden(animated: false)) + } + + func test_navigationController_appliesInstanceControllerConfig() { + let scene = DynamicConfiguredScene( + viewModel: ConfiguredViewModel(), + layout: .testVisible + ) + let nav = NavigationBarLayoutingNavigationController() + + nav.pushViewController(scene, animated: false) + + XCTAssertEqual(nav.lastLayout?.visibility, .visible(animated: false)) + } +} + +private final class ConfiguredViewModel: AbstractViewModel { + override func transform(input _: Input) -> Output { + Output(publishers: ConfiguredPublishers()) + } +} + +private struct ConfiguredInputFromView {} +private struct ConfiguredPublishers {} + +private final class ConfiguredView: UIView, ViewModelled { + typealias ViewModel = ConfiguredViewModel + + var inputFromView: ConfiguredInputFromView { + ConfiguredInputFromView() + } + + init() { + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + interfaceBuilderSucks + } +} + +private final class ConfiguredScene: NanoViewController, ControllerConfigProviding { + static let config = ControllerConfig( + title: "Configured", + hidesBackButton: true, + leftBarButton: BarButtonContent(system: .cancel), + rightBarButton: BarButtonContent(system: .done), + navigationBarLayout: .testHidden + ) +} + +private final class DynamicConfiguredScene: NanoViewController { + private let layout: NavigationBarLayout + + override var controllerConfig: ControllerConfig { + ControllerConfig(navigationBarLayout: layout) + } + + init(viewModel: ConfiguredViewModel, layout: NavigationBarLayout) { + self.layout = layout + super.init(viewModel: viewModel) + } + + required init(viewModel: ConfiguredViewModel) { + self.layout = .testVisible + super.init(viewModel: viewModel) + } +} + +private extension NavigationBarLayout { + static var testHidden: NavigationBarLayout { + test(visibility: .hidden(animated: false)) + } + + static var testVisible: NavigationBarLayout { + test(visibility: .visible(animated: false)) + } + + static func test(visibility: Visibility) -> NavigationBarLayout { + NavigationBarLayout( + barStyle: .default, + visibility: visibility, + isTranslucent: false, + barTintColor: .black, + tintColor: .white, + backgroundColor: .black, + backgroundImage: UIImage(), + shadowImage: UIImage(), + titleFont: .systemFont(ofSize: 17), + titleColor: .white + ) + } +} + +private extension BarButtonContent { + var systemItem: UIBarButtonItem.SystemItem? { + guard case let .system(systemItem) = type else { + return nil + } + return systemItem + } +} diff --git a/Tests/NanoViewControllerControllerTests/NavigationBarAndToastTests.swift b/Tests/NanoViewControllerControllerTests/NavigationBarAndToastTests.swift new file mode 100644 index 0000000..09f7c49 --- /dev/null +++ b/Tests/NanoViewControllerControllerTests/NavigationBarAndToastTests.swift @@ -0,0 +1,173 @@ +// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) + +@testable import NanoViewControllerController +import NanoViewControllerCore +import NanoViewControllerDIPrimitives +import UIKit +import XCTest + +@MainActor +final class NavigationBarAndToastTests: XCTestCase { + func test_barButtonContentCreatesTextImageAndSystemItems() { + let target = ButtonTarget() + let selector = #selector(ButtonTarget.action) + let image = UIImage() + + let text = BarButtonContent(title: "Save", style: .prominent).makeBarButtonItem(target: target, selector: selector) + let imageItem = BarButtonContent(image: image).makeBarButtonItem(target: target, selector: selector) + let system = BarButtonContent(system: .cancel).makeBarButtonItem(target: target, selector: selector) + + XCTAssertEqual(text.title, "Save") + XCTAssertEqual(text.style, .prominent) + XCTAssertTrue(imageItem.image === image) + XCTAssertNil(system.title) + } + + func test_navigationBarLayoutVisibilityEqualityAndApplication() { + let visible = NavigationBarLayout.test(visibility: .visible(animated: true), translucent: false) + let hidden = NavigationBarLayout.test(visibility: .hidden(animated: false), translucent: true) + let navBar = UINavigationBar() + + XCTAssertFalse(visible.visibility.isHidden) + XCTAssertTrue(visible.visibility.animated) + XCTAssertTrue(hidden.visibility.isHidden) + XCTAssertFalse(hidden.visibility.animated) + XCTAssertNotEqual(visible, hidden) + + XCTAssertEqual(navBar.applyLayout(visible), visible) + XCTAssertEqual(navBar.barStyle, visible.barStyle) + XCTAssertEqual(navBar.tintColor, visible.tintColor) + XCTAssertEqual(navBar.standardAppearance.titleTextAttributes[.foregroundColor] as? UIColor, visible.titleColor) + + navBar.applyLayout(hidden) + XCTAssertEqual(navBar.standardAppearance.backgroundColor, nil) + } + + func test_navigationBarLayoutingNavigationControllerAppliesConfiguredLayouts() { + let nav = NavigationBarLayoutingNavigationController() + let first = LayoutViewController(layout: .test(visibility: .visible(animated: false))) + let second = LayoutViewController(layout: .test(visibility: .hidden(animated: false))) + + nav.applyLayoutToViewController(nil) + nav.applyLayoutToViewController(UIViewController()) + XCTAssertNil(nav.lastLayout) + + nav.pushViewController(first, animated: false) + XCTAssertEqual(nav.lastLayout, first.controllerConfig.navigationBarLayout) + XCTAssertFalse(nav.isNavigationBarHidden) + + nav.pushViewController(second, animated: false) + XCTAssertEqual(nav.lastLayout, second.controllerConfig.navigationBarLayout) + XCTAssertTrue(nav.isNavigationBarHidden) + + let third = LayoutViewController(layout: .test(visibility: .visible(animated: false))) + nav.pushViewController(third, animated: false) + XCTAssertEqual(nav.popToViewController(first, animated: false), [second, third]) + XCTAssertEqual(nav.lastLayout, first.controllerConfig.navigationBarLayout) + + nav.pushViewController(second, animated: false) + nav.pushViewController(third, animated: false) + XCTAssertEqual(nav.popToRootViewController(animated: false), [second, third]) + XCTAssertEqual(nav.lastLayout, first.controllerConfig.navigationBarLayout) + + let modal = LayoutViewController(layout: .test(visibility: .hidden(animated: false))) + nav.present(modal, animated: false) + XCTAssertEqual(nav.lastLayout, modal.controllerConfig.navigationBarLayout) + + nav.pushViewController(second, animated: false) + XCTAssertTrue(nav.popViewController(animated: false) === second) + XCTAssertEqual(nav.lastLayout, first.controllerConfig.navigationBarLayout) + + nav.beginAppearanceTransition(true, animated: false) + nav.endAppearanceTransition() + XCTAssertTrue(nav.gestureRecognizer(UIGestureRecognizer(), shouldRecognizeSimultaneouslyWith: UIGestureRecognizer())) + XCTAssertTrue(nav.gestureRecognizer(UIGestureRecognizer(), shouldRequireFailureOf: UIScreenEdgePanGestureRecognizer())) + } + + func test_toastPresentationManualAndAutomaticDismissPaths() { + let host = PresentationCapturingViewController() + let clock = ImmediateClock() + var autoDismissed = false + + let literal: Toast = "Literal" + literal.present(using: host, clock: clock) + XCTAssertEqual((host.presentedViewControllerCapture as? UIAlertController)?.message, "Literal") + + Toast("Manual", dismissing: .manual(dismissButtonTitle: "OK")).present(using: host, clock: clock) + let manualAlert = host.presentedViewControllerCapture as? UIAlertController + XCTAssertEqual(manualAlert?.message, "Manual") + XCTAssertEqual(manualAlert?.actions.first?.title, "OK") + + Toast("Automatic", dismissing: .after(duration: 1.5)).present( + using: host, + clock: clock, + dismissedCompletion: { autoDismissed = true } + ) + + let automaticAlert = host.presentedViewControllerCapture as? UIAlertController + XCTAssertEqual(automaticAlert?.message, "Automatic") + XCTAssertEqual(clock.delays, [0.6, 1.5]) + XCTAssertTrue(autoDismissed) + } +} + +private final class ButtonTarget: NSObject { + @objc func action() {} +} + +private final class LayoutViewController: UIViewController, ControllerConfigReadable { + let controllerConfig: ControllerConfig + + init(layout: NavigationBarLayout) { + self.controllerConfig = ControllerConfig(navigationBarLayout: layout) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + interfaceBuilderSucks + } +} + +private final class PresentationCapturingViewController: UIViewController { + private(set) var presentedViewControllerCapture: UIViewController? + + override func present( + _ viewControllerToPresent: UIViewController, + animated _: Bool, + completion: (() -> Void)? = nil + ) { + presentedViewControllerCapture = viewControllerToPresent + completion?() + } +} + +private final class ImmediateClock: Clock { + private(set) var delays = [TimeInterval]() + + func schedule(after delay: TimeInterval, execute block: @escaping () -> Void) -> Task { + delays.append(delay) + block() + return Task {} + } +} + +private extension NavigationBarLayout { + static func test( + visibility: Visibility, + translucent: Bool = false + ) -> NavigationBarLayout { + NavigationBarLayout( + barStyle: .black, + visibility: visibility, + isTranslucent: translucent, + barTintColor: .black, + tintColor: .red, + backgroundColor: translucent ? .clear : .black, + backgroundImage: UIImage(), + shadowImage: UIImage(), + titleFont: .boldSystemFont(ofSize: 17), + titleColor: .blue + ) + } +} diff --git a/Tests/NanoViewControllerControllerTests/TestSupport.swift b/Tests/NanoViewControllerControllerTests/TestSupport.swift new file mode 100644 index 0000000..4be297a --- /dev/null +++ b/Tests/NanoViewControllerControllerTests/TestSupport.swift @@ -0,0 +1,8 @@ +// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) + +import Foundation + +@MainActor +func pumpMainRunLoop() { + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.02)) +} diff --git a/Tests/NanoViewControllerCoreTests/AbstractViewModelTests.swift b/Tests/NanoViewControllerCoreTests/AbstractViewModelTests.swift deleted file mode 100644 index 5f656e7..0000000 --- a/Tests/NanoViewControllerCoreTests/AbstractViewModelTests.swift +++ /dev/null @@ -1,76 +0,0 @@ -// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) - -import Combine -@testable import NanoViewControllerCore -import XCTest - -/// Tests for `AbstractViewModel` — the generic base class every concrete -/// ViewModel inherits from. Covers the synthesised `Input` struct, the empty -/// `cancellables` bag, and verifies that subclassing + overriding `transform` -/// works as documented. -@MainActor -final class AbstractViewModelTests: XCTestCase { - private struct FromView { let tap: AnyPublisher } - private struct FromController { let viewDidAppear: AnyPublisher } - private struct Output { let title: AnyPublisher } - - private final class StubViewModel: AbstractViewModel { - private(set) var transformCalls = 0 - override func transform(input: Input) -> Output { - transformCalls += 1 - // Use both channels so the synthesised stitching is genuinely - // exercised by the test, not just the storage. - let title = input.fromView.tap - .merge(with: input.fromController.viewDidAppear) - .map { _ in "tapped" } - .eraseToAnyPublisher() - return Output(title: title) - } - } - - func test_init_createsEmptyCancellables() { - // ARRANGE / ACT - let vm = StubViewModel() - - // ASSERT - XCTAssertTrue(vm.cancellables.isEmpty) - } - - func test_input_initStitchesBothChannels() { - // ARRANGE - let view = FromView(tap: Empty().eraseToAnyPublisher()) - let controller = FromController(viewDidAppear: Empty().eraseToAnyPublisher()) - - // ACT - let input = StubViewModel.Input(fromView: view, fromController: controller) - - // ASSERT - // Reading the channels back via Mirror is unnecessary — type-checking - // alone proves the init wired the right slot to the right channel. - // We just exercise the path so the line is covered. - _ = input.fromView - _ = input.fromController - } - - func test_subclass_transformIsInvoked() { - // ARRANGE - let vm = StubViewModel() - let tap = PassthroughSubject() - let appear = PassthroughSubject() - let input = StubViewModel.Input( - fromView: FromView(tap: tap.eraseToAnyPublisher()), - fromController: FromController(viewDidAppear: appear.eraseToAnyPublisher()) - ) - var received: [String] = [] - - // ACT - let output = vm.transform(input: input) - output.title.sink { received.append($0) }.store(in: &vm.cancellables) - tap.send(()) - appear.send(()) - - // ASSERT - XCTAssertEqual(vm.transformCalls, 1) - XCTAssertEqual(received, ["tapped", "tapped"]) - } -} diff --git a/Tests/NanoViewControllerCombineTests/BindingsBuilderTests.swift b/Tests/NanoViewControllerCoreTests/BindingsBuilderTests.swift similarity index 99% rename from Tests/NanoViewControllerCombineTests/BindingsBuilderTests.swift rename to Tests/NanoViewControllerCoreTests/BindingsBuilderTests.swift index 6d1c947..e80597e 100644 --- a/Tests/NanoViewControllerCombineTests/BindingsBuilderTests.swift +++ b/Tests/NanoViewControllerCoreTests/BindingsBuilderTests.swift @@ -1,7 +1,7 @@ // MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) import Combine -@testable import NanoViewControllerCombine +@testable import NanoViewControllerCore import XCTest /// Tests for `@BindingsBuilder` — the result builder that lets diff --git a/Tests/NanoViewControllerCoreTests/NanoViewControllerCoreSmokeTests.swift b/Tests/NanoViewControllerCoreTests/NanoViewControllerCoreSmokeTests.swift index c165af6..88fdd95 100644 --- a/Tests/NanoViewControllerCoreTests/NanoViewControllerCoreSmokeTests.swift +++ b/Tests/NanoViewControllerCoreTests/NanoViewControllerCoreSmokeTests.swift @@ -54,4 +54,17 @@ final class NanoViewControllerCoreSmokeTests: XCTestCase { trackerCancellable.cancel() pipelineCancellable.cancel() } + + @MainActor + func test_output_designatedInitializerDefaultsToEmptySubscriptions() { + // ACT + let output = Output( + publishers: "publishers", + navigation: Empty().eraseToAnyPublisher() + ) + + // ASSERT + XCTAssertEqual(output.publishers, "publishers") + XCTAssertTrue(output.cancellables.isEmpty) + } }