Search code examples
mvvmswiftuicombine

How to create a NavigationManager that would change tab within view models?


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)
    }
  }
}

Solution

  • 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
        }
      }
    }