anciltech

Swift & SwiftUI

ScreenView - Simplify your View <-> VM handling

Move startup, focus, return, and teardown wiring out of your SwiftUI view and into ready-to-use view model hooks.

6 min readAnciltech

Get ScreenView on GitHub

A screen needs to create its model, start its work, react to app focus, resume after navigation, and clean up. ScreenView gives the view model ready-to-use hooks for each of those moments: onStartup(),onFirstFocus(), onLostFocus(),onRegainedFocus(), onReturn(), and teardown(). You fill in the blocks you need. The wrapper handles calling them.

The difference is easiest to see in code. Here is a shortened manual destination wrapper, with appearance, task ownership, and focus wiring sitting beside the UI:

Before: the view does the wiring

struct AccountDestination: View {
    @Environment(\.scenePhase) private var scenePhase
    @State private var viewModel: AccountViewModel?
    @State private var isAppeared = false
    @State private var hasGainedFocus = false
    @State private var isFocused = false
    @State private var focusTask: Task<Void, Never>?

    var body: some View {
        Group {
            if let viewModel {
                AccountContent(viewModel: viewModel)
            } else {
                ProgressView()
            }
        }
        .onAppear { isAppeared = true }
        .task {
            let isNew = viewModel == nil
            let model = viewModel ?? AccountViewModel()
            viewModel = model
            defer { model.teardown() }

            if isNew { await model.onStartup() }
            guard !Task.isCancelled else { return }
            await handleFocus(scenePhase, model: model)
            guard !Task.isCancelled else { return }

            if isNew {
                await model.runSubscribers()
            } else {
                await model.onReturn()
            }
        }
        .onChange(of: scenePhase) { _, phase in
            guard isAppeared, let model = viewModel else { return }
            focusTask?.cancel()
            focusTask = Task { await handleFocus(phase, model: model) }
        }
        .onDisappear {
            isAppeared = false
            focusTask?.cancel()
        }
    }

    private func handleFocus(
        _ phase: ScenePhase, model: AccountViewModel
    ) async {
        switch phase {
        case .active:
            guard !isFocused else { return }
            isFocused = true
            if hasGainedFocus {
                await model.onRegainedFocus()
            } else {
                hasGainedFocus = true
                await model.onFirstFocus()
            }
        case .inactive, .background:
            guard isFocused else { return }
            isFocused = false
            await model.onLostFocus()
        @unknown default:
            break
        }
    }
}

This is already a lot of bookkeeping for one screen. It is still only a sketch: AccountContent is app-defined, and independent focus tasks need coordination with startup and teardown. Rapidly returning to a retained destination adds another race to handle. Every screen copying this pattern inherits those responsibilities.

After: lifecycle blocks in the model

import Observation
import ScreenView
import SwiftUI

struct AccountView: ScreenView {
    let viewModel: AccountViewModel

    var body: some View {
        @Bindable var viewModel = viewModel
        TextField("Name", text: $viewModel.name)
    }
}

@MainActor
@Observable
final class AccountViewModel: ScreenViewModel {
    var name = ""
    init() {}

    func onStartup() async {
        // Load initial data. Runs once per model.
    }

    func onFirstFocus() async {
        // The app is active for the first time.
    }

    func onLostFocus() async {
        // The app became inactive or entered the background.
    }

    func onRegainedFocus() async {
        // Refresh after the app becomes active again.
    }

    func onReturn() async {
        // Finite refresh when a retained screen's task restarts.
        await runSubscribers()
    }

    func teardown() {
        // Release resources when the screen task exits.
    }
}

// At the navigation boundary:
Screen<AccountView, AccountViewModel>()

These hooks come with default implementations. You do not need to write all of them, add flags to the content view, or connect each one to a SwiftUI modifier. Leave out the hooks your screen does not need. Business logic lives in the model; the view renders it.

