0

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)
    }
  }
}
Roland Lariotte
  • 2,606
  • 1
  • 16
  • 40
  • Make `NavigationManager` to have shared instance and use it from everywhere needed directly. Here is useful example https://stackoverflow.com/a/64580356/12299030. – Asperi Sep 05 '21 at 19:37

1 Answers1

1

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
    }
  }
}
Roland Lariotte
  • 2,606
  • 1
  • 16
  • 40