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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 45 additions & 41 deletions Examples/SignUpDemo/Sources/Onboarding/SignUpViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Never>
public let email: AnyPublisher<String, Never>
public let submitTrigger: AnyPublisher<Void, Never>

public init(
name: AnyPublisher<String, Never>,
email: AnyPublisher<String, Never>,
submitTrigger: AnyPublisher<Void, Never>
) {
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<Bool, Never>

/// `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<Bool, Never>
}
}
public extension SignUpViewModel.Output {
var loadingText: AnyPublisher<String, Never> {
isLoading.map { $0 ? "" : "Sign Up" }.eraseToAnyPublisher()
}
Comment on lines +17 to +51
}

/// 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.
Expand All @@ -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.
Expand Down Expand Up @@ -70,39 +110,3 @@ public final class SignUpViewModel: BaseViewModel<
)
}
}

public extension SignUpViewModel.Output {
var loadingText: AnyPublisher<String, Never> {
isLoading.map { $0 ? "" : "Sign Up" }.eraseToAnyPublisher()
}
}

public extension SignUpViewModel {
/// User-event publishers the view streams in.
struct InputFromView {
public let name: AnyPublisher<String, Never>
public let email: AnyPublisher<String, Never>
public let submitTrigger: AnyPublisher<Void, Never>

public init(
name: AnyPublisher<String, Never>,
email: AnyPublisher<String, Never>,
submitTrigger: AnyPublisher<Void, Never>
) {
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<Bool, Never>

/// `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<Bool, Never>
}
}
116 changes: 115 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,120 @@
# 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<SignUpView> { // 🤯 3 lines VC!
public static let title = "Sign Up"
}

// MARK: View
public final class SignUpView: UIView {
private lazy var nameField: UITextField = { ... }()
private lazy var emailField: UITextField = { ... }()
private lazy var submitButton: UIButton = { ... }()
private lazy var spinner: UIActivityIndicatorView = { ... }()
}
Comment on lines +13 to +19
extension SignUpView: ViewModelled {
public typealias ViewModel = SignUpViewModel

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.InputFromView
public extension SignUpViewModel {
struct InputFromView {
public let name: AnyPublisher<String, Never>
public let email: AnyPublisher<String, Never>
public let submitTrigger: AnyPublisher<Void, Never>
}
}

// MARK: ViewModel.Output
public extension SignUpViewModel {
struct Output {
public let isSubmitEnabled: AnyPublisher<Bool, Never>
public let isLoading: AnyPublisher<Bool, Never>
}
}
public extension SignUpViewModel.Output {
var loadingText: AnyPublisher<String, Never> {
isLoading.map { $0 ? "" : "Sign Up" }.eraseToAnyPublisher()
}
}

// MARK: NavigationStep
public enum SignUpUserAction: Sendable {
case signedUp(SignedUpUser)
}


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

// MARK: BaseViewModel Overrides
override public func transform(input: Input) -> Output {
let activity = ActivityIndicator()

// Name + email both non-empty → form is valid.
let isFormValid: AnyPublisher<Bool, Never> = 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:

Expand Down Expand Up @@ -52,4 +166,4 @@ open Examples/SignUpDemo/SignUpDemo.xcodeproj # then ⌘R in Xcode
The example shows the canonical wiring: scene = `SceneController<View>`, view-model subclasses the package's `BaseViewModel<NavigationStep, InputFromView, Output>` (which fixes `FromController` to `InputFromController` and provides a `Navigator<Step>`), coordinator subscribes to that navigator and routes the user-actions to push / pop / present transitions.

# 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.
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.
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []

Expand Down
Loading