[Перевод] Организация навигации в iOS-приложениях с помощью Root Controller

uf0h3qqyj6c6tnvj6fbf4089bye.jpeg

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

В данной статье мы спроектируем навигацию в приложении так, чтобы избежать наиболее частых ошибок, которые приводят к утечкам памяти, портят архитектуру и ломают структуру навигации.
У большинства приложений есть как минимум две части: аутентификации (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:

kckucqq5vpiklb5p7kyp7rwgqfu.png

Теперь давайте изменим метод 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 new = 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 new = 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) {
   let initialFrame = 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.

zqn5ssn9toyzyjhas3wvljnfj9m.gif

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

© Habrahabr.ru