3.5. Getting Started with VMx — Swift¶
This tutorial walks you through building viewmodels with the VMx Swift
package. You will build a ComponentVMOf<UserModel>, a RelayCommand, and a
CompositeVM<TabVM> with tab selection — all in a Swift Package or playground.
The Swift flavor is at full parity on the v3.24.0 source line: 403/403 library conformance IDs plus the 5
THEME-00xscenario IDs covered by the SwiftUI Notes Workspace flagship. See the Swift flavor page for the current matrix and documented Swift-specific divergences.For the contracts behind each type, see the component family, command families, and composite family.
3.5.1. Install¶
VMx Swift 3.24.0 is publicly installable from the repository root. SwiftPM
resolves the immutable semantic tag v3.24.0; swift-v3.24.0 is the matching
operational tag and GitHub Release.
Add VMx as a Swift Package dependency in Package.swift:
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "MyApp",
platforms: [
.iOS(.v16), .macOS(.v13), .tvOS(.v16), .watchOS(.v9),
],
dependencies: [
.package(url: "https://github.com/thekaveh/VMx.git", from: "3.24.0"),
],
targets: [
.target(name: "MyApp", dependencies: [
.product(name: "VMx", package: "vmx"),
]),
]
)
Or in Xcode: File → Add Package Dependencies → enter
https://github.com/thekaveh/VMx.git.
The lowercase package: "vmx" value is SwiftPM's canonical repository
identity. The library product and imported module remain uppercase VMx.
For local development from a checked-out clone, use a path dependency:
.package(path: "/path/to/VMx")
The Swift package uses Combine for reactive primitives and Dispatch for scheduling. No additional dependencies are required.
3.5.2. Wire up MessageHub and a Dispatcher¶
Every viewmodel needs two services: a hub that carries messages between viewmodels and a dispatcher that knows about your scheduler pair.
3.5.2.1. Option A — immediate (test suites / synchronous scripts)¶
import VMx
let hub = MessageHub()
let dispatcher = ImmediateDispatcher.INSTANCE
// Both foreground and background run synchronously on the calling thread.
// Safe for XCTest suites with no async event loop.
3.5.2.2. Option B — default (SwiftUI / UIKit / AppKit apps)¶
import VMx
let hub = MessageHub()
let dispatcher = DefaultDispatcher()
// foreground → DispatchQueue.main
// background → DispatchQueue.global(qos: .userInitiated)
3.5.3. Build a ComponentVMOf<UserModel>¶
ComponentVMOf<M> is the primary leaf viewmodel. It holds a typed model,
fires PropertyChangedMessage on the hub when the model changes, and
participates in the lifecycle state machine
(destructed → constructing → constructed → destructing → destructed).
import VMx
struct UserModel: Equatable {
let name: String
let email: String
}
let hub = MessageHub()
let dispatcher = ImmediateDispatcher.INSTANCE
// Build the viewmodel — every builder setter returns a NEW builder (immutable).
let userVM = try ComponentVMOf<UserModel>.builder()
.name("user-card")
.model(UserModel(name: "Alice", email: "alice@example.com"))
.services(hub: hub, dispatcher: dispatcher)
// Derive a display hint from the model.
.modeledHinter { $0.name }
// Optional callbacks.
.onConstruct { print("user-card constructed") }
.onDestruct { print("user-card destructed") }
.build()
// construct() transitions destructed → constructing → constructed.
try userVM.construct()
// stdout: "user-card constructed"
// Update the model.
userVM.model = UserModel(name: "Alice Smith", email: "asmith@example.com")
print(userVM.modeledHint) // "Alice Smith"
print(userVM.isConstructed) // true
See the component family for the full component contract.
3.5.4. Build a RelayCommand¶
RelayCommand wraps an optional task closure (the execute body), an
optional predicate (the canExecute test), and Combine Publisher
triggers that signal canExecute may have changed. (Builder methods are
.task(_:) and .predicate(_:); the resulting command's runtime methods
are execute() and canExecute() per the cross-language spec.)
import Combine
import VMx
let canSaveTrigger = PassthroughSubject<Void, Never>()
var isDirty = false
let saveCommand = RelayCommand.builder()
.task {
print("Saving…")
isDirty = false
canSaveTrigger.send()
}
.predicate { isDirty }
.triggers(canSaveTrigger.eraseToAnyPublisher())
.build()
print(saveCommand.canExecute()) // false
isDirty = true
canSaveTrigger.send() // fires canExecuteChanged
print(saveCommand.canExecute()) // true
saveCommand.execute() // prints "Saving…"
print(saveCommand.canExecute()) // false again
saveCommand.dispose()
See command families for the full command contract.
3.5.5. Build a CompositeVM<TabVM>¶
CompositeVM<VM> owns an ordered child collection and a current
selection slot. Children are provided by a factory that runs on the first
construct() call.
import VMx
struct TabModel: Equatable {
let title: String
}
let hub = MessageHub()
let dispatcher = ImmediateDispatcher.INSTANCE
let tab1 = try ComponentVMOf<TabModel>.builder()
.name("home-tab")
.model(TabModel(title: "Home"))
.services(hub: hub, dispatcher: dispatcher)
.build()
let tab2 = try ComponentVMOf<TabModel>.builder()
.name("settings-tab")
.model(TabModel(title: "Settings"))
.services(hub: hub, dispatcher: dispatcher)
.build()
let tabs = try CompositeVM<ComponentVMOf<TabModel>>.builder()
.name("tab-bar")
.services(hub: hub, dispatcher: dispatcher)
.children { [tab1, tab2] }
.build()
try tabs.construct()
tabs.current = tab2
print(tabs.current?.model.title ?? "(none)") // "Settings"
tabs.current = tab1
print(tabs.current?.model.title ?? "(none)") // "Home"
print((0..<tabs.count).compactMap { tabs.at($0).name }) // ["home-tab", "settings-tab"]
See the composite family for the full
CompositeVMcontract.
3.5.6. Lifecycle and cleanup¶
Every VM follows a five-state lifecycle:
destructed → constructing → constructed → destructing → destructed,
plus the terminal disposed.
print(userVM.status) // ConstructionStatus.constructed
try userVM.reconstruct() // destruct + construct in one call — only valid
// from .constructed; round-trips back to it
print(userVM.status) // ConstructionStatus.constructed
try userVM.destruct()
print(userVM.status) // ConstructionStatus.destructed
userVM.dispose() // idempotent + terminal
print(userVM.status) // ConstructionStatus.disposed
tabs.dispose() // disposes children, then itself
hub.dispose()
An illegal transition (e.g. calling construct() on a disposed VM) surfaces a
catchable StatusTransitionError under the v3 lifecycle convergence
(ADR-0053) — construct() / destruct() / reconstruct() are throws (hence
the try above), so wrap them in do/catch, or gate with canConstruct() /
canDestruct() if a state is uncertain. (Swift still traps only where a
setter cannot throw — e.g. assigning a non-child to CompositeVM.current; see
ADR-0009/ADR-0037.) A BuilderValidationError is likewise thrown when a builder
is missing a required field at build() time.
See Lifecycle & Messaging for the full lifecycle contract (
LIFE-001..015), including the transition table and admitted-hook/disposal coordination.
3.5.7. Threading¶
Dispatcher is a closure-routing protocol — not a Combine Scheduler. It
exposes two scheduling sinks that the rest of VMx uses when it needs to
hop work between queues:
| Method | Default mapping (DefaultDispatcher) |
|---|---|
dispatcher.scheduleForeground { ... } |
DispatchQueue.main (sync if already on main) |
dispatcher.scheduleBackground { ... } |
DispatchQueue.global(qos: .userInitiated) |
Use it for imperative marshaling — e.g., load data on a background queue then apply the result on main:
dispatcher.scheduleBackground {
let data = loadFromDatabase()
dispatcher.scheduleForeground {
userVM.model = data
userVM.construct()
}
}
For Combine subscriptions on hub.messages, marshal to the main queue
with Combine's own .receive(on:) — DispatchQueue.main is the
idiomatic Scheduler for SwiftUI / UIKit binding:
import Combine
import VMx
let cancellable = hub.messages
.compactMap { $0 as? PropertyChangedMessage }
.receive(on: DispatchQueue.main) // marshal to the main queue
.sink { msg in
// updateLabel(msg) — safe to touch UIKit / SwiftUI state here
}
See Services, Messages & Dispatching for the
THR-001..THR-004conformance rules. The Swift flavor implements the dispatcher contract; the Combine / async-await integration patterns above are flavor-idiomatic.
3.5.8. Where to go next¶
| Resource | Documentation page |
|---|---|
| Specification status | Specification & Conformance |
| Lifecycle contract | Lifecycle & Messaging |
| Messages & threading | Services, Messages & Dispatching |
| Commands | Command Families |
| Component contract | Component Family |
| Composite contract | Composite Family |
| Builders & tree | Builders, Collections & Tree Utilities |
| Architecture | Architecture Map |
| Swift status | Swift Flavor |
| SwiftUI integration | SwiftUI Integration |