Большинство мобильных приложений содержит не один десяток экранов, сложные переходы, а также части приложения, разделенные по смыслу и назначению. Следовательно, возникает потребность в организации правильной структуры навигации приложения, которая будет гибкой, удобной, расширяемой, обеспечит комфортный доступ к различным частям приложения, а также будет бережно относиться к ресурсам системы.

В данной статье мы спроектируем навигацию в приложении так, чтобы избежать наиболее частых ошибок, которые приводят к утечкам памяти, портят архитектуру и ломают структуру навигации.

У большинства приложений есть как минимум две части: аутентификации(pre-login) и закрытая часть(post-login). У некоторых приложений может быть и более сложная структура, множественные профили с одним логином, условные переходы после запуска приложения(deeplinks) и т.д.

Для перемещения по приложению на практике в основном используют два подхода:

  1. Один навигационный стек и для контроллеров представления(present) и для контроллеров навигации(push), без возможности вернуться назад. Такой подход приводит к тому, что все предыдущие ViewController'ы остаются в памяти.
  2. Используется переключение window.rootViewController. При таком подходе все предыдущие ViewController'ы уничтожаются в памяти, но это выглядит не лучшим образом с точки зрения UI. Также это не позволяет перемещаться вперед-назад при необходимости.

А теперь давайте посмотрим, как можно сделать легко поддерживаемую структуру, которая позволяет без проблем переключаться между различными частями приложения, без спагетти-кода и с удобной навигацией.

Давайте представим, что мы пишем приложение, состоящее из:

  • Первичный экран(Splash screen): это самый первый экран, который вы видите, как только запускается приложение, туда можно добавить, например, анимацию или сделать какие-либо первичные API-запросы.
  • Экраны аутентификации(Authentification part): экраны логина, регистрации, сброса пароля, подтверждения email и т.д. Рабочая сессия пользователя обычно сохраняется, поэтому нет необходимости вводить логин каждый раз при запуске приложения.
  • Основное приложение(Main part): бизнес-логика основного приложения

Все эти части приложения изолированы друг от друга и существуют каждая в своем навигационном стеке. Таким образом нам могут потребоваться следующие переходы:

  • Splash screen -> Authentication screen, в случае если текущая сессия активного пользователя отсутствует.
  • Splash screen -> Main screen, в случае если пользователь уже совершил ранее вход в приложение и есть активная сессия.
  • Main screen -> Authentication screen, в случае если пользователь разлогинился


Базовая настройка

Когда приложение запускается, нам необходимо инициализировать RootViewController, который будет загружаться в первую очередь. Это можно сделать как кодом, так и через Interface Builder. Создайте в xCode новый проект и все это уже будет сделано по умолчанию: main.storyboard уже привязана к window.rootViewController.

Но для того чтобы сфокусироваться на основной теме статьи мы не будем использовать сториборды в нашем проекте. Поэтому удалите main.storyboard, а также очистите поле «Main Interface» в пункте Targets -> General -> Deployment info:



Теперь давайте изменим метод didFinishLaunchingWithOptions в AppDelegate чтобы он выглядел следующим образом:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
   window = UIWindow(frame: UIScreen.main.bounds)
   window?.rootViewController = RootViewController()
   window?.makeKeyAndVisible()
   return true
}

Теперь приложение в первую очередь запустит RootViewController. Переименуйте базовый ViewController в RootViewController:

class RootViewController: UIViewController {

}

Это будет основной контроллер, ответственный за все переходы между различными разделами приложения. Поэтому нам будет нужна ссылка на него каждый раз, когда мы захотим совершить переход. Для этого добавим расширение в AppDelegate:

extension AppDelegate {
   static var shared: AppDelegate {
      return UIApplication.shared.delegate as! AppDelegate
   }
var rootViewController: RootViewController {
      return window!.rootViewController as! RootViewController 
   }
}

Принудительное извлечение опционала в данном случае оправдано, потому что RootViewController не меняется, и если это вдруг случайно произойдет, то падение приложения при этом является нормальной ситуацией.

