Pull to refresh

SwiftUI — Custom NavigationView

Reading time12 min
Views4.1K

Я не буду описывать глюки родного NavigationView в SwiftUI, этому и без меня посвящены целый темы обсуждений, пример - Тыц. Просто скажу, что багов много и в один прекрасный момент, мне просто надоело их решать т.к. решения бага (какой нибудь костыль) для iOS 15, могло убить работающий вариант для iOS 14, так же все это отнимало жутко много времени и т.д.. Собственно был создан свой кастомный аналог, который удовлетворял моим требованиям. С его помощью можно управлять анимацией, переходить к указаным представлениям, обозначать маршруты тегом и он не использует UIKit.

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

Примеры использования

Примеры использования

Рекомендую размещать в главном представлении

Маршрутизатор

Дефолтное использование

struct MainView: View {
    
		var body: some View {
        Router{
            MyFirstView()
            MySecondtView()
        }
    }
}

Дефолтное использование с указанием длительности анимации

var body: some View {
    Router(duration: 0.6){
    		MyFirstView()
    }
}

Указание своего универсального перехода

var body: some View {
    Router(transition: .single(AnyTransition.scale(scale: 0.5))){
    		MyFirstView()
    }
}

Указание двух переходов (вперед и назад)

//...код

let doubleFirstTransition = AnyTransition.asymmetric(insertion: .move(edge: .bottom), removal: .move(edge: .top))
let doubleSecondTransition = AnyTransition.asymmetric(insertion: .move(edge: .top), removal: .move(edge: .bottom))

var body: some View {
    Router(transition: .double(doubleFirstTransition, doubleSecondTransition)){
    		MyFirstView()
    }
}

Указание переходов и анимации

var body: some View {
    Router(easing: .spring(), transition: .single(AnyTransition.scale(scale: 0.5))){
    		MyFirstView()
    }
}

Указание только анимации

var body: some View {
    Router(easing: .spring()){
    		MyFirstView()
    }
}

Переходы

Базовое использование предполагает указание ссылки с лейблом внутри маршрутизатора (степень вложенности значения не имеет)

struct ContentView: View {
    var body: some View {
        Router{
            RouteLink(destination: MyFirstView()){
                Text("К первому")
            }
        }
    }
}


struct MyFirstView: View {
    var body: some View {
        ZStack {
            Color.gray
            VStack{
                Text("Первое")
                
                RouteLink(destination: MySecondView(), tag: "второй"){
                    Text("Ко второму")
                }
                RouteLink(action: .back){
                    Text("Назад")
                }
            }
        }
        .ignoresSafeArea()
    }
}

struct MySecondView: View {
    var body: some View {
        ZStack {
            Color.yellow
            VStack{
                Text("Второе")
                
                RouteLink(destination: MyThirdView()){
                    Text("К третьему")
                }
                RouteLink(action: .back){
                    Text("Назад")
                }
                RouteLink(action: .home){
                    Text("На главную")
                }
            }
        }
        .ignoresSafeArea()
    }
}


struct MyThirdView: View {
        
    var body: some View {
        ZStack {
            Color.green
            VStack{
                Text("Третье")
                
                RouteLink(toTag: "второй"){
                    Text("Ко второму")
                }
                RouteLink(action: .back){
                    Text("Назад")
                }
                RouteLink(action: .home){
                    Text("На главную")
                }
            }
        }
        .ignoresSafeArea()
    }
}

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

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

Нам понадобится

  • struct RouterView (корневой View)

  • class RoutingController

  • struct RouterLinkView (для переходов)

  • struct WrapperRoutingView (оболочка для хранения View)

  • enum RoutingTransition (переходы)

  • struct RoutingStack (для хранения наших View)

  • enum RoutingType (тип перехода)

  • enum RoutingAction (действия ссылки - назад и на главную)

У нас будет два перехода (вперед и назад) для анимации по дефолту, определим их

public enum RoutingType {
    case forward
    case backward
}

public указывается т.к. мы будем использовать публичные методы в контроллере, которые в свою очередь будут обладать дефолтными значениями.

Два действия для линка

public enum RoutingAction {
    case back
    case home
}

Далее определим наши переходы и переход по дефолту

public enum RoutingTransition {
    case single(AnyTransition)
    case double(AnyTransition, AnyTransition)
    case none
    case `default`
    
    public static var transitions: (backward: AnyTransition, forward: AnyTransition) {
        (
            AnyTransition.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing)),
            AnyTransition.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))
        )
    }
}

single будет использоваться если необходимо передать простой переход, например scale, а double для случаев, когда мы хотим определить более сложные переходы с разным поведением для forward и backward

Так же нам необходима оболочка для хранения наших View

