Site icon AppTractor

Удобная навигация в SwiftUI для iOS 16 и выше

Давайте пропустим известные проблемы с навигацией в SwiftUI до iOS 16, ведь об этом уже написано множество отличных статей. С релизом iOS 18 минимальные цели скоро сместятся ближе к iOS 16.

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

Реализация

Наш подход будет основана на модели экрана, где мы можем разместить все необходимое для его представлений — давайте создадим немного рок-н-ролла!

/// Example of routes
enum Route {
  case goToServices(PasswordViewModel, PasswordTagViewModel)
  case addPassword(Service, PasswordViewModel, PasswordTagViewModel)
  case editPassword(PasswordViewModel, PasswordTagViewModel)
}

После определения экранов мы переходим к логике маршрутизации. Для этого нам нужен класс, соответствующий ObservableObject. С самого начала мы пометим его атрибутом @MainActor, чтобы избежать использования средств синхронизации, таких как NSLock(). Атрибут @MainActor гарантирует, что все методы и свойства класса Router будут выполняться в главном потоке, предотвращая проблемы гонки данных при обращении к пути или другим свойствам.

final class Router: ObservableObject {
  // MARK: - Public properties
  
  @Published var path = NavigationPath()
  // Any path you want to navigate to is already added here.
}

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

@ViewBuilder
    func view(for route: Route) -> some View {
        switch route {
        case let .goToServices(passwordViewModel, passwordTagViewModel):
            ServicesView(
                passwordViewModel: passwordViewModel,
                passwordTagViewModel: passwordTagViewModel
            )
        case let .addPassword(service, passwordViewModel, passwordTagViewModel):
            AddPasswordView(
                service: service,
                passwordViewModel: passwordViewModel,
                passwordTagViewModel: passwordTagViewModel
            )
        case let .editPassword(passwordViewModel, passwordTagViewModel):
            EditPasswordView(
                passwordViewModel: passwordViewModel,
                passwordTagViewModel: passwordTagViewModel
            )
        }
    }

Последний шаг прост: давайте опишем основную логику маршрутизации.

@inlinable
@inline(__always)
func push(_ appRoute: Route) {
  path.append(appRoute)
}

@inlinable
@inline(__always)
func pop() {
  guard !path.isEmpty else { return }
  path.removeLast()
}

@inlinable
@inline(__always)
func popToRoot() {
  path.removeLast(path.count)
}

Самое сложное уже позади. Теперь, чтобы заставить это чудо работать, нам нужно написать корневое представление маршрутизации, которое мы сделаем один раз и забудем о нем.

struct RouterView<Content: View>: View {
    @inlinable
    init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content()
    }

    var body: some View {
        NavigationStack(path: $router.path) {
            content
                .navigationDestination(for: Router.Route.self) {
                    router.view(for: $0)
                        .navigationBarBackButtonHidden()
                }
        }
        .environmentObject(router)
    }

    @StateObject private var router = Router()
    private let content: Content
}

Если кто не знал, модификатор .navigationBarBackButtonHidden() немного ломает родную функциональность пролистывания назад. К счастью, я знаю, как это исправить. Поскольку большая часть SwiftUI все еще построена на UIKit, решение будет довольно простым:

extension UINavigationController {
    override open func viewDidLoad() {
        super.viewDidLoad()
        interactivePopGestureRecognizer?.delegate = nil
    }
}

Отлично! Теперь давайте потыкаем в это палкой.

Как реализовать это в коде и использовать?

Надеюсь, у вас есть корневое представление. Если нет, вам придется его создать.

import SwiftUI

struct RootView: View {
    var body: some View {
        RouterView {
            // We put the necessary screens here,
            // write the display logic, and success!
            ZStack {
                TabBarView()
                    .toolbar(.hidden, for: .navigationBar)
            }
        }
        .task {
            isNeedUpdate = await appUpdateManager.isUpdateRequired()
        }
        .sheet(isPresented: $isNeedUpdate) {
            VStack {
                Text("Update app")
            }
            .interactiveDismissDisabled()
        }
    }

    // This is the code section with my variables. 
    // You don't need to implement it.
    @State private var isNeedUpdate = false
    private let appUpdateManager = AppUpdateManagerImpl()
}

Пришло время наконец протестировать наш роутер. Поскольку мы умные люди и не хотим передавать ObservedObject на каждый экран, мы воспользуемся отличным EnvironmentObject. В будущем нам не понадобятся модификаторы .environmentObject(_), потому что мы справились с этим в представлении маршрутизации.

import SwiftUI

struct TestView: View {
    var body: some View {
        VStack {
            Button {
                router.push(
                    .service(
                        passwordViewModel,
                        passwordTagViewModel
                    )
                )
            } label: {
                ZStack {
                    RoundedRectangle(cornerRadius: 20)
                        .fill(.blue)
                        .frame(height: 60)
                    
                    Text("Services")
                        .font(
                            .system(
                                size: 16,
                                weight: .bold,
                                design: .rounded
                            )
                        )
                        .foregroundStyle(.white)
                }
            }

            Button {
                router.push(
                    .addPassword(
                        service,
                        passwordViewModel,
                        passwordTagViewModel
                    )
                )
            } label: {
                ZStack {
                    RoundedRectangle(cornerRadius: 20)
                        .fill(.blue)
                        .frame(height: 60)
                    
                    Text("Add Password")
                        .font(
                            .system(
                                size: 16,
                                weight: .bold,
                                design: .rounded
                            )
                        )
                        .foregroundStyle(.white)
                }
            }

            Button {
                router.push(
                    .editPassword(
                        passwordViewModel,
                        passwordTagViewModel
                    )
                )
            } label: {
                ZStack {
                    RoundedRectangle(cornerRadius: 20)
                        .fill(.blue)
                        .frame(height: 60)
                    
                    Text("Edit Password")
                        .font(
                            .system(
                                size: 16,
                                weight: .bold,
                                design: .rounded
                            )
                        )
                        .foregroundStyle(.white)
                }
            }
        }
        .padding(.horizontal)
    }
    
    @StateObject private var passwordViewModel = PasswordViewModel(manager: .shared)
    @StateObject private var passwordTagViewModel = PasswordTagViewModel(manager: .shared)
    @EnvironmentObject private var router: Router
    private var service = PasswordService(id: .zero, title: "Medium", url: "medium.com", icon: Data())
}

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

router.pop()
router.popToRoot()

Опыт использования в продакшене

Со временем я перепробовал множество методов навигации, но этот оказался самым удачным и приятным для меня в реализации. Этот пример хорошо масштабируется как по горизонтали, так и по вертикали и отлично подходит для покрытия UI и Unit-тестами.

Больше интересных вещей можно найти на моем LinkedIn.

Источник

Exit mobile version