Большинство мобильных приложений содержит не один десяток экранов, сложные переходы, а также части приложения, разделенные по смыслу и назначению. Следовательно, возникает потребность в организации правильной структуры навигации приложения, которая будет гибкой, удобной, расширяемой, обеспечит комфортный доступ к различным частям приложения, а также будет бережно относиться к ресурсам системы.
В данной статье мы спроектируем навигацию в приложении так, чтобы избежать наиболее частых ошибок, которые приводят к утечкам памяти, портят архитектуру и ломают структуру навигации.
У большинства приложений есть как минимум две части: аутентификации(pre-login) и закрытая часть(post-login). У некоторых приложений может быть и более сложная структура, множественные профили с одним логином, условные переходы после запуска приложения(deeplinks) и т.д.
Для перемещения по приложению на практике в основном используют два подхода:
- Один навигационный стек и для контроллеров представления(present) и для контроллеров навигации(push), без возможности вернуться назад. Такой подход приводит к тому, что все предыдущие ViewController'ы остаются в памяти.
- Используется переключение 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)
alexwillrock
04.07.2018 16:43а что будет — если экранов станет больше 10 хотя бы, и если будут использоваться разные сценарии — длинный кейс авторизации, кейс редактирования, кейс оплаты?
RootController распухнет до тысяч строк, а еще и синглтон — как менять реализацию на лету например?
И по итогу каждый VC знает, куда ему шагать дальше, а если необходимо в N-ое количество VC заменить вызываемый экран (корзину -> на оплату)?
подход неплох, но и проблем не малоGDXRepo
04.07.2018 16:55Проблемы есть в любом подходе, к сожалению. По поводу множества экранов — основным распухающим контроллером тогда будет Main, так как суть рута — это переброска между авторизацией, сплешем и основным Main экраном. Кейсы (пошаговые flow) я обычно выделяю в отдельный контроллер, чтобы не плодить разную логику в корне, но там под ситуацию уже подбирать надо.
tapoton
04.07.2018 18:17Длинные кейсы рулятся уже внутри, для этого в рутовом контроллере вместо непосредственно контроллеров логина/главного добавляют, например,
UINavigationController
, в который уже и «навигирует».
Координация в таких случаях осуществляется, например, при помощи координаторов. Никаких тысяч строк тут нет.
iStaZzzz
04.07.2018 18:35Если не требуется вычурной анимации все тоже самое достигается с помощью UITabBarController'a со спрятанным UITabBar.
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)
…
}
GDXRepo
Спасибо за статью, такой информации мало. Есть пара ошибок. initialFrame в методе animateDismissTransition не используется вообще. Ну и по мелочи, в блоках нет обертки [unowned/weak self], очепяток в showLoginScreen() — там лишняя квадратная скобка закрывающая, также не очень понятно, как «стандартная» реализация вставки нового контроллера из 9 шагов должна превратиться в анимированный переход, неплохо бы привести этот пример в статье. В остальном нормально, лишней статья точно не будет.