Как стать автором
Обновить

SwiftUI. Есть ли жизнь без NavigationView или пару слов о координаторе

Время на прочтение7 мин
Количество просмотров4K

В далекие – далекие времена, когда iOS была совсем маленькой, разработчики, гордо именуемые iOS-девелоперами, задумались о кастомизации навигационного стека. Не то что навигационный стек был плох – он отлично вписывался в картину мира Apple, но вот навигационная панель часто была «бельмом в глазу» для пользователей и дизайнеров. Поэтому разработчики применяли простой трюк – скрывали панель в приложении, а вместо нее показывали свою собственную панель, со своим собственным дизайном интерфейса, управляющие элементы которого были привязаны все к тем же методам push и pop доступных им из коробки.

Со временем, даже Apple поняли, что так дальше жить нельзя, выпустив iOS 7... Сколько негатива вылилось на головы разработчиков... Но те кто научился кастомизировать панель навигации, выбрались из тех мрачных времен весьма достойно.<cut />

...пока на горизонте не замельтешил SwiftUI.

На этом присказка закончилась

Те из разработчиков, кто перешел на SwiftUI рано или поздно сталкиваются с проблемой непредсказуемого поведения NavigationView. Обычно это происходит при повторном входе на отображаемое вью в стеке навигации – почему-то навигационная панель смещается, по отношению к ранее заданному положению. А при использовании контейнера совместно с UIKit может приводить к появлению дублирующей навигации. Кроме того, навигационное вью вызывает множество проблем при использовании других компонентов. Все это наводит на мысль, что SwiftUI слишком сырой, для использовании его в коммерческих проектах. (На секундочку, на текущий момент анонсирован SwiftUI 4 который станет доступен в iOS16). 

Несколько лет разработчики ждали, что Apple исправит широко известные вопиющие проблемы навигации, и надежда появилась, после заявления о том, что NavigationView будет депрекейтнут, а в SwiftUI 4 появится новый удобный способ навигации. Исследование данного вопроса показали, что кода можно писать немого меньше, но это можно легко было сделать без Apple, просто использовав switch вместо if, а вот проблемы навигации никуда не пропали – все те же прежние проблемы появились и в новых компонентах навигации.

SwiftUI слишком хорош, чтоб игнорировать его из-за его детских болезней. Но, увы, многие делают это просто потому что не могут обойти набившие оскомину ошибки. Причем, в профессиональных сообществах часто возникают дискуссии, а можно ли вообще обойтись без NavigationView.

Что удивительно, сделать это не просто, а очень просто. Но, на пути к решению возникает некоторые камушки, не переступив через которые невозможно двигаться дальше. Те кто их преодолел, даже не вспоминают об возникших трудностях, остальные, ждут спасительного чуда от Apple, которая, конечно же, готова протянуть руку помощи нуждающимися, сделав некоторые изменения относительно AnyView в Swift 5.7. Правда, это все будет доступно в новой iOS, и всем, кто хочет продолжать работать с более ранней версией таргета придется обновиться.

В дискуссиях о том, как можно обойти «проблему» часто возникает понятия «Координатор». Для тех, у кого имеется опыт работы с UIKit – паттер координатор хорошо знаком, поскольку он позволяет упростить навигацию, и сделать ее доступной из любой части приложения. Но вот те, кто «рождаются на свет» вместе со SwiftUI задаются вопросами, собственно о чем идет речь, и не понимают как обсуждаемый паттерн может помочь решить проблему навигации в их приложении.

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

Demo
Demo

Допустим, мы хотим сделать навигацию между цепочкой View. Это могут какие угодно some View. Но для простоты мы будем использовать цветные вью. Цвет каждого вью будет отличаться от другого в последовательности цветов радуги. Единственная особенность такого вью будет наличие кнопки «Back».

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

Coordinator.next(state: .root)
Coordinator.next(state: .end)

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

Coordinator.push(view: ColoredView(index: index + 1))
Coordinator.pop()

Здесь index  – это номер цвета в массиве Colors от красного к пурпурному).

Выглядит просто? Кажется, что задача по реализации самого ColoredView – это, задача trainee-уровня.

Hidden text
import SwiftUI

struct ColoredView: View {
    var index: Int = 0

    private (set) var colors:[Color] = [.red, .orange, .yellow, .green, .teal, .blue, .purple]
    
    var body: some View {
        ZStack {
            TitlePlace()
            HelloPlace()
            NextPlace()
            ModalaPlace()
            CoordinatorPlace()
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(viewColor)
    }
    
    private var viewColor: Color {
        return colors[index]
    }
    
    private func HelloPlace() -> some View {
        VStack {
            Text("Hello, World!")
            Text("Index \(index)")
        }
        .foregroundColor(.white)
    }
    
    private func TitlePlace() -> some View {
        VStack {
            HStack {
                if index > 0 {
                    Button {
                        Coordinator.pop()
                    } label: {
                        Image(systemName: "arrow.left")
                            .foregroundColor(.white)

                    }
                    .padding()
                }
                Spacer()
            }
            .frame(height: 44)
            .frame(maxWidth: .infinity)
            Spacer()
        }
    }
    
    private func NextPlace() -> some View {
        VStack {
            if index < colors.count - 1 {
                Button {
                    Coordinator.push(view: ColoredView(index: index + 1))

                } label: {
                    Image(systemName: "arrow.right")
                        .foregroundColor(.white)
                }
            }
        }
        .offset(y: 44)
    }

    private func ModalaPlace() -> some View {
        VStack {
            Button {
                Coordinator.modal(view: ModalView())
            } label: {
                Text("SHOW MODAL")
                    .foregroundColor(.white)
                    .fontWeight(.heavy)
            }
        }
        .offset(y: 120)
    }

    private func CoordinatorPlace() -> some View {
        HStack {
            Button {
                Coordinator.next(state: .root)
            } label: {
                Text("ROOT")
                    .foregroundColor(.white)
                    .fontWeight(.heavy)
            }
            .padding()
            .border(.white, width: 1)

            Button {
                Coordinator.next(state: .end)
            } label: {
                Text("END")
                    .foregroundColor(.white)
                    .fontWeight(.heavy)
            }
            .padding()
            .border(.white, width: 1)
        }
        .offset(y: 200)
    }

}

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

Реализация ColoredView и ModalView отличаются только кнопками в заголовке окна, в соответствии с общепринятой практикой – кнопка «Back» возвращает к предыдущему вью, а кнопка с крестиком – закрывает модальное окно. Ну и по логике, модальное окно закрывается, при выполнении конечного действия.

Немного интересней, но не намного сложнее – класс координатора.

Во-первых, здесь присутствует обобщающая структура ContainerView.

struct ContainerView : View {
    var view : AnyView

    init<V>(view: V) where V: View {
        self.view = AnyView(view)
    }

    var body: some View {
        view
       }
}

Во-вторых, весь координатор выполнен в виде синглтона. Синглтон – это не лучшая практика при мобильной разработки, но, в данном случае он позволяет упростить код, чтоб показать, действительно важные части. Вы же, можете использовать Dependency Injection (DI), ServiceLocator или EnvirontmentObject для его реализации. Любой из этих способов будет работать схожим образом.

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

Hidden text
import SwiftUI

let Coordinator = CoordinatorService.instance

struct ContainerView : View {
    var view : AnyView

    init<V>(view: V) where V: View {
        self.view = AnyView(view)
    }

    var body: some View {
        view
       }
}


final class CoordinatorService: ObservableObject {
    
    enum State {
        case root
        case end
    }
    
    static let instance = CoordinatorService()
    
    @Published var modalVisibled = false
    @Published var modalView : ContainerView!
    @Published var container : ContainerView!

    private var stack  = [ContainerView]()
    
    private init() {
        self.push(view: ColoredView(index: 0))
    }

    func pop() {
        guard self.stack.count > 1 else { return }
        self.stack.remove(at: self.stack.count - 1)
        guard let last = self.stack.last else { return }
        self.container = last
    }
    
    func push<V: View>(view: V) {
        let containered = ContainerView(view: view)
        self.stack += [containered]
        self.container = containered
    }
    
    
    func modal<V: View>(view: V) {
        self.modalView = ContainerView(view: view)
        withAnimation {
            self.modalVisibled.toggle()
        }
    }

    func close() {
        withAnimation {
            self.modalVisibled.toggle()
        }
    }
    
    func next(state: State) {
        switch state {
            case .root :
                self.stack.removeAll()
                self.push(view: ColoredView())
            case.end:
                self.push(view: ColoredView(index: 6))
        }
    }
}

 При создании экземпляра класса в стек навигации пушится вью, с индексом 0, что соответствует красному цвету в известной мнемонике: «Каждый охотник желает знать, где сидит фазан». В принципе, не важно, какое вью туда запушить. Важно, только чтоб было хоть какое-то вью. EmptyView для этой роли не очень подходит,так как не имеет интерактивных элементов управления.

Методы «push» и «modal» работают с обобщенным вью. Каждый из них принимает вью, укладывает его в удобный контейнер, а затем сетит контейнер к «наблюдаемым» переменным, что в тот же момент приводит к изменению окон навигации.

Дополнительно к этом, метод «push» укладывает контейнер в стек навигации. Реализацию стека можно сделать «сто-пятьсот» способами – приведенный, достаточно очевиден. Комплиментарный метод «pop» удаляет последний элемент из стека, после чего, актуализирует последний в стеке элемент, через переменную «container».

Обычно, дополнительно к методу «pop», еще добавляют метод «popToRoot». Но тогда было бы сложно ответить на следующий вопрос: «А какое все это отношение имеет к паттерну координтор»? Вот как раз для того, чтоб позволить перемещаться к любому целевому вью, был добавлен метод «next» в который мы передаем наш таргет. Сами же вью описываем как выбор состояний при помощи switch (Прошу не путать с паттерном состояний – сейчас он не имеется ввиду, хотя его тоже можно было бы реализовать и заменить им switch. Но, в рамках статьи switch куда проще для понимания).

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

import SwiftUI

struct ContentView: View {
    @ObservedObject private var coordinator = Coordinator
    
    var body: some View {
        ZStack {
            coordinator.container.view
            if coordinator.modalVisibled {
                coordinator.modalView
                    .transition(.move(edge: .bottom))
            }
        }
    }
}

В общем-то на этом все. Демистификация Координатора завершен. Вишенкой можно считать визуальную иерархию, которая доступна для отображения в XCode Debug View Hierarchy. После полного прохода через все семь вью, в визуальной иерархии содержится только последнее отображаемое вью с единственным вью-контроллером. Сравните с тем, что обычно бывает при использовании UIKit.

XCode Debug View Hierarchy
XCode Debug View Hierarchy

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

Исходный код можно взять здесь на GitHub.

Теги:
Хабы:
Всего голосов 4: ↑2 и ↓2+1
Комментарии2

Публикации

Истории

Работа

Swift разработчик
26 вакансий
iOS разработчик
23 вакансии

Ближайшие события