struct WrapperRoutingView {
    public let tag: String
    public let view: AnyView
    init(_ tag: String, _ view: AnyView){
        self.tag = id
        self.view = view
    }
}

этого уже будет достаточно, для планируемого функционала, но давайте сразу приведем нашу оболочку в соответствие с протоколом Equatable, который наследуется от Hashable, добавив статический метод ==

public static func == (lhs: WrapperRoutingView, rhs: WrapperRoutingView) -> Bool {
		lhs.id == rhs.id
}

и с протоколом Identifiable т.к. мы будем хранить свойство для идентификации, заменив tag на id. По итогу получим вот такой тип.

struct WrapperRoutingView: Equatable, Identifiable {
    public let id: String
    public let view: AnyView
    init(_ id: String, _ view: AnyView){
        self.id = id
        self.view = view
    }
    public static func == (lhs: WrapperRoutingView, rhs: WrapperRoutingView) -> Bool {
        lhs.id == rhs.id
    }
}

Теперь определим stack для хранения наших View в оболочках

struct RoutingStack {
    
    private var storage: [WrapperRoutingView] = []  
}

и теперь нам нужны следующие возможности для:

  • Добавления

  • Полной очистки

  • Удаление последнего элемента

  • Получение последнего элемента

  • Проверка наличия элемента с тегом

  • Перемещения элемента с указанным тегом в конец

  • Получения индекса по тегу(так как мы планируем использовать взаимодействие с тегом в двух разных реализациях)

internal mutating func append(_ view: WrapperRoutingView){
    storage.append(view)
}

internal mutating func removeAll() {
    storage.removeAll()
}

public mutating func removeLast() {
    if storage.count > 0 { storage.removeLast() }
}

internal var last: WrapperRoutingView? { storage.last }

public func checkTag(tag: String) -> Bool {
    getIndex(tag) == nil ? false : true
}

public mutating func move(tag: String, force: Bool = false) {
    let index = getIndex(tag)
    
    if index == nil && checkTag(tag: tag) || force {
        storage.append(storage.remove(at: index!))
    } else {
        removeLast()
    }
}

private func getIndex(_ tag: String) -> Int? {
    let cell = self.storage.firstIndex(where: { $0.id == tag })
    return cell
}

почему в методе move, в случае провала проверок, происходит удаление последнего элемента, поймете чуть позже.

о force так же позже

перед удалением последенго элемента мы убеждаемся, что не работаем с пустым массивом т.к. removeLast нельзя с ним использовать

метод массива firstIndex вернет первый попавший под условие индекс (то есть одинаковые теги потенциально возможны) либо nil

остальное полагаю понятно

Создадим контроллер, который будет соответствовать ObservableObject т.к. нам необходимы уведомления об изменениях для корректной работы с View (рендер при изменении)

public final class RoutingController: ObservableObject{
  
}

Что понадобится:

  • Наблюдаемое свойство с нашим стеком

  • Анимация переходов

  • Свойство с текущим типом перехода

  • Опциональное свойство с текущим View

  • Метод возврата к главному View

  • Метод возврата назад

  • Метод перехода к View

  • Метод перехода к View по тегу

private var stack: RoutingStack {
    didSet {
        withAnimation(self.easing) {
            self.current = self.stack.last
        }
    }
}

private let easing: Animation

private(set) var routingType: RoutingType
    
@Published internal var current: WrapperRoutingView?

public func home(routingType: RoutingType = .backward){
    self.routingType = routingType
    self.stack.removeAll()
}

public func back(routingType: RoutingType = .backward){
    self.routingType = routingType
    self.stack.removeLast()
}

public func goTo<Element: View>(
    element: Element,
    tag: String = UUID().uuidString,
    routingType: RoutingType = .forward)
{
    self.routingType = routingType
    if element is MainRouter { self.home() }
    stack.append(WrapperRoutingView(tag, AnyView(element)))
}

public func goTo(toTag tag: String, routingType: RoutingType = .backward, force: Bool = false){
    self.stack.move(tag: tag, force: force)
}

Вы могли обратить внимание, на метод goTo, который осуществляет переход к View по тегу. В нем так же присутствует аргумент force. Теперь объясню почему он там и зачем. У нас нет жесткого ограничения на уникальность тегов, по умолчанию конечно выполняется проверка на уникальность и возврат к предыдущему View, но с помощью force, мы можем игнорировать альтернативное действие и продолжить выполнение. Полезно в ситуациях, когда несколько одинаковых View, имеют одинаковый тег и нам по сути, без разницы, к какому из них переходить. Учитывая, что метод для получения индекса возвращает только один индекс, это вполне допустимо. Конечно стоит отметить - использование разных View с одинаковым тегом, приведет к неопределенному поведении, поэтому по умолчанию force в false.

В остальном я думаю все понятно, логика максимально простая. Есть стек, возможность работать со стеком и посредством манипуляций с массивом внутри стека, мы получаем необходимое View последним элементом, от которого зависит свойство current (при изменении стека, в него попадает последний View). Анимацию мы определяем в наблюдателе. Осталось добавить инициализатор

public init(_ easing: Animation){
    self.easing = easing
    self.stack = RoutingStack()
    self.routingType = .forward
}

Теперь сделаем наш главный View, который будет содержать в себе все прочие View.

struct RouterView: View {
    
    var body: some View {
        ZStack{

        }
    }
}

Нам будет нужно:

  • Контроллер

  • Свойство с переходами

  • Вычисляемое свойство с текущим переходом (для удобства)

  • свойство с главным View

Но перед этим, т.к. мы будем использовать @ViewBuilder, необходимо определить тип соответствующий View

struct RouterView<Main>: View where Main: View {
	//...код
}

реализуем свойства

@StateObject private var controller: RoutingController

private let transitionsCell: (backward: AnyTransition, forward: AnyTransition)

private let main: Main

private var transitions: AnyTransition {
    controller.routingType == .forward ? transitionsCell.forward : transitionsCell.backward
}

и два инициализатора. Первый предполагает использование анимации по умолчанию и позволяет нам задавать длительность анимации.

public init(
    duration: Double,
    transition: RoutingTransition = .default,
    @ViewBuilder main: () -> Main)
{
    self.init(
        easing: Animation.easeOut(duration: duration),
        transition: transition,
        main: main
    )
}

public init(
    easing: Animation = Animation.easeOut(duration: 0.4),
    transition: RoutingTransition = .default,
    @ViewBuilder main: () -> Main)
{
    self.main = main()
    self._controller = StateObject(wrappedValue: RoutingController(easing))
    
    switch transition {
    case .single(let ani):
        self.transitionsCell = (ani, ani)
    case .double(let first, let second):
        self.transitionsCell = (first, second)
    case .none:
        self.transitionsCell = (.identity, .identity)
    default:
        self.transitionsCell = RoutingTransition.transitions
    }
}

Если у Вас возник вопрос, касательно участка кода self._controller = StateObject(wrappedValue: RoutingController(easing)) , то о присваивании свойств в оболочки, я писал вот здесь - Тыц.

Зададим логику для body

//...код

var body: some View {
    ZStack{
        if (controller.current == nil){
            main
                .transition(transitions)
                .environmentObject(controller)
        } else {
            controller.current!.view
                .transition(transitions)
                .environmentObject(controller)
        }
    }
}

//...код

environmentObject пробрасываем для потенциального управления маршрутизацией во внутренних представлениях.

Небольшое отступление, можно пропустить

При желании, реализацию можно сделать более «изящно» и вынести View в вычисляемое свойство, но учитывайте:

1. main должен быть AnyView.


//...код

var body: some View {
    ZStack{
				isShowedView
						.transition(transitions)
						.environmentObject(controller)
    }
}

//...код

Вариант 1 (более логичный)

//...код

