Блог Roxie Mobile

Как реализовать сложную навигацию в приложениях на iOS для iPhone

30 мая 2018 6 002
iOS разработка

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

Проблема организации навигации

Один из основных способов организации навигации в iOS — использование UINavigationController, который позволяет добавлять и убирать другие view-контроллеры, например, вот так:

1
2
3
4
5
6
7
8
class ImageListViewController: UITableViewController {
    override func tableView(_ tableView: UITableView,
                            didSelectRowAt indexPath: IndexPath) {
        let image = images[indexPath.row]
        let detailVC = ImageDetailViewController(image: image)
        navigationController?.pushViewController(detailVC, animated: true)
    }
}

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

Аргументация в пользу координаторов

Одним из способов сделать навигацию более гибкой (при этом избавить view-контроллеры от необходимости знать друг о друге) — использовать шаблон проектирования «Координатор». Идея заключается в том, чтобы добавить промежуточный/родительский объект, который бы координировал несколько view-контроллеров.
Предположим, мы создаем серию экранов для ознакомления с приложением, где пользователю кратко рассказывается об основной функциональности. Вместо того, чтобы заставлять каждый view-контроллер самостоятельно добавлять в navigationController следующий экран, можно поручить эту задачу координатору.
Начнем с создания протокола делегата, с помощью которого наши контроллеры смогут уведомлять своего владельца о нажатии кнопки перехода на следующий экран:

1
2
3
4
5
6
7
8
9
10
11
12
13
protocol OnboardingViewControllerDelegate: AnyObject {
    func onboardingViewControllerNextButtonTapped(
        _ viewController: OnboardingViewController
    )
}

class OnboardingViewController: UIViewController {
    weak var delegate: OnboardingViewControllerDelegate?

    private func handleNextButtonTap() {
        delegate?.onboardingViewControllerNextButtonTapped(self)
    }
}

Затем добавим класс координатора, который будет выполнять роль делегата для всех view-контроллеров и управлять навигацией между ними с помощью навигационного контроллера:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class OnboardingCoordinator: OnboardingViewControllerDelegate {
    weak var delegate: OnboardingCoordinatorDelegate?

    private let navigationController: UINavigationController
    private var nextPageIndex = 0

    // MARK: - Initializer

    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }

    // MARK: - API

    func activate() {
        goToNextPageOrFinish()
    }

    // MARK: - OnboardingViewControllerDelegate

    func onboardingViewControllerNextButtonTapped(
        _ viewController: OnboardingViewController) {
        goToNextPageOrFinish()
    }

    // MARK: - Private

    private func goToNextPageOrFinish() {
        // We use an enum to store all content for a given onboarding page
        guard let page = OnboardingPage(rawValue: nextPageIndex) else {
            delegate?.onboardingCoordinatorDidFinish(self)
            return
        }

        let nextVC = OnboardingViewController(page: page)
        nextVC.delegate = self
        navigationController.pushViewController(nextVC, animated: true)

        nextPageIndex += 1
    }
}

Одним из главных преимуществ координаторов является вынесение логики навигации из view-контроллеров. Это позволяет view-контроллерам сосредоточиться на том, что они делают лучше всего — управлять представлениями (views).
Следует отметить, что у OnboardingCoordinator есть собственный делегат. Мы можем сделать так, чтобы AppDelegate управлял координатором, сохранив его у себя и, тем самым, став его делегатом. Или же использовать несколько уровней координаторов, чтобы организовать основную часть навигации в приложении. Например, AppCoordinator может управлять OnboardingCoordinator и другими координаторами на том же уровне навигационной иерархии. Весьма круто, не правда ли?

Куда идём, навигатор?

Другим полезным подходом (особенно для приложений с множеством экранов и сложной системой переходов между ними) будет добавление специально предназначенных для этого типов — навигаторов.
Для того, чтобы это сделать, начнём с создания протокола Navigator. Добавим ему связанный тип, указывающий, к какому виду Destination возможен переход:

1
2
3
4
5
protocol Navigator {
    associatedtype Destination

    func navigate(to destination: Destination)
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class LoginNavigator: Navigator {
    // Here we define a set of supported destinations using an
    // enum, and we can also use associated values to add support
    // for passing arguments from one screen to another.
    enum Destination {
        case loginCompleted(user: User)
        case forgotPassword
        case signup
    }

    // In most cases it's totally safe to make this a strong
    // reference, but in some situations it could end up
    // causing a retain cycle, so better be safe than sorry :)
    private weak var navigationController: UINavigationController?

    // MARK: - Initializer

    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }

