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

6035d5342a21c6b4141f2de5a43c4265.jpeg
Весь цикл
  1. SwiftUI (iOS 15). Есть ли жизнь без NavigationView или пару слов о координаторе

  2. SwiftUI (iOS 16+): Навигация по-новому

  3. Разделяемый координатор в SwiftUI

  4. SwiftUI. Навигация по строке в разделяемом координаторе.

Для атомарного перемещения внутрь иерархии вложенных вью весьма удобно, и, главное, просто использовать путь в виде строки. К примеру, строка вида »/auth/a//b/c/profile/a/c» открывает экран «c» в иерархии экранов «profile», что позволяет откатываться назад по «back» аж до самого корня, проходя через каждый экран. А легкое изменение строки на »/profile/c» откроет только нужный экран без остальных степеней вложенности.

Кроме того, когда путь задан строкой, можно с легкостью сохранять и передавать путь в виде параметров команды. Область применения такого пути — безграничная. В честности, такой путь может быть извлечен из:

  1. UserDefaults или любого другого персистивного хранилища

  2. DeepLink или UniversalLink

  3. Параметров Push Notifications

  4. Параметров API

  5. Параметров Schedule

И если для монолитного координатора, у которого перечисление базируется на типа «String» эта задача кажется тривиальной, то в случае с разделяемым координатором придется приложить небольшие усилия.

Но прежде следует понять что указанный путь »/auth/a//b/c/profile/a/c» содержит 8 сегментов, но всего 6 экранов. «auth» и «profile» не представлены собственным экраном. Они лишь указывают на иерархию вложенности, АКА, координатор который будет применен для развертывания последовательности. 

Это вытекает из того, что перечисление, которое описывает секции разделяемого координатора содержит ассоциированные параметры, а следовательно, не может быть унаследовано от типа «String».

Чтоб сохранить строгую типизацию перечисления придется внутрь самого перечисления добавить еще несколько структур данных:

  1. Перечисление с именами секций, унаследованное от «String», которое будет совпадать с именами секций в строковом пути. Как правило, эти же имена используются и для имен секций ассоциированных типов, но это делать не обязательно. Так если Вы делаете вызов экрана Coordinator.next (.auth (.a)), то, строковый путь к этому экрану может быть »/signin/a». И в этом случае, будет удобно использовать различные имена для элементов перечислений.

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

К остальной обвязке относится:

  1. Публичный метод для перехода по пути, заданному строкой.

  2. Метод извлениея строкового пути из корневого координатора.

  3. Приватные методы создания иерарии экранов и перехода внутрь заданной иерархии.

Дисклеймер

Не смотря на то, что некоторые разработчики бояться рекурсии как огня, в данном подходе будет использован рекурсивный вызов приватного метода, потому что «Итерации свойственны человеку. Рекурсия — божественна» © Ален.

Чтоб продемонстрировать работу приложения с указанным координатором, на экран DummyView были добавлены кнопки:

  1. Сохранение пройденного пути в приватную переменную.

  2. Переход по сохраненному пути.

Остальные кнопки остались теми же самыми, которые были использованы в проекте из предыдущей статьи.

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

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

Важно понимание еще одного важного момента:

Когда мы используем метод Coordinator.next (…), мы добавляем новый экран к существующей иерархии. Аналогичное поведение будет, если мы используем setPath (…) c путем вида «auth/c». Но если строка пути будет начинаться со знака слеш (»/auth/c»), то вся иерархия будет развернута от корневого экрана. Поскольку любой экран не может быть представлен без корневого экрана, то, при создании пути методом buildPath () мы всегда будем получать путь от корня.

С целью проверки сделаем переход «Auth c», «Profile a». Затем сохраним пройденный путь при помощи кнопки «Keep current path». В процессе сохранения мы будем автоматически перемещены на корневой экран. После чего можем снова углубиться внутрь пройденной иерархии одном нажатием на «Path by path» без лишней промежуточной анимации. Чтоб убедиться, что мы находимся по правильному, ранее пройденному пути, сделаем выход через кнопку «Back», отображая каждый экран иерархии. Демонстрацию можно увидеть на видео:

demo
demo

 Рассмотрим исходный код.

Как уже ранее говорилось, у перечислению Step было добавлено перечисление Segment с именами секций и свойство screen, возвращающее отмапленный tuple.

enum Step: Hashable {
        enum Segment: String {
            case auth
            case profile
        }

        case auth(_ val: CoordinatorAuth)
        case profile(_ val: CoordinatorProfile)
        
        var screen: (String, String) {
            switch self {
                case .auth(let screen): return (Segment.auth.rawValue, screen.rawValue)
                case .profile(let screen): return (Segment.profile.rawValue, screen.rawValue)
            }
        }

        // ....
    }

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

    func setPath(_ path: String) {
        self.currentSegment = nil
        let array = path.components(separatedBy: "/")
        self.handlePath(array)
    }

Приватный метод handlePath рекурсивно вызывает сам себя, до тех пор, пока на вход передается массив строк. Если массив пустой — то обработка прекращается. Рекурсивный вызов не сложно заменить циклом, если цикл больше нравится. В самом методе, извлекается шаг с вершины массива, и выполняется переход методом handleStep, после чего осуществляется рекурсия.

    private func handlePath(_ array:[String]) {
        guard array.count > 0 else { return }
        var array = array
        let step = array.removeFirst()
        self.handleStep(step)
        self.handlePath(array) // Recursive call
    }

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

    private func handleStep(_ step: String) {
        switch step {
            case "": self.root()                 // if path starts with '/'
            case Step.Segment.auth.rawValue,     // if path has coordinator
            Step.Segment.profile.rawValue:
            self.currentSegment = Step.Segment(rawValue: step)

            default: self.handleCoordinator(step)
        }
    }

Приватный метод handleCoordinator представляет собой маппинг имен секций, на собственно сами секции, с добавлением ассоциированного параметра. После получения элемента с ассоциированным значением, оно добавляется к текущему пути экранов. Поскольку путь экранов является наблюдаемым свойством NavigationStack, то происходит визуальный переход на указанный экран. Так как заданная операция выполняется в главном потоке без передачи управления куда-то во вне, то пользователь видит переход на конечный экран пути. При большом желании, можно модифицировать метод таким образом, чтоб пользователь видел все открывающиеся экраны (но, мне не известны сценарии когда это будет полезно).

    private func handleCoordinator(_ step: String) {
        
        guard let segment = self.currentSegment else { return }
        
        switch segment {
            case .auth: 
                if let c = CoordinatorAuth(rawValue: step) {
                    self.next(.auth(c))
                }
            
            case .profile:
                if let c = CoordinatorProfile(rawValue: step) {
                    self.next(.profile(c))
                }
        }
    }

Как buildPath может быть как публичным так и приватным, в зависимости от постановки задачи в текущем проекте. К примеру, если в приложении используется возможность сохранения черновиков пройденного мастера (визарда) при заполнении форм.

 func buildPath() -> String {
        var fullPath = ""
        var segment = ""
        
        for step in self.path {
            
            let (seg, screen) = step.screen
            
            if segment != seg {
                segment = seg
                fullPath += "/\(seg)"
            }

            fullPath += "/\(screen)"
        }
        
        return fullPath
    }

Итог:

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

Исходный код проекта можно загрузить с GitHub.

Подробности реализации можно обсудить на телеграмм канале.

© Habrahabr.ru