Skip to content

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-00x scenario 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 CompositeVM contract.


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-004 conformance 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