Итак, теперь у нас есть ссылка на RootViewController из любой точки приложения:

let rootViewController = AppDelegate.shared.rootViewController

Теперь давайте создадим еще несколько контроллеров, которые нам понадобятся: SplashViewController, LoginViewController, и MainViewController.

Splash Screen это первый экран, который увидит пользователь после запуска приложения. В это время обычно производятся все необходимые API-запросы, проверяется активность сессии пользователя и т.д. Для отображения происходящих фоновых действий используем UIActivityIndicatorView:

class SplashViewController: UIViewController {
   private let activityIndicator = UIActivityIndicatorView(activityIndicatorStyle: .whiteLarge)
   override func viewDidLoad() {
      super.viewDidLoad()
      view.backgroundColor = UIColor.white
      view.addSubview(activityIndicator)
      activityIndicator.frame = view.bounds
      activityIndicator.backgroundColor = UIColor(white: 0, alpha: 0.4)
      makeServiceCall()
   }
   private func makeServiceCall() {
   
   }
}

Для того чтобы симулировать API-запросы добавим метод DispatchQueue.main.asyncAfter с задержкой 3 секунды:

private func makeServiceCall() {
   activityIndicator.startAnimating()
   DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(3)) {
      self.activityIndicator.stopAnimating()
   }
}

Полагаем, что в этих запросах также устанавливается сессия пользователя. В нашем приложении мы используем для этого UserDefaults:

private func makeServiceCall() {
   activityIndicator.startAnimating()
   DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(3)) {
      self.activityIndicator.stopAnimating()
      
      if UserDefaults.standard.bool(forKey: “LOGGED_IN”) {
         // navigate to protected page
      } else {
         // navigate to login screen
      }
   }
}

Вы определенно не будете использовать UserDefaults для сохранения состояния сессии пользователя в релизной версии программы. Мы используем локальные настройки в нашем проекте для упрощения понимания и чтобы не выходить сильно за основную тему статьи.

Создайте LoginViewController. Он будет использоваться для аутентификации пользователя, в том случае, если текущая сессия пользователя неактивна. Вы можете добавить в контроллер свой кастомный UI, но я добавлю сюда только заголовок экрана и кнопку логина в Navigation Bar.

