Pull to refresh

Примеры конфигурации UIViewController-ов используя RouteComposer

Reading time 9 min
Views 5K

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



Как роутер разбирает конфигурацию


Для начала рассмотрим как рутер разбирает конфигурацию которую вы написали. Возьмем пример из предыдущей статьи:


let productScreen = StepAssembly(finder: ClassFinder(options: [.current, .visible]), factory: ProductViewControllerFactory())
        .using(UINavigationController.pushToNavigation())
        .from(SingleContainerStep(finder: NilFinder(), factory: NavigationControllerFactory())) 
        .using(GeneralAction.presentModally())
        .from(GeneralStep.current())
        .assemble()

Роутер будет идти по цепочке шагов начиная с самого первого, пока один из шагов (используя предоставленный Finder) не "сообщит" что искомый UIViewController уже присутствует в стеке. (Так напримерGeneralStep.current() гарантировано присутствует в стеке вью контроллеров) Тогда роутер начнет двигаться обратно по цепочке шагов создавая требуемые UIViewControllerы используя предоставленные Fabricи и интегрируя их используя указанные Actionы. Благодаря проверке типов еще на этапе компиляции, чаще всего, вы не сможете использовать Actionы несовместимые с предоставленной Fabricой (то есть не сможете использовать UITabBarController.addTab во вью контроллер построенный NavigationControllerFactory).