    // MARK: - Navigator

    func navigate(to destination: Destination) {
        let viewController = makeViewController(for: destination)
        navigationController?.pushViewController(viewController, animated: true)
    }

    // MARK: - Private

    private func makeViewController(for destination: Destination) -> UIViewController {
        switch destination {
        case .loginCompleted(let user):
            return WelcomeViewController(user: user)
        case .forgotPassword:
            return PasswordResetViewController()
        case .signup:
            return SignUpViewController()
        }
    }
}

С помощью навигаторов переход к другому view-контроллеру осуществляется простым вызовом navigator.navigate(to: destination), и нам вовсе не требуется несколько уровней делегирования, чтобы это сделать. Единственное, что требуется каждому view-контроллеру, — хранить ссылку на навигатор, поддерживающий все необходимые состояния:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class LoginViewController: UIViewController {
    private let navigator: LoginNavigator

    init(navigator: LoginNavigator) {
        self.navigator = navigator
        super.init(nibName: nil, bundle: nil)
    }

    private func handleLoginButtonTap() {
        performLogin { [weak self] result in
            switch result {
            case .success(let user):
                self?.navigator.navigate(to: .loginCompleted(user: user))
            case .failure(let error):
                self?.show(error)
            }
        }
    }

    private func handleForgotPasswordButtonTap() {
        navigator.navigate(to: .forgotPassword)
    }

    private func handleSignUpButtonTap() {
        navigator.navigate(to: .signup)
    }
}

Мы можем пойти еще дальше и объединить навигаторы с помощью шаблона проектирования «Фабрика», чтобы вынести создание view-контроллеров из самих навигаторов и сделать логику ещё более разделённой:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class LoginNavigator: Navigator {
    private weak var navigationController: UINavigationController?
    private let viewControllerFactory: LoginViewControllerFactory

    init(navigationController: UINavigationController,
         viewControllerFactory: LoginViewControllerFactory) {
        self.navigationController = navigationController
        self.viewControllerFactory = viewControllerFactory
    }

    func navigate(to destination: Destination) {
        let viewController = makeViewController(for: destination)
        navigationController?.pushViewController(viewController, animated: true)
    }

    private func makeViewController(for destination: Destination) -> UIViewController {
        switch destination {
        case .loginCompleted(let user):
            return viewControllerFactory.makeWelcomeViewController(forUser: user)
        case .forgotPassword:
            return viewControllerFactory.makePasswordResetViewController()
        case .signup:
            return viewControllerFactory.makeSignUpViewController()
        }
    }
}

Используя вышеописанный подход, у нас появляется замечательная возможность передавать во view-контроллеры различные типы навигаторов таким образом, чтобы они не знали друг о друге. К примеру, с помощью «Фабрики» можно воспользоваться WelcomeNavigator внутри WelcomeViewController, при этом LoginNavigator не будет в курсе о существовании WelcomeNavigator.

URL-адреса и глубокие ссылки

Часто мы хотим не только упростить саму навигацию, но и позволить другим приложениям и веб-сайтам вызывать наше приложение посредством глубоких ссылок (deep linking). Стандартный способ сделать подобное в iOS — добавить новую URL-схему, чтобы другие приложения могли ссылаться на определённые экраны или функции нашего приложения.
Используя координаторы/навигаторы (по отдельности или вместе), реализовать поддержку URL-адресов и глубоких ссылок становится гораздо проще. Ведь благодаря им у нас есть конкретные места, в которые можно добавить логику обработки ссылок.

Выводы

Перемещение логики навигации из view-контроллеров в специальные объекты, такие как координаторы и навигаторы, делает переходы между несколькими view-контроллерами намного проще. В координаторах и навигаторах нам нравится то, что с их помощью легко разделить логику навигации на несколько областей видимости (scopes) и объектов, избавившись от централизованной маршрутизации в той или иной форме.
Ещё одно преимущество — отсутствие необходимости использовать неопциональные опционалы (non-optional optionals). К примеру, в навигационной логике не нужно использовать ссылку на navigationController во view-контроллере. Что, как правило, приводит к более предсказуемому и поддерживаемому коду.

Оригинал статьи: тут

Вам может быть интересно

Запросить консультацию