class LoginViewController: UIViewController {
   override func viewDidLoad() {
      super.viewDidLoad()
      view.backgroundColor = UIColor.white
      title = "Login Screen"
      let loginButton = UIBarButtonItem(title: "Log In", style: .plain, target: self, action: #selector(login))
      navigationItem.setLeftBarButton(loginButton, animated: true)
   }
@objc
   private func login() {
      // store the user session (example only, not for the production)
      UserDefaults.standard.set(true, forKey: "LOGGED_IN")
      // navigate to the Main Screen
   }
}

И, наконец, создадим основной контроллер приложения MainViewController:

class MainViewController: UIViewController {
   override func viewDidLoad() {
      super.viewDidLoad()
      view.backgroundColor = UIColor.lightGray // to visually distinguish the protected part
      title = “Main Screen”
      let logoutButton = UIBarButtonItem(title: “Log Out”, style: .plain, target: self, action: #selector(logout))
      navigationItem.setLeftBarButton(logoutButton, animated: true)
   }
   @objc
   private func logout() {
      // clear the user session (example only, not for the production)
      UserDefaults.standard.set(false, forKey: “LOGGED_IN”)
      // navigate to the Main Screen
   }
}

Root Navigation

Теперь вернемся к RootViewController.
Как мы говорили ранее, RootViewController это единственный объект, который отвечает за переходы между различными независимыми стеками контроллеров. Для того, чтобы быть в курсе о текущем состоянии приложения, мы создадим переменную, в которой будем хранить текущий ViewController:

class RootViewController: UIViewController {
   private var current: UIViewController
}

Добавим инициализатор класса и создадим первый ViewController, который мы хотим загрузить при запуске приложения. В нашем случае это будет SplashViewController:

class RootViewController: UIViewController {
   private var current: UIViewController
   init() {
      self.current = SplashViewController()
      super.init(nibName: nil, bundle: nil)
   }
}

В viewDidLoad добавим текущий viewController в RootViewController:

class RootViewController: UIViewController {
   ...
   override func viewDidLoad() {
      super.viewDidLoad()
      
      addChildViewController(current)               // 1
      current.view.frame = view.bounds              // 2             
      view.addSubview(current.view)                 // 3
      current.didMove(toParentViewController: self) // 4
   }
}

Как только мы добавляем childViewController (1), мы настраиваем его размер, присваивая current.view.frame значение view.bounds (2).

Если мы пропустим эту строку, viewController все равно будет размещен правильно в большинстве случаев, но могут появиться проблемы, если размер frame изменится.

Добавляем новый subview(3) и вызываем метод didMove(toParentViewController:). Это завершит операцию добавления контроллера. Как только загрузится RootViewController, сразу же после этого отобразится SplashViewController.

Теперь можно добавить несколько методов для навигации в приложении. Мы будем отображать LoginViewController без какоц-либо анимации, MainViewController будет использовать анимацию с плавным затемнением, и переход экранов при разлогинивании пользователя будет иметь эффект слайда.

class RootViewController: UIViewController {
   ...
func showLoginScreen() {
  
      let new = UINavigationController(rootViewController: LoginViewController())                               // 1
      addChildViewController(new)                    // 2
      new.view.frame = view.bounds                   // 3
      view.addSubview(new.view)                      // 4
      new.didMove(toParentViewController: self)      // 5
      current.willMove(toParentViewController: nil)  // 6
      current.view.removeFromSuperview()]            // 7
      current.removeFromParentViewController()       // 8
      current = new                                  // 9
}

Создайте LoginViewController(1), добавьте как дочерний контроллер(2), установите frame(3). Добавьте view LoginController'а как subview(4) и вызовите метод didMove(5). Далее, подготовим текущий контроллер к удалению методом willMove(6). Наконец, удалим текущий view из superview(7), и удалим текущий контроллер из RootViewController(8). Не забудьте обновить значение текущего контроллера(9).

Теперь давайте создадим метод switchToMainScreen:

func switchToMainScreen() {   
   let mainViewController = MainViewController()
   let mainScreen = UINavigationController(rootViewController: mainViewController)
   ...
}

Для анимации перехода потребуется другой метод:

private func animateFadeTransition(to new: UIViewController, completion: (() -> Void)? = nil) {
   current.willMove(toParentViewController: nil)
   addChildViewController(new)
   
   transition(from: current, to: new, duration: 0.3, options: [.transitionCrossDissolve, .curveEaseOut], animations: {
   }) { completed in
        self.current.removeFromParentViewController()
        new.didMove(toParentViewController: self)
        self.current = new
        completion?()  //1
   }
}

Этот метод очень похож на showLoginScreen, но все последние шаги выполняются после завершения анимации. Для того чтобы уведомить вызывающий метод об окончании перехода, мы в самом конце вызываем замыкание(1).

Теперь конечный вариант метода switchToMainScreen будет выглядеть следующим образом:

func switchToMainScreen() {   
   let mainViewController = MainViewController()
   let mainScreen = UINavigationController(rootViewController: mainViewController)
   animateFadeTransition(to: mainScreen)
}

И, наконец, давайте создадим последний метод, который будет отвечать за переход из MainViewController в LoginViewController:

func switchToLogout() {
   let loginViewController = LoginViewController()
   let logoutScreen = UINavigationController(rootViewController: loginViewController)
   animateDismissTransition(to: logoutScreen)
}

Метод AnimateDismissTransition обеспечивает слайд-анимацию:

private func animateDismissTransition(to new: UIViewController, completion: (() -> Void)? = nil) {
   new.view.frame = CGRect(x: -view.bounds.width, y: 0, width: view.bounds.width, height: view.bounds.height)
   current.willMove(toParentViewController: nil)
   addChildViewController(new)
   transition(from: current, to: new, duration: 0.3, options: [], animations: {
      new.view.frame = self.view.bounds
   }) { completed in
      self.current.removeFromParentViewController()
      new.didMove(toParentViewController: self)
      self.current = new
      completion?()
   }
}

Это только два примера анимации, используя тот же подход можно создать любые сложные анимации, которые вам требуются

Для завершения настройки добавим вызовы методов с анимациией из SplashViewController, LoginViewController, и MainViewController:

class SplashViewController: UIViewController {
   ...
   private func makeServiceCall() {
      if UserDefaults.standard.bool(forKey: “LOGGED_IN”) {
         // navigate to protected page
         AppDelegate.shared.rootViewController.switchToMainScreen()
      } else {
         // navigate to login screen
         AppDelegate.shared.rootViewController.switchToLogout()
      }
   }
}

class LoginViewController: UIViewController {
   ...
   
