6.5 KiB
App wiring and dependency graph
Intent
Show how to wire the app shell (TabView + NavigationStack + sheets) and install a global dependency graph (environment objects, services, streaming clients, SwiftData ModelContainer) in one place.
Recommended structure
- Root view sets up tabs, per-tab routers, and sheets.
- A dedicated view modifier installs global dependencies and lifecycle tasks (auth state, streaming watchers, push tokens, data containers).
- Feature views pull only what they need from the environment; feature-specific state stays local.
Dependency selection
- Use
@Environmentfor app-level services, shared clients, theme/configuration, and values that many descendants genuinely need. - Prefer initializer injection for feature-local dependencies and models. Do not move a dependency into the environment just to avoid passing one or two arguments.
- Keep mutable feature state out of the environment unless it is intentionally shared across broad parts of the app.
- Use
@EnvironmentObjectonly as a legacy fallback or when the project already standardizes on it for a truly shared object.
Root shell example (generic)
@MainActor
struct AppView: View {
@State private var selectedTab: AppTab = .home
@State private var tabRouter = TabRouter()
var body: some View {
TabView(selection: $selectedTab) {
ForEach(AppTab.allCases) { tab in
let router = tabRouter.router(for: tab)
NavigationStack(path: tabRouter.binding(for: tab)) {
tab.makeContentView()
}
.withSheetDestinations(sheet: Binding(
get: { router.presentedSheet },
set: { router.presentedSheet = $0 }
))
.environment(router)
.tabItem { tab.label }
.tag(tab)
}
}
.withAppDependencyGraph()
}
}
Minimal AppTab example:
@MainActor
enum AppTab: Identifiable, Hashable, CaseIterable {
case home, notifications, settings
var id: String { String(describing: self) }
@ViewBuilder
func makeContentView() -> some View {
switch self {
case .home: HomeView()
case .notifications: NotificationsView()
case .settings: SettingsView()
}
}
@ViewBuilder
var label: some View {
switch self {
case .home: Label("Home", systemImage: "house")
case .notifications: Label("Notifications", systemImage: "bell")
case .settings: Label("Settings", systemImage: "gear")
}
}
}
Router skeleton:
@MainActor
@Observable
final class RouterPath {
var path: [Route] = []
var presentedSheet: SheetDestination?
}
enum Route: Hashable {
case detail(id: String)
}
Dependency graph modifier (generic)
Use a single modifier to install environment objects and handle lifecycle hooks when the active account/client changes. This keeps wiring consistent and avoids forgetting a dependency in call sites.
extension View {
func withAppDependencyGraph(
accountManager: AccountManager = .shared,
currentAccount: CurrentAccount = .shared,
currentInstance: CurrentInstance = .shared,
userPreferences: UserPreferences = .shared,
theme: Theme = .shared,
watcher: StreamWatcher = .shared,
pushNotifications: PushNotificationsService = .shared,
intentService: AppIntentService = .shared,
quickLook: QuickLook = .shared,
toastCenter: ToastCenter = .shared,
namespace: Namespace.ID? = nil,
isSupporter: Bool = false
) -> some View {
environment(accountManager)
.environment(accountManager.currentClient)
.environment(quickLook)
.environment(currentAccount)
.environment(currentInstance)
.environment(userPreferences)
.environment(theme)
.environment(watcher)
.environment(pushNotifications)
.environment(intentService)
.environment(toastCenter)
.environment(\.isSupporter, isSupporter)
.task(id: accountManager.currentClient.id) {
let client = accountManager.currentClient
if let namespace { quickLook.namespace = namespace }
currentAccount.setClient(client: client)
currentInstance.setClient(client: client)
userPreferences.setClient(client: client)
await currentInstance.fetchCurrentInstance()
watcher.setClient(client: client, instanceStreamingURL: currentInstance.instance?.streamingURL)
if client.isAuth {
watcher.watch(streams: [.user, .direct])
} else {
watcher.stopWatching()
}
}
.task(id: accountManager.pushAccounts.map(\.token)) {
pushNotifications.tokens = accountManager.pushAccounts.map(\.token)
}
}
}
Notes:
- The
.task(id:)hooks respond to account/client changes, re-seeding services and watcher state. - Keep the modifier focused on global wiring; feature-specific state stays within features.
- Adjust types (AccountManager, StreamWatcher, etc.) to match your project.
SwiftData / ModelContainer
Install your ModelContainer at the root so all feature views share the same store. Keep the list minimal to the models that need persistence.
extension View {
func withModelContainer() -> some View {
modelContainer(for: [Draft.self, LocalTimeline.self, TagGroup.self])
}
}
Why: a single container avoids duplicated stores per sheet or tab and keeps data consistent.
Sheet routing (enum-driven)
Centralize sheets with a small enum and a helper modifier.
enum SheetDestination: Identifiable {
case composer
case settings
var id: String { String(describing: self) }
}
extension View {
func withSheetDestinations(sheet: Binding<SheetDestination?>) -> some View {
sheet(item: sheet) { destination in
switch destination {
case .composer:
ComposerView().withEnvironments()
case .settings:
SettingsView().withEnvironments()
}
}
}
}
Why: enum-driven sheets keep presentation centralized and testable; adding a new sheet means adding one enum case and one switch branch.
When to use
- Apps with multiple packages/modules that share environment objects and services.
- Apps that need to react to account/client changes and rewire streaming/push safely.
- Any app that wants consistent TabView + NavigationStack + sheet wiring without repeating environment setup.
Caveats
- Keep the dependency modifier slim; do not put feature state or heavy logic there.
- Ensure
.task(id:)work is lightweight or cancelled appropriately; long-running work belongs in services. - If unauthenticated clients exist, gate streaming/watch calls to avoid reconnect spam.