Routing with MVVM on iOS

I have been using MVVM for a while in several projects and I really like its simplicity. Especially, if you are moving from MVC, like many people, you only need to add one more layer to your architecture; view model. That really makes things easier if you find too many layers complicated.

It’s a good start, however this simplicity is not always good. In MVVM, you move business logic out of view controller (VC), then realise it is still fat. View model (VM) now has business logic, but how about presentation data (formatting) or routing? They are still stuck in VC and we need to move them out.

Sample Flow

Let’s say we are implementing a login screen as shown below.

Vevo Login Screen

List of routes:

  • Login > Home Screen
  • Sign Up > Sign Up Screen
  • Forgot Password (?) > Forgot Password Screen

This may seem like a simple screen to implement using a storyboard with 3 segues. But believe me, it is not. For example, you will normally open the home screen on login. But user’s password might be expired and you would need to implement a redirection to change-password screen, in that case. So the login route becomes:

  • Login > Home Screen OR Change Password Screen

This is where storyboard routing fails. It just can’t handle this kind of dynamism. So what you normally do is to let VC handle it:

func loginButtonTapped() {
   // Start network request...
   // Upon response:
   if viewModel.shouldChangePassword {
      performSegue(id: "ChangePasswordScreen", sender: nil)
   } else {
      performSegue(id: "HomeScreen", sender: nil)
   }
}

This is routing logic and it should not be in VC. If you want light VCs, think twice before writing if statements. They are decisions, and they don’t belong there. In my understanding, VC should only have view-related and glue code. No decisions, ever.

Let’s define a router protocol and take these if statements out of VC. We will need:

  • Route ID: A string identifier like segue ID.
  • Context: Current view controller to route from.
  • Optional parameters: Temporary data needed for transition. (Tapped row index, etc.)
protocol Router {
   func route(
      to routeID: String,
      from context: UIViewController,
      parameters: Any?
   )
}

VC should only define route names, and don’t care about where that route leads to. That will be router’s job.

class LoginViewController: UIViewController {

   enum Route: String {
      case login
      case signUp
      case forgotPassword
   }
   
   var viewModel: LoginViewModel!
   var router: Router!
   
   ...
   
   func loginButtonTapped() {
      router.route(to: Route.login.rawValue, from: self)
   }
   
   func signUpTapped() {
      router.route(to: Route.signUp.rawValue, from: self)
   }
   
   func forgotPasswordTapped() {
      router.route(to: Route.forgotPassword.rawValue, from: self)
   }
}

As mentioned, login button can lead to home screen or change password screen. So how will router choose the correct destination? In cases like this, your router may need access to your VM. This way it can read business decisions directly and decide on destination.

Beware that VC already retains VM and Router. So the Router should have a weak/unowned reference to VM.

class LoginRouter: Router {

   unowned var viewModel: LoginViewModel
   
   init(viewModel: LoginViewModel) {
      self.viewModel = viewModel
   }
   
   func route(
      to routeID: String,
      from context: UIViewController,
      parameters: Any?)
   {
      guard let route = LoginVC.Route(rawValue: routeID) else {
         return
      }
      switch route {
      case .login:
         if viewModel.shouldChangePassword {
            // Push change-password-screen.
         } else {
            // Push home-screen.
         }
      case .signUp:
         // Push sign-up-screen:
         let vc = SignUpViewController()
         let vm = SignUpViewModel()
         vc.viewModel = vm
         vc.router = SignUpRouter(viewModel: vm)
         context.navigationController.push(vc, animated: true)
      case . forgotPasswordScreen:
         // Push forgot-password-screen.
      }
   }
}

Bottom Line

  • We completely moved routing code out of VC. This is good for separation of concerns. If routing logic changes, you will just edit router, instead of searching for push/present statements in VC.
  • You will get a lot of design changes over time. So it’s important to keep view controllers light, since it is tightly coupled with the view. You wouldn’t want to break routing logic while doing a UI overhaul.
  • You don’t get to use storyboard segues with this approach. I don’t know if I broke your heart but you just can’t implement dynamic flows like this with segues. Storyboards should only be responsible from layout (again, separation of concerns).


Thank you for reading! Please let me know what you think. All suggestions and questions are welcome.


Image Credit: Photo by Rob Bates on Unsplash