Если представить описанную выше конфигурацию, то в случае если у вас на экране просто некий не ProductViewController, то будут выполнены следующие шаги:


  1. ClassFinder не найдет ProductViewController и роутер двинется дальше
  2. NilFinder никогда ничего не найдет и роутер двинется дальше
  3. GeneralStep.current всегда вернет самый верхний UIViewController в стеке.
  4. Стартовый UIViewController найден, роутер повернет назад
  5. Построит UINavigationController используя `NavigationControllerFactory
  6. Покажет его модально используя GeneralAction.presentModally
  7. Создаст ProductViewController ипользуя ProductViewControllerFactory
  8. Интегрирует созданный ProductViewController в предыдущий UINavigationController ипользуя UINavigationController.pushToNavigation
  9. Закончит навигацию

NB: Следует понимать что в реальности нельзя показать модально UINavigationController без какого-то UIViewController внутри него. Поэтому шаги 5-8 будут выполнены роутером немного в другом порядке. Но об этом не следует задумываться. Описывается конфигурация последовательно.


Хорошей практикой при написании конфигурации является допущение, что пользователь в данный момент может находиться где угодно в вашем приложении, и, вдруг, получает push-сообщение с требованием попасть на экран который вы описываете, и попытаться ответить на вопрос — "Как должно повести себя приложение?", "Как поведут себя Finderы в конфигурации которую я описываю?". Если все эти вопросы учтены — вы получаете конфигурацию которая гарантировано покажет пользователю требуемый экран где бы он не находился. А это главное требование к современным приложениям со стороны команд занимающихся маркетингом и привлечением (энгейджментом) пользователей.


StackIteratingFinder и его опции:


Вы можете реализовать концепцию Finderа любым способом который посчитаете наиболее приемлемым. Однако, наиболее простым является итерация по графу вью контроллеров на экране. Для упрощения этой цели библиотека предоставляет StackIteratingFinder и различные реализации которые возьмут эту задачу на себя. Вам же только останется ответить на вопрос — тот ли это UIViewController который вы ожидаете.


Для того что бы повлиять на поведение StackIteratingFinder и сообщить ему в каких частях графа (стека) вью контроллеров вы хотите что бы он искал, при его создании можно указать комбинацию SearchOptions. И на них следует остановиться подробнее:


  • current: Самый верхний вью контроллер в стеке. (Тот что является rootViewController у UIWindow или тот который показан модально на самом верху)
  • visible: В том случае если UIViewController является контейнером — искать в его видимых UIViewControllerах (Например: у UINavigationController всегда есть один видимый UIViewController, у UISplitController их может быть один или два в зависимости от того как он представлен.)
  • contained: В том случае если UIViewController является контейнером — искать во всех его вложеных UIViewControllerах (Например: Пройтись по всем вью контроллерам UINavigationController включая видимый)
  • presenting: Искать также во всех UIViewControllerах под самым верхним (если они имеются конечно)
  • presented: Искать во UIViewControllerах над предоставленным (для StackIteratingFinder эта опция не имеет смысла, так как он всегда начинает с самого верхнего)

Следующий рисунок возможно сделает пояснение выше более наглядным:


Я бы рекомендовал ознакомиться с концепцией контейнеров в предыдущей статье.


Пример Если вы хотите что бы ваш Finder искал AccountViewController во всем стеке но только среди видимых UIViewControllerов то это следует записать так:


ClassFinder<AccountViewController, Any?>(options: [.current, .visible, .presenting])

NB Если по какой то причине предоставленных настроек будет мало — вы всегда сможете легко написать свою реализацию Finderа. Один из примеров будет и в этой статье


Перейдем, собственно, к примерам.


Примеры конфигураций с пояснениями


У меня есть некий UIViewController, который является rootViewControllerом UIWindow, и я хочу, чтобы по окончании навигации он заменился на некий HomeViewController:


let screen = StepAssembly(
        finder: ClassFinder<HomeViewController, Any?>(),
        factory: XibFactory())
        .using(GeneralAction.replaceRoot())
        .from(GeneralStep.root())
        .assemble()

XibFactory загрузит HomeViewController из xib файла HomeViewController.xib


Не забудьте, что если вы используете абстрактные реализации Finder и Factory в комбинации, вы должны указать тип UIViewController и контекста как минимум у одной из сущностей — ClassFinder<HomeViewController, Any?>


Что произойдет, если, в примере выше, я заменю GeneralStep.root на GeneralStep.current?


Конфигурация будет работать до тех пор пока не будет вызвана в тот момент, когда на экране будет любой модальный UIViewController. В этом случае GeneralAction.replaceRoot не сможет заменить рутовый контроллер, так как над ним есть модальный, и роутер сообщит об ошибке. Если же вы хотите чтобы эта конфигурация работала в любом случае, то вам нужно объяснить роутеру, что вы хотите чтобы GeneralAction.replaceRoot было применено именно к рутовому UIViewController. Тогда роутер уберет все модально представленные UIViewControllerы и конфигурация отработает при любом раскладе.


Я хочу показать некий AccountViewController, в случае если он еще ну показан, внутри любого UINavigationControllerа который в данный момент есть где либо на экране (даже если этот UINavigationController под неким модальным UIViewControllerом):


let screen = StepAssembly(
        finder: ClassFinder<AccountViewController, Any?>(),
        factory: XibFactory())
        .using(UINavigationController.pushToNavigation())
        .from(SingleStep(ClassFinder<UINavigationController, Any?>(), NilFactory()))
        .from(GeneralStep.current())
        .assemble()

Что означает в данной конфигурации NilFactory? Этим вы говорите роутеру, что, в случае, если ему не удалось найти ни одного UINavigationControllerа на экране, вы не хотите чтобы он его создавал и чтобы он просто ничего не делал в данном случае. Кстати, раз это NilFactory — вы не сможете использовать Action после него.


Я хочу показать некий AccountViewController, в случае, если он еще не показан, внутри любого UINavigationControllerа который в данный момент есть где либо на экране, а если такового UINavigationControllerа не окажется — создать его и показать модально:


let screen = StepAssembly(
        finder: ClassFinder<AccountViewController, Any?>(),
        factory: XibFactory())
        .using(UINavigationController.PushToNavigation())
        .from(SwitchAssembly<UINavigationController, Any?>()
                .addCase(expecting: ClassFinder<UINavigationController, Any?>(options: .visible)) // Если найден - работаем от него
                .assemble(default: { // в противном случае такая конфигурация
                    return ChainAssembly()
                            .from(SingleContainerStep(finder: NilFinder(), factory: NavigationControllerFactory()))
                            .using(GeneralAction.presentModally())
                            .from(GeneralStep.current())
                            .assemble()
                })
        ).assemble()

Я хочу показать UITabBarController с табами содержащими HomeViewController и AccountViewController заменив им текущий рут:


let tabScreen = SingleContainerStep(
        finder: ClassFinder(),
        factory: CompleteFactoryAssembly(factory: TabBarControllerFactory())
                .with(XibFactory<HomeViewController, Any?>(), using: UITabBarController.addTab())
                .with(XibFactory<AccountViewController, Any?>(), using: UITabBarController.addTab())
                .assemble())
        .using(GeneralAction.replaceRoot())
        .from(GeneralStep.root())
        .assemble()

Могу ли я использовать кастомный UIViewControllerTransitioningDelegate с экшеном GeneralAction.presentModally:


let transitionController = CustomViewControllerTransitioningDelegate()

// Где нужно в конфигурации
.using(GeneralAction.PresentModally(transitioningDelegate: transitionController))

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


let screen = StepAssembly(
        finder: ClassFinder<AccountViewController, Any?>(),
        factory: NilFactory())
        .from(tabScreen)
        .assemble()

Почему тут мы используем NilFactory? Нам не нужно строить AccountViewController в случае если он не найден. Он будет построен в конфигурации tabScreen. Смотрите ее выше.


Я хочу показать модально ForgotPasswordViewController, но, обязательно, после LoginViewControllerа внутри UINavigationControllerа:


let loginScreen = StepAssembly(
        finder: ClassFinder<LoginViewController, Any?>(),
        factory: XibFactory())
        .using(UINavigationController.pushToNavigation())
        .from(NavigationControllerStep())
        .using(GeneralAction.presentModally())
        .from(GeneralStep.current())
        .assemble()

let forgotPasswordScreen = StepAssembly(
        finder: ClassFinder<ForgotPasswordViewController, Any?>(),
        factory: XibFactory())
        .using(UINavigationController.pushToNavigation())
        .from(loginScreen.expectingContainer())
        .assemble()

Вы можете использовать конфигурацию в примере для навигации и в ForgotPasswordViewController и в LoginViewController


Для чего expectingContainer в примере выше?


Так как экшен pushToNavigation требует присутствия UINavigationControllerа в конфигурации после него, метод expectingContainer позволяет нам избежать ошибки компиляции, гарантируя что мы позаботились, что когда роутер дойдет до loginScreen в рантайме — UINavigationController там будет.


Что произойдет если в конфигурации выше я заменю GeneralStep.current на GeneralStep.root?


Она будет работать, но так как вы говорите роутеру, что хотите чтобы он начал строить цепочку от рутового UIViewController, то, если над ним будут открыты какие либо модальные UIViewControllerы, роутер скроет их перед тем как начать строить цепочку.


В моем приложении есть UITabBarController содержащий HomeViewController и BagViewController в качестве табов. Я хочу, чтобы пользователь мог между ними переключаться используя иконки на табах как обычно. Но если я вызову конфигурация программно (Например пользователь нажмет "Go to Bag" внутри HomeViewController), приложение должно не переключить таб, а показать BagViewController модально.


Тут 3 способа добиться этого в конфигурации:


  1. Настрить StackIteratingFinder искать только в видимых используя [.current, .visible]
  2. Использовать NilFinder что будет означать что рутер никогда не найдет имеющийся в табах BagViewControllerи всегда будет создавать его. Однако, у этого подхода есть побочный эффект — если, допустим, пользователь уже в BagViewControllerе представленном модально, и, допустим, кликает на универсальную ссылку, которая должна показать ему BagViewController — то роутер его не найдет и создаст еще один экземпляр и покажет над ним модально. Это, возможно, не то что вы хотите
  3. Изменить немного ClassFinder чтобы он находил только BagViewController показаный модально и игнорировал остальные, и, уже его и использовать в конфигурации.

struct ModalBagFinder: StackIteratingFinder  {

    func isTarget(_ viewController: BagViewController, with context: Any?) -> Bool {
        return viewController.presentingViewController != nil
    }

}

let screen = StepAssembly(
    finder: ModalBagFinder(),
    factory: XibFactory())
    .using(UINavigationController.pushToNavigation())
    .from(NavigationControllerStep())
    .using(GeneralAction.presentModally())
    .from(GeneralStep.current())
    .assemble()

Вместо заключения


Надеюсь, способы конфигурации роутера стали несколько понятнее. Как я уже говорил, мы используем этот подход в 3х приложениях и еще не столкнулись с ситуацией, где бы он был не достаточно гибким. Библиотека, как и предоставляемая ей реализация роутера, не использует никаких трюков с objective c рантаймом и полностью следует всем концепциям Cocoa Touch, лишь помогая разбить процесс композиции на шаги и выполняет их в заданной последовательности и протестирована с версиями iOS с 9 по 12. Кроме того, данный подход вписывается во все архитектурный паттерны которые подразумевают работу с UIViewController стеком (MVC, MVVM, VIP, RIB, VIPER и т.д.)


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

Tags:
Hubs:
+4
Comments 2
Comments Comments 2

Articles