Разработка
Шаблоны проектирования для SwiftUI
Давайте рассмотрим некоторые паттерны, которые можно применить в SwiftUI.
Шаблоны проектирования путают умы людей, работающих со SwiftUI. Они задаются такими вопросами, как «Какой паттерн больше подходит для SwiftUI?» или «Нужно ли вообще использовать шаблон?». Apple также предоставила некоторую информацию об использовании паттернов для SwiftUI на WWDC. Давайте рассмотрим некоторые паттерны, которые можно применить в SwiftUI.
1. MVVM (Model-View-ViewModel)
В MVVM нам нужно обратить внимание на то, чтобы правильно применить логику наблюдаемости, так как в шаблоне есть другой слой представления по сравнению с проектом Swift. Пример класса представления вы можете увидеть ниже.
struct SearchView: View { @ObservedObject var viewModel: SearchViewModel var body: some View { NavigationView { VStack { SearchBarView(text: self.$viewModel.searchTerm, onSearchTermChanged: self.viewModel.onSearchTermChanged) } } } }
Экземпляр SearchViewModel определен как ObservedObject
, поэтому при изменении модели представления UI будет перезагружен, и мы сможем увидеть соответствующие изменения. Еще одним важным моментом здесь является то, что класс ViewModel соответствует протоколу ObservableObject
.
final class SearchViewModel: ObservableObject { }
Мы можем использовать несколько специальных макросов для переменных класса ViewModel, к которым мы хотим применить изменения. Одним из них является макрос @Published
. Мы определили переменную results
в классе SearchViewModel как Published
. Каждый раз, когда переменная results
будет обновляться, соответствующее представление будет перезагружаться.
2. Координатор
Координатор выделяется тем, что предлагает более упрощенную структуру навигационных переходов между представлениями. Другими словами, он позволяет нам взять навигационные операции из представления и использовать их в более управляемой и многократно используемой структуре. Возможно, вы уже слышали о MVVM+C, поэтому мы можем использовать координатор для навигационных операций с MVVM.
Допустим, в проекте есть представление login. Давайте создадим структуру Output
, которая позволит нам перенести навигационные операции из слоя представления. Для этого примера предположим, что из LoginView мы можем перенаправлять на главный экран и экран ForgotPasswordView, поэтому мы создали два метода внутри структуры Output
.
struct LoginView: View { struct Output { var goToMainScreen: () -> Void var goToForgotPassword: () -> Void } var output: Output }
Мы можем осуществлять переход к соответствующим экранам через экземпляр output
, как в примере в body ниже.
var body: some View { Button( action: { self.output.goToMainScreen() }, label: { Text("Login") } ).padding() Button( action: { self.output.goToForgotPassword() }, label: { Text("Forgot password") } ) }
Итак, теперь давайте рассмотрим, как мы можем инициировать экземпляр output
в классе координатора.
Предположим, что у нас есть класс координатора для каждого модуля и главный класс координатора, из которого мы можем управлять всеми классами координаторов. Для этого примера создадим класс AuthenticationCoordinator
для управления операциями навигации в модуле login и класс AppCoordinator для управления всеми координаторами.
Создадим наблюдаемый экземпляр AppCoordinator в главной структуре приложения. Выполним маршрутизацию через NavigationStack и определим путь NavigationStack как appCoordinator.path
, а rootView NavigationStack как appCoordinator.view()
.
@main struct SwiftUI_CApp: App { @StateObject private var appCoordinator = AppCoordinator(path: NavigationPath()) var body: some Scene { WindowGroup { NavigationStack(path: $appCoordinator.path) { appCoordinator.view() .navigationDestination( for: AuthenticationCoordinator.self ) { coordinator in coordinator.view() } } .environmentObject(appCoordinator) } } }
Для .navigationDestination
, если он имеет тип AuthenticationCoordinator
, мы вызываем метод view()
соответствующего координатора.
Теперь давайте посмотрим на rootView из NavigationStack, который является методом view()
координатора AppCoordinator.
final class AppCoordinator: ObservableObject { @Published var path: NavigationPath init(path: NavigationPath) { self.path = path } @ViewBuilder func view() -> some View { MainView() } }
struct MainView: View { @EnvironmentObject var appCoordinator: AppCoordinator var body: some View { Group { AuthenticationCoordinator( page: .login, navigationPath: $appCoordinator.path, output: .init( goToMainScreen: { print("Go to main screen") } ) ).view() } } }
Мы инициализировали MainView в view
методе AppCoordinator. В MainView мы перешли к AuthenticationCoordinator
.
В класс AuthenticationCoordinator
мы инжектировали страницу, на которую хотим перейти, navigationPath
и output
. В классе координатора у нас также есть структура Output
, потому что у него также есть вывод, например, переход на главный экран после завершения операций входа в систему.
enum AuthenticationPage { case login, forgotPassword } final class AuthenticationCoordinator: Hashable { @Binding var navigationPath: NavigationPath private var output: Output? private var page: AuthenticationPage struct Output { var goToMainScreen: () -> Void } init( page: AuthenticationPage, navigationPath: Binding<NavigationPath>, output: Output? = nil ) { self.page = page self.output = output self._navigationPath = navigationPath } }
После создания AuthenticationCoordinator
в MainView мы вызвали метод view()
, который я напишу ниже. С помощью этого метода мы создаем экземпляр соответствующего экрана (login или forgotPassword). Таким образом, мы инициируем соответствующее представление.
@ViewBuilder func view() -> some View { switch self.page { case .login: loginView() case .forgotPassword: forgotPasswordView() } } private func loginView() -> some View { let loginView = LoginView(output: .init(goToMainScreen: { self.output?.goToMainScreen()}, goToForgotPassword: { self.push(AuthenticationCoordinator(page: .forgotPassword, navigationPath: self.$navigationPath)) })) return loginView } private func forgotPasswordView() -> some View { let forgotPasswordView = ForgotPasswordView(output: .init(goToForgotPasswordWebsite: { self.goToForgotPasswordWebsite() })) return forgotPasswordView }
3. Clean Swift (VIP)
В Clean Swift мы выполняем все операции обновления представления с помощью наблюдения. Мы можем начать применять шаблон, создав шаблон VIP. В этом примере есть 4 различных слоя (Worker-Presenter-Interactor-View). В ContentView мы вызываем метод builder
, который позволяет нам инициализировать экземпляры слоев.
struct ContentView: View { var body: some View { CreateChocolateBuilder.build() } }
Мы определяем переменную interactor
в слое представления, чтобы запустить use-cases. Также мы инициализируем наблюдаемую displayModel
, чтобы быть в курсе обновлений в viewModel
и применять их к представлению.
struct CreateChocolateView: View { var interactor: CreateChocolateBusinessLogic? @StateObject var chocolate = ChocolateDisplayModel() }
Мы вызвали метод interactor.loadChocolate
в .onAppear
, так что VIP-цикл начался.
var body: some View { NavigationView { Form { } .navigationTitle("Chocolate") .onAppear { interactor?.loadChocolate(request: CreateChocolateModels.LoadChocolate.Request()) } } .navigationViewStyle(.stack) }
Давайте посмотрим на класс interactor
. Вместо реального вызова API мы извлекаем данные из JSON, создаем ответ и форвардим его презентеру.
protocol CreateChocolateBusinessLogic { func loadChocolate(request: CreateChocolateModels.LoadChocolate.Request) } final class CreateChocolateInteractor: CreateChocolateBusinessLogic{ // MARK: Private Properties private let worker: CreateChocolateWorker private let presenter: CreateChocolatePresentationLogic // MARK: Initializers init(presenter: CreateChocolatePresentationLogic, worker: CreateChocolateWorker) { self.presenter = presenter self.worker = worker } // MARK: Business Logic func loadChocolate(request: CreateChocolateModels.LoadChocolate.Request) { let chocolate = Bundle.main.decode(Chocolate.self, from: "chocolate.json") let response = CreateChocolateModels.LoadChocolate.Response(chocolateData: chocolate) presenter.presentChocolate(response: response) } }
В презентере мы создаем viewModel
с соответствующей ей моделью displayModel
и отправляем ее на слой представления через метод показа.
protocol CreateChocolatePresentationLogic { func presentChocolate(response: CreateChocolateModels.LoadChocolate.Response) } final class CreateChocolatePresenter: CreateChocolatePresentationLogic { // MARK: Public Properties var view: CreateChocolateDisplayLogic? // MARK: Presentation Logic func presentChocolate(response: CreateChocolateModels.LoadChocolate.Response) { let displayModel = ChocolateDisplayModel(displayedChocolate: response.chocolateData.godiva) let viewModel = CreateChocolateModels.LoadChocolate.ViewModel(displayModel: displayModel) view?.displayChocolate(viewModel: viewModel) } }
Мы обновляем обозреваемую переменную экземпляра displayModel
в методе display, чтобы соответствующее представление было обновлено новыми данными.
protocol CreateChocolateDisplayLogic { func displayChocolate(viewModel: CreateChocolateModels.LoadChocolate.ViewModel) } extension CreateChocolateView: CreateChocolateDisplayLogic { func displayChocolate(viewModel: CreateChocolateModels.LoadChocolate.ViewModel) { chocolate.displayedChocolate = viewModel.displayModel.displayedChocolate } }
4. MV (Model-View)
Этот паттерн, который Apple представила на конференции WWDC, основан на логике «View is the ViewModel». Все бизнес-операции мы выполняем в классе модели. Пример вы можете увидеть ниже.
@MainActor class Model: ObservableObject { @Published var orders: [Order] = [] let orderService: OrderService init(orderService: OrderService) { self.orderService = orderService } func sortOrders() { // sort orders } func filterOrders() { // filter orders } }
В классе представления выполняются все операции над представлением и, кроме того, все операции над ViewModel.
struct AddNewCoffeeOrderView: View { @State private var name: String = "" @State private var coffeeName: String = "" @State private var total: String = "" @State private var errors: AddNewCoffeeOrderError = AddNewCoffeeOrderError() let order: Order? @Environment(\.dismiss) private var dismiss @EnvironmentObject private var model: Model init(order: Order? = nil) { self.order = order } private func placeOrder(_ order: Order) async { try? await model.placeOrder(order) dismiss() } private func updateOrder(_ order: Order) async { try? await model.updateOrder(order) dismiss() } private func updateOrPlaceOrder() async { if let order { // update the order let order = Order(id: order.id) await updateOrder(order) } else { // place the order let order = Order(id: 1) await placeOrder(order) } } var isFormValid: Bool { if name.isEmpty { errors.name = "Name cannot be empty!" } if coffeeName.isEmpty { errors.coffeeName = "Coffee name cannot be empty" } return errors.name.isEmpty && errors.coffeeName.isEmpty && errors.total.isEmpty } var body: some View { NavigationStack { Form { TextField("Name", text: $name) !errors.name.isEmpty ? Text(errors.name) : nil TextField("Coffee name", text: $coffeeName) !errors.coffeeName.isEmpty ? Text(errors.coffeeName) : nil TextField("Total", text: $total) !errors.total.isEmpty ? Text(errors.total) : nil } } } }
Вот и все. Не забудьте поделиться с коллегами статьей.