   @objc
   private func login() {
      ...
      AppDelegate.shared.rootViewController.switchToMainScreen()
   }
}

class MainViewController: UIViewController {
   ...
   @objc
   private func logout() {
      ...
      AppDelegate.shared.rootViewController.switchToLogout()
   }
}

Скомпилируйте, запустите приложение и проверьте его работу в двух вариантах:

— когда пользователь уже имеет активную текущую сессию(залогинен)
— когда активной сессии нет и необходима аутентификация

И в том и в другом случае вы должны увидеть переход на нужный экран, сразу после загрузки SplashScreen.



В итоге мы создали небольшую тестовую модель приложения, с навигацией по ее основным модулям. В случае, если требуется расширение возможностей приложения, добавление дополнительных модулей и переходов между ними, вы всегда можете быстро и удобно расширять и масштабировать данную систему навигации.

Комментарии (6)


  1. GDXRepo
    04.07.2018 14:51

    Спасибо за статью, такой информации мало. Есть пара ошибок. initialFrame в методе animateDismissTransition не используется вообще. Ну и по мелочи, в блоках нет обертки [unowned/weak self], очепяток в showLoginScreen() — там лишняя квадратная скобка закрывающая, также не очень понятно, как «стандартная» реализация вставки нового контроллера из 9 шагов должна превратиться в анимированный переход, неплохо бы привести этот пример в статье. В остальном нормально, лишней статья точно не будет.


  1. alexwillrock
    04.07.2018 16:43

    а что будет — если экранов станет больше 10 хотя бы, и если будут использоваться разные сценарии — длинный кейс авторизации, кейс редактирования, кейс оплаты?

    RootController распухнет до тысяч строк, а еще и синглтон — как менять реализацию на лету например?

    И по итогу каждый VC знает, куда ему шагать дальше, а если необходимо в N-ое количество VC заменить вызываемый экран (корзину -> на оплату)?

    подход неплох, но и проблем не мало


    1. GDXRepo
      04.07.2018 16:55

      Проблемы есть в любом подходе, к сожалению. По поводу множества экранов — основным распухающим контроллером тогда будет Main, так как суть рута — это переброска между авторизацией, сплешем и основным Main экраном. Кейсы (пошаговые flow) я обычно выделяю в отдельный контроллер, чтобы не плодить разную логику в корне, но там под ситуацию уже подбирать надо.


    1. tapoton
      04.07.2018 18:17

      Длинные кейсы рулятся уже внутри, для этого в рутовом контроллере вместо непосредственно контроллеров логина/главного добавляют, например, UINavigationController, в который уже и «навигирует».
      Координация в таких случаях осуществляется, например, при помощи координаторов. Никаких тысяч строк тут нет.


  1. iStaZzzz
    04.07.2018 18:35

    Если не требуется вычурной анимации все тоже самое достигается с помощью UITabBarController'a со спрятанным UITabBar.


  1. varton86
    05.07.2018 09:04

    Поправьте в методах:

    func switchToMainScreen() {

    let mainScreen = UINavigationController(rootViewController: mainViewController)

    }

    private func animateDismissTransition(to new: UIViewController, completion: (() -> Void)? = nil) {
    new.view.frame = CGRect(x: -view.bounds.width, y: 0, width: view.bounds.width, height: view.bounds.height)

    }