From 0b7382d99a75851572784a6f4fa61cb05d284e5f Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Wed, 13 May 2026 12:09:55 +0200 Subject: [PATCH 1/3] polish --- .../Sources/Onboarding/SignUpViewModel.swift | 86 ++++++------ README.md | 130 ++++++++++++++++++ 2 files changed, 175 insertions(+), 41 deletions(-) diff --git a/Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift b/Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift index 485a692..db9bcf3 100644 --- a/Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift +++ b/Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift @@ -12,6 +12,45 @@ public enum SignUpUserAction: Sendable { case signedUp(SignedUpUser) } +// MARK: InputFromView +public extension SignUpViewModel { + /// User-event publishers the view streams in. + struct InputFromView { + public let name: AnyPublisher + public let email: AnyPublisher + public let submitTrigger: AnyPublisher + + public init( + name: AnyPublisher, + email: AnyPublisher, + submitTrigger: AnyPublisher + ) { + self.name = name + self.email = email + self.submitTrigger = submitTrigger + } + } +} + +// MARK: Output +public extension SignUpViewModel { + /// Reactive bindings the view installs. + struct Output { + /// Drives the Sign Up button's `isEnabled` (`isFormValid && !isLoading`). + public let isSubmitEnabled: AnyPublisher + + /// `true` while the sign-up service call is in flight. The view + /// reflects this on a `UIActivityIndicatorView` overlaid on the + /// submit button. + public let isLoading: AnyPublisher + } +} +public extension SignUpViewModel.Output { + var loadingText: AnyPublisher { + isLoading.map { $0 ? "" : "Sign Up" }.eraseToAnyPublisher() + } +} + /// Drives `SignUpView`: validates the (very loose) name + email rules, /// gates the submit button, and on tap calls the injected service. The /// returned user is forwarded as `.signedUp` to the parent coordinator. @@ -20,13 +59,14 @@ public final class SignUpViewModel: BaseViewModel< SignUpViewModel.InputFromView, SignUpViewModel.Output > { - private let service: SignUpServicing + private let service: SignUpServicing - public init(service: SignUpServicing) { - self.service = service - super.init() - } + public init(service: SignUpServicing) { + self.service = service + super.init() + } + // MARK: BaseViewModel Overrides 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. @@ -70,39 +110,3 @@ public final class SignUpViewModel: BaseViewModel< ) } } - -public extension SignUpViewModel.Output { - var loadingText: AnyPublisher { - isLoading.map { $0 ? "" : "Sign Up" }.eraseToAnyPublisher() - } -} - -public extension SignUpViewModel { - /// User-event publishers the view streams in. - struct InputFromView { - public let name: AnyPublisher - public let email: AnyPublisher - public let submitTrigger: AnyPublisher - - public init( - name: AnyPublisher, - email: AnyPublisher, - submitTrigger: AnyPublisher - ) { - self.name = name - self.email = email - self.submitTrigger = submitTrigger - } - } - - /// Reactive bindings the view installs. - struct Output { - /// Drives the Sign Up button's `isEnabled` (`isFormValid && !isLoading`). - public let isSubmitEnabled: AnyPublisher - - /// `true` while the sign-up service call is in flight. The view - /// reflects this on a `UIActivityIndicatorView` overlaid on the - /// submit button. - public let isLoading: AnyPublisher - } -} diff --git a/README.md b/README.md index b0de45a..0268b0d 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,136 @@ # NVC: NanoViewController An **extremely** opinionated UIKit architecture built on top of MVVM-C allowing you to create `UIViewController`s declaratively with as little as a single line of code. +## Show me code +```swift +// MARK: NanoViewController +public final class SignUpScene: Scene { // 🤯 3 lines VC! + public static let title = "Sign Up" +} + +// MARK: View +public final class SignUpView { + private lazy var nameField: UITextField = { ... }() + private lazy var emailField: UITextField = { ... }() + private lazy var submitButton: UIButton = { ... }() + private lazy var spinner: UIActivityIndicatorView = { ... }() +} +extension SignUpView: ViewModelled { + public typealias ViewModel = SignUpViewModel + + /// Streams the field text + the button-tap into the ViewModel. Uses the + /// package's `UITextField.textPublisher` (`String?`) lifted to a non-optional + /// `String` via the `orEmpty` helper from `NanoViewControllerCombine`. + public var inputFromView: InputFromView { + InputFromView( + name: nameField.textPublisher.orEmpty, + email: emailField.textPublisher.orEmpty, + submitTrigger: submitButton.tapPublisher + ) + } + + public func populate(with output: ViewModel.Output) -> [AnyCancellable] { + output.isSubmitEnabled --> submitButton.isEnabledBinder + output.loadingText --> submitButton.titleBinder(for: .normal) + output.isLoading --> spinner.isAnimatingBinder + } +} +// MARK: ViewModel + +/// User outcomes the SignUp scene can emit. The coordinator subscribes and +/// decides what happens next (here: `signedUp(_:)` advances to Home). +public enum SignUpUserAction: Sendable { + case signedUp(SignedUpUser) +} + +// MARK: InputFromView +public extension SignUpViewModel { + /// User-event publishers the view streams in. + struct InputFromView { + public let name: AnyPublisher + public let email: AnyPublisher + public let submitTrigger: AnyPublisher + } +} + +// MARK: Output +public extension SignUpViewModel { + /// Reactive bindings the view installs. + struct Output { + /// Drives the Sign Up button's `isEnabled` (`isFormValid && !isLoading`). + public let isSubmitEnabled: AnyPublisher + + /// `true` while the sign-up service call is in flight. The view + /// reflects this on a `UIActivityIndicatorView` overlaid on the + /// submit button. + public let isLoading: AnyPublisher + } +} +public extension SignUpViewModel.Output { + var loadingText: AnyPublisher { + isLoading.map { $0 ? "" : "Sign Up" }.eraseToAnyPublisher() + } +} + +/// Drives `SignUpView`: validates the (very loose) name + email rules, +/// gates the submit button, and on tap calls the injected service. The +/// returned user is forwarded as `.signedUp` to the parent coordinator. +public final class SignUpViewModel: BaseViewModel< + SignUpUserAction, + SignUpViewModel.InputFromView, + SignUpViewModel.Output +> { + private let service: SignUpServicing + /* BaseViewModel declared `public let navigator = Navigator()` */ + /* BaseViewModel declared `public var cancellables = Set()` */ + + // MARK: BaseViewModel Overrides + override public func transform(input: Input) -> Output { + // 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() + + // Name + email both non-empty → form is valid. + let isFormValid: AnyPublisher = input.fromView.name + .combineLatest(input.fromView.email) + .map { name, email in + !name.trimmingCharacters(in: .whitespaces).isEmpty + && !email.trimmingCharacters(in: .whitespaces).isEmpty + } + .eraseToAnyPublisher() + + let isLoading = activity.asPublisher() + + // Submit is enabled only when the form is valid AND we're not already + // mid-request (prevents double-taps from firing two sign-ups). + let isSubmitEnabled = isFormValid + .combineLatest(isLoading) + .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 + ) + } +} + +``` + # Library products The package ships six independent SPM library targets so consumers can pick exactly what they need: From 3ddf73cdaee9cd5437a00fcc2745f742e138ff80 Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Wed, 13 May 2026 20:27:24 +0200 Subject: [PATCH 2/3] update readme --- README.md | 40 ++++++++++++---------------------------- 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 0268b0d..f822987 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ public final class SignUpScene: Scene { // 🤯 3 lines VC! } // MARK: View -public final class SignUpView { +public final class SignUpView: UIView { private lazy var nameField: UITextField = { ... }() private lazy var emailField: UITextField = { ... }() private lazy var submitButton: UIButton = { ... }() @@ -20,9 +20,6 @@ public final class SignUpView { extension SignUpView: ViewModelled { public typealias ViewModel = SignUpViewModel - /// Streams the field text + the button-tap into the ViewModel. Uses the - /// package's `UITextField.textPublisher` (`String?`) lifted to a non-optional - /// `String` via the `orEmpty` helper from `NanoViewControllerCombine`. public var inputFromView: InputFromView { InputFromView( name: nameField.textPublisher.orEmpty, @@ -37,17 +34,9 @@ extension SignUpView: ViewModelled { output.isLoading --> spinner.isAnimatingBinder } } -// MARK: ViewModel -/// User outcomes the SignUp scene can emit. The coordinator subscribes and -/// decides what happens next (here: `signedUp(_:)` advances to Home). -public enum SignUpUserAction: Sendable { - case signedUp(SignedUpUser) -} - -// MARK: InputFromView +// MARK: ViewModel.InputFromView public extension SignUpViewModel { - /// User-event publishers the view streams in. struct InputFromView { public let name: AnyPublisher public let email: AnyPublisher @@ -55,16 +44,10 @@ public extension SignUpViewModel { } } -// MARK: Output +// MARK: ViewModel.Output public extension SignUpViewModel { - /// Reactive bindings the view installs. struct Output { - /// Drives the Sign Up button's `isEnabled` (`isFormValid && !isLoading`). public let isSubmitEnabled: AnyPublisher - - /// `true` while the sign-up service call is in flight. The view - /// reflects this on a `UIActivityIndicatorView` overlaid on the - /// submit button. public let isLoading: AnyPublisher } } @@ -74,11 +57,15 @@ public extension SignUpViewModel.Output { } } -/// Drives `SignUpView`: validates the (very loose) name + email rules, -/// gates the submit button, and on tap calls the injected service. The -/// returned user is forwarded as `.signedUp` to the parent coordinator. +// MARK: NavigationStep +public enum SignUpUserAction: Sendable { + case signedUp(SignedUpUser) +} + + +// MARK: ViewModel.InputFromView public final class SignUpViewModel: BaseViewModel< - SignUpUserAction, + SignUpUserAction, // NavigationStep SignUpViewModel.InputFromView, SignUpViewModel.Output > { @@ -88,8 +75,6 @@ public final class SignUpViewModel: BaseViewModel< // MARK: BaseViewModel Overrides 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() // Name + email both non-empty → form is valid. @@ -130,7 +115,6 @@ public final class SignUpViewModel: BaseViewModel< ) } } - ``` # Library products @@ -182,4 +166,4 @@ 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. # History -Implementation happened in 2018 in https://github.com/sajjon/zhip (originally https://github.com/openzesame/zhip); then called "SLC: SingleLineController". You can read my blog posts from 2018, [part one](https://medium.com/@sajjon/single-line-controller-fbe474857787) and [part two](https://medium.com/@sajjon/single-line-controller-advanced-case-406e76731ee6) - since ported from RxSwift to Combine and extracted into this separate repo. \ No newline at end of file +Implementation happened in 2018 in https://github.com/sajjon/zhip (originally https://github.com/openzesame/zhip); then called "SLC: SingleLineController". You can read my blog posts from 2018, [part one](https://medium.com/@sajjon/single-line-controller-fbe474857787) and [part two](https://medium.com/@sajjon/single-line-controller-advanced-case-406e76731ee6) - since ported from RxSwift to Combine and extracted into this separate repo. From fe348a1b5162e0ffb8a08fb8d6caa23882f147a3 Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Wed, 13 May 2026 20:32:46 +0200 Subject: [PATCH 3/3] lint --- .../UIControlPublisherDeadControlTests.swift | 4 ++-- .../UITextFieldPublishersTests.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/NanoViewControllerCombineTests/UIControlPublisherDeadControlTests.swift b/Tests/NanoViewControllerCombineTests/UIControlPublisherDeadControlTests.swift index c69adbc..d38dd50 100644 --- a/Tests/NanoViewControllerCombineTests/UIControlPublisherDeadControlTests.swift +++ b/Tests/NanoViewControllerCombineTests/UIControlPublisherDeadControlTests.swift @@ -11,10 +11,10 @@ import XCTest /// dangling source. @MainActor final class UIControlPublisherDeadControlTests: XCTestCase { - func test_subscribingAfterControlDeallocated_completesImmediately() { + func test_subscribingAfterControlDeallocated_completesImmediately() throws { // ARRANGE var control: UIButton? = UIButton(type: .system) - let publisher = control!.publisher(for: .touchUpInside) + let publisher = try XCTUnwrap(control?.publisher(for: .touchUpInside)) // ACT // Drop the only strong reference. The publisher holds a `WeakBox`, diff --git a/Tests/NanoViewControllerCombineTests/UITextFieldPublishersTests.swift b/Tests/NanoViewControllerCombineTests/UITextFieldPublishersTests.swift index 2dcf082..9cbf5d5 100644 --- a/Tests/NanoViewControllerCombineTests/UITextFieldPublishersTests.swift +++ b/Tests/NanoViewControllerCombineTests/UITextFieldPublishersTests.swift @@ -177,7 +177,7 @@ final class UITextFieldPublishersTests: XCTestCase { // Force a contentSize that exceeds the frame so the publisher exercises // the `return contentOffset.y >= yThreshold * excess` branch (line 172). let view = UITextView(frame: CGRect(x: 0, y: 0, width: 100, height: 50)) - view.contentSize = CGSize(width: 100, height: 1_000) + view.contentSize = CGSize(width: 100, height: 1000) view.contentOffset = CGPoint(x: 0, y: 990) var received: [Bool] = []