public init(
    easing: Animation = Animation.easeOut(duration: 0.4),
    transition: RoutingTransition = .default,
    @ViewBuilder main: () -> Main)
{
    self.main = AnyView(main())

//...код

private let main: AnyView

private var isShowedView: AnyView {
		(controller.current == nil) ? main : controller.current!.view
}

//...код

Вариант 2 (попроще)

//...код

private var isShowedView: AnyView {
		(controller.current == nil) ? AnyView(main) : controller.current!.view
}

//...код
  1. Присуствие жирного нюанса.

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

var body: some View {
    ZStack {
        if true {
            isShowedView
                .transition(transitions)
                .environmentObject(controller)
        }
    }
}

И это будет работать. Добро пожаловать в SwiftUI так сказать.

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

Но и не обозначить этот момент я тоже не мог.

Теперь осталось определить View для линка и действий. Он будет принимать два типа, один для перехода, второй для лейбла, а т.к. нам вновь поможет @ViewBuilder, мы сразу же определим их.

struct RouteLinkView<Destination, Label>:
    View where Destination: View, Label: View {
}

Нам потребуется:

  • Свойство для лейбла

  • Опциональное свойство для назначения

  • Опциональное свойство с тегом

  • Упомянутое выше войство force

  • Свойство с вариантом действий

  • Метод нажатия, который делегирует выолнение вспомогательным методам

  • Вспомогательный метод для обработки переходов

  • Вспомогательный метод для обработки действий

private let label: Label
private let destination: Destination?
private let tag: String?
private let force: Bool
private let action: RoutingAction?
    
private func go() {
    if action == nil {
        toTransition()
    } else {
        toAction()
    }
}

private func toTransition(){
    if destination != nil {
        controller.goTo(element: destination!, tag: self.tag!)
    } else {
        controller.goTo(toTag: self.tag!, force: force)
    }
}

private func toAction(){
    switch self.action! {
    case .back:
        controller.back()
    case .home:
        controller.home()
    }
}

Реализуем три инициализатора. Для перехода по тегу, View и для действий соответственно. Первый даст возможность указать назначение и задать тег. Второй позволит указать тег для перехода т.к. определять подобную логику в рамках одного инициализатора будет крайне громоздским решением. Третий игнорирует теги и назначение, задавая тип действия.

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

init
(
    destination: Destination,
    tag: String = UUID().uuidString,
    @ViewBuilder label: () -> Label
)
{
    self.destination = destination
    self.label = label()
    self.tag = tag
    self.force = false
    self.action = nil
}
extension RouteLinkView where Destination == Never {
    init
    (
        toTag: String = UUID().uuidString,
        force: Bool = false,
        @ViewBuilder label: () -> Label
    )
    {
        self.destination = nil
        self.label = label()
        self.tag = toTag
        self.force = force
        self.action = nil
    }
    
    init
    (
        action: RoutingAction = .back,
        @ViewBuilder label: () -> Label
    )
    {
        self.destination = nil
        self.label = label()
        self.tag = nil
        self.force = false
        self.action = action
    }
}

опишем body

var body: some View {
    Button(action: { go() }) { label }
}

Для обработки нажатий используем Button т.к. это единственный элемент, который корректно обрабатывает нажатия в ScrollView, дает обратную визульную связь и конечно его можно стилизовать, при необходимости.

Собственно базис готов, осталось добавить алиасов для удобного взаимодействия, чтобы пример использования в начале, соответствовал, тому, что мы изобразили.

typealias Router = RouterView
typealias RouteLink = RouteLinkView

Для управления переходами (назад, домой), можно использовать контроллер посредством @EnvironmentObject

struct ContentView: View {
    var body: some View {
        Router{
            RouteLink(destination: TestView()){
                Text("Переход")
            }
        }
    }
}


struct TestView: View {
    @EnvironmentObject private var controller: RoutingController
    var body: some View {
        VStack{
            Button(action: { controller.home() }) { Text("Домой") }
            Button(action: { controller.back() }) { Text("Домой") }
        }
    }
}

как и для переходов по тегу или View тоже.

Button(action: { controller.goTo(element: MyView())}) { Text("Переход") }
Button(action: { controller.goTo(toTag: "Маршрут") } ) { Text("Домой") }

Разумеется можно написать другие View (подобно RouteLinkView) для реализации прочих задач, добавить возможностей как вариант (например Binding свойство isActive), но это уже другая история, на текущюю публикацию, информации достаточно.

Дополнительно

Избегание безвыходности

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

Страховка от передачи ни тех представлений

Если вы перейдете в представление, которое содержит маршрутизатор из которого осуществляется переход, через прямое назначение controller.goTo(element: MainView()), получите утечку памяти. Инициализируется новый контроллер с новым стеком и маршрутами, а доступ к родительскому, будет потерян. Обезапасить себя от такой напасти лишнем не будет. Проекты имеют свойства разрастаться и становиться запутанее. Просто создаем протокол (можно пустой)

protocol MainRouter {}

Затем подписываем содержащее представление на него и

struct ContentView: View, MainRouter {
    
    var isMainView: Bool = true
    
    var body: some View {
        Router{
            RouteLink(destination: TestView()){
                Text("Переход")
            }
        }
    }
}

в местод контроллера goTo вносим условие.

if element is MainRouter { self.home() }

Теперь при передаче любого представления, которое соответствует протоколу MainRouter, будет выполнен переход на главную.

Уязвимость инициализатора

Optional соответсвует View, следовательно в инициализаторах, подобных этому

//...код

init
(
    destination: Destination,
    tag: String = UUID().uuidString,
    @ViewBuilder label: () -> Label
)

//...код

можно так или иначе присвоить Optional(nil), подробнее я уже писал об этом - Тыц

Анимация переходов

Учитывайте, что анимация переходов действует в рамках frame View, следовательно для полноэкранного перехода, Вам необходимо обеспечить его полное заполнение.

Сброс @State

В момент перехода c одного представления на другое, все @State свойста будут сброшены на дефолт (особенность работы SwiftUI), хорошей альтернативой использования, является @StateObject, позволяющий контролировать и хранить состояние при необходимости.

На этом, текущая публикация кончается, хорошей вам разработки.

See you later...

Tags:
Hubs:
Total votes 2: ↑2 and ↓0+2
Comments0

Articles