onStartup() replaces ad hoc initialization work. onReturn() provides a place for return-specific work when SwiftUI restarts a retained destination’s task. The focus hooks describe app scene activation, rather than every onAppear or a navigation route becoming foreground. View-specific animations can still use SwiftUI’s appearance callbacks.

One owner at the navigation boundary

SwiftUI can recreate view values while evaluating navigation and updates. A model initializer that starts work can therefore run at the wrong time if it is called while constructing those values. With Observation, a view-owned model is usually @Observable and stored in @State. When construction must be deferred, creating an optional model in .task makes the timing explicit.

Screen owns that state and creates the model from its SwiftUI-managed task. The content receives a let viewModel; Swift synthesizes init(viewModel:). Use @Bindablewhere the content needs bindings. Keep the model’s initializer free of startup work and put finite setup in onStartup().

Routes that carry input can configure the new model before startup:

Screen<DocumentUploadView, DocumentUploadViewModel>(
    configure: { $0.configure(urls: documentURLs) }
)

Configuration runs once for a newly created model. A different route input needs a different destination identity if it should create a different model; changing the closure on an existing screen does not reconfigure it. The upload types here are app-defined.

Keep live updates inside the screen task

ScreenSubscribers runs each registered subscriber in a structured task group. Subscribers can wait for different streams concurrently; each one processes its own values in order. Reactions run on the main actor, so heavy work should be delegated to an appropriate service or actor.

The app that inspired this pattern used its own async publisher. The public package accepts a ChangesSubscription source or a closure that supplies an AsyncStream. It has no dependency on that app’s publisher or logging system.

ScreenSubscribers {
    subscribe(to: { await accountService.changes() })
        .react { [weak self] change in
            await self?.reactToAccountChange(change)
        }
}

Supply a fresh stream when subscribers restart. Producers should release resources through onTermination. Avoid detached tasks inside subscriber declarations: they would create work outside the lifetime this wrapper owns.

Startup, focus, return, and teardown

  • onStartup() runs once per model, after configuration.
  • onFirstFocus(), onLostFocus(), and onRegainedFocus() respond to scene activation. They do not indicate which navigation route is in front.
  • onReturn() runs when SwiftUI restarts the task for a retained model. Its default restarts subscriptions. An override should finish its return-specific work, then call await runSubscribers().
  • teardown() runs synchronously when each screen task exits, including a return task. A retained model must be usable again after teardown.

The model’s identity and its running work have different lifetimes. Native navigation can retain state while cancelling the destination task. Returning can reuse that model and start its subscriptions again. This wrapper follows SwiftUI’s task lifecycle; it does not implement its own navigation coordinator or infer exact visibility from scene focus.

The public extraction also tightens two edges in the original code: teardown wraps both the first task and return tasks, and scene hooks run serially in a structured child task after startup. Creating an independent task for every scene change would allow hooks to overlap and outlive the screen task.

Use simpler state where it fits

ScreenView earns its place when several destinations share lifecycle work. It is unnecessary for every view in an app.

  • Optional @State plus .task: a clear local solution for small, self-contained content, an app root, or a developer-tools overlay.
  • A model passed by the parent: appropriate for child content that does not own the lifecycle. ScreenView’s content follows this pattern.
  • Plain @State: enough for presentation flags, selection, and small local interactions. These do not need a view model.
  • ObservableObject and @StateObject: still useful where existing Combine-based code needs them. Observation is the default for new model code here.

Apple’s migration guidance explains using @Observable and @State for models owned by a view. Deferring an optional model to .task is the adaptation used here when construction must happen later; it is not a requirement of Observation.

Try the package

ScreenView is available on GitHub as a Swift package with lifecycle tests and a complete account example. It uses Swift 6 and supports iOS 17, macOS 14, watchOS 10, tvOS 17, and visionOS 1 or later. Add the repository URL in Xcode’s package dependencies and import ScreenView at your navigation boundary.

References