Как переключаться между частями приложения и управлять параметрами запуска.

Это продолжение моей статьи от 11 июня, где мы создали универсальный менеджер для обработки всех типов глубинных ссылок (уведомления, ярлыки, универсальные ссылки, deeplink). Единственный вопрос, который мы не обсуждали:

«Как на самом деле перейти к соответствующему экрану после обработки deeplink?»

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

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

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

Есть два распространенных способа перехода между частями приложения, которые я видел в нескольких приложениях:

  1. Использование единого стека навигации для представления или отправки нового контроллера представления без интерфейса для возврата назад. Этот подход обычно сохраняет старые контроллеры представления в памяти.
  2. Использование ключевого окна для переключения window.rootViewController. Такой подход убьет старые ViewControllers, но он не выглядит хорошо с точки зрения пользовательского интерфейса. Это также не позволяет вам легко перемещаться вперед и назад, когда это необходимо.

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

Представим, что мы создаем приложение из следующих частей:

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

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

  • От заставки к экрану аутентификации, когда нет активного сеанса пользователя.
  • От заставки к главному экрану, когда сеанс пользователя действителен. Это то, что будет происходить в большинстве случаев.
  • Главный экран к экрану аутентификации, когда пользователь выходит из системы или текущий сеанс становится недействительным.
  • Обработка удаленных ссылок: откройте приложение с определенной страницы в зависимости от условий перед запуском - уведомлений, ярлыков, универсальных ссылок и т. д.

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

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

Чтобы сосредоточиться на основной теме, мы не будем использовать раскадровки в этом проекте. Поэтому удалите файл main.storyboard, а также очистите поле основного интерфейса в информации о развертывании в целевых настройках:

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

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

Теперь приложение запустится с RootViewControllerr. Создайте его, заменив 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

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

Экран-заставка будет первым экраном, который пользователь увидит при запуске приложения. Это лучшее время для выполнения всех вызовов 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 с задержкой в ​​три секунды:

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

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 добавьте текущий 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 в большинстве случаев все равно будет правильно выровнен, но это может вызвать проблемы при изменении кадра. Например, при переключении строки состояния во время вызова верхний ViewController не будет реагировать на новый кадр.

Добавьте новое подвид (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) и выровняйте его фрейм (3). Добавьте его представление как подпредставление (4) и вызовите didMove (5). Затем подготовьте текущий контроллер представления к удалению, вызвав willMove (6). Наконец, удалите текущее представление из супервизора (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 и MainViewControllers:

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.

Детализированная навигация

Другой вариант использования этого подхода - обработка уведомлений (как удаленных, так и локальных), ярлыков и Deeplink. Если вы хотите перейти прямо к определенной странице в своем приложении, вы можете добавить соответствующий метод маршрута в RootViewController.

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



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

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

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

В этом примере все Deeplinks могут запускаться только из 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. Давайте займемся ярлыком действия:

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 является активностью deeplink (2), мы хотим отклонить все контроллеры представления, которые могут быть уже отправлены (3), и подтолкнуть контроллер представления активности из родительского контроллера навигации (4).

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

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

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

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

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

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

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

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

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

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

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

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

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

Если что-то не работает должным образом или вы хотите следить за процессом в отладчике, нажмите на схему редактирования - ›информация -› установите флажок «дождаться запуска исполняемого файла».

Спасибо за чтение! Пожалуйста, нажмите 👏, если вам понравилась эта статья, и я буду знать, что вы заинтересованы. Если у вас есть вопросы, предложения или замечания, не стесняйтесь оставлять комментарии.

Вы можете найти полный код ниже.



Я также пишу для инженерного блога American Express. Посмотрите другие мои работы и работы моих талантливых коллег на AmericanExpress.io.