A small, cross-platform Swift package to model screen state and render it in SwiftUI using a single source of truth.
ViewStateKit replaces multiple UI flags (isLoading, hasError, isEmpty) with one expressive enum that scales from simple screens to complex flows.
- ✅ Generic
ViewState<Content, ErrorState, EmptyState> - ✅ SwiftUI renderer (
StateDrivenView) with sensible defaults - ✅ Designed for Swift 6 + Observation
- ✅ iOS, macOS, tvOS and watchOS examples included
UI should be a pure function of state.
Instead of juggling multiple flags like:
isLoadinghasErrorisEmpty
you model your screen with a single value:
ViewState<Content, ErrorState, EmptyState>and let the view react to it.
- Single source of truth for UI state
- Generic state model (you choose the error/empty types)
- Helpers for common queries (
isLoading,hasContent, etc.) map/flatMapfor state transformationsStateDrivenViewto render state-driven SwiftUI screens- Cross-platform (iOS, macOS, tvOS, watchOS)
- Swift 6
- SwiftUI
- iOS 17+
- macOS 14+
- tvOS 17+
- watchOS 10+
File → Add Package Dependencies… and paste the repository URL.
.package(
url: "https://github.com/rgmez/ViewStateKit",
from: "0.2.0"
)ViewState represents the complete state of a screen:
.idle.loading.content(Content).empty(EmptyState).error(ErrorState)
public enum ViewState<Content, ErrorState, EmptyState> {
case idle
case loading
case content(Content)
case empty(EmptyState)
case error(ErrorState)
}ViewStateKit includes two ready-to-use "presentation" models:
ErrorDisplayModel– user-facing error info (title/message/recovery)EmptyDisplayModel– common empty reasons (no results / no data / no connection / custom)
These models are used by the default SwiftUI placeholders.
import Observation
import ViewStateKit
@MainActor
@Observable
final class SearchResultsViewModel {
private(set) var state: ViewState<[String], ErrorDisplayModel, EmptyDisplayModel> = .idle
func load() async {
state = .loading
try? await Task.sleep(nanoseconds: 700_000_000)
state = .content([
"Swift Concurrency: async/await essentials",
"SwiftUI NavigationStack patterns",
"Swift Testing: best practices"
])
}
}When your state uses ErrorDisplayModel + EmptyDisplayModel, empty and error have sensible defaults:
import SwiftUI
import ViewStateKit
struct SearchResultsView: View {
@State private var viewModel = SearchResultsViewModel()
var body: some View {
StateDrivenView(state: viewModel.state) { items in
List(items, id: \.self) { Text($0) }
}
.task { await viewModel.load() }
}
}Override any placeholder when needed:
StateDrivenView(state: viewModel.state) { items in
List(items, id: \.self) { Text($0) }
} empty: { _ in
Text("Nothing here yet")
} error: { error in
VStack(spacing: 8) {
Text(error.title).font(.headline)
Text(error.message).font(.subheadline)
}
}You can also provide custom loading/idle views:
StateDrivenView(state: viewModel.state) { items in
List(items, id: \.self) { Text($0) }
} loading: {
ProgressView("Loading…")
} idle: {
Text("Ready").foregroundStyle(.secondary)
}You can remove "impossible" states for certain screens:
If failures are handled upstream (or are impossible), use Never:
typealias NonFailingState<Content, Empty> = ViewState<Content, Never, Empty>If empty is not meaningful:
typealias NonEmptyState<Content, Failure> = ViewState<Content, Failure, Never>ViewState includes common helpers:
isLoading,hasContent,isEmpty,hasErrorcontent,error,emptyStatemapandflatMapto transform content while preserving the other states
Example:
let stringState = intState.map { "\($0)" }The Examples/ folder includes a small app per platform showing the same three screen types:
- Full state (supports
.content,.empty,.error) - Can't fail (
Failure == Never) - Never empty (
Empty == Never)
This helps you see how ViewState and StateDrivenView scale as you "remove" impossible states.
- A
HomeViewwith navigation to three screens:- Search Results (full state)
- Recent Searches (can't fail)
- Account Summary (never empty)
- Each screen uses a simple
@Observableview model and renders state withStateDrivenView.
- A user-friendly navigation layout that lets you move back to the main menu after opening an example.
- The same three screens as iOS, adapted to macOS UI conventions (toolbar/title handling, list layout).
- The same three examples, adapted for tvOS:
- Focus-friendly layout
- Bigger tap targets and list styling
- Simple navigation between the home menu and each example
- A watch-native flow that avoids the common pitfalls of
Picker(.wheel)+ScrollView. - Each example is split into:
- a Controls screen (choose an outcome + load)
- a Result screen that displays the current
ViewState
- Uses
NavigationStack+navigationDestinationand renders results in aListfor stability and readability.
To add translations for a new language, follow these steps:
-
Create the language resource folder: add a new
.lprojdirectory underSources/ViewStateKit/Resources, for examplefr.lprojfor French.- Path example: Sources/ViewStateKit/Resources/en.lproj/Localizable.strings is the English reference.
-
Add a
Localizable.stringsfile: copy the keys from the English file and provide translated values. -
Regenerate the type-safe accessors: you can regenerate the
L10naccessors in two ways:-
Locally via Makefile (recommended for editing and committing generated API):
make generate-localizations
-
Or let SwiftPM run the generation during build (the package includes a build tool plugin):
swift build
The Makefile runs the generator script at
Tools/generate_localized_strings.swift. The build-time plugin also invokes the same script so CI and local builds will generate the accessors automatically. -
-
Run tests / verify:
swift test
Notes:
- The package processes resources declared in the manifest;
defaultLocalizationis set to English (en). You do not need to change the manifest to add additional languages — simply add the new.lproj/Localizable.stringsfile. - If you prefer committing generated sources, run
make generate-localizationsand commit the producedSources/ViewStateKit/Generated/Strings+Localized.swiftfile. Otherwise, rely on the SwiftPM plugin which generates the file duringswift build.
MIT License. See LICENSE.
