Навигация средствами корневого контроллера

10 июля 2018

Как переходить между разделами приложения и обрабатывать launch options.

Это продолжение моей статьи, в которой мы создали универсальный менеджер обработки всех типов deep link’ов (уведомления, шорткаты, универсальные ссылки, deep link’и). Единственный вопрос, который мы ещё не обсудили, это

“Как, собственно, перейти на определённый экран, когда deep link уже обработан?”

В этой статье мы пойдём дальше и применим прозрачный и гибкий метод “Корневой навигации приложения” (“App Root Navigation”), включающий и deep link-навигацию. Вы получите возможность переходить в любой раздел вашего приложения как внутри самого приложения, так и через любой deep link.

*   *   *

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

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

При изучении множества приложений я обнаружил два основных способа навигации по разделам приложения:

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

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

*   *   *

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

  • Splash screen: этот экран отображается сразу после запуска приложения. Здесь мы можем воспроизводить анимацию или обращаться к API сервиса.
  • Аутентификация: стандартные экраны входа в систему, регистрации, сброса пароля, подтверждения адреса электронной почты и т.п. Сессия пользователя будет сохраняться, чтобы ему не требовалось вводить свои данные всякий раз при запуске приложения.
  • Основная часть: собственно само приложение.

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

  • От splash screen’а к экрану аутентификации, когда сессии активного пользователя не существует.
  • От splash screen’а к основному экрану, когда пользовательская сессия действительна. Это наиболее частый сценарий.
  • От основного экрана к экрану аутентификации, когда пользователь выходит из системы (logout) или текущая сессия становится недействительной.
  • Обработка deeplink’а: открытие определённого экрана приложения на основе внешних (pre-launch) условий: уведомлений, шорткатов, универсальных ссылок и т.п.

*   *   *

Базовая подготовка

Когда приложение запускается, в первую очередь необходимо обеспечить загрузку RootViewController’а. Это можно сделать в коде или через конструктор интерфейсов. Создайте новый проект в Xcode, и эта часть будет сгенерирована автоматически (main.storyboard связан с window.rootViewController).

Чтобы не отвлекаться от основной темы, мы не будем использовать storyboard в данном проекте. Поэтому удалите файл main.storyboard и очистите поле main interface в разделе Deployment Info под Target Settings: 

Обновите в AppDelegate метод didFinishLaunchingWithOptions:


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

Это будет корневой view controller, ответственный за все переходы между разделами приложения. Поэтому нам всегда нужно хранить ссылку на этот view controller и использовать его всякий раз, когда необходимо перейти на другой поток навигации (user flow). Чтобы упростить доступ к этому контроллеру, добавьте расширение в 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

Создадим ещё несколько ViewController’ов, которые нам понадобятся в этом проекте: 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() {
   
   }
}

Добавьте метод DispatchQueue.main.asyncAfter с трёхсекундной задержкой, который будет симулировать обращение к API.


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. Он будет использоваться для аутентификации пользователя в случае, если текущая сессия недействительна. Здесь вы можете добавить нужные вам элементы пользовательского интерфейса, а я ограничусь лишь заголовком экрана и кнопкой “login” в панели навигации.


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

*   *   *

Корневая навигация

Вернитесь к 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 RootViewController’а добавьте текущий viewController:


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), необходимо настроить его рамку, присвоив view.bounds значение current.view.frame (2).

Отсутствие этой строки может вызвать проблемы при изменении размеров viewController’а, хоть в большинстве случаев он и будет располагаться корректно. Например, при возникновении панели входящего вызова верхний ViewController не будет реагировать на изменение рамки.

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

Теперь можем добавить несколько методов навигации. При отображении LoginViewController’а мы не будем использовать анимацию. В MainViewController’е будет использоваться fade-in, а в переходе при выходе из системы (logout) будет применён эффект slide-in.


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), добавьте его в качестве дочернего view controller’а (2) и выровняйте его размеры (3). Добавьте его view в качестве subview (4) и вызовите didMove (5). Далее мы должны подготовить текущий view controller к удалению, вызвав willMove (6). И, наконец, удаляем текущий view из родительского (7), а также удаляем текущий view controller из RootViewController’а (8). Не забудьте обновить текущий view controller (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, но с тем отличием, что здесь последние шаги выполняются после завершения показа анимации. Чтобы уведомить вызывавшего об успешном завершении перехода, в конце метода вызываем completion (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 будет использоваться slide-out-эффект:


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’а завершится. 

1. Корневая навигация

*   *   *

Более тонкая навигация

Другой вариант использования этого подхода — обработка уведомлений (как удалённых, так и локальных), шорткатов и deeplink’ов. Когда вам необходимо перейти непосредственно на некоторый экран вашего приложения, вы можете добавить нужный вам метод-маршрут в RootViewController.

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

iOS: How to open Deep Links, Notifications and Shortcuts

Вот deeplink-типы, которые мы определили в проекте: 


enum DeeplinkType {
   enum Messages {
      case root
      case details(id: String)
   }
   case messages(Messages)
   case activity
   case newListing
   case request(id: String)
}

Мы уже обработали уведомления/шорткаты/deeplink’и, и при каждом запуске приложения или переходе в активный режим, у нас есть DeeplinkType, готовый быть использованным для навигации. Чтобы работать с этим DeeplinkType’ом, создадим переменную в RootViewController’е:


class RootViewController: UIViewController {
   ...
   var deeplink: DeeplinkType?
}

Здесь возможны два сценария:

  1. Приложение вызывается из фонового режима. Значит, нет необходимости отображать splash screen, проводить аутентификацию пользователя или выполнять другие обращения к API прежде, чем мы инициируем переход.
  2. Приложение ещё не было запущено. Вначале необходимо показать splash screen, проверить действительность пользовательской сессии, запросить при необходимости аутентификацию (и выполнить все необходимые для этого переходы по экранам), совершить все необходимые обращения к API, и только после всех этих шагов мы будем готовы непосредственно обработать deeplink.

Первый сценарий достаточно прост: добавьте блок did-set для observer’а deeplink-переменной и запустите метод handleDeeplink, когда будет установлено новое значение:


class RootViewController: UIViewController {
   ...
   var deeplink: DeeplinkType? {
      didSet {
         handleDeeplink()
      }
   }
   private func handleDeeplink() {
   }
}

В данном примере все Deeplink’и могут запускаться только из MainViewController’а. Поэтому проверка довольно прямолинейна. Вначале создадим MainNavigationController, подкласс UINavigationController’а.


class MainNavigationController: UINavigationController { }

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


class RootViewController: UIViewController {
   ...
private func handleDeeplink() {
      // make sure we are on the correct screen
      if let mainNavigationController = current as? MainNavigationController, let deeplink = deeplink {
         // handle deeplink from here
      }
   }
}

Если мы в данный момент находимся в MainNavigationController’е, этот метод просто пропустит всю deeplink-часть. Перейдём к обработке Activity Shortcut:


class RootViewController: UIViewController {
   ...
private func handleDeeplink() {
      if let mainViewController = current as? MainViewController, let deeplink = deeplink {
         switch deeplink { // 1
            case .activity: //2
                      mainNavigationController.popToRootViewController(animated: false) //3
(mainNavigationController.topViewController as? MainViewController)?.showActivityScreen() //4
            default:
                // handle any other types of Deeplinks here
                break
         }
 
      self.deeplink = nil.  //5
      }
   }
}

Сначала мы проверяем текущий deeplink-тип (1), чтобы затем действовать соответствующим образом. Если deeplink является activity deeplink’ом (2), необходимо пропустить все view controller’ы, которые уже могли быть вызваны (3), и вызвать activity view controller из родительского navigation controller’а (4).

Это зависит от структуры приложения. Здесь главное правило — не нарушать основной поток навигации приложения. Например, если пользователь не может переходить к экрану действий из экрана мессенджера, мы не должны этого делать и через deeplink.

Затем сбросьте текущий deeplink, чтобы он не обрабатывался более одного раза (5).

Не забудьте добавить метод showActivityScreen в MainViewController, а также создайте ActivityViewController, отображение которого будет вызываться в этом методе:


class ActivityViewController: UIViewController {
   override func viewDidLoad() {
      super.viewDidLoad()
      view.backgroundColor = UIColor.lightGray
      title = “Activity”
   }
}

Наконец, изменим в классе DeeplinkManager метод checkDeplink:


func checkDeepLink() {
   AppDelegate.shared.rootViewController.deeplink = deeplinkType
   
   // reset deeplink after handling
   self.deeplinkType = nil
}

Запустите приложение и протестируйте его поведение. У вас должна появиться возможность открывать ActivityViewController из меню быстрых действий иконки приложения.

2. Открытие deeplink’а из неактивного состояния

Но что если приложение не запущено, когда мы открываем шорткат? Или ещё хуже, когда пользователю необходимо вначале пройти аутентификацию?

Это решается намного проще, чем можно предположить. И в SplashViewController’е, и в LoginViewController’е у нас уже есть вся логика, необходимая для проверки сессии пользователя, аутентификации и перехода к MainViewController’у, когда всё готово.

Нам нужно лишь добавить несколько строк кода в конце метода showMainScreen:


class RootViewController: UIViewController {
   ...
func showMainScreen() {
      ...
      animateFadeTransition(to: mainScreen) { [weak self] in
         self?.handleDeeplink()
      }
   }
}

Как это будет работать? Когда приложение запущено, мы всегда передаём разобранный DeeplinkType (или nil, если не использовался deeplink) RootViewController’у. Поэтому он остаётся там и ожидает, когда приложение завершит всю необходимую логику: отобразит анимацию splash screen’а, выполнит служебный API-вызов, проверит пользовательскую сессию, авторизует пользователя и т.п. Как только отобразится MainScreen, он совершит обратный вызов и активизирует существующий deeplink. Если deeplink не nil, он будет обработан. Если deeplink равен nil, приложение загрузит основной экран.

Чтобы проверить это, закройте приложение и запустите снова, используя ActivityShortcut. Как только завершится анимация и будет пропущен LoginScreen, вы увидите ActivityViewController, не предприняв никаких дополнительных шагов.  

3. Открытие deeplink’а из неавторизованного состояния

Если что-то не работает надлежащим образом, или если вы хотите проследить исполнение в дебагере, нажмите edit scheme -> info -> отметьте флажком “wait for executable to be launched”.

*   *   *

Благодарю за внимание! Пожалуйста, нажмите ?, если вам понравилась статья — так я пойму, что вам было интересно. Если у вас есть вопросы, предложения или наблюдения, не стесняйтесь оставлять комментарии.

Полный код проекта вы найдёте по ссылке ниже.

https://github.com/Stan-Ost/RootControllerNavigation

*   *   *

Я также пишу в “American Express Engineering Blog”. Оцените мои другие работы и работы моих талантливых коллег на AmericanExpress.io.

Автор: Stan Ostrovskiy

Дата публикации: 11.12.2017

Оригинал статьи: https://medium.com/@stasost/ios-root-controller-navigation-3625eedbbff

Перевод: Борис Радченко, radchenko.boris@gmail.com

Дата перевода: 19.06.2018

Содержание