I have a NavigationManager to handle changing SwiftUI tab bar selection.
It work if it is set as a @EnvironmentObject
in my SwiftUI views, but not when the NavigationManager is called as a service in my view models. The thing is that I would like to use a simpler solution than passing around @EnvironmentObject var navigationManager
around and pass them inside view model initializer as I have a lot of them and I am looking for a cleaner approach.
How can I use my NavigationManager to change tabs from inside my view models without passing it in init()?
import SwiftUI
struct ContentView: View {
@StateObject var navigationManager = NavigationManager()
var body: some View {
TabView(selection: $navigationManager.selection) {
AccountView()
.tabItem {
Text("Account")
Image(systemName: "person.crop.circle") }
.tag(NavigationItem.account)
SettingsView()
.tabItem {
Text("Settings")
Image(systemName: "gear") }
.tag(NavigationItem.settings)
.environmentObject(navigationManager)
}
}
}
The navigation manager that I would like to use within view models.
class NavigationManager: ObservableObject {
@Published var selection: NavigationItem = .account
}
enum NavigationItem {
case account
case settings
}
My AccountViewModel and Settings View Model:
class AccountViewModel: ObservableObject {
let navigationManager = NavigationManager()
}
struct AccountView: View {
@StateObject var viewModel = AccountViewModel()
var body: some View {
VStack(spacing: 16) {
Text("AccountView")
.font(.title3)
Button(action: {
viewModel.navigationManager.selection = .settings
}) {
Text("Go to Settings tab")
.font(.headline)
}
.buttonStyle(.borderedProminent)
}
}
}
class SettingsViewModel: ObservableObject {
let navigationManager = NavigationManager()
}
struct SettingsView: View {
@EnvironmentObject var navigationManager: NavigationManager
@StateObject var viewModel = SettingsViewModel()
var body: some View {
VStack(spacing: 16) {
Text("SettingsView")
.font(.title3)
Button(action: {
navigationManager.selection = .account
}) {
Text("Go to Account tab")
.font(.headline)
}
.buttonStyle(.borderedProminent)
}
}
}
I managed to successfully inject my navigationManager as a shared dependency using a property wrapper and change its selection
variable using Combine.
So I have created a protocol to wrap NavigationManager
to a property wrapper @Injection and make its value a CurrentValueSubject
import Combine
final class NavigationManager: NavigationManagerProtocol, ObservableObject {
var selection = CurrentValueSubject<NavigationItem, Never>(.settings)
}
protocol NavigationManagerProtocol {
var selection: CurrentValueSubject<NavigationItem, Never> { get set }
}
This is the @Injection property wrapper to pass my instance of NavigationManager
between .swift files.
@propertyWrapper
struct Injection<T> {
private let keyPath: WritableKeyPath<InjectedDependency, T>
var wrappedValue: T {
get { InjectedDependency[keyPath] }
set { InjectedDependency[keyPath] = newValue }
}
init(_ keyPath: WritableKeyPath<InjectedDependency, T>) {
self.keyPath = keyPath
}
}
protocol InjectedKeyProtocol {
associatedtype Value
static var currentValue: Self.Value { get set }
}
struct InjectedDependency {
private static var current = InjectedDependency()
static subscript<K>(key: K.Type) -> K.Value where K: InjectedKeyProtocol {
get { key.currentValue }
set { key.currentValue = newValue }
}
static subscript<T>(_ keyPath: WritableKeyPath<InjectedDependency, T>) -> T {
get { current[keyPath: keyPath] }
set { current[keyPath: keyPath] = newValue }
}
}
extension InjectedDependency {
var navigationManager: NavigationManagerProtocol {
get { Self[NavigationManagerKey.self] }
set { Self[NavigationManagerKey.self] = newValue }
}
}
private struct NavigationManagerKey: InjectedKeyProtocol {
static var currentValue: NavigationManagerProtocol = NavigationManager()
}
With this in place, I can pass my NavigationManager
between my view models and send new value using Combine on button tap:
class AccountViewModel: ObservableObject {
@Injection(\.navigationManager) var navigationManager
}
struct AccountView: View {
var viewModel = AccountViewModel()
var body: some View {
VStack(spacing: 16) {
Text("AccountView")
.font(.title3)
Button(action: {
viewModel.navigationManager.selection.send(.settings)
}) {
Text("Go to Settings tab")
.font(.headline)
}
.buttonStyle(.borderedProminent)
}
}
}
class SettingsViewModel: ObservableObject {
@Injection(\.navigationManager) var navigationManager
}
struct SettingsView: View {
@StateObject var viewModel = SettingsViewModel()
var body: some View {
VStack(spacing: 16) {
Text("SettingsView")
.font(.title3)
Button(action: {
viewModel.navigationManager.selection.send(.account)
}) {
Text("Go to Account tab")
.font(.headline)
}
.buttonStyle(.borderedProminent)
}
}
}
To wrap things up, I inject NavigationManager
in my ContentView and use the .onReceive(_:action:)
modifier to keep track of the newly selected tab from anywhere in code.
struct ContentView: View {
@Injection(\.navigationManager) var navigationManager
@State var selection: NavigationItem = .account
var body: some View {
TabView(selection: $selection) {
AccountView()
.tabItem {
Text("Account")
Image(systemName: "person.crop.circle") }
.tag(NavigationItem.account)
SettingsView()
.tabItem {
Text("Settings")
Image(systemName: "gear") }
.tag(NavigationItem.settings)
}
.onReceive(navigationManager.selection) { newValue in
selection = newValue
}
}
}