В этой статье я продемонстрирую, как можно использовать паттерн Flow Coordinator (далее флоу-координатор) в SwiftUI, чтобы отделить логику навигации от логики представления.

В UIKit этот шаблон был очень популярен - координаторы такого рода позволяют легко заменять или создавать новые вью-контроллеры (view controller), отделяя эту работу от кода вью-контроллеров и вью-моделей (view model). Это позволяет разделить во вью-контроллере код, затрагивающий саму вьюху, и навигацию, что в свою очередь облегчает изменение “потока приложения” (того самого флоу).

В некоторой степени подобное можно реализовать и в SwiftUI.

1. Навигационные примитивы в SwiftUI

Большую часть навигации в SwiftUI можно выполнить с помощью @Binding, сохраняющего состояние активации навигации, а также специальных модификаторов и вьюх SwiftUI, таких как fullScreenCover, sheet, alert, confirmationDialog или NavigationLink:

NavigationLink( “Purple”, destination: ColorDetail(color: .purple), isActive: $shouldShowPurple)
NavigationLink(tag: .firstLink, selection: activeLink, destination: firstDestination) { EmptyView() }

И для представления модальных окон мы можем использовать что-то вроде это:

view .sheet(isPresented: $isShowingSheet, onDismiss: didDismiss) { 
  Text(“License Agreement”)
 } 
view.sheet(item: sheetItem, content: sheetContent)

2. Вью и вью-модель 

Обычно мы отделяем логику вью от бизнес-логики (или подготовки данных вью) путем разделения кода на непосредственно вью (View) и вью-модель (ViewModel).

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

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

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

struct ContentView<VM: ContentViewModelProtocol>: View {

    @StateObject var viewModel: VM

    var body: some View {
        ZStack {
            Color.white.ignoresSafeArea()

            VStack {
                Text(viewModel.text)

                Button("First view >", action: viewModel.firstAction)
            }
        }
        .navigationBarTitle("Title", displayMode: .inline)
    }
}

Вью-модель для этого вью будет подготавливать текст для отображения и обрабатывать firstAction следующим образом:

protocol ContentViewModelProtocol: ObservableObject {
    var text: String { get }

    func firstAction()
}

final class ContentViewModel: ContentViewModelProtocol {

    let text: String = "Content View"

    init() { }

    func firstAction() {
        // handle action
    }
}

3. Создаем флоу-координатор

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

1. Флоу-координатор — это вью.

2. У нас должны быть флоу-координаторы для каждого экрана.

3. События навигации должны передаваться флоу-координатору из вью-модели.

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

3.1 Создаем протокол, представляющий состояние флоу-координатора 

Этот протокол позволяет нам передавать события навигации из вью-модели во флоу-координатор.

protocol ContentFlowStateProtocol: ObservableObject {
    var activeLink: ContentLink? { get set }
}

ContentLink — это перечисление, представляющее различные навигационные события/экшены.

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

Итак, наша полная ContentViewModel, обрабатывающая несколько пользовательских действий и реализующая ContentFlowStateProtocol, может выглядеть следующим образом:

protocol ContentViewModelProtocol: ObservableObject {
    var text: String { get }

    func firstAction()
    func secondAction()
    func thirdAction()
    func sheetAction()
}

final class ContentViewModel: ContentViewModelProtocol, ContentFlowStateProtocol {

    // MARK: - Flow State
    @Published var activeLink: ContentLink?

    // MARK: - View Model
    let text: String = "Content View"

    init() { }

    func firstAction() {
        activeLink = .firstLinkParametrized(text: "Some param")
    }

    func secondAction() {
        activeLink = .secondLinkParametrized(number: 2)
    }

    func thirdAction() {
        activeLink = .thirdLink
    }

    func sheetAction() {
        activeLink = .sheetLink(item: "Sheet param")
    }
}

3.2 Создаем перечисления ContentLink для навигационных событий 

Это перечисление определяет различные навигационные события, которые могут происходить в рамках экрана нашего приложения. Этим событиям могут передаваться параметры. Кроме того перечисление ContentLink должно быть Identifiable и Hashable.

enum ContentLink: Hashable, Identifiable {
    case firstLink
    case firstLinkParametrized(text: String)
    case secondLink
    case secondLinkParametrized(number: Int)
    case thirdLink

    case sheetLink(item: String)

    var navigationLink: ContentLink {
        switch self {
        case .firstLinkParametrized:
            return .firstLink
        case .secondLinkParametrized:
            return .secondLink
        default:
            return self
        }
    }

    var sheetItem: ContentLink? {
        switch self {
        case .sheetLink:
            return self
        default:
            return nil
        }
    }

    var id: String {
        switch self {
        case .firstLink, .firstLinkParametrized:
            return "first"
        case .secondLink, .secondLinkParametrized:
            return "second"
        case .thirdLink:
            return "third"
        case let .sheetLink(item):
            return item
        }
    }
}

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

3.3 Реализуем вью флоу-координаторов для каждого экрана

Наиболее важной частью нашего паттерна флоу-координатора является вью ContentFlowCoordinator. Оно будет обрабатывать всю логику навигации по экрану.

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

struct ContentFlowCoordinator<State: ContentFlowStateProtocol, Content: View>: View {

    @ObservedObject var state: State
    let content: () -> Content

    private var activeLink: Binding<ContentLink?> {
        $state.activeLink.map(get: { $0?.navigationLink }, set: { $0 })
    }

    private var sheetItem: Binding<ContentLink?> {
        $state.activeLink.map(get: { $0?.sheetItem }, set: { $0 })
    }

    var body: some View {
        NavigationView {
            ZStack {
                content()
                    .sheet(item: sheetItem, content: sheetContent)

                navigationLinks
            }
        }
        .navigationViewStyle(.stack)
    }

    @ViewBuilder private var navigationLinks: some View {
        NavigationLink(tag: .firstLink, selection: activeLink, destination: firstDestination) { EmptyView() }
        NavigationLink(tag: .secondLink, selection: activeLink, destination: secondDestination) { EmptyView() }
        NavigationLink(tag: .thirdLink, selection: activeLink, destination: secondDestination) { EmptyView() }
    }

    private func firstDestination() -> some View {
        var text: String?
        if case let .firstLinkParametrized(param) = state.activeLink {
            text = param
        }

        let viewModel = FirstViewModel(text: text)
        let view = FirstView(viewModel: viewModel)
        return view 
    }

    private func secondDestination() -> some View {
        var number: Int?
        if case let .secondLinkParametrized(param) = state.activeLink {
            number = param
        }

        let viewModel = SecondViewModel(number: number)
        let view = SecondView(viewModel: viewModel)
        return view
    }

    private func thirdDestination() -> some View {
        let viewModel = ThirdViewModel()
        let view = ThirdView(viewModel: viewModel)
        return view
    }

    @ViewBuilder private func sheetContent(sheetItem: ContentLink) -> some View {
        switch sheetItem {
        case let .sheetLink(item):
            SheetView(viewModel: SheetViewModel(text: item))
        default:
            EmptyView()
        }
    }
}

Во-первых, его init (здесь неявная) должна иметь параметры, использующие дженерики.

1. state типа реализующего ContentFlowStateProtocol.

2. content, которое будет @ViewBuilder экранного вью.

Во-вторых state должно быть сохранено как @ObservedObject и оно не должно быть @StateObject, поскольку ContentFlowStateProtocol реализуется ContentViewModel, и эта вью-модель уже будет сохранена как @StateObject на экранном ContentView.

В-третьих, у нас есть вспомогательные биндинги, созданные как вычисляемые свойства, для NavigationLink, т.е. activeLink, и для представления страницы, т.е. sheetItem.

Вся логика навигации реализована внутри вычисляемого свойства тела ContentFlowCoordinator. Там мы можем наблюдать добавленное NavigationView, встроенное свойство navigationLinks и прикрепленный модификатор sheet(item:…).

И последнее, но не менее важное: у нас есть фабричные функции (factory functions), которые создают наши вью назначения/контента. Они извлекают возможные параметры навигационного события, создают с их помощью вью-модель и, наконец, с помощью этой вью-модели само вью.

4. Использование флоу-координатора с вью

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

struct ContentView<VM: ContentViewModelProtocol & ContentFlowStateProtocol>: View {

    @StateObject var viewModel: VM

    var body: some View {
        ContentFlowCoordinator(state: viewModel, content: content)
    }

    @ViewBuilder private func content() -> some View {
        ZStack {
            Color.white.ignoresSafeArea()

            VStack {
                Text(viewModel.text)

                Button("First view >", action: viewModel.firstAction)
                Button("Second view >", action: viewModel.secondAction)
                Button("Third view >", action: viewModel.thirdAction)
                Button("Sheet view", action: viewModel.sheetAction)
            }
        }
        .navigationBarTitle("Title", displayMode: .inline)
    }
}

Мы также добавили несколько дополнительных экшенов в это вью.

Если вам интересно самим потрогать исходный код, вы можете клонировать репозиторий и протестировать этот паттерн флоу-контроллера в действии, просто перейдя по этой ссылке на мой github.


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

Комментарии (0)