Connect with us

Разработка

Шаблоны проектирования для 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
            }
        }
    }
}

Вот и все. Не забудьте поделиться с коллегами статьей.

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.

Наши партнеры:

LEGALBET

Мобильные приложения для ставок на спорт
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: