An extremely opinionated UIKit architecture built on top of MVVM-C allowing you to create UIViewControllers declaratively with as little as a single line of code.
A quick inline taste of the architecture β for a runnable toy example and a real-world app see Showcase.
// 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 = { ... }()
}
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>
}
}
// 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 navigator] user in
navigator?.next(.signedUp(user))
}
.store(in: &cancellables)
return Output(
isSubmitEnabled: isSubmitEnabled,
isLoading: isLoading
)
}
}
// MARK: NavigationStep
public enum SignUpUserAction: Sendable {
case signedUp(SignedUpUser)
}The package ships six independent SPM library targets so consumers can pick exactly what they need:
| Product | Layer | Notes |
|---|---|---|
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<View>, 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 |
Combine, Navigation, Controller, SceneViews, DIPrimitives all depend on Core. UIKit modules (Controller, SceneViews, DIPrimitives) need iOS 26+; pure value-type modules build on macOS 14+ too. Every target compiles in Swift 6.2 language mode (swift-tools-version: 6.2) so the package's @MainActor-on-UIKit annotations are enforced as hard errors at the consumer's call site.
First-time setup on a fresh clone:
brew install just # bootstraps the rest
just bootstrap # brew bundle install + git hooks (pre-commit + pre-push)Then:
just test # build + run the per-package XCTest bundles on iPhone 17 / iOS 26.1
just cov # tests with coverage report
just fmt # swiftformat + swiftlint --fix
just # list every recipeCI runs the same pipeline on every push / PR β typos check, swiftformat lint, swiftlint strict, build + test on the iOS Simulator. See .github/workflows/ci.yml.
The pre-commit hook installed by just bootstrap enforces: typos, shellcheck, swiftformat lint, swiftlint strict on every commit; and the full test suite on every push.
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.
just example-gen # generate Examples/SignUpDemo/SignUpDemo.xcodeproj from project.yml
just example-build # xcodebuild for iPhone 17 simulator
open Examples/SignUpDemo/SignUpDemo.xcodeproj # then βR in XcodeThe 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.
Zhip is a full-featured iOS wallet app built on the same architecture (originally with the predecessor "SLC: SingleLineController" β see History below). A larger, real-world example of NanoViewController in production use.
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 and part two - since ported from RxSwift to Combine and extracted into this separate repo.