From f4c6deaa6d07561c85504eac501c77def27c055f Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Fri, 15 May 2026 20:03:59 +0200 Subject: [PATCH 01/11] Explore: fold VM cancellables into Output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the mutable `cancellables` bag on `AbstractViewModel` with an explicit `Output` return type. `transform(input:)` now returns a value carrying both the publisher bag the view binds and every subscription started inside `transform`; `SceneController` retains the cancellables for the scene's lifetime. Consumer call site: override func transform(input: Input) -> Output { // … intermediates … return Output( publishers: Publishers(isLoading: …, isSubmitEnabled: …) ) { input.fromView.submitTrigger .sink { [navigator] in navigator.next(.signedUp($0)) } } } The trailing closure is `@BindingsBuilder`-annotated, so each `.sink` is a statement, not `.store(in: &cancellables)`. The consumer-facing publisher-bundle struct is renamed `Output` → `Publishers`, matching the wrapper's terminology. Other moves: * `BindingsBuilder` moves Combine → Core so the new `Output` type can use it without crossing the dep graph (Core has no upstream deps). Its tests move accordingly. * `ViewModelType.OutputVM` associated type renamed to `Publishers`. * `AbstractViewModel` / `BaseViewModel` rename their third generic param from `OutputFromViewModel`/`Output` → `Publishers`. * `populate(with:)` now takes `ViewModel.Publishers` directly. * Example app + tests + docstrings updated to match. Exploration branch — not for merge as-is; this is meant to feel out whether the ergonomics are worth the rename + breaking API change vs the current `cancellables`-on-VM model. Co-Authored-By: Claude Opus 4.7 --- .../SignUpDemo/Sources/Home/HomeView.swift | 6 +- .../Sources/Home/HomeViewModel.swift | 27 ++-- .../Sources/Onboarding/SignUpView.swift | 8 +- .../Sources/Onboarding/SignUpViewModel.swift | 45 +++---- README.md | 56 ++++---- .../NanoViewControllerCombine/Binder.swift | 10 +- .../BarButtonContent.swift | 18 +-- .../BaseViewModel.swift | 76 ++++++----- .../NanoViewControllerController/Scene.swift | 4 +- .../SceneController.swift | 15 ++- .../ViewModelled.swift | 28 ++-- .../AbstractViewModel.swift | 49 +++---- .../BindingsBuilder.swift | 28 ++-- Sources/NanoViewControllerCore/Output.swift | 82 ++++++++++++ .../ViewModelType.swift | 124 +++++++++--------- .../AbstractSceneView.swift | 4 +- .../BaseScrollableStackViewOwner.swift | 4 +- .../BaseTableViewOwner.swift | 4 +- .../PullToRefreshCapable.swift | 4 +- .../SingleCellTypeTableView.swift | 18 +-- .../AbstractViewModelTests.swift | 59 ++++++--- .../BindingsBuilderTests.swift | 2 +- 22 files changed, 394 insertions(+), 277 deletions(-) rename Sources/{NanoViewControllerCombine => NanoViewControllerCore}/BindingsBuilder.swift (77%) create mode 100644 Sources/NanoViewControllerCore/Output.swift rename Tests/{NanoViewControllerCombineTests => NanoViewControllerCoreTests}/BindingsBuilderTests.swift (99%) 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 9080da7..24b892d 100644 --- a/Examples/SignUpDemo/Sources/Home/HomeViewModel.swift +++ b/Examples/SignUpDemo/Sources/Home/HomeViewModel.swift @@ -14,7 +14,7 @@ public enum HomeUserAction: Sendable { public final class HomeViewModel: BaseViewModel< HomeUserAction, HomeViewModel.InputFromView, - HomeViewModel.Output + HomeViewModel.Publishers > { private let user: SignedUpUser @@ -23,17 +23,18 @@ public final class HomeViewModel: BaseViewModel< super.init() } - override public func transform(input: Input) -> Output { - input.fromView.logoutTrigger - .sink { [weak self] in self?.navigator.next(.logout) } - .store(in: &cancellables) - - // 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() - ) + override public func transform(input: Input) -> Output { + Output( + 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() + ) + ) { + input.fromView.logoutTrigger + .sink { [navigator] in navigator.next(.logout) } + } } } @@ -48,7 +49,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/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 db9bcf3..312d54b 100644 --- a/Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift +++ b/Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift @@ -32,10 +32,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 +45,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() } @@ -57,7 +57,7 @@ public extension SignUpViewModel.Output { public final class SignUpViewModel: BaseViewModel< SignUpUserAction, SignUpViewModel.InputFromView, - SignUpViewModel.Output + SignUpViewModel.Publishers > { private let service: SignUpServicing @@ -67,7 +67,7 @@ public final class SignUpViewModel: BaseViewModel< } // MARK: BaseViewModel Overrides - override public func transform(input: Input) -> Output { + override public func transform(input: Input) -> Output { // 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 +90,24 @@ 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 self] user in - self?.navigator.next(.signedUp(user)) - } - .store(in: &cancellables) - return Output( - isSubmitEnabled: isSubmitEnabled, - isLoading: isLoading - ) + publishers: Publishers( + isSubmitEnabled: isSubmitEnabled, + isLoading: isLoading + ) + ) { + // 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/README.md b/README.md index ada5534..2bb3741 100644 --- a/README.md +++ b/README.md @@ -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,26 +47,25 @@ 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 +// MARK: SignUpViewModel public final class SignUpViewModel: BaseViewModel< SignUpUserAction, // NavigationStep SignUpViewModel.InputFromView, - SignUpViewModel.Output + SignUpViewModel.Publishers > { private let service: SignUpServicing - /* BaseViewModel declared `public let navigator = Navigator()` */ - /* BaseViewModel declared `public var cancellables = Set()` */ + /* BaseViewModel declares `public let navigator = Navigator()` */ // MARK: BaseViewModel Overrides - override public func transform(input: Input) -> Output { + override public func transform(input: Input) -> Output { let activity = ActivityIndicator() // Name + email both non-empty → form is valid. @@ -87,24 +86,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 self] user in - self?.navigator.next(.signedUp(user)) - } - .store(in: &cancellables) - return Output( - isSubmitEnabled: isSubmitEnabled, - isLoading: isLoading - ) + publishers: Publishers( + isSubmitEnabled: isSubmitEnabled, + isLoading: isLoading + ) + ) { + // 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/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/BarButtonContent.swift b/Sources/NanoViewControllerController/BarButtonContent.swift index 0746b86..d2ff665 100644 --- a/Sources/NanoViewControllerController/BarButtonContent.swift +++ b/Sources/NanoViewControllerController/BarButtonContent.swift @@ -25,18 +25,18 @@ import UIKit /// /// ```swift /// final class EditProfileViewModel: BaseViewModel<…> { -/// override func transform(input: Input) -> Output { +/// override func transform(input: Input) -> Output { /// 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(/* … */)) { +/// // 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) } +/// } /// } /// } /// ``` diff --git a/Sources/NanoViewControllerController/BaseViewModel.swift b/Sources/NanoViewControllerController/BaseViewModel.swift index 0a3203f..bb2a480 100644 --- a/Sources/NanoViewControllerController/BaseViewModel.swift +++ b/Sources/NanoViewControllerController/BaseViewModel.swift @@ -14,7 +14,7 @@ import NanoViewControllerNavigation /// 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. +/// * `Publishers` — 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 @@ -44,47 +44,51 @@ import NanoViewControllerNavigation /// 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 { +/// // 3. The ViewModel itself — subclass BaseViewModel. +/// final class SignUpViewModel: BaseViewModel { /// private let service: SignUpServicing /// -/// init(service: SignUpServicing) { self.service = service } +/// init(service: SignUpServicing) { self.service = service; super.init() } +/// } +/// +/// // 4. Declare the publisher bag the view binds to. +/// extension SignUpViewModel { +/// struct Publishers { +/// let isSignUpEnabled: AnyPublisher +/// let isLoading: AnyPublisher +/// } +/// } /// -/// override func transform(input: Input) -> SignUpOutput { +/// // 5. Implement transform. +/// extension SignUpViewModel { +/// override func transform(input: Input) -> Output { /// 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() -/// ) +/// return Output( +/// publishers: Publishers( +/// isSignUpEnabled: isValid.eraseToAnyPublisher(), +/// isLoading: activity.asPublisher() +/// ) +/// ) { +/// 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)) } +/// +/// input.fromView.userPressedHaveAccount +/// .sink { [navigator] in navigator.next(.userPressedHaveAccount) } +/// +/// input.fromView.userPressedTermsOfService +/// .sink { [navigator] in navigator.next(.userPressedTermsOfService) } +/// } /// } /// } /// ``` @@ -92,8 +96,8 @@ import NanoViewControllerNavigation /// 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, +open class BaseViewModel: + AbstractViewModel, Navigating { /// Stepper the coordinator subscribes to. diff --git a/Sources/NanoViewControllerController/Scene.swift b/Sources/NanoViewControllerController/Scene.swift index b3525fc..369b267 100644 --- a/Sources/NanoViewControllerController/Scene.swift +++ b/Sources/NanoViewControllerController/Scene.swift @@ -44,8 +44,8 @@ import UIKit /// ) /// } /// -/// func populate(with output: WelcomeViewModel.OutputVM) -> [AnyCancellable] { -/// [output.title --> titleLabel] +/// func populate(with publishers: WelcomeViewModel.Publishers) -> [AnyCancellable] { +/// publishers.title --> titleLabel /// } /// } /// diff --git a/Sources/NanoViewControllerController/SceneController.swift b/Sources/NanoViewControllerController/SceneController.swift index 4f7b331..76ee57a 100644 --- a/Sources/NanoViewControllerController/SceneController.swift +++ b/Sources/NanoViewControllerController/SceneController.swift @@ -15,8 +15,9 @@ import UIKit /// 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 @@ -269,11 +270,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 +283,8 @@ 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) } + output.cancellables.forEach { $0.store(in: &cancellables) } + rootContentView.populate(with: output.publishers).forEach { $0.store(in: &cancellables) } } /// Drives ``NavigationBarLayoutingNavigationController`` to apply the diff --git a/Sources/NanoViewControllerController/ViewModelled.swift b/Sources/NanoViewControllerController/ViewModelled.swift index c23004e..3b45e01 100644 --- a/Sources/NanoViewControllerController/ViewModelled.swift +++ b/Sources/NanoViewControllerController/ViewModelled.swift @@ -9,8 +9,8 @@ import NanoViewControllerCore /// /// 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 +/// `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 /// `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``, @@ -89,12 +88,12 @@ public protocol ViewModelled: EmptyInitializable { /// Called exactly once after `transform`. The returned cancellables are /// retained by ``SceneController`` 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,7 +109,7 @@ public extension ViewModelled { /// } /// ``` @BindingsBuilder - func populate(with _: ViewModel.OutputVM) -> [AnyCancellable] { + func populate(with _: ViewModel.Publishers) -> [AnyCancellable] { [] } } @@ -127,7 +126,7 @@ public extension ViewModelled { /// /// ```swift /// final class CounterViewModel: AbstractViewModel< -/// CounterInputFromView, NoControllerInput, CounterOutput +/// CounterInputFromView, NoControllerInput, CounterViewModel.Publishers /// > { /// // FromController is NoControllerInput — we ignore the lifecycle channel. /// } @@ -140,7 +139,8 @@ public extension ViewModelled { /// // 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) } +/// output.cancellables.forEach { $0.store(in: &cancellables) } +/// counterView.populate(with: output.publishers).forEach { $0.store(in: &cancellables) } /// ``` public struct NoControllerInput { public init() {} diff --git a/Sources/NanoViewControllerCore/AbstractViewModel.swift b/Sources/NanoViewControllerCore/AbstractViewModel.swift index 0c2d7c2..474362a 100644 --- a/Sources/NanoViewControllerCore/AbstractViewModel.swift +++ b/Sources/NanoViewControllerCore/AbstractViewModel.swift @@ -7,18 +7,20 @@ import Foundation /// /// `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. /// +/// Subscriptions started inside `transform` are returned in the resulting +/// ``Output`` and stored by ``SceneController`` for the lifetime of the +/// scene — `AbstractViewModel` does **not** carry a `cancellables` bag. +/// /// 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`. +/// * `Publishers` — the publisher bag returned to the view. /// /// Most consumers should subclass ``BaseViewModel`` instead — that variant /// fixes `FromController` to ``InputFromController`` and adds a typed @@ -39,19 +41,14 @@ import Foundation /// 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 +/// CounterViewModel.Publishers /// > { -/// override func transform(input: Input) -> CounterOutput { +/// override func transform(input: Input) -> Output { /// let count = Publishers.Merge( /// input.fromView.increment.map { +1 }, /// input.fromView.decrement.map { -1 } @@ -59,11 +56,20 @@ import Foundation /// .scan(0, +) /// .prepend(0) /// -/// return CounterOutput( -/// countText: count.map { String($0) }.eraseToAnyPublisher() +/// return Output( +/// publishers: Publishers( +/// countText: count.map { String($0) }.eraseToAnyPublisher() +/// ) /// ) /// } /// } +/// +/// extension CounterViewModel { +/// /// Bindings back to UILabel/UIButton in the view. +/// struct Publishers { +/// let countText: AnyPublisher +/// } +/// } /// ``` /// /// In tests you can drive the ViewModel without any UIKit: @@ -72,13 +78,14 @@ import Foundation /// let inc = PassthroughSubject() /// let dec = PassthroughSubject() /// let vm = CounterViewModel() -/// let out = vm.transform(input: CounterViewModel.Input( +/// let output = vm.transform(input: CounterViewModel.Input( /// fromView: CounterInputFromView(increment: inc.eraseToAnyPublisher(), /// decrement: dec.eraseToAnyPublisher()), /// fromController: NoControllerInput() /// )) +/// var bag: [AnyCancellable] = output.cancellables /// var collected: [String] = [] -/// out.countText.sink { collected.append($0) }.store(in: &vm.cancellables) +/// output.publishers.countText.sink { collected.append($0) }.store(in: &bag) /// /// inc.send(()); inc.send(()); dec.send(()) /// XCTAssertEqual(collected, ["0", "1", "2", "1"]) @@ -89,14 +96,7 @@ import Foundation /// 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() - +open class AbstractViewModel: ViewModelType { /// The concrete ``InputType`` Swift synthesizes for each `AbstractViewModel` /// specialisation. /// @@ -131,8 +131,9 @@ open class AbstractViewModel: Vie /// 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 { + /// - Returns: An ``Output`` wrapping the publisher bag and the + /// subscriptions started inside `transform`. + open func transform(input _: Input) -> Output { abstract } } diff --git a/Sources/NanoViewControllerCombine/BindingsBuilder.swift b/Sources/NanoViewControllerCore/BindingsBuilder.swift similarity index 77% rename from Sources/NanoViewControllerCombine/BindingsBuilder.swift rename to Sources/NanoViewControllerCore/BindingsBuilder.swift index 0a68483..b0f8f90 100644 --- a/Sources/NanoViewControllerCombine/BindingsBuilder.swift +++ b/Sources/NanoViewControllerCore/BindingsBuilder.swift @@ -8,21 +8,21 @@ import Combine /// 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 +41,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,11 +52,11 @@ 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] /// } /// ``` /// diff --git a/Sources/NanoViewControllerCore/Output.swift b/Sources/NanoViewControllerCore/Output.swift new file mode 100644 index 0000000..8eed303 --- /dev/null +++ b/Sources/NanoViewControllerCore/Output.swift @@ -0,0 +1,82 @@ +// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) + +import Combine + +/// What every ViewModel's ``ViewModelType/transform(input:)`` returns: the +/// `Publishers` bag the view binds to UI controls, plus 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 `populate(with:)`. +/// Folding both into one value moves subscription ownership out of the +/// ViewModel — ``AbstractViewModel`` no longer carries a `cancellables` bag. +/// +/// ## Example — at the call site +/// +/// ```swift +/// public extension SignUpViewModel { +/// struct Publishers { +/// let isSubmitEnabled: AnyPublisher +/// let isLoading: AnyPublisher +/// } +/// } +/// +/// override func transform(input: Input) -> Output { +/// let activity = ActivityIndicator() +/// let isLoading = activity.asPublisher() +/// let isSubmitEnabled = … +/// +/// return Output( +/// publishers: Publishers( +/// isSubmitEnabled: isSubmitEnabled, +/// isLoading: isLoading +/// ) +/// ) { +/// 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. +/// +/// ## 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 ``ViewModelled/populate(with:)``. + public let publishers: Publishers + + /// Subscriptions started inside `transform` that must outlive the call. + /// ``SceneController`` 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. + /// - 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, + @BindingsBuilder subscriptions: () -> [AnyCancellable] = { [] } + ) { + self.publishers = publishers + self.cancellables = subscriptions() + } +} diff --git a/Sources/NanoViewControllerCore/ViewModelType.swift b/Sources/NanoViewControllerCore/ViewModelType.swift index bf5bdd8..c3ad9dc 100644 --- a/Sources/NanoViewControllerCore/ViewModelType.swift +++ b/Sources/NanoViewControllerCore/ViewModelType.swift @@ -4,24 +4,27 @@ 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 +/// A ViewModel in NanoViewController is a *pure* `Input → Output` +/// transformation: it holds no mutable subscription state, 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. +/// 4. The ViewModel returns an ``Output`` containing the `Publishers` bag and +/// every subscription started inside `transform`. +/// 5. ``SceneController`` retains the cancellables and forwards `publishers` +/// to the view's `populate(with:)`. /// /// ## 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`. +/// All stateful side effects (timers, network calls, navigation pulses) are +/// launched *inside* `transform` and returned in the ``Output/cancellables`` +/// array. The ViewModel itself has no mutable bag, 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/publishers``. /// /// ## Example — minimal sign-up ViewModel /// @@ -38,62 +41,61 @@ import Foundation /// 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 { +/// final class SignUpViewModel: BaseViewModel { /// private let service: SignUpServicing -/// init(service: SignUpServicing) { self.service = service } +/// init(service: SignUpServicing) { self.service = service; super.init() } +/// } /// -/// override func transform(input: Input) -> SignUpOutput { +/// extension SignUpViewModel { +/// /// What the view binds to its UI. +/// struct Publishers { +/// let isSignUpEnabled: AnyPublisher +/// let isLoading: AnyPublisher +/// let errorMessage: AnyPublisher +/// } +/// } +/// +/// extension SignUpViewModel { +/// override func transform(input: Input) -> Output { /// 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 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() -/// ) +/// return Output( +/// publishers: Publishers( +/// isSignUpEnabled: isValid.eraseToAnyPublisher(), +/// isLoading: activity.asPublisher(), +/// errorMessage: errors.asPublisher().map(\.localizedDescription).eraseToAnyPublisher() +/// ) +/// ) { +/// 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)) } +/// } /// } /// } /// ``` /// -/// The view binds the three output publishers to its controls in -/// `populate(with:)` (see ``ViewModelled``). The coordinator subscribes to +/// The view binds the three publishers 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, ...). +/// transition makes sense. /// -/// `@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` because 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. @MainActor public protocol ViewModelType { /// The combined user-action + controller-lifecycle input the ViewModel consumes. @@ -103,26 +105,26 @@ public protocol ViewModelType { /// type from scratch. associatedtype Input: InputType - /// The bag of publishers the View binds to UI controls. + /// The publisher bag 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 + /// Conventionally a `struct` named `Publishers` nested inside the + /// concrete ViewModel, with one publisher per UI control the view drives. + associatedtype Publishers /// Runs the ViewModel's business logic. /// - /// Called exactly once per instance, typically by ``SceneController`` during - /// scene construction. Implementations: + /// 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. + /// * Return an ``Output`` whose `publishers:` field holds the + /// ``Publishers`` value 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: A bag of publishers the View binds to UI controls in - /// `populate(with:)`. - func transform(input: Input) -> OutputVM + /// - Returns: An ``Output`` wrapping the publisher bag and the + /// subscriptions started inside `transform`. + func transform(input: Input) -> Output } 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..369b6d1 100644 --- a/Sources/NanoViewControllerSceneViews/BaseScrollableStackViewOwner.swift +++ b/Sources/NanoViewControllerSceneViews/BaseScrollableStackViewOwner.swift @@ -60,8 +60,8 @@ import UIKit /// ) /// } /// -/// func populate(with output: WelcomeViewModel.OutputVM) -> [AnyCancellable] { -/// [output.headline --> titleLabel] +/// func populate(with publishers: WelcomeViewModel.Publishers) -> [AnyCancellable] { +/// publishers.headline --> titleLabel /// } /// } /// 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..21153bb 100644 --- a/Sources/NanoViewControllerSceneViews/SingleCellTypeTableView.swift +++ b/Sources/NanoViewControllerSceneViews/SingleCellTypeTableView.swift @@ -58,20 +58,17 @@ 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: BaseViewModel { +/// 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 sections = api.fetchWallets() /// .replaceError(with: []) /// .map { wallets in [SectionModel( @@ -79,7 +76,10 @@ 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)) { +/// input.fromView.selected +/// .sink { [navigator] indexPath in navigator.next(.userTappedRow(at: indexPath)) } +/// } /// } /// } /// ``` diff --git a/Tests/NanoViewControllerCoreTests/AbstractViewModelTests.swift b/Tests/NanoViewControllerCoreTests/AbstractViewModelTests.swift index 5f656e7..c0bb205 100644 --- a/Tests/NanoViewControllerCoreTests/AbstractViewModelTests.swift +++ b/Tests/NanoViewControllerCoreTests/AbstractViewModelTests.swift @@ -5,18 +5,21 @@ import Combine 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. +/// ViewModel inherits from. Covers the synthesised `Input` struct, and +/// verifies that subclassing + overriding `transform` produces an ``Output`` +/// carrying both the publisher bag and any subscriptions started inside +/// `transform`. @MainActor final class AbstractViewModelTests: XCTestCase { private struct FromView { let tap: AnyPublisher } private struct FromController { let viewDidAppear: AnyPublisher } - private struct Output { let title: AnyPublisher } + private struct Publishers { let title: AnyPublisher } - private final class StubViewModel: AbstractViewModel { + private final class StubViewModel: AbstractViewModel { private(set) var transformCalls = 0 - override func transform(input: Input) -> Output { + // 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. @@ -24,18 +27,13 @@ final class AbstractViewModelTests: XCTestCase { .merge(with: input.fromController.viewDidAppear) .map { _ in "tapped" } .eraseToAnyPublisher() - return Output(title: title) + 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_init_createsEmptyCancellables() { - // ARRANGE / ACT - let vm = StubViewModel() - - // ASSERT - XCTAssertTrue(vm.cancellables.isEmpty) - } - func test_input_initStitchesBothChannels() { // ARRANGE let view = FromView(tap: Empty().eraseToAnyPublisher()) @@ -52,7 +50,7 @@ final class AbstractViewModelTests: XCTestCase { _ = input.fromController } - func test_subclass_transformIsInvoked() { + func test_subclass_transformReturnsPublishersAndCancellables() { // ARRANGE let vm = StubViewModel() let tap = PassthroughSubject() @@ -61,16 +59,43 @@ final class AbstractViewModelTests: XCTestCase { fromView: FromView(tap: tap.eraseToAnyPublisher()), fromController: FromController(viewDidAppear: appear.eraseToAnyPublisher()) ) + var bag: [AnyCancellable] = [] var received: [String] = [] // ACT let output = vm.transform(input: input) - output.title.sink { received.append($0) }.store(in: &vm.cancellables) + // Output carries both the publisher bag and the subscriptions + // started inside transform — retain both 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: FromController(viewDidAppear: Empty().eraseToAnyPublisher()) + ) + + // ACT + let output = vm.transform(input: input) + + // ASSERT + XCTAssertTrue(output.cancellables.isEmpty) } } 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 From afc09afb1469a077b51426a74cce9c2b92837c97 Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Fri, 15 May 2026 22:02:45 +0200 Subject: [PATCH 02/11] Explore: remove BaseViewModel, fold navigation into Output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building on the previous Output commit. The navigation publisher moves into the Output return value, mirroring how cancellables already do — the VM now carries zero stored state (no `cancellables`, no `navigator`). * `Output` gains the navigation publisher. * `AbstractViewModel` gains a 4th generic parameter (`NavigationStep`). A `NavigationStep == Never` convenience `Output.init` keeps no-nav scenes terse. * `BaseViewModel` is removed. Consumers spell out `AbstractViewModel< FromView, InputFromController, Publishers, Step>` directly. * `Navigating` protocol survives, but only for coordinators (`BaseCoordinator` still owns its own `navigator`). ViewModels no longer conform. * `Navigator` becomes an opt-in local helper instantiated *inside* `transform` (kept around for its thread-safety hop). Consumers can swap in a raw `PassthroughSubject` instead. * `SceneController` stores the `Output.navigation` publisher and re-exposes it as `scene.navigation` for coordinator subscription. * `Coordinating+Scene+{Push,Present,Replace}.swift` subscribe via `scene.navigation` instead of `viewModel.navigator.navigation`. The `where V.ViewModel: Navigating` constraints disappear — the `NavigationStep` associated type now comes from `ViewModelType`. * Example app (SignUpDemo) + tests + key docstrings updated. Late-subscription caveat: the coordinator can only subscribe to `scene.navigation` *after* `transform` runs (inside scene init), so `transform` must not emit navigation synchronously during construction. This was already implicitly true today — `pushSceneInstance` triggers scene init (and therefore `transform`) before subscribing to the VM's navigator — so no behavioural regression. Still on the exploration branch — not for merge as-is. Stacked on top of the earlier "fold cancellables into Output" commit so the design can be reviewed as one piece. Co-Authored-By: Claude Opus 4.7 --- .../Sources/Home/HomeViewModel.swift | 19 ++- .../Sources/Onboarding/SignUpViewModel.swift | 23 +++- README.md | 20 +-- .../BarButtonContent.swift | 10 +- .../BaseViewModel.swift | 108 --------------- .../Coordinating+Scene+Present.swift | 14 +- .../Coordinating+Scene+Push.swift | 17 ++- .../Coordinating+Scene+Replace.swift | 15 +- .../SceneController.swift | 24 ++++ .../AbstractViewModel.swift | 129 ++++++++++++------ Sources/NanoViewControllerCore/Output.swift | 56 ++++++-- .../ViewModelType.swift | 84 +++++++----- .../Navigating.swift | 66 ++++----- .../Navigator.swift | 25 ++-- .../SingleCellTypeTableView.swift | 10 +- .../AbstractViewModelTests.swift | 50 +++++-- 16 files changed, 367 insertions(+), 303 deletions(-) delete mode 100644 Sources/NanoViewControllerController/BaseViewModel.swift diff --git a/Examples/SignUpDemo/Sources/Home/HomeViewModel.swift b/Examples/SignUpDemo/Sources/Home/HomeViewModel.swift index 24b892d..e4f6cc7 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,12 @@ 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.Publishers + InputFromController, + HomeViewModel.Publishers, + HomeUserAction > { private let user: SignedUpUser @@ -23,14 +25,17 @@ public final class HomeViewModel: BaseViewModel< super.init() } - override public func transform(input: Input) -> Output { - Output( + override public func transform(input: Input) -> Output { + let navigator = Navigator() + + return Output( 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) } diff --git a/Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift b/Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift index 312d54b..d6266bf 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). @@ -53,11 +54,13 @@ public extension SignUpViewModel.Publishers { /// 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.Publishers + InputFromController, + SignUpViewModel.Publishers, + SignUpUserAction > { private let service: SignUpServicing @@ -66,8 +69,13 @@ 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 { + // Local navigator — constructed inside transform so the VM itself + // carries no stored state. The SceneController retains it via the + // cancellables/publisher capture inside 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() @@ -94,7 +102,8 @@ public final class SignUpViewModel: BaseViewModel< 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`. diff --git a/README.md b/README.md index 2bb3741..ee09fb2 100644 --- a/README.md +++ b/README.md @@ -56,17 +56,18 @@ public extension SignUpViewModel { } // MARK: SignUpViewModel -public final class SignUpViewModel: BaseViewModel< - SignUpUserAction, // NavigationStep +public final class SignUpViewModel: AbstractViewModel< SignUpViewModel.InputFromView, - SignUpViewModel.Publishers + InputFromController, + SignUpViewModel.Publishers, + SignUpUserAction // NavigationStep > { private let service: SignUpServicing - /* BaseViewModel declares `public let navigator = Navigator()` */ - // 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() // local — VM holds no state + let activity = ActivityIndicator() // Name + email both non-empty → form is valid. let isFormValid: AnyPublisher = input.fromView.name @@ -90,7 +91,8 @@ public final class SignUpViewModel: BaseViewModel< 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`. @@ -162,7 +164,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: scene = `SceneController`, 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 scene's `.navigation`) and routes the user-actions to push / pop / present transitions. ## Zhip (real-world iOS wallet) diff --git a/Sources/NanoViewControllerController/BarButtonContent.swift b/Sources/NanoViewControllerController/BarButtonContent.swift index d2ff665..e3bc1cb 100644 --- a/Sources/NanoViewControllerController/BarButtonContent.swift +++ b/Sources/NanoViewControllerController/BarButtonContent.swift @@ -24,12 +24,16 @@ import UIKit /// ## 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 } /// -/// return Output(publishers: Publishers(/* … */)) { +/// return Output( +/// publishers: Publishers(/* … */), +/// navigation: navigator.navigation +/// ) { /// // Update the right bar button's enabled-ness via dynamic content. /// canSave /// .map { enabled in diff --git a/Sources/NanoViewControllerController/BaseViewModel.swift b/Sources/NanoViewControllerController/BaseViewModel.swift deleted file mode 100644 index bb2a480..0000000 --- a/Sources/NanoViewControllerController/BaseViewModel.swift +++ /dev/null @@ -1,108 +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). -/// * `Publishers` — 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. The ViewModel itself — subclass BaseViewModel. -/// final class SignUpViewModel: BaseViewModel { -/// private let service: SignUpServicing -/// -/// init(service: SignUpServicing) { self.service = service; super.init() } -/// } -/// -/// // 4. Declare the publisher bag the view binds to. -/// extension SignUpViewModel { -/// struct Publishers { -/// let isSignUpEnabled: AnyPublisher -/// let isLoading: AnyPublisher -/// } -/// } -/// -/// // 5. Implement transform. -/// extension SignUpViewModel { -/// override func transform(input: Input) -> Output { -/// 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() -/// ) -/// ) { -/// 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)) } -/// -/// input.fromView.userPressedHaveAccount -/// .sink { [navigator] in navigator.next(.userPressedHaveAccount) } -/// -/// input.fromView.userPressedTermsOfService -/// .sink { [navigator] in navigator.next(.userPressedTermsOfService) } -/// } -/// } -/// } -/// ``` -/// -/// 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/Coordinating+Scene+Present.swift b/Sources/NanoViewControllerController/Coordinating+Scene+Present.swift index 97439e3..3846e23 100644 --- a/Sources/NanoViewControllerController/Coordinating+Scene+Present.swift +++ b/Sources/NanoViewControllerController/Coordinating+Scene+Present.swift @@ -2,6 +2,7 @@ import Combine import NanoViewControllerCombine +import NanoViewControllerCore import NanoViewControllerNavigation import UIKit @@ -36,7 +37,7 @@ public extension Coordinating { animated: Bool = true, presentationCompletion: Completion? = nil, navigationHandler: @escaping NavigationHandlerModalScene - ) where V.ViewModel: Navigating { + ) { let scene = S(viewModel: viewModel) modallyPresent( scene: scene, @@ -61,21 +62,20 @@ public extension Coordinating { /// - 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 + ) { // Wrap in a nav controller so the modal sheet has its own navigation // bar (and our shared layout owner 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, + // Bridge the scene's navigation pulses to the caller's handler, // handing the handler a closure it can call to dismiss this modal. - viewModel.navigator.navigation + scene.navigation .sinkOnMain { [weak scene] step in navigationHandler(step) { animated, navigationCompletion in scene?.dismiss(animated: animated, completion: navigationCompletion) diff --git a/Sources/NanoViewControllerController/Coordinating+Scene+Push.swift b/Sources/NanoViewControllerController/Coordinating+Scene+Push.swift index 8eb6c3c..61991a6 100644 --- a/Sources/NanoViewControllerController/Coordinating+Scene+Push.swift +++ b/Sources/NanoViewControllerController/Coordinating+Scene+Push.swift @@ -2,6 +2,7 @@ import Combine import NanoViewControllerCombine +import NanoViewControllerCore import NanoViewControllerNavigation import UIKit @@ -48,7 +49,7 @@ public extension Coordinating { 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,7 +60,7 @@ 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. /// @@ -71,24 +72,22 @@ public extension Coordinating { /// - 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. + // Forward navigation steps from the scene 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 + scene.navigation .sinkOnMain { navigationHandler($0) } .store(in: &cancellables) } diff --git a/Sources/NanoViewControllerController/Coordinating+Scene+Replace.swift b/Sources/NanoViewControllerController/Coordinating+Scene+Replace.swift index da51a80..61a56f9 100644 --- a/Sources/NanoViewControllerController/Coordinating+Scene+Replace.swift +++ b/Sources/NanoViewControllerController/Coordinating+Scene+Replace.swift @@ -2,6 +2,7 @@ import Combine import NanoViewControllerCombine +import NanoViewControllerCore import NanoViewControllerNavigation import UIKit @@ -22,7 +23,7 @@ public extension Coordinating { /// } /// } /// ``` - typealias NavigationHandlerModalScene = (N.NavigationStep, @escaping DismissScene) -> Void + typealias NavigationHandlerModalScene = (VM.NavigationStep, @escaping DismissScene) -> Void /// Replaces every scene in the current navigation stack with `scene`. /// @@ -53,7 +54,7 @@ public extension Coordinating { animated: Bool = true, whenReplacingFinished: Completion? = nil, navigationHandler: @escaping NavigationHandlerModalScene - ) where V.ViewModel: Navigating { + ) { // Create a new instance of the `Scene`, injecting its ViewModel let scene = S(viewModel: viewModel) @@ -69,14 +70,12 @@ public extension Coordinating { /// ``replaceAllScenes(with:viewModel:animated:whenReplacingFinished:navigationHandler:)``. /// /// Use when you already have a scene instance. - func replaceAllScenes( - with scene: some Scene, + func replaceAllScenes, V: ContentView>( + with scene: S, animated: Bool = true, whenReplacingFinished: Completion? = nil, navigationHandler: @escaping NavigationHandlerModalScene - ) where V.ViewModel: Navigating { - let viewModel = scene.viewModel - + ) { let oldVCs = navigationController.viewControllers navigationController.setRootViewControllerIfEmptyElsePush( @@ -88,7 +87,7 @@ public extension Coordinating { oldVCs.forEach { $0.dismiss(animated: false, completion: nil) } } - viewModel.navigator.navigation + scene.navigation .sinkOnMain { [weak scene] step in navigationHandler(step) { animated, navigationCompletion in scene?.dismiss(animated: animated, completion: navigationCompletion) diff --git a/Sources/NanoViewControllerController/SceneController.swift b/Sources/NanoViewControllerController/SceneController.swift index 76ee57a..6471c78 100644 --- a/Sources/NanoViewControllerController/SceneController.swift +++ b/Sources/NanoViewControllerController/SceneController.swift @@ -72,6 +72,23 @@ open class SceneController: AbstractController /// The ViewModel injected by the coordinator at construction time. public let viewModel: ViewModel + /// The navigation stream produced by `viewModel.transform(input:)`. + /// + /// Set during `bindViewToViewModel` (which runs eagerly from the + /// designated init, before the controller is handed back to the + /// coordinator). Coordinators subscribe to it via the typed + /// ``navigation`` accessor below. + private var transformedNavigation: AnyPublisher! + + /// The navigation publisher the coordinator subscribes to. + /// + /// Lazily exposed (never re-emits — it's a stable handle on the stream + /// produced by `transform`). Use this from coordinator hookup code + /// instead of reaching into the ViewModel. + public var navigation: AnyPublisher { + transformedNavigation + } + /// Clock used to auto-dismiss toasts emitted via /// ``InputFromController/toastSubject``. /// @@ -285,6 +302,13 @@ private extension SceneController { output.cancellables.forEach { $0.store(in: &cancellables) } rootContentView.populate(with: output.publishers).forEach { $0.store(in: &cancellables) } + + // Expose the navigation publisher for the coordinator to subscribe + // to via `scene.navigation`. The publisher itself lives as long as + // the closures stored in `output.cancellables` keep its upstream + // (typically a `Navigator` instance constructed inside `transform`) + // alive. + transformedNavigation = output.navigation } /// Drives ``NavigationBarLayoutingNavigationController`` to apply the diff --git a/Sources/NanoViewControllerCore/AbstractViewModel.swift b/Sources/NanoViewControllerCore/AbstractViewModel.swift index 474362a..e3d9ee6 100644 --- a/Sources/NanoViewControllerCore/AbstractViewModel.swift +++ b/Sources/NanoViewControllerCore/AbstractViewModel.swift @@ -11,25 +11,85 @@ import Foundation /// * an open `transform(input:)` method that traps if not overridden — so /// forgetting to override surfaces immediately at runtime. /// -/// Subscriptions started inside `transform` are returned in the resulting -/// ``Output`` and stored by ``SceneController`` for the lifetime of the -/// scene — `AbstractViewModel` does **not** carry a `cancellables` bag. +/// Subscriptions started inside `transform`, plus the navigation publisher, +/// are returned in the resulting ``Output`` and consumed by +/// ``SceneController`` for the lifetime of the scene — `AbstractViewModel` +/// does **not** carry a `cancellables` bag or a stored `navigator`. /// -/// The class is generic over three slots: +/// The class is generic over four slots: /// /// * `FromView` — the view-driven publisher struct the View exposes. /// * `FromController` — the controller-driven channel; usually -/// ``InputFromController`` (and that's what ``BaseViewModel`` pins). +/// ``InputFromController``. /// * `Publishers` — the publisher bag returned to the view. +/// * `NavigationStep` — the enum of navigation transitions; use `Never` +/// for scenes that emit none. /// -/// 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``). +/// There is no longer a separate `BaseViewModel` convenience subclass; the +/// `InputFromController` pin and the `NavigationStep` slot are both expressed +/// directly via the four generic parameters. /// -/// ## Example — a screen-less ViewModel for a self-contained view +/// ## Example — a sign-up ViewModel with navigation +/// +/// ```swift +/// import Combine +/// import NanoViewControllerController // for InputFromController +/// import NanoViewControllerCore +/// import NanoViewControllerNavigation // for Navigator +/// +/// enum SignUpStep: Sendable { case signedUp(User) } +/// +/// struct SignUpInputFromView { +/// let username: AnyPublisher +/// let password: AnyPublisher +/// let signUpTapped: AnyPublisher +/// } +/// +/// final class SignUpViewModel: AbstractViewModel< +/// SignUpInputFromView, +/// InputFromController, +/// 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)) } +/// } +/// } +/// } +/// ``` +/// +/// ## Example — a screen-less ViewModel (no navigation, no controller) /// /// ```swift /// import Combine @@ -42,13 +102,15 @@ import Foundation /// } /// /// /// No SceneController in play — this view is embedded inside another screen. -/// /// We use NoControllerInput as the controller channel. +/// /// We use NoControllerInput as the controller channel and `Never` for +/// /// NavigationStep so the convenience init on `Output` applies. /// final class CounterViewModel: AbstractViewModel< /// CounterInputFromView, /// NoControllerInput, -/// CounterViewModel.Publishers +/// CounterViewModel.Publishers, +/// Never /// > { -/// override func transform(input: Input) -> Output { +/// override func transform(input: Input) -> Output { /// let count = Publishers.Merge( /// input.fromView.increment.map { +1 }, /// input.fromView.decrement.map { -1 } @@ -65,40 +127,25 @@ import Foundation /// } /// /// extension CounterViewModel { -/// /// Bindings back to UILabel/UIButton in the view. /// struct Publishers { /// let countText: AnyPublisher /// } /// } /// ``` /// -/// In tests you can drive the ViewModel without any UIKit: -/// -/// ```swift -/// let inc = PassthroughSubject() -/// let dec = PassthroughSubject() -/// let vm = CounterViewModel() -/// let output = vm.transform(input: CounterViewModel.Input( -/// fromView: CounterInputFromView(increment: inc.eraseToAnyPublisher(), -/// decrement: dec.eraseToAnyPublisher()), -/// fromController: NoControllerInput() -/// )) -/// var bag: [AnyCancellable] = output.cancellables -/// var collected: [String] = [] -/// output.publishers.countText.sink { collected.append($0) }.store(in: &bag) -/// -/// 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 { - /// The concrete ``InputType`` Swift synthesizes for each `AbstractViewModel` - /// specialisation. +open class AbstractViewModel< + FromView, + FromController, + Publishers, + NavigationStep: Sendable +>: ViewModelType { + /// 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 @@ -131,9 +178,9 @@ open class AbstractViewModel: ViewModelTyp /// 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 and the - /// subscriptions started inside `transform`. - open func transform(input _: Input) -> Output { + /// - 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/NanoViewControllerCore/Output.swift b/Sources/NanoViewControllerCore/Output.swift index 8eed303..dc84e7e 100644 --- a/Sources/NanoViewControllerCore/Output.swift +++ b/Sources/NanoViewControllerCore/Output.swift @@ -3,13 +3,16 @@ import Combine /// What every ViewModel's ``ViewModelType/transform(input:)`` returns: the -/// `Publishers` bag the view binds to UI controls, plus the `[AnyCancellable]` -/// the controller retains for the lifetime of the scene. +/// `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 `populate(with:)`. -/// Folding both into one value moves subscription ownership out of the -/// ViewModel — ``AbstractViewModel`` no longer carries a `cancellables` bag. +/// ViewModel-specific publisher-bundle the view consumes in `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 /// @@ -21,7 +24,8 @@ import Combine /// } /// } /// -/// override func transform(input: Input) -> Output { +/// override func transform(input: Input) -> Output { +/// let navigator = Navigator() /// let activity = ActivityIndicator() /// let isLoading = activity.asPublisher() /// let isSubmitEnabled = … @@ -30,7 +34,8 @@ import Combine /// publishers: Publishers( /// isSubmitEnabled: isSubmitEnabled, /// isLoading: isLoading -/// ) +/// ), +/// navigation: navigator.navigation /// ) { /// input.fromView.submitTrigger /// .map { [service] in service.signUp(…).trackActivity(activity) } @@ -45,6 +50,17 @@ import Combine /// 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). In practice +/// this is identical to the prior `viewModel.navigator.navigation` flow, +/// which was also subscribed-to after the scene's `bindViewToViewModel` +/// triggered `transform`. 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 @@ -55,10 +71,15 @@ import Combine /// that explicitly reaches into Combine's namespace can qualify as /// `Combine.Publishers.X`. @MainActor -public struct Output { +public struct Output { /// The publisher bag the view binds to UI controls via ``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 + /// ``Navigator``) — the coordinator pattern-matches on the cases. + public let navigation: AnyPublisher + /// Subscriptions started inside `transform` that must outlive the call. /// ``SceneController`` stores these in its own bag so they live as long /// as the scene. @@ -68,15 +89,34 @@ public struct Output { /// /// - 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.publishers = publishers + self.navigation = Empty().eraseToAnyPublisher() self.cancellables = subscriptions() } } diff --git a/Sources/NanoViewControllerCore/ViewModelType.swift b/Sources/NanoViewControllerCore/ViewModelType.swift index c3ad9dc..acf037f 100644 --- a/Sources/NanoViewControllerCore/ViewModelType.swift +++ b/Sources/NanoViewControllerCore/ViewModelType.swift @@ -4,47 +4,55 @@ import Foundation /// The central contract every ViewModel conforms to. /// -/// A ViewModel in NanoViewController is a *pure* `Input → Output` -/// transformation: it holds no mutable subscription state, and it produces all -/// of its outputs as Combine publishers. The flow is: +/// 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. ``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 ``Output`` containing the `Publishers` bag and -/// every subscription started inside `transform`. -/// 5. ``SceneController`` retains the cancellables and forwards `publishers` -/// to the view's `populate(with:)`. +/// 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. ``SceneController`` 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 -/// launched *inside* `transform` and returned in the ``Output/cancellables`` -/// array. The ViewModel itself has no mutable bag, 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/publishers``. +/// 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 NanoViewControllerCore -/// import NanoViewControllerController // for InputFromController + BaseViewModel +/// import NanoViewControllerController // for InputFromController /// 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 +/// let username: AnyPublisher +/// let password: AnyPublisher +/// let signUpTapped: AnyPublisher /// } /// /// /// Where the coordinator listens for "what should happen next". -/// enum SignUpStep { case signedUp(User) } +/// enum SignUpStep: Sendable { case signedUp(User) } /// -/// final class SignUpViewModel: BaseViewModel { +/// final class SignUpViewModel: AbstractViewModel< +/// SignUpInputFromView, +/// InputFromController, +/// SignUpViewModel.Publishers, +/// SignUpStep +/// > { /// private let service: SignUpServicing /// init(service: SignUpServicing) { self.service = service; super.init() } /// } @@ -54,14 +62,13 @@ import Foundation /// struct Publishers { /// let isSignUpEnabled: AnyPublisher /// let isLoading: AnyPublisher -/// let errorMessage: AnyPublisher /// } /// } /// /// extension SignUpViewModel { -/// override func transform(input: Input) -> Output { -/// let activity = ActivityIndicator() -/// let errors = ErrorTracker() +/// 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 } @@ -69,17 +76,14 @@ import Foundation /// return Output( /// publishers: Publishers( /// isSignUpEnabled: isValid.eraseToAnyPublisher(), -/// isLoading: activity.asPublisher(), -/// errorMessage: errors.asPublisher().map(\.localizedDescription).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) -/// .trackError(errors) -/// .replaceErrorWithEmpty() +/// service.signUp(username: u, password: p).trackActivity(activity) /// } /// .switchToLatest() /// .sink { [navigator] user in navigator.next(.signedUp(user)) } @@ -88,9 +92,9 @@ import Foundation /// } /// ``` /// -/// The view binds the three publishers in `populate(with:)` (see -/// ``ViewModelled``). The coordinator subscribes to -/// `viewModel.navigator.navigation` and routes `.signedUp(user)` to whatever +/// The view binds the publishers in `populate(with:)` (see ``ViewModelled``). +/// The coordinator subscribes to the navigation publisher exposed by the +/// hosting ``SceneController`` and routes `.signedUp(user)` to whatever /// transition makes sense. /// /// `@MainActor` because every concrete ViewModel is constructed by, observed @@ -111,6 +115,10 @@ public protocol ViewModelType { /// 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 ``SceneController`` @@ -118,13 +126,17 @@ public protocol ViewModelType { /// /// * 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 and whose `subscriptions:` builder block + /// ``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 and the - /// subscriptions started inside `transform`. - func transform(input: Input) -> Output + /// - 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/NanoViewControllerNavigation/Navigating.swift b/Sources/NanoViewControllerNavigation/Navigating.swift index 5281a52..f58187a 100644 --- a/Sources/NanoViewControllerNavigation/Navigating.swift +++ b/Sources/NanoViewControllerNavigation/Navigating.swift @@ -2,65 +2,53 @@ /// 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. /// -/// ## Example — defining a screen's navigation contract +/// ViewModels no longer conform to `Navigating` — they expose their +/// navigation channel as part of the value returned from +/// ``ViewModelType/transform(input:)`` (see ``Output``), and the hosting +/// ``SceneController`` re-exposes it for the coordinator to subscribe to. /// -/// ```swift -/// import NanoViewControllerNavigation +/// ## Example — child coordinator emitting flow-completion steps /// -/// /// 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 +/// ```swift +/// 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..2b37a60 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, InputFromController, 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/SingleCellTypeTableView.swift b/Sources/NanoViewControllerSceneViews/SingleCellTypeTableView.swift index 21153bb..b739c6f 100644 --- a/Sources/NanoViewControllerSceneViews/SingleCellTypeTableView.swift +++ b/Sources/NanoViewControllerSceneViews/SingleCellTypeTableView.swift @@ -64,11 +64,12 @@ public typealias ListCell = CellConfigurable & UITableViewCell /// } /// /// /// View-model — exposes `sections` and routes selections. -/// final class WalletsViewModel: BaseViewModel { +/// final class WalletsViewModel: AbstractViewModel { /// struct Publishers { /// let sections: AnyPublisher<[SectionModel], Never> /// } -/// override func transform(input: Input) -> Output { +/// override func transform(input: Input) -> Output { +/// let navigator = Navigator() /// let sections = api.fetchWallets() /// .replaceError(with: []) /// .map { wallets in [SectionModel( @@ -76,7 +77,10 @@ public typealias ListCell = CellConfigurable & UITableViewCell /// items: wallets.map { WalletRow(name: $0.name, balance: $0.balance) } /// )] } /// .eraseToAnyPublisher() -/// return Output(publishers: Publishers(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/NanoViewControllerCoreTests/AbstractViewModelTests.swift b/Tests/NanoViewControllerCoreTests/AbstractViewModelTests.swift index c0bb205..25c21c6 100644 --- a/Tests/NanoViewControllerCoreTests/AbstractViewModelTests.swift +++ b/Tests/NanoViewControllerCoreTests/AbstractViewModelTests.swift @@ -7,19 +7,19 @@ import XCTest /// Tests for `AbstractViewModel` — the generic base class every concrete /// ViewModel inherits from. Covers the synthesised `Input` struct, and /// verifies that subclassing + overriding `transform` produces an ``Output`` -/// carrying both the publisher bag and any subscriptions started inside -/// `transform`. +/// carrying the publisher bag, the navigation publisher, and the +/// subscriptions started inside `transform`. @MainActor final class AbstractViewModelTests: XCTestCase { private struct FromView { let tap: AnyPublisher } private struct FromController { let viewDidAppear: AnyPublisher } private struct Publishers { let title: AnyPublisher } - private final class StubViewModel: AbstractViewModel { + 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 { + 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. @@ -64,8 +64,9 @@ final class AbstractViewModelTests: XCTestCase { // ACT let output = vm.transform(input: input) - // Output carries both the publisher bag and the subscriptions - // started inside transform — retain both for the test's lifetime. + // 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(()) @@ -81,8 +82,8 @@ final class AbstractViewModelTests: XCTestCase { func test_transform_withNoSubscriptions_returnsEmptyCancellables() { // ARRANGE - final class NoSideEffectVM: AbstractViewModel { - override func transform(input: Input) -> Output { + final class NoSideEffectVM: AbstractViewModel { + override func transform(input: Input) -> Output { Output(publishers: Publishers(title: input.fromView.tap.map { "x" }.eraseToAnyPublisher())) } } @@ -98,4 +99,37 @@ final class AbstractViewModelTests: XCTestCase { // ASSERT XCTAssertTrue(output.cancellables.isEmpty) } + + func test_transform_withNavigation_emitsThroughOutputChannel() { + // ARRANGE + enum Step: Sendable { 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: FromController(viewDidAppear: Empty().eraseToAnyPublisher()) + ) + 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.count, 1) + } } From dcae5125f20a6c307cc530ad6176fe95559675ae Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Fri, 15 May 2026 22:28:53 +0200 Subject: [PATCH 03/11] Explore: pin FromController = InputFromController, drop the generic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Survey of Zhip's ~25 scene view-models found zero usage of any non-InputFromController FromController — every scene-bound VM uses InputFromController, and the one VM that doesn't (a sub-VM owned by its parent view) doesn't subclass anything at all. So the FromController generic is dead weight in practice. Pinning it drops a generic from AbstractViewModel: AbstractViewModel ↓ AbstractViewModel Same 3-generic shape as the old BaseViewModel had. Surface changes: * AbstractViewModel moves Core → Controller (it now imports InputFromController). The ViewModelType protocol stays in Core so sub-VMs can conform without UIKit. * NoControllerInput removed (unused). The `ViewModelled where Input.FromController == NoControllerInput` convenience disappears with it. * AbstractViewModel.Input.fromController is now typed InputFromController directly — no more associated-type indirection at the subclass call site. * SceneController and the Scene typealias keep their `where ... FromController == InputFromController` constraint because they're parameterised on a generic View, not on AbstractViewModel — the constraint disambiguates the input shape. * AbstractViewModelTests move from CoreTests to a new NanoViewControllerControllerTests target so they can build against the now-Controller-resident class. Tests construct a real InputFromController via a small helper. Consumer-facing diff: // Before public final class SignUpViewModel: BaseViewModel< SignUpUserAction, SignUpInputFromView, SignUpOutput > { /* stores `cancellables` and `navigator` */ } // After public final class SignUpViewModel: AbstractViewModel< SignUpInputFromView, SignUpViewModel.Publishers, SignUpUserAction > { /* no stored state */ } Same 3 generic args, no base class, no stored bag, no stored navigator. Tests: 112 across Core / Combine / DIPrimitives / Controller — all green. Example app builds. Co-Authored-By: Claude Opus 4.7 --- .../Sources/Home/HomeViewModel.swift | 1 - .../Sources/Onboarding/SignUpViewModel.swift | 1 - Package.swift | 5 ++ README.md | 3 +- .../AbstractViewModel.swift | 75 +++++-------------- .../NanoViewControllerController/Scene.swift | 5 +- .../ViewModelled.swift | 39 ---------- .../ViewModelType.swift | 1 - .../Navigator.swift | 2 +- .../SingleCellTypeTableView.swift | 2 +- .../AbstractViewModelTests.swift | 47 +++++++++--- 11 files changed, 62 insertions(+), 119 deletions(-) rename Sources/{NanoViewControllerCore => NanoViewControllerController}/AbstractViewModel.swift (70%) rename Tests/{NanoViewControllerCoreTests => NanoViewControllerControllerTests}/AbstractViewModelTests.swift (72%) diff --git a/Examples/SignUpDemo/Sources/Home/HomeViewModel.swift b/Examples/SignUpDemo/Sources/Home/HomeViewModel.swift index e4f6cc7..3a5e91c 100644 --- a/Examples/SignUpDemo/Sources/Home/HomeViewModel.swift +++ b/Examples/SignUpDemo/Sources/Home/HomeViewModel.swift @@ -14,7 +14,6 @@ public enum HomeUserAction: Sendable { /// the logout-button tap to the coordinator via the navigation publisher. public final class HomeViewModel: AbstractViewModel< HomeViewModel.InputFromView, - InputFromController, HomeViewModel.Publishers, HomeUserAction > { diff --git a/Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift b/Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift index d6266bf..a761894 100644 --- a/Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift +++ b/Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift @@ -58,7 +58,6 @@ public extension SignUpViewModel.Publishers { /// exposed in `Output`, which the coordinator subscribes to. public final class SignUpViewModel: AbstractViewModel< SignUpViewModel.InputFromView, - InputFromController, SignUpViewModel.Publishers, SignUpUserAction > { diff --git a/Package.swift b/Package.swift index 720dc71..ec1260d 100644 --- a/Package.swift +++ b/Package.swift @@ -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 ee09fb2..f97b8b0 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,6 @@ public extension SignUpViewModel { // MARK: SignUpViewModel public final class SignUpViewModel: AbstractViewModel< SignUpViewModel.InputFromView, - InputFromController, SignUpViewModel.Publishers, SignUpUserAction // NavigationStep > { @@ -164,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 `AbstractViewModel`, declares a local `Navigator` inside `transform` and surfaces it on the returned `Output`. The coordinator subscribes to that publisher (via the hosting scene's `.navigation`) and routes the user-actions to push / pop / present transitions. +The example shows the canonical wiring: scene = `SceneController`, 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 scene's `.navigation`) and routes the user-actions to push / pop / present transitions. ## Zhip (real-world iOS wallet) diff --git a/Sources/NanoViewControllerCore/AbstractViewModel.swift b/Sources/NanoViewControllerController/AbstractViewModel.swift similarity index 70% rename from Sources/NanoViewControllerCore/AbstractViewModel.swift rename to Sources/NanoViewControllerController/AbstractViewModel.swift index e3d9ee6..0f24806 100644 --- a/Sources/NanoViewControllerCore/AbstractViewModel.swift +++ b/Sources/NanoViewControllerController/AbstractViewModel.swift @@ -2,12 +2,15 @@ import Combine import Foundation +import NanoViewControllerCore -/// Abstract base class supplying the boilerplate every concrete ViewModel needs. +/// Abstract base class supplying the boilerplate every concrete scene-bound +/// ViewModel needs. /// /// `AbstractViewModel` provides: /// -/// * a synthesised nested ``Input`` struct conforming to ``InputType``, and +/// * 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. /// @@ -16,26 +19,28 @@ import Foundation /// ``SceneController`` for the lifetime of the scene — `AbstractViewModel` /// does **not** carry a `cancellables` bag or a stored `navigator`. /// -/// The class is generic over four slots: +/// The class is generic over three slots: /// /// * `FromView` — the view-driven publisher struct the View exposes. -/// * `FromController` — the controller-driven channel; usually -/// ``InputFromController``. /// * `Publishers` — the publisher bag returned to the view. /// * `NavigationStep` — the enum of navigation transitions; use `Never` /// for scenes that emit none. /// -/// There is no longer a separate `BaseViewModel` convenience subclass; the -/// `InputFromController` pin and the `NavigationStep` slot are both expressed -/// directly via the four generic parameters. +/// `FromController` is pinned to ``InputFromController`` (the controller-side +/// channel `SceneController` builds). There is no longer a separate +/// `BaseViewModel` convenience subclass, and no `FromController` generic — +/// every scene-bound view-model in practice uses ``InputFromController``. +/// View-models that don't run on a ``SceneController`` (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 // for InputFromController +/// import NanoViewControllerController /// import NanoViewControllerCore -/// import NanoViewControllerNavigation // for Navigator +/// import NanoViewControllerNavigation /// /// enum SignUpStep: Sendable { case signedUp(User) } /// @@ -47,7 +52,6 @@ import Foundation /// /// final class SignUpViewModel: AbstractViewModel< /// SignUpInputFromView, -/// InputFromController, /// SignUpViewModel.Publishers, /// SignUpStep /// > { @@ -89,50 +93,6 @@ import Foundation /// } /// ``` /// -/// ## Example — a screen-less ViewModel (no navigation, no controller) -/// -/// ```swift -/// import Combine -/// import NanoViewControllerCore -/// -/// /// View-driven channel for a tiny standalone counter view. -/// struct CounterInputFromView { -/// let increment: AnyPublisher -/// let decrement: AnyPublisher -/// } -/// -/// /// No SceneController in play — this view is embedded inside another screen. -/// /// We use NoControllerInput as the controller channel and `Never` for -/// /// NavigationStep so the convenience init on `Output` applies. -/// final class CounterViewModel: AbstractViewModel< -/// CounterInputFromView, -/// NoControllerInput, -/// CounterViewModel.Publishers, -/// Never -/// > { -/// override func transform(input: Input) -> Output { -/// let count = Publishers.Merge( -/// input.fromView.increment.map { +1 }, -/// input.fromView.decrement.map { -1 } -/// ) -/// .scan(0, +) -/// .prepend(0) -/// -/// return Output( -/// publishers: Publishers( -/// countText: count.map { String($0) }.eraseToAnyPublisher() -/// ) -/// ) -/// } -/// } -/// -/// extension CounterViewModel { -/// struct Publishers { -/// let countText: AnyPublisher -/// } -/// } -/// ``` -/// /// `@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 @@ -140,7 +100,6 @@ import Foundation @MainActor open class AbstractViewModel< FromView, - FromController, Publishers, NavigationStep: Sendable >: ViewModelType { @@ -152,7 +111,7 @@ open class AbstractViewModel< /// owns, and hands it to ``transform(input:)``. public struct Input: InputType { /// Controller-lifecycle + write-back subjects channel. - public let fromController: FromController + public let fromController: InputFromController /// User-driven publishers channel (taps, text, toggles). public let fromView: FromView @@ -162,7 +121,7 @@ open class AbstractViewModel< /// `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) { + public init(fromView: FromView, fromController: InputFromController) { self.fromView = fromView self.fromController = fromController } diff --git a/Sources/NanoViewControllerController/Scene.swift b/Sources/NanoViewControllerController/Scene.swift index 369b267..d465bbf 100644 --- a/Sources/NanoViewControllerController/Scene.swift +++ b/Sources/NanoViewControllerController/Scene.swift @@ -56,10 +56,7 @@ 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. +/// Equivalent to ``SceneController`` plus a static ``TitledScene`` title. /// /// Use this typealias when you don't need a subclass. If your screen requires /// a subclass (custom lifecycle, extra UIKit hooks), inherit from diff --git a/Sources/NanoViewControllerController/ViewModelled.swift b/Sources/NanoViewControllerController/ViewModelled.swift index 3b45e01..188fc6a 100644 --- a/Sources/NanoViewControllerController/ViewModelled.swift +++ b/Sources/NanoViewControllerController/ViewModelled.swift @@ -114,42 +114,3 @@ public extension ViewModelled { } } -/// 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, CounterViewModel.Publishers -/// > { -/// // 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) -/// output.cancellables.forEach { $0.store(in: &cancellables) } -/// counterView.populate(with: output.publishers).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/ViewModelType.swift b/Sources/NanoViewControllerCore/ViewModelType.swift index acf037f..37d5fd8 100644 --- a/Sources/NanoViewControllerCore/ViewModelType.swift +++ b/Sources/NanoViewControllerCore/ViewModelType.swift @@ -49,7 +49,6 @@ import Foundation /// /// final class SignUpViewModel: AbstractViewModel< /// SignUpInputFromView, -/// InputFromController, /// SignUpViewModel.Publishers, /// SignUpStep /// > { diff --git a/Sources/NanoViewControllerNavigation/Navigator.swift b/Sources/NanoViewControllerNavigation/Navigator.swift index 2b37a60..4ed7bac 100644 --- a/Sources/NanoViewControllerNavigation/Navigator.swift +++ b/Sources/NanoViewControllerNavigation/Navigator.swift @@ -26,7 +26,7 @@ import Foundation /// } /// /// final class SignUpViewModel: AbstractViewModel< -/// SignUpInputFromView, InputFromController, SignUpViewModel.Publishers, SignUpStep +/// SignUpInputFromView, SignUpViewModel.Publishers, SignUpStep /// > { /// override func transform(input: Input) -> Output { /// let navigator = Navigator() diff --git a/Sources/NanoViewControllerSceneViews/SingleCellTypeTableView.swift b/Sources/NanoViewControllerSceneViews/SingleCellTypeTableView.swift index b739c6f..a8007f0 100644 --- a/Sources/NanoViewControllerSceneViews/SingleCellTypeTableView.swift +++ b/Sources/NanoViewControllerSceneViews/SingleCellTypeTableView.swift @@ -64,7 +64,7 @@ public typealias ListCell = CellConfigurable & UITableViewCell /// } /// /// /// View-model — exposes `sections` and routes selections. -/// final class WalletsViewModel: AbstractViewModel { +/// final class WalletsViewModel: AbstractViewModel { /// struct Publishers { /// let sections: AnyPublisher<[SectionModel], Never> /// } diff --git a/Tests/NanoViewControllerCoreTests/AbstractViewModelTests.swift b/Tests/NanoViewControllerControllerTests/AbstractViewModelTests.swift similarity index 72% rename from Tests/NanoViewControllerCoreTests/AbstractViewModelTests.swift rename to Tests/NanoViewControllerControllerTests/AbstractViewModelTests.swift index 25c21c6..7869e6b 100644 --- a/Tests/NanoViewControllerCoreTests/AbstractViewModelTests.swift +++ b/Tests/NanoViewControllerControllerTests/AbstractViewModelTests.swift @@ -1,21 +1,44 @@ // 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 -/// 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 +/// 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` pins `FromController` to ``InputFromController``; +/// these tests construct a stub `InputFromController` filled with `Empty()` +/// publishers and `PassthroughSubject`s where the wiring matters. @MainActor final class AbstractViewModelTests: XCTestCase { private struct FromView { let tap: AnyPublisher } - private struct FromController { let viewDidAppear: AnyPublisher } private struct Publishers { let title: AnyPublisher } - private final class StubViewModel: AbstractViewModel { + /// 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 @@ -37,7 +60,7 @@ final class AbstractViewModelTests: XCTestCase { func test_input_initStitchesBothChannels() { // ARRANGE let view = FromView(tap: Empty().eraseToAnyPublisher()) - let controller = FromController(viewDidAppear: Empty().eraseToAnyPublisher()) + let controller = Self.makeStubInputFromController() // ACT let input = StubViewModel.Input(fromView: view, fromController: controller) @@ -57,7 +80,9 @@ final class AbstractViewModelTests: XCTestCase { let appear = PassthroughSubject() let input = StubViewModel.Input( fromView: FromView(tap: tap.eraseToAnyPublisher()), - fromController: FromController(viewDidAppear: appear.eraseToAnyPublisher()) + fromController: Self.makeStubInputFromController( + viewDidAppear: appear.eraseToAnyPublisher() + ) ) var bag: [AnyCancellable] = [] var received: [String] = [] @@ -82,7 +107,7 @@ final class AbstractViewModelTests: XCTestCase { func test_transform_withNoSubscriptions_returnsEmptyCancellables() { // ARRANGE - final class NoSideEffectVM: AbstractViewModel { + final class NoSideEffectVM: AbstractViewModel { override func transform(input: Input) -> Output { Output(publishers: Publishers(title: input.fromView.tap.map { "x" }.eraseToAnyPublisher())) } @@ -90,7 +115,7 @@ final class AbstractViewModelTests: XCTestCase { let vm = NoSideEffectVM() let input = NoSideEffectVM.Input( fromView: FromView(tap: Empty().eraseToAnyPublisher()), - fromController: FromController(viewDidAppear: Empty().eraseToAnyPublisher()) + fromController: Self.makeStubInputFromController() ) // ACT @@ -103,7 +128,7 @@ final class AbstractViewModelTests: XCTestCase { func test_transform_withNavigation_emitsThroughOutputChannel() { // ARRANGE enum Step: Sendable { case finished } - final class NavigatingVM: AbstractViewModel { + final class NavigatingVM: AbstractViewModel { override func transform(input: Input) -> Output { let nav = PassthroughSubject() return Output( @@ -118,7 +143,7 @@ final class AbstractViewModelTests: XCTestCase { let tap = PassthroughSubject() let input = NavigatingVM.Input( fromView: FromView(tap: tap.eraseToAnyPublisher()), - fromController: FromController(viewDidAppear: Empty().eraseToAnyPublisher()) + fromController: Self.makeStubInputFromController() ) var bag: [AnyCancellable] = [] var steps: [Step] = [] From d55b220f87aaf778965cc1285d1022eaabfd2513 Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Fri, 15 May 2026 22:39:54 +0200 Subject: [PATCH 04/11] Simplify: address /simplify findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from a multi-agent review of the three exploration commits: * SceneController no longer carries the IUO `transformedNavigation!`. Replaced with a `PassthroughSubject` set up at construction time and forwarded into by `bindViewToViewModel` — coordinators see a stable publisher regardless of ordering. * Extracted `subscribeToNavigation` + `subscribeToModalNavigation` helpers on `Coordinating` so the three `Coordinating+Scene+*` overloads stop duplicating the same `scene.navigation.sinkOnMain { … }.store(in: cancellables)` block. * Folded `setup()` into `init` — single-caller speculative wrapper. * `Output where NavigationStep == Never` convenience init delegates to the designated init instead of copying field assignments. * `cancellables.formUnion(...)` replaces `.forEach { $0.store(in:) }` in `SceneController.bindViewToViewModel`. * Trimmed change-history narration from doc comments in `AbstractViewModel`, `Navigating`, `Output`, and the `SignUpViewModel` example. * Tests: deleted the no-op `test_input_initStitchesBothChannels` (asserted nothing); tightened the navigation test to assert `steps == [.finished]` rather than `steps.count == 1`. Skipped findings (judged out of scope or over-engineering): removing the now-vestigial `Navigating` protocol; adding `Output.init(...navigator:)` overload coupling Core→Navigation; shipping an `AnyPublisher.never` helper; promoting the test fixture `makeStubInputFromController` onto `InputFromController` itself. Tests: 111 across Core / Combine / DIPrimitives / Controller — green. Example app builds. Co-Authored-By: Claude Opus 4.7 --- .../Sources/Onboarding/SignUpViewModel.swift | 3 -- README.md | 2 +- .../AbstractViewModel.swift | 4 +- ...Coordinating+Scene+NavigationHelpers.swift | 40 +++++++++++++++ .../Coordinating+Scene+Present.swift | 10 +--- .../Coordinating+Scene+Push.swift | 7 +-- .../Coordinating+Scene+Replace.swift | 8 +-- .../SceneController.swift | 50 +++++++------------ Sources/NanoViewControllerCore/Output.swift | 20 ++++---- .../Navigating.swift | 8 ++- .../AbstractViewModelTests.swift | 20 +------- 11 files changed, 78 insertions(+), 94 deletions(-) create mode 100644 Sources/NanoViewControllerController/Coordinating+Scene+NavigationHelpers.swift diff --git a/Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift b/Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift index a761894..3a93450 100644 --- a/Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift +++ b/Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift @@ -70,9 +70,6 @@ public final class SignUpViewModel: AbstractViewModel< // MARK: AbstractViewModel Overrides override public func transform(input: Input) -> Output { - // Local navigator — constructed inside transform so the VM itself - // carries no stored state. The SceneController retains it via the - // cancellables/publisher capture inside Output. let navigator = Navigator() // Track the in-flight state of the sign-up call so the view can show diff --git a/README.md b/README.md index f97b8b0..0922172 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ public final class SignUpViewModel: AbstractViewModel< // MARK: AbstractViewModel Overrides override public func transform(input: Input) -> Output { - let navigator = Navigator() // local — VM holds no state + let navigator = Navigator() let activity = ActivityIndicator() // Name + email both non-empty → form is valid. diff --git a/Sources/NanoViewControllerController/AbstractViewModel.swift b/Sources/NanoViewControllerController/AbstractViewModel.swift index 0f24806..33bd1bf 100644 --- a/Sources/NanoViewControllerController/AbstractViewModel.swift +++ b/Sources/NanoViewControllerController/AbstractViewModel.swift @@ -27,9 +27,7 @@ import NanoViewControllerCore /// for scenes that emit none. /// /// `FromController` is pinned to ``InputFromController`` (the controller-side -/// channel `SceneController` builds). There is no longer a separate -/// `BaseViewModel` convenience subclass, and no `FromController` generic — -/// every scene-bound view-model in practice uses ``InputFromController``. +/// channel `SceneController` builds) — every scene-bound view-model uses it. /// View-models that don't run on a ``SceneController`` (embedded sub-views, /// for example) should conform to ``ViewModelType`` directly without /// subclassing `AbstractViewModel`. diff --git a/Sources/NanoViewControllerController/Coordinating+Scene+NavigationHelpers.swift b/Sources/NanoViewControllerController/Coordinating+Scene+NavigationHelpers.swift new file mode 100644 index 0000000..89750b5 --- /dev/null +++ b/Sources/NanoViewControllerController/Coordinating+Scene+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 scene'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 scene's navigation publisher and + /// hands the caller's handler a `DismissScene` callback that dismisses + /// the scene 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 NavigationHandlerModalScene + ) { + 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+Scene+Present.swift index 3846e23..e943e12 100644 --- a/Sources/NanoViewControllerController/Coordinating+Scene+Present.swift +++ b/Sources/NanoViewControllerController/Coordinating+Scene+Present.swift @@ -73,14 +73,6 @@ public extension Coordinating { let viewControllerToPresent = NavigationBarLayoutingNavigationController(rootViewController: scene) navigationController.present(viewControllerToPresent, animated: animated, completion: presentationCompletion) - // Bridge the scene's navigation pulses to the caller's handler, - // handing the handler a closure it can call to dismiss this modal. - scene.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+Scene+Push.swift index 61991a6..4d42642 100644 --- a/Sources/NanoViewControllerController/Coordinating+Scene+Push.swift +++ b/Sources/NanoViewControllerController/Coordinating+Scene+Push.swift @@ -84,11 +84,6 @@ public extension Coordinating { completion: navigationPresentationCompletion ) - // Forward navigation steps from the scene 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. - scene.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+Scene+Replace.swift index 61a56f9..1185578 100644 --- a/Sources/NanoViewControllerController/Coordinating+Scene+Replace.swift +++ b/Sources/NanoViewControllerController/Coordinating+Scene+Replace.swift @@ -87,13 +87,7 @@ public extension Coordinating { oldVCs.forEach { $0.dismiss(animated: false, completion: nil) } } - scene.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/SceneController.swift b/Sources/NanoViewControllerController/SceneController.swift index 6471c78..f9401a6 100644 --- a/Sources/NanoViewControllerController/SceneController.swift +++ b/Sources/NanoViewControllerController/SceneController.swift @@ -72,22 +72,15 @@ open class SceneController: AbstractController /// The ViewModel injected by the coordinator at construction time. public let viewModel: ViewModel - /// The navigation stream produced by `viewModel.transform(input:)`. - /// - /// Set during `bindViewToViewModel` (which runs eagerly from the - /// designated init, before the controller is handed back to the - /// coordinator). Coordinators subscribe to it via the typed - /// ``navigation`` accessor below. - private var transformedNavigation: AnyPublisher! + /// 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. - /// - /// Lazily exposed (never re-emits — it's a stable handle on the stream - /// produced by `transform`). Use this from coordinator hookup code - /// instead of reaching into the ViewModel. - public var navigation: AnyPublisher { - transformedNavigation - } + /// 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``. @@ -140,7 +133,7 @@ open class SceneController: AbstractController 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 @@ -229,14 +222,6 @@ open class SceneController: AbstractController // 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() - } - /// 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. @@ -300,15 +285,16 @@ private extension SceneController { let input = ViewModel.Input(fromView: inputFromView, fromController: inputFromController) let output = viewModel.transform(input: input) - output.cancellables.forEach { $0.store(in: &cancellables) } - rootContentView.populate(with: output.publishers).forEach { $0.store(in: &cancellables) } + cancellables.formUnion(output.cancellables) + cancellables.formUnion(rootContentView.populate(with: output.publishers)) - // Expose the navigation publisher for the coordinator to subscribe - // to via `scene.navigation`. The publisher itself lives as long as - // the closures stored in `output.cancellables` keep its upstream - // (typically a `Navigator` instance constructed inside `transform`) - // alive. - transformedNavigation = output.navigation + // 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 diff --git a/Sources/NanoViewControllerCore/Output.swift b/Sources/NanoViewControllerCore/Output.swift index dc84e7e..5d8afde 100644 --- a/Sources/NanoViewControllerCore/Output.swift +++ b/Sources/NanoViewControllerCore/Output.swift @@ -53,13 +53,11 @@ import Combine /// ## Late navigation subscription /// /// The coordinator subscribes to ``navigation`` *after* `transform` returns -/// (it can't subscribe sooner — the publisher doesn't exist yet). In practice -/// this is identical to the prior `viewModel.navigator.navigation` flow, -/// which was also subscribed-to after the scene's `bindViewToViewModel` -/// triggered `transform`. 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. +/// (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` /// @@ -115,8 +113,10 @@ public extension Output where NavigationStep == Never { publishers: Publishers, @BindingsBuilder subscriptions: () -> [AnyCancellable] = { [] } ) { - self.publishers = publishers - self.navigation = Empty().eraseToAnyPublisher() - self.cancellables = subscriptions() + self.init( + publishers: publishers, + navigation: Empty().eraseToAnyPublisher(), + subscriptions: subscriptions + ) } } diff --git a/Sources/NanoViewControllerNavigation/Navigating.swift b/Sources/NanoViewControllerNavigation/Navigating.swift index f58187a..8392e1d 100644 --- a/Sources/NanoViewControllerNavigation/Navigating.swift +++ b/Sources/NanoViewControllerNavigation/Navigating.swift @@ -9,11 +9,9 @@ /// /// Coordinators conform to `Navigating` (via ``BaseCoordinator``) so a parent /// coordinator can listen to a child coordinator's flow-completion events. -/// -/// ViewModels no longer conform to `Navigating` — they expose their -/// navigation channel as part of the value returned from -/// ``ViewModelType/transform(input:)`` (see ``Output``), and the hosting -/// ``SceneController`` re-exposes it for the coordinator to subscribe to. +/// ViewModels expose their navigation channel through the ``Output`` returned +/// from ``ViewModelType/transform(input:)`` instead — see ``SceneController`` +/// for how the coordinator subscribes. /// /// ## Example — child coordinator emitting flow-completion steps /// diff --git a/Tests/NanoViewControllerControllerTests/AbstractViewModelTests.swift b/Tests/NanoViewControllerControllerTests/AbstractViewModelTests.swift index 7869e6b..16449bb 100644 --- a/Tests/NanoViewControllerControllerTests/AbstractViewModelTests.swift +++ b/Tests/NanoViewControllerControllerTests/AbstractViewModelTests.swift @@ -57,22 +57,6 @@ final class AbstractViewModelTests: XCTestCase { } } - func test_input_initStitchesBothChannels() { - // ARRANGE - let view = FromView(tap: Empty().eraseToAnyPublisher()) - let controller = Self.makeStubInputFromController() - - // 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_transformReturnsPublishersAndCancellables() { // ARRANGE let vm = StubViewModel() @@ -127,7 +111,7 @@ final class AbstractViewModelTests: XCTestCase { func test_transform_withNavigation_emitsThroughOutputChannel() { // ARRANGE - enum Step: Sendable { case finished } + enum Step: Sendable, Equatable { case finished } final class NavigatingVM: AbstractViewModel { override func transform(input: Input) -> Output { let nav = PassthroughSubject() @@ -155,6 +139,6 @@ final class AbstractViewModelTests: XCTestCase { tap.send(()) // ASSERT - XCTAssertEqual(steps.count, 1) + XCTAssertEqual(steps, [.finished]) } } From 1ac8ae1c76b0c3932368f74d64cd0aab8d42523d Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Sat, 16 May 2026 13:24:55 +0200 Subject: [PATCH 05/11] Introduce NanoViewController --- .../AbstractController.swift | 100 +----------------- .../AbstractViewModel.swift | 4 +- .../Coordinating+Stack.swift | 12 +-- .../InputType.swift | 50 ++++----- .../LeftBarButtonContentMaking.swift | 2 +- ...NanoViewController+BarButtonContent.swift} | 4 +- ...troller.swift => NanoViewController.swift} | 41 +++++-- .../NanoViewControllerWithoutVM.swift | 73 +++++++++++++ .../RightBarButtonContentMaking.swift | 2 +- .../NanoViewControllerController/Scene.swift | 6 +- .../ViewModelType.swift | 3 +- 11 files changed, 150 insertions(+), 147 deletions(-) rename Sources/{NanoViewControllerCore => NanoViewControllerController}/InputType.swift (78%) rename Sources/NanoViewControllerController/{AbstractController+BarButtonContent.swift => NanoViewController+BarButtonContent.swift} (94%) rename Sources/NanoViewControllerController/{SceneController.swift => NanoViewController.swift} (89%) create mode 100644 Sources/NanoViewControllerController/NanoViewControllerWithoutVM.swift rename Sources/{NanoViewControllerCore => NanoViewControllerController}/ViewModelType.swift (98%) 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 index 33bd1bf..45ac21e 100644 --- a/Sources/NanoViewControllerController/AbstractViewModel.swift +++ b/Sources/NanoViewControllerController/AbstractViewModel.swift @@ -26,8 +26,8 @@ import NanoViewControllerCore /// * `NavigationStep` — the enum of navigation transitions; use `Never` /// for scenes that emit none. /// -/// `FromController` is pinned to ``InputFromController`` (the controller-side -/// channel `SceneController` builds) — every scene-bound view-model uses it. +/// 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 ``SceneController`` (embedded sub-views, /// for example) should conform to ``ViewModelType`` directly without /// subclassing `AbstractViewModel`. 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/NanoViewControllerCore/InputType.swift b/Sources/NanoViewControllerController/InputType.swift similarity index 78% rename from Sources/NanoViewControllerCore/InputType.swift rename to Sources/NanoViewControllerController/InputType.swift index 2e78399..4e7586a 100644 --- a/Sources/NanoViewControllerCore/InputType.swift +++ b/Sources/NanoViewControllerController/InputType.swift @@ -20,34 +20,38 @@ 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 { +/// 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) } +/// } /// } /// } /// ``` @@ -103,16 +107,12 @@ 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 ``SceneController`` builds. + var fromController: InputFromController { get } /// Designated initializer. /// @@ -120,5 +120,5 @@ public protocol InputType { /// 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 index 51452b0..7aa1aac 100644 --- a/Sources/NanoViewControllerController/LeftBarButtonContentMaking.swift +++ b/Sources/NanoViewControllerController/LeftBarButtonContentMaking.swift @@ -36,7 +36,7 @@ public extension LeftBarButtonContentMaking { /// indirection at every call site. /// /// - Parameter viewController: The controller to install the button on. - func setLeftBarButton(for viewController: AbstractController) { + func setLeftBarButton(for viewController: NanoViewController) { viewController.setLeftBarButtonUsing(content: Self.makeLeftContent) } } diff --git a/Sources/NanoViewControllerController/AbstractController+BarButtonContent.swift b/Sources/NanoViewControllerController/NanoViewController+BarButtonContent.swift similarity index 94% rename from Sources/NanoViewControllerController/AbstractController+BarButtonContent.swift rename to Sources/NanoViewControllerController/NanoViewController+BarButtonContent.swift index 0667c70..b296970 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 @@ -22,7 +22,7 @@ public extension AbstractController { /// ## Example — imperative use from a custom subclass /// /// ```swift - /// final class CustomScene: SceneController, TitledScene { + /// final class CustomScene: NanoViewController, TitledScene { /// static var title: String { "Custom" } /// override func viewDidLoad() { /// super.viewDidLoad() diff --git a/Sources/NanoViewControllerController/SceneController.swift b/Sources/NanoViewControllerController/NanoViewController.swift similarity index 89% rename from Sources/NanoViewControllerController/SceneController.swift rename to Sources/NanoViewControllerController/NanoViewController.swift index f9401a6..58930c9 100644 --- a/Sources/NanoViewControllerController/SceneController.swift +++ b/Sources/NanoViewControllerController/NanoViewController.swift @@ -8,7 +8,7 @@ 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 @@ -21,7 +21,7 @@ import UIKit /// ``ViewModelled/populate(with:)``. /// /// This is the load-bearing class of the package — coordinators push instances -/// of `SceneController<…>` directly through the ``Scene`` typealias, and you +/// of `NanoViewController<…>` 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. /// @@ -39,7 +39,7 @@ import UIKit /// ``` /// /// `WelcomeScene` is just a `Scene` typealias — there is *no* -/// hand-written controller class for the welcome screen. `SceneController` +/// hand-written controller class for the welcome screen. `NanoViewController` /// is doing all the work generically. /// /// ## Subclassing — when (rarely) needed @@ -52,18 +52,34 @@ import UIKit /// layout (translucent / opaque / hidden). /// /// ```swift -/// final class BrandedWelcomeScene: SceneController, TitledScene, NavigationBarLayoutOwner { +/// final class BrandedWelcomeScene: NanoViewController, TitledScene, NavigationBarLayoutOwner { /// static var title: String { "Welcome" } /// 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. @@ -96,7 +112,7 @@ 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 } @@ -217,11 +233,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 { +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. 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/RightBarButtonContentMaking.swift b/Sources/NanoViewControllerController/RightBarButtonContentMaking.swift index c1ab653..1ad8c36 100644 --- a/Sources/NanoViewControllerController/RightBarButtonContentMaking.swift +++ b/Sources/NanoViewControllerController/RightBarButtonContentMaking.swift @@ -37,7 +37,7 @@ public extension RightBarButtonContentMaking { /// right bar button on the supplied controller. /// /// - Parameter viewController: The controller to install the button on. - func setRightBarButton(for viewController: AbstractController) { + func setRightBarButton(for viewController: NanoViewController) { viewController.setRightBarButtonUsing(content: Self.makeRightContent) } } diff --git a/Sources/NanoViewControllerController/Scene.swift b/Sources/NanoViewControllerController/Scene.swift index d465bbf..7096f36 100644 --- a/Sources/NanoViewControllerController/Scene.swift +++ b/Sources/NanoViewControllerController/Scene.swift @@ -78,5 +78,7 @@ public typealias ContentView = UIView & ViewModelled /// // … /// } /// ``` -public typealias Scene = SceneController & TitledScene - where View.ViewModel.Input.FromController == InputFromController +public typealias Scene = NanoViewController & TitledScene + +@available(*, deprecated, renamed: "NanoViewController") +public typealias SceneController = NanoViewController diff --git a/Sources/NanoViewControllerCore/ViewModelType.swift b/Sources/NanoViewControllerController/ViewModelType.swift similarity index 98% rename from Sources/NanoViewControllerCore/ViewModelType.swift rename to Sources/NanoViewControllerController/ViewModelType.swift index 37d5fd8..8106d71 100644 --- a/Sources/NanoViewControllerCore/ViewModelType.swift +++ b/Sources/NanoViewControllerController/ViewModelType.swift @@ -1,6 +1,7 @@ // MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) import Foundation +import NanoViewControllerCore /// The central contract every ViewModel conforms to. /// @@ -33,8 +34,8 @@ import Foundation /// /// ```swift /// import Combine +/// import NanoViewControllerController /// import NanoViewControllerCore -/// import NanoViewControllerController // for InputFromController /// import NanoViewControllerNavigation // for Navigator /// /// /// What the user can do on the sign-up screen. From 12fe1c2f32aec738071a7df7c75bdaa1ecb8a6c6 Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Sat, 16 May 2026 14:01:28 +0200 Subject: [PATCH 06/11] Replace scene protocols with controller config --- .../SignUpDemo/Sources/Home/HomeScene.swift | 7 +- .../Sources/Onboarding/SignUpScene.swift | 8 +- Package.swift | 2 +- README.md | 10 +- .../AbstractViewModel.swift | 10 +- .../BarButtonContent.swift | 17 ++- .../ControllerConfig.swift | 58 +++++++++ ...Coordinating+Scene+NavigationHelpers.swift | 4 +- .../Coordinating+Scene+Present.swift | 4 +- .../Coordinating+Scene+Push.swift | 4 +- .../Coordinating+Scene+Replace.swift | 4 +- .../InputFromController.swift | 8 +- .../InputType.swift | 8 +- .../LeftBarButtonContentMaking.swift | 42 ------ .../NanoViewController+BarButtonContent.swift | 7 +- .../NanoViewController.swift | 80 ++++++------ .../NavigationBarLayout.swift | 42 +----- ...tionBarLayoutingNavigationController.swift | 18 ++- .../RightBarButtonContentMaking.swift | 63 --------- .../NanoViewControllerController/Scene.swift | 35 +---- .../TitledScene.swift | 53 -------- .../NanoViewControllerController/Toast.swift | 4 +- .../ViewModelType.swift | 14 +- .../ViewModelled.swift | 7 +- .../AbstractTarget.swift | 10 +- .../EmptyInitializable.swift | 6 +- Sources/NanoViewControllerCore/Output.swift | 2 +- .../Navigating.swift | 2 +- .../BaseScrollableStackViewOwner.swift | 8 +- .../NanoViewControllerConfigTests.swift | 121 ++++++++++++++++++ 30 files changed, 310 insertions(+), 348 deletions(-) create mode 100644 Sources/NanoViewControllerController/ControllerConfig.swift delete mode 100644 Sources/NanoViewControllerController/LeftBarButtonContentMaking.swift delete mode 100644 Sources/NanoViewControllerController/RightBarButtonContentMaking.swift delete mode 100644 Sources/NanoViewControllerController/TitledScene.swift create mode 100644 Tests/NanoViewControllerControllerTests/NanoViewControllerConfigTests.swift 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/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/Package.swift b/Package.swift index ec1260d..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, …) // diff --git a/README.md b/README.md index 0922172..4242991 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 @@ -123,7 +123,7 @@ The package ships six independent SPM library targets so consumers can pick exac | `NanoViewControllerCore` | value types | `ViewModelType`, `InputType`, `EmptyInitializable`, `AbstractViewModel`, `AbstractTarget`, `ActivityIndicator`, `ErrorTracker` | | `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`, `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` | @@ -155,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 @@ -163,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 `AbstractViewModel`, declares a local `Navigator` inside `transform` and surfaces it on the returned `Output`. The coordinator subscribes to that publisher (via the hosting scene's `.navigation`) and routes the user-actions to push / pop / present transitions. +The example shows the canonical wiring: scene = `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 scene's `.navigation`) and routes the user-actions to push / pop / present transitions. ## Zhip (real-world iOS wallet) diff --git a/Sources/NanoViewControllerController/AbstractViewModel.swift b/Sources/NanoViewControllerController/AbstractViewModel.swift index 45ac21e..f06ece1 100644 --- a/Sources/NanoViewControllerController/AbstractViewModel.swift +++ b/Sources/NanoViewControllerController/AbstractViewModel.swift @@ -16,7 +16,7 @@ import NanoViewControllerCore /// /// Subscriptions started inside `transform`, plus the navigation publisher, /// are returned in the resulting ``Output`` and consumed by -/// ``SceneController`` for the lifetime of the scene — `AbstractViewModel` +/// ``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: @@ -28,7 +28,7 @@ import NanoViewControllerCore /// /// 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 ``SceneController`` (embedded sub-views, +/// View-models that don't run on a ``NanoViewController`` (embedded sub-views, /// for example) should conform to ``ViewModelType`` directly without /// subclassing `AbstractViewModel`. /// @@ -93,7 +93,7 @@ import NanoViewControllerCore /// /// `@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 +/// they're owned by `NanoViewController` (a `UIViewController` subclass) and /// their `transform(input:)` runs on the main actor. @MainActor open class AbstractViewModel< @@ -104,7 +104,7 @@ open class AbstractViewModel< /// The concrete ``InputType`` Swift synthesizes for each /// `AbstractViewModel` specialisation. /// - /// `SceneController` constructs this struct by combining the View's + /// `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 { @@ -116,7 +116,7 @@ open class AbstractViewModel< /// Designated initializer. /// - /// `SceneController` calls this to stitch together the two input + /// `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) { diff --git a/Sources/NanoViewControllerController/BarButtonContent.swift b/Sources/NanoViewControllerController/BarButtonContent.swift index e3bc1cb..64b132b 100644 --- a/Sources/NanoViewControllerController/BarButtonContent.swift +++ b/Sources/NanoViewControllerController/BarButtonContent.swift @@ -12,12 +12,11 @@ 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) +/// ) /// } /// ``` /// @@ -128,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/ControllerConfig.swift b/Sources/NanoViewControllerController/ControllerConfig.swift new file mode 100644 index 0000000..8a08000 --- /dev/null +++ b/Sources/NanoViewControllerController/ControllerConfig.swift @@ -0,0 +1,58 @@ +// MIT License — Copyright (c) 2018-2026 Alexander Cyon (github.com/sajjon) + +/// 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 + } +} + +/// 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+Scene+NavigationHelpers.swift b/Sources/NanoViewControllerController/Coordinating+Scene+NavigationHelpers.swift index 89750b5..e5568cb 100644 --- a/Sources/NanoViewControllerController/Coordinating+Scene+NavigationHelpers.swift +++ b/Sources/NanoViewControllerController/Coordinating+Scene+NavigationHelpers.swift @@ -11,7 +11,7 @@ extension Coordinating { /// /// Used by the push-style hookup in /// ``Coordinating/pushSceneInstance(_:animated:navigationPresentationCompletion:navigationHandler:)``. - func subscribeToNavigation, V: ContentView>( + func subscribeToNavigation, V: ContentView>( of scene: S, handler: @escaping (V.ViewModel.NavigationStep) -> Void ) { @@ -25,7 +25,7 @@ extension Coordinating { /// the scene 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>( + func subscribeToModalNavigation, V: ContentView>( of scene: S, handler: @escaping NavigationHandlerModalScene ) { diff --git a/Sources/NanoViewControllerController/Coordinating+Scene+Present.swift b/Sources/NanoViewControllerController/Coordinating+Scene+Present.swift index e943e12..589e1ce 100644 --- a/Sources/NanoViewControllerController/Coordinating+Scene+Present.swift +++ b/Sources/NanoViewControllerController/Coordinating+Scene+Present.swift @@ -31,7 +31,7 @@ public extension Coordinating { /// - 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, @@ -62,7 +62,7 @@ public extension Coordinating { /// - presentationCompletion: Fires after presentation completes. /// - navigationHandler: Step-handling closure. Use the trailing /// ``DismissScene`` to dismiss. - func modallyPresent, V: ContentView>( + func modallyPresent, V: ContentView>( scene: S, animated: Bool = true, presentationCompletion: Completion? = nil, diff --git a/Sources/NanoViewControllerController/Coordinating+Scene+Push.swift b/Sources/NanoViewControllerController/Coordinating+Scene+Push.swift index 4d42642..baa83f2 100644 --- a/Sources/NanoViewControllerController/Coordinating+Scene+Push.swift +++ b/Sources/NanoViewControllerController/Coordinating+Scene+Push.swift @@ -43,7 +43,7 @@ public extension Coordinating { /// - 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, @@ -72,7 +72,7 @@ public extension Coordinating { /// - navigationPresentationCompletion: Fires after the push transition. /// - navigationHandler: Pattern-match on `V.ViewModel.NavigationStep` /// and route each case. - func pushSceneInstance, V: ContentView>( + func pushSceneInstance, V: ContentView>( _ scene: S, animated: Bool = true, navigationPresentationCompletion: Completion? = nil, diff --git a/Sources/NanoViewControllerController/Coordinating+Scene+Replace.swift b/Sources/NanoViewControllerController/Coordinating+Scene+Replace.swift index 1185578..2014b69 100644 --- a/Sources/NanoViewControllerController/Coordinating+Scene+Replace.swift +++ b/Sources/NanoViewControllerController/Coordinating+Scene+Replace.swift @@ -48,7 +48,7 @@ public extension Coordinating { /// } /// } /// ``` - func replaceAllScenes, V: ContentView>( + func replaceAllScenes, V: ContentView>( with _: S.Type, viewModel: V.ViewModel, animated: Bool = true, @@ -70,7 +70,7 @@ public extension Coordinating { /// ``replaceAllScenes(with:viewModel:animated:whenReplacingFinished:navigationHandler:)``. /// /// Use when you already have a scene instance. - func replaceAllScenes, V: ContentView>( + func replaceAllScenes, V: ContentView>( with scene: S, animated: Bool = true, whenReplacingFinished: Completion? = nil, diff --git a/Sources/NanoViewControllerController/InputFromController.swift b/Sources/NanoViewControllerController/InputFromController.swift index 6491b96..f3751bc 100644 --- a/Sources/NanoViewControllerController/InputFromController.swift +++ b/Sources/NanoViewControllerController/InputFromController.swift @@ -7,7 +7,7 @@ import Foundation /// receives. /// /// 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. /// @@ -45,7 +45,7 @@ import Foundation /// } /// ``` /// -/// 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 +74,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 +85,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/NanoViewControllerController/InputType.swift b/Sources/NanoViewControllerController/InputType.swift index 4e7586a..e5955e5 100644 --- a/Sources/NanoViewControllerController/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 @@ -99,7 +99,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 { @@ -111,12 +111,12 @@ public protocol InputType { var fromView: FromView { get } /// The controller channel — pinned to ``InputFromController``, the - /// concrete write-back surface every ``SceneController`` builds. + /// 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`. diff --git a/Sources/NanoViewControllerController/LeftBarButtonContentMaking.swift b/Sources/NanoViewControllerController/LeftBarButtonContentMaking.swift deleted file mode 100644 index 7aa1aac..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: NanoViewController) { - viewController.setLeftBarButtonUsing(content: Self.makeLeftContent) - } -} diff --git a/Sources/NanoViewControllerController/NanoViewController+BarButtonContent.swift b/Sources/NanoViewControllerController/NanoViewController+BarButtonContent.swift index b296970..f949730 100644 --- a/Sources/NanoViewControllerController/NanoViewController+BarButtonContent.swift +++ b/Sources/NanoViewControllerController/NanoViewController+BarButtonContent.swift @@ -13,7 +13,7 @@ public extension NanoViewController { /// 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 NanoViewController { /// ## Example — imperative use from a custom subclass /// /// ```swift - /// final class CustomScene: NanoViewController, 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/NanoViewController.swift b/Sources/NanoViewControllerController/NanoViewController.swift index 58930c9..14161aa 100644 --- a/Sources/NanoViewControllerController/NanoViewController.swift +++ b/Sources/NanoViewControllerController/NanoViewController.swift @@ -21,9 +21,8 @@ import UIKit /// ``ViewModelled/populate(with:)``. /// /// This is the load-bearing class of the package — coordinators push instances -/// of `NanoViewController<…>` 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 /// @@ -38,9 +37,8 @@ import UIKit /// } /// ``` /// -/// `WelcomeScene` is just a `Scene` typealias — there is *no* -/// hand-written controller class for the welcome screen. `NanoViewController` -/// is doing all the work generically. +/// `WelcomeScene` can be an empty `NanoViewController` subclass. +/// `NanoViewController` is doing all the work generically. /// /// ## Subclassing — when (rarely) needed /// @@ -48,14 +46,17 @@ 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: NanoViewController, 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 NanoViewController: UIViewController { @@ -117,6 +118,15 @@ open class NanoViewController: UIViewController { .systemBackground } + /// Instance-level chrome configuration. + /// + /// Override this when a scene'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() @@ -165,12 +175,11 @@ open class NanoViewController: UIViewController { /// 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 @@ -186,33 +195,27 @@ open class NanoViewController: UIViewController { 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 @@ -224,7 +227,7 @@ open class NanoViewController: UIViewController { /// previous scene) and forwards the lifecycle event. override open func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - applyLayoutIfNeeded() + applyLayoutIfNeeded(controllerConfig.navigationBarLayout) viewWillAppearSubject.send(()) } @@ -329,7 +332,8 @@ private extension NanoViewController { /// 3. Scene 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( @@ -337,16 +341,18 @@ private extension NanoViewController { ) } - 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 AnyNanoViewController: AnyObject { + var controllerConfig: ControllerConfig { get } +} + +extension NanoViewController: AnyNanoViewController {} 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..300a69a 100644 --- a/Sources/NanoViewControllerController/NavigationBarLayoutingNavigationController.swift +++ b/Sources/NanoViewControllerController/NavigationBarLayoutingNavigationController.swift @@ -31,7 +31,7 @@ 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? @@ -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. /// /// - 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? AnyNanoViewController, + 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 1ad8c36..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: NanoViewController) { - 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/Scene.swift b/Sources/NanoViewControllerController/Scene.swift index 7096f36..f122986 100644 --- a/Sources/NanoViewControllerController/Scene.swift +++ b/Sources/NanoViewControllerController/Scene.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 /// @@ -50,35 +50,6 @@ import UIKit /// } /// /// // 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. -/// -/// 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 = NanoViewController & TitledScene - -@available(*, deprecated, renamed: "NanoViewController") -public typealias SceneController = NanoViewController 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 index 8106d71..effb199 100644 --- a/Sources/NanoViewControllerController/ViewModelType.swift +++ b/Sources/NanoViewControllerController/ViewModelType.swift @@ -11,15 +11,15 @@ import NanoViewControllerCore /// carries its navigation channel as part of the return value rather than as a /// stored property. 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 +/// 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. ``SceneController`` retains the cancellables, exposes `navigation` for +/// 5. ``NanoViewController`` retains the cancellables, exposes `navigation` for /// the coordinator, and forwards `publishers` to the view's `populate`. /// /// ## Why "pure" transform? @@ -94,11 +94,11 @@ import NanoViewControllerCore /// /// The view binds the publishers in `populate(with:)` (see ``ViewModelled``). /// The coordinator subscribes to the navigation publisher exposed by the -/// hosting ``SceneController`` and routes `.signedUp(user)` to whatever +/// 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 `SceneController` (a `UIViewController` +/// from, and torn down with a `NanoViewController` (a `UIViewController` /// subclass), all of which run on the main actor. @MainActor public protocol ViewModelType { @@ -121,7 +121,7 @@ public protocol ViewModelType { /// Runs the ViewModel's business logic. /// - /// Called exactly once per instance, typically by ``SceneController`` + /// Called exactly once per instance, typically by ``NanoViewController`` /// during scene construction. Implementations: /// /// * Wire `input.fromView` and `input.fromController` publishers into diff --git a/Sources/NanoViewControllerController/ViewModelled.swift b/Sources/NanoViewControllerController/ViewModelled.swift index 188fc6a..95b5c17 100644 --- a/Sources/NanoViewControllerController/ViewModelled.swift +++ b/Sources/NanoViewControllerController/ViewModelled.swift @@ -8,7 +8,7 @@ import NanoViewControllerCore /// reactive MVVM pipeline. /// /// A `ViewModelled` view exposes its user-driven publishers as -/// ``inputFromView`` (read by ``SceneController``), and binds the ViewModel'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. @@ -62,7 +62,7 @@ import NanoViewControllerCore /// } /// ``` /// -/// `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:)` *and* every cancellable carried /// in the `Output` from `transform` on its own `cancellables` bag. @@ -86,7 +86,7 @@ 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 publishers: The publisher bag carried in the ``Output`` /// returned from ``ViewModelType/transform(input:)``. @@ -113,4 +113,3 @@ public extension ViewModelled { [] } } - diff --git a/Sources/NanoViewControllerCore/AbstractTarget.swift b/Sources/NanoViewControllerCore/AbstractTarget.swift index c71c978..ec5017d 100644 --- a/Sources/NanoViewControllerCore/AbstractTarget.swift +++ b/Sources/NanoViewControllerCore/AbstractTarget.swift @@ -12,7 +12,7 @@ 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 — +/// ``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 ``InputFromController``. /// @@ -58,9 +58,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 +/// ``NanoViewController`` already exposes +/// ``NanoViewController/leftBarButtonAbstractTarget`` and +/// ``NanoViewController/rightBarButtonAbstractTarget`` for navigation bar /// buttons, and pure Combine extensions on `UIControl` (see /// `UIControl+Publisher.swift`) cover regular controls. /// @@ -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/EmptyInitializable.swift b/Sources/NanoViewControllerCore/EmptyInitializable.swift index 19fae11..8dc8ad0 100644 --- a/Sources/NanoViewControllerCore/EmptyInitializable.swift +++ b/Sources/NanoViewControllerCore/EmptyInitializable.swift @@ -4,7 +4,7 @@ import Foundation /// Marker protocol asserting "this type can be constructed with no arguments". /// -/// ``SceneController`` instantiates the root content view via +/// ``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: @@ -38,7 +38,7 @@ import Foundation /// 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 +55,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/Output.swift b/Sources/NanoViewControllerCore/Output.swift index 5d8afde..0397440 100644 --- a/Sources/NanoViewControllerCore/Output.swift +++ b/Sources/NanoViewControllerCore/Output.swift @@ -79,7 +79,7 @@ public struct Output { public let navigation: AnyPublisher /// Subscriptions started inside `transform` that must outlive the call. - /// ``SceneController`` stores these in its own bag so they live as long + /// ``NanoViewController`` stores these in its own bag so they live as long /// as the scene. public let cancellables: [AnyCancellable] diff --git a/Sources/NanoViewControllerNavigation/Navigating.swift b/Sources/NanoViewControllerNavigation/Navigating.swift index 8392e1d..2f79f4f 100644 --- a/Sources/NanoViewControllerNavigation/Navigating.swift +++ b/Sources/NanoViewControllerNavigation/Navigating.swift @@ -10,7 +10,7 @@ /// 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 ``SceneController`` +/// from ``ViewModelType/transform(input:)`` instead — see ``NanoViewController`` /// for how the coordinator subscribes. /// /// ## Example — child coordinator emitting flow-completion steps diff --git a/Sources/NanoViewControllerSceneViews/BaseScrollableStackViewOwner.swift b/Sources/NanoViewControllerSceneViews/BaseScrollableStackViewOwner.swift index 369b6d1..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. /// @@ -65,13 +65,13 @@ import UIKit /// } /// } /// -/// // 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/Tests/NanoViewControllerControllerTests/NanoViewControllerConfigTests.swift b/Tests/NanoViewControllerControllerTests/NanoViewControllerConfigTests.swift new file mode 100644 index 0000000..408f2cb --- /dev/null +++ b/Tests/NanoViewControllerControllerTests/NanoViewControllerConfigTests.swift @@ -0,0 +1,121 @@ +// 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_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 + ) + } +} From faf1ed54aaff0ef604a68a379674a50765af616a Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Sat, 16 May 2026 19:29:52 +0200 Subject: [PATCH 07/11] Clean up controller naming --- README.md | 2 +- .../{Scene.swift => ContentView.swift} | 0 ...NanoViewController+NavigationHelpers.swift} | 8 ++++---- ...rdinating+NanoViewController+Present.swift} | 18 +++++++++--------- ...Coordinating+NanoViewController+Push.swift} | 10 +++++----- ...rdinating+NanoViewController+Replace.swift} | 16 ++++++++-------- .../Coordinating+NavigationStack.swift | 10 +++++----- .../NanoViewController.swift | 8 ++++---- ...ationBarLayoutingNavigationController.swift | 8 ++++---- .../ViewModelled.swift | 2 +- 10 files changed, 41 insertions(+), 41 deletions(-) rename Sources/NanoViewControllerController/{Scene.swift => ContentView.swift} (100%) rename Sources/NanoViewControllerController/{Coordinating+Scene+NavigationHelpers.swift => Coordinating+NanoViewController+NavigationHelpers.swift} (81%) rename Sources/NanoViewControllerController/{Coordinating+Scene+Present.swift => Coordinating+NanoViewController+Present.swift} (80%) rename Sources/NanoViewControllerController/{Coordinating+Scene+Push.swift => Coordinating+NanoViewController+Push.swift} (90%) rename Sources/NanoViewControllerController/{Coordinating+Scene+Replace.swift => Coordinating+NanoViewController+Replace.swift} (91%) diff --git a/README.md b/README.md index 4242991..641b0fd 100644 --- a/README.md +++ b/README.md @@ -163,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 = `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 scene's `.navigation`) 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/NanoViewControllerController/Scene.swift b/Sources/NanoViewControllerController/ContentView.swift similarity index 100% rename from Sources/NanoViewControllerController/Scene.swift rename to Sources/NanoViewControllerController/ContentView.swift diff --git a/Sources/NanoViewControllerController/Coordinating+Scene+NavigationHelpers.swift b/Sources/NanoViewControllerController/Coordinating+NanoViewController+NavigationHelpers.swift similarity index 81% rename from Sources/NanoViewControllerController/Coordinating+Scene+NavigationHelpers.swift rename to Sources/NanoViewControllerController/Coordinating+NanoViewController+NavigationHelpers.swift index e5568cb..aad46fd 100644 --- a/Sources/NanoViewControllerController/Coordinating+Scene+NavigationHelpers.swift +++ b/Sources/NanoViewControllerController/Coordinating+NanoViewController+NavigationHelpers.swift @@ -6,7 +6,7 @@ import NanoViewControllerCore import NanoViewControllerNavigation extension Coordinating { - /// Subscribes the coordinator to the scene's navigation publisher and + /// 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 @@ -20,14 +20,14 @@ extension Coordinating { .store(in: &cancellables) } - /// Subscribes the coordinator to the scene's navigation publisher and + /// Subscribes the coordinator to the controller's navigation publisher and /// hands the caller's handler a `DismissScene` callback that dismisses - /// the scene with optional animation. Used by the modal-style hookups in + /// 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 NavigationHandlerModalScene + handler: @escaping ModalNavigationHandler ) { scene.navigation .sinkOnMain { [weak scene] step in diff --git a/Sources/NanoViewControllerController/Coordinating+Scene+Present.swift b/Sources/NanoViewControllerController/Coordinating+NanoViewController+Present.swift similarity index 80% rename from Sources/NanoViewControllerController/Coordinating+Scene+Present.swift rename to Sources/NanoViewControllerController/Coordinating+NanoViewController+Present.swift index 589e1ce..717c108 100644 --- a/Sources/NanoViewControllerController/Coordinating+Scene+Present.swift +++ b/Sources/NanoViewControllerController/Coordinating+NanoViewController+Present.swift @@ -7,10 +7,10 @@ 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( @@ -24,7 +24,7 @@ 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`. @@ -36,7 +36,7 @@ public extension Coordinating { viewModel: V.ViewModel, animated: Bool = true, presentationCompletion: Completion? = nil, - navigationHandler: @escaping NavigationHandlerModalScene + navigationHandler: @escaping ModalNavigationHandler ) { let scene = S(viewModel: viewModel) modallyPresent( @@ -50,14 +50,14 @@ 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 @@ -66,10 +66,10 @@ public extension Coordinating { scene: S, animated: Bool = true, presentationCompletion: Completion? = nil, - navigationHandler: @escaping NavigationHandlerModalScene + 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) diff --git a/Sources/NanoViewControllerController/Coordinating+Scene+Push.swift b/Sources/NanoViewControllerController/Coordinating+NanoViewController+Push.swift similarity index 90% rename from Sources/NanoViewControllerController/Coordinating+Scene+Push.swift rename to Sources/NanoViewControllerController/Coordinating+NanoViewController+Push.swift index baa83f2..c927351 100644 --- a/Sources/NanoViewControllerController/Coordinating+Scene+Push.swift +++ b/Sources/NanoViewControllerController/Coordinating+NanoViewController+Push.swift @@ -7,12 +7,12 @@ 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 { @@ -37,7 +37,7 @@ 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. @@ -64,10 +64,10 @@ public extension Coordinating { /// 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` diff --git a/Sources/NanoViewControllerController/Coordinating+Scene+Replace.swift b/Sources/NanoViewControllerController/Coordinating+NanoViewController+Replace.swift similarity index 91% rename from Sources/NanoViewControllerController/Coordinating+Scene+Replace.swift rename to Sources/NanoViewControllerController/Coordinating+NanoViewController+Replace.swift index 2014b69..a584aa0 100644 --- a/Sources/NanoViewControllerController/Coordinating+Scene+Replace.swift +++ b/Sources/NanoViewControllerController/Coordinating+NanoViewController+Replace.swift @@ -10,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 @@ -23,9 +23,9 @@ public extension Coordinating { /// } /// } /// ``` - typealias NavigationHandlerModalScene = (VM.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 @@ -53,9 +53,9 @@ public extension Coordinating { viewModel: V.ViewModel, animated: Bool = true, whenReplacingFinished: Completion? = nil, - navigationHandler: @escaping NavigationHandlerModalScene + navigationHandler: @escaping ModalNavigationHandler ) { - // Create a new instance of the `Scene`, injecting its ViewModel + // Create a new instance of the controller, injecting its ViewModel. let scene = S(viewModel: viewModel) replaceAllScenes( @@ -69,12 +69,12 @@ public extension Coordinating { /// Instance-level variant of /// ``replaceAllScenes(with:viewModel:animated:whenReplacingFinished:navigationHandler:)``. /// - /// Use when you already have a scene instance. + /// Use when you already have a controller instance. func replaceAllScenes, V: ContentView>( with scene: S, animated: Bool = true, whenReplacingFinished: Completion? = nil, - navigationHandler: @escaping NavigationHandlerModalScene + navigationHandler: @escaping ModalNavigationHandler ) { let oldVCs = navigationController.viewControllers @@ -101,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/NanoViewController.swift b/Sources/NanoViewControllerController/NanoViewController.swift index 14161aa..4b6859b 100644 --- a/Sources/NanoViewControllerController/NanoViewController.swift +++ b/Sources/NanoViewControllerController/NanoViewController.swift @@ -120,7 +120,7 @@ open class NanoViewController: UIViewController { /// Instance-level chrome configuration. /// - /// Override this when a scene's chrome depends on construction-time state. + /// 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 { @@ -329,7 +329,7 @@ private extension NanoViewController { /// 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(_ layout: NavigationBarLayout?) { @@ -351,8 +351,8 @@ private extension NanoViewController { } @MainActor -protocol AnyNanoViewController: AnyObject { +protocol ControllerConfigReadable: AnyObject { var controllerConfig: ControllerConfig { get } } -extension NanoViewController: AnyNanoViewController {} +extension NanoViewController: ControllerConfigReadable {} diff --git a/Sources/NanoViewControllerController/NavigationBarLayoutingNavigationController.swift b/Sources/NanoViewControllerController/NavigationBarLayoutingNavigationController.swift index 300a69a..4de3962 100644 --- a/Sources/NanoViewControllerController/NavigationBarLayoutingNavigationController.swift +++ b/Sources/NanoViewControllerController/NavigationBarLayoutingNavigationController.swift @@ -38,7 +38,7 @@ public final class NavigationBarLayoutingNavigationController: UINavigationContr // 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) { @@ -106,12 +106,12 @@ public extension NavigationBarLayoutingNavigationController { /// /// 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 = viewController as? AnyNanoViewController, + let viewController = viewController as? ControllerConfigReadable, let navigationBarLayout = viewController.controllerConfig.navigationBarLayout else { return diff --git a/Sources/NanoViewControllerController/ViewModelled.swift b/Sources/NanoViewControllerController/ViewModelled.swift index 95b5c17..07bb8be 100644 --- a/Sources/NanoViewControllerController/ViewModelled.swift +++ b/Sources/NanoViewControllerController/ViewModelled.swift @@ -4,7 +4,7 @@ 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 From abb5626f59f6a1f13d06fe32ad0b631bcc8bf6e5 Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Sat, 16 May 2026 20:01:35 +0200 Subject: [PATCH 08/11] Address PR review docs --- README.md | 4 +- .../InputFromController.swift | 53 ++++++++++--------- .../NanoViewController.swift | 5 +- .../ActivityIndicator.swift | 47 +++++++++------- .../NanoViewControllerCore/ErrorTracker.swift | 44 ++++++++------- .../AbstractViewModelTests.swift | 6 +-- 6 files changed, 89 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 641b0fd..99bd6b4 100644 --- a/README.md +++ b/README.md @@ -120,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 | `NanoViewController`, `ControllerConfig`, `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` | diff --git a/Sources/NanoViewControllerController/InputFromController.swift b/Sources/NanoViewControllerController/InputFromController.swift index f3751bc..eff711a 100644 --- a/Sources/NanoViewControllerController/InputFromController.swift +++ b/Sources/NanoViewControllerController/InputFromController.swift @@ -3,44 +3,49 @@ 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 /// ``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 { +/// 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") } +/// } /// } /// } /// ``` diff --git a/Sources/NanoViewControllerController/NanoViewController.swift b/Sources/NanoViewControllerController/NanoViewController.swift index 4b6859b..b29f571 100644 --- a/Sources/NanoViewControllerController/NanoViewController.swift +++ b/Sources/NanoViewControllerController/NanoViewController.swift @@ -152,8 +152,9 @@ open class NanoViewController: UIViewController { /// 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) { diff --git a/Sources/NanoViewControllerCore/ActivityIndicator.swift b/Sources/NanoViewControllerCore/ActivityIndicator.swift index 0f7a0ce..fca6fbb 100644 --- a/Sources/NanoViewControllerCore/ActivityIndicator.swift +++ b/Sources/NanoViewControllerCore/ActivityIndicator.swift @@ -22,32 +22,39 @@ import Foundation /// import Combine /// import NanoViewControllerCore /// -/// 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/NanoViewControllerCore/ErrorTracker.swift b/Sources/NanoViewControllerCore/ErrorTracker.swift index 94912ab..2bb4b1f 100644 --- a/Sources/NanoViewControllerCore/ErrorTracker.swift +++ b/Sources/NanoViewControllerCore/ErrorTracker.swift @@ -22,8 +22,12 @@ import Foundation /// ## Example — global error toast plus per-chain typed handling /// /// ```swift -/// final class HomeViewModel: BaseViewModel { -/// override func transform(input: Input) -> HomeOutput { +/// final class HomeViewModel: AbstractViewModel< +/// HomeInputFromView, +/// HomeViewModel.Publishers, +/// Never +/// > { +/// override func transform(input: Input) -> Output { /// let activity = ActivityIndicator() /// let errors = ErrorTracker() /// @@ -43,20 +47,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 +153,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/Tests/NanoViewControllerControllerTests/AbstractViewModelTests.swift b/Tests/NanoViewControllerControllerTests/AbstractViewModelTests.swift index 16449bb..ea73517 100644 --- a/Tests/NanoViewControllerControllerTests/AbstractViewModelTests.swift +++ b/Tests/NanoViewControllerControllerTests/AbstractViewModelTests.swift @@ -11,9 +11,9 @@ import XCTest /// ``Output`` carrying the publisher bag, the navigation publisher, and the /// subscriptions started inside `transform`. /// -/// `AbstractViewModel` pins `FromController` to ``InputFromController``; -/// these tests construct a stub `InputFromController` filled with `Empty()` -/// publishers and `PassthroughSubject`s where the wiring matters. +/// `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 } From 38d56f64d800e164916e13b3fdaa977f82aac3a2 Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Sun, 17 May 2026 12:21:08 +0200 Subject: [PATCH 09/11] Add localized controller config title init --- .../ControllerConfig.swift | 26 +++++++++++++++++++ .../NanoViewControllerConfigTests.swift | 25 ++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/Sources/NanoViewControllerController/ControllerConfig.swift b/Sources/NanoViewControllerController/ControllerConfig.swift index 8a08000..d184510 100644 --- a/Sources/NanoViewControllerController/ControllerConfig.swift +++ b/Sources/NanoViewControllerController/ControllerConfig.swift @@ -1,5 +1,7 @@ // 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, @@ -37,6 +39,30 @@ public struct ControllerConfig: Sendable { 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. diff --git a/Tests/NanoViewControllerControllerTests/NanoViewControllerConfigTests.swift b/Tests/NanoViewControllerControllerTests/NanoViewControllerConfigTests.swift index 408f2cb..9f5d0e3 100644 --- a/Tests/NanoViewControllerControllerTests/NanoViewControllerConfigTests.swift +++ b/Tests/NanoViewControllerControllerTests/NanoViewControllerConfigTests.swift @@ -8,6 +8,22 @@ 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()) @@ -119,3 +135,12 @@ private extension NavigationBarLayout { ) } } + +private extension BarButtonContent { + var systemItem: UIBarButtonItem.SystemItem? { + guard case let .system(systemItem) = type else { + return nil + } + return systemItem + } +} From bc0e78097c5ffb32099c5a209a57e3e04083336d Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Sun, 17 May 2026 19:38:03 +0200 Subject: [PATCH 10/11] Raise controller test coverage --- .../UIControlPublisherTests.swift | 59 ++- .../CoordinatorHelperTests.swift | 339 ++++++++++++++++++ .../NanoViewControllerBehaviorTests.swift | 249 +++++++++++++ .../NavigationBarAndToastTests.swift | 173 +++++++++ .../TestSupport.swift | 8 + .../NanoViewControllerCoreSmokeTests.swift | 13 + 6 files changed, 810 insertions(+), 31 deletions(-) create mode 100644 Tests/NanoViewControllerControllerTests/CoordinatorHelperTests.swift create mode 100644 Tests/NanoViewControllerControllerTests/NanoViewControllerBehaviorTests.swift create mode 100644 Tests/NanoViewControllerControllerTests/NavigationBarAndToastTests.swift create mode 100644 Tests/NanoViewControllerControllerTests/TestSupport.swift 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/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/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/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) + } } From 6c8e87e25b107ce81328a5f3abf03d191bbe5a98 Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Mon, 18 May 2026 20:52:35 +0200 Subject: [PATCH 11/11] Clarify cross-module docs --- .../InputFromController.swift | 6 +++++ .../InputType.swift | 8 +++++++ .../AbstractTarget.swift | 14 +++++------ .../ActivityIndicator.swift | 3 +++ .../BindingsBuilder.swift | 11 +++++---- .../EmptyInitializable.swift | 12 ++++++---- .../NanoViewControllerCore/ErrorTracker.swift | 5 ++++ .../Never+Helpers.swift | 5 ++-- Sources/NanoViewControllerCore/Output.swift | 24 ++++++++++++------- 9 files changed, 61 insertions(+), 27 deletions(-) diff --git a/Sources/NanoViewControllerController/InputFromController.swift b/Sources/NanoViewControllerController/InputFromController.swift index eff711a..1075dd2 100644 --- a/Sources/NanoViewControllerController/InputFromController.swift +++ b/Sources/NanoViewControllerController/InputFromController.swift @@ -14,6 +14,12 @@ import Foundation /// ## Example — using all four directions in a single ViewModel transform /// /// ```swift +/// import Combine +/// import NanoViewControllerCombine +/// import NanoViewControllerController +/// import NanoViewControllerCore +/// import NanoViewControllerNavigation +/// /// final class HomeViewModel: AbstractViewModel< /// HomeInputFromView, /// HomeViewModel.Publishers, diff --git a/Sources/NanoViewControllerController/InputType.swift b/Sources/NanoViewControllerController/InputType.swift index e5955e5..26f3c3f 100644 --- a/Sources/NanoViewControllerController/InputType.swift +++ b/Sources/NanoViewControllerController/InputType.swift @@ -23,6 +23,11 @@ import Foundation /// ## Example — synthesised Input on an `AbstractViewModel` subclass /// /// ```swift +/// import Combine +/// import NanoViewControllerCombine +/// import NanoViewControllerController +/// import NanoViewControllerCore +/// /// final class HomeViewModel: AbstractViewModel< /// HomeInputFromView, /// HomeViewModel.Publishers, @@ -59,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("") diff --git a/Sources/NanoViewControllerCore/AbstractTarget.swift b/Sources/NanoViewControllerCore/AbstractTarget.swift index ec5017d..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. /// -/// ``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 ``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 — -/// ``NanoViewController`` already exposes -/// ``NanoViewController/leftBarButtonAbstractTarget`` and -/// ``NanoViewController/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 diff --git a/Sources/NanoViewControllerCore/ActivityIndicator.swift b/Sources/NanoViewControllerCore/ActivityIndicator.swift index fca6fbb..febd432 100644 --- a/Sources/NanoViewControllerCore/ActivityIndicator.swift +++ b/Sources/NanoViewControllerCore/ActivityIndicator.swift @@ -20,7 +20,10 @@ import Foundation /// /// ```swift /// import Combine +/// import NanoViewControllerCombine +/// import NanoViewControllerController /// import NanoViewControllerCore +/// import NanoViewControllerNavigation /// /// final class SignUpViewModel: AbstractViewModel< /// SignUpInputFromView, diff --git a/Sources/NanoViewControllerCore/BindingsBuilder.swift b/Sources/NanoViewControllerCore/BindingsBuilder.swift index b0f8f90..8c36d1c 100644 --- a/Sources/NanoViewControllerCore/BindingsBuilder.swift +++ b/Sources/NanoViewControllerCore/BindingsBuilder.swift @@ -3,7 +3,8 @@ 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: /// @@ -60,10 +61,10 @@ import Combine /// } /// ``` /// -/// ``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 8dc8ad0..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". /// -/// ``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: +/// ``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,12 +29,14 @@ 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 /// diff --git a/Sources/NanoViewControllerCore/ErrorTracker.swift b/Sources/NanoViewControllerCore/ErrorTracker.swift index 2bb4b1f..2eea8f3 100644 --- a/Sources/NanoViewControllerCore/ErrorTracker.swift +++ b/Sources/NanoViewControllerCore/ErrorTracker.swift @@ -22,6 +22,11 @@ import Foundation /// ## Example — global error toast plus per-chain typed handling /// /// ```swift +/// import Combine +/// import NanoViewControllerCombine +/// import NanoViewControllerController +/// import NanoViewControllerCore +/// /// final class HomeViewModel: AbstractViewModel< /// HomeInputFromView, /// HomeViewModel.Publishers, 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 index 0397440..c388534 100644 --- a/Sources/NanoViewControllerCore/Output.swift +++ b/Sources/NanoViewControllerCore/Output.swift @@ -2,14 +2,16 @@ import Combine -/// What every ViewModel's ``ViewModelType/transform(input:)`` returns: the -/// `Publishers` bag the view binds to UI controls, the navigation publisher +/// 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 `populate(with:)`, -/// and `NavigationStep` is the enum the coordinator pattern-matches on. +/// 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. @@ -17,6 +19,10 @@ import Combine /// ## Example — at the call site /// /// ```swift +/// import Combine +/// import NanoViewControllerCore +/// import NanoViewControllerNavigation +/// /// public extension SignUpViewModel { /// struct Publishers { /// let isSubmitEnabled: AnyPublisher @@ -70,17 +76,19 @@ import Combine /// `Combine.Publishers.X`. @MainActor public struct Output { - /// The publisher bag the view binds to UI controls via ``ViewModelled/populate(with:)``. + /// 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 - /// ``Navigator``) — the coordinator pattern-matches on the cases. + /// ``NanoViewControllerNavigation/Navigator``) — the coordinator + /// pattern-matches on the cases. public let navigation: AnyPublisher /// Subscriptions started inside `transform` that must outlive the call. - /// ``NanoViewController`` stores these in its own bag so they live as long - /// as the scene. + /// ``NanoViewControllerController/NanoViewController`` stores these in its + /// own bag so they live as long as the scene. public let cancellables: [AnyCancellable] /// Designated initializer.