В этой статье я продемонстрирую, как можно использовать паттерн 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, и для чего их можно использовать. Регистрация доступна по ссылке для всех желающих.