Pull to refresh

Взаимодействие SwiftUI с Redux

Reading time 10 min
Views 8.7K
image

Всем привет. В данной статье мы будем говорить о фреймворке SwiftUI в связке с Redux, эта связка позволяет нам быстро и просто создавать приложения. SwiftUI служит для создания пользовательского интерфейса в декларативном стиле в отличии от UIKit. В свою очередь Redux служит для управления состоянием приложения.

State — фундаментальное понятие в SwiftUI и Redux. В нашем случае это не только модное слово, но и сущность, которая связывает их и позволяет им очень хорошо работать вместе. В этой статье я постараюсь показать, что приведенный выше тезис правдив, поэтому давайте начнем!

Перед тем как мы погрузимся в написания кода, давайте для начала разберемся, что такое Redux и из чего он состоит.

Redux — библиотека с открытым исходным кодом, предназначенная для управления состоянием приложения. Чаще всего используется в связке с React или Angular для разработки клиентской части. Содержит ряд инструментов, позволяющих значительно упростить передачу данных хранилища через контекст. Создатели: Даниил Абрамов и Эндрю Кларк.

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

Многонаправленный или однонаправленный поток


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

image

В свою очередь Redux является однонаправленным потоком данных и его легче всего всего объяснить, исходя из составляющих его компонентов.

image

Давайте немного детально обговорим каждый компонент Redux.

State — это единственный источник правды, который содержит всю необходимую информацию для нашего приложения.

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

Reducer — это функция, которая принимает Action и текущии State в качестве параметров и возвращает новый State. Это единственный способ создать его. Также стоит отметить, что эта функция должна быть чистой.

Store — это объект, который содержит State и предоставляет все необходимые инструменты для его обновления.

Полагаю, что теории достаточно, давайте перейдем к практике.

Реализация Redux


Один из самых простых способов познакомится с инструментом это начать его использовать, как говорил мой учитель по программированию, если вы хотите выучить язык программирования, напишите на нем приложение. Так давайте и мы создадим небольшое приложение, пускай это будет простой дневник тренировок, он будет иметь всего четыре возможности, первая — это отображение списка тренировок, вторая — добавление выполненной тренировки, третья — удаление и четвертая это сортировка тренировок. Довольно простое приложение, но в тот же время позволит нам познакомится с Redux и SwiftUI.

Создайте чистый проект в Xcode, дайте ему название WorkoutsDiary, и самое главное выберите SwiftUI для User Interface, так как мы будет использовать SwiftUI для создания нашего пользовательского интерфейса.

После создания проекта. Создайте структуру Workout которая будет отвечать за тренировку, которую мы выполнили.

import Foundation

struct Workout: Identifiable {
    let id: UUID = .init()
    let name: String
    let distance: String
    let date: Date
    let complexity: Complexity
}

Как вы можете видеть, в этой структуре нет ничего сумасшедшего, поле id необходимо для соответствия протоколу Identifiable, а поле complexity — это просто enum со следующим определением:

enum Complexity: Int {
    case low
    case medium
    case high
}

Теперь у нас есть все, что нужно для начала реализации Redux, давайте начнем с создания State.

struct AppState {
    var workouts: [Workout]
    var sortType: SortType?
}

State — это простая структура, которая содержит два поля: workouts и sortType. Первое — это список тренировок, а второе — необязательное поле, которое определяет, как сортируется список.

SortType — это перечисление, которое определяется следующим образом:

enum SortType {
    case distance
    case complexity
}

Для простоты мы будет делать сортировку по дистанции и сложности по убыванию, то есть чем выше сложность нашей тренировки, тем выше она будет отображаться в нашем списке. Стоит заметить, что sortType это опциональный тип и он может быть nil, что будет означать, что список не отсортирован в данный момент.

Продолжим реализацию наших компонентов. Давайте создадим Action

enum Action {
    case addWorkout(_ workout: Workout)
    case removeWorkout(at: IndexSet)
    case sort(by: SortType)
}

Как мы видим, Action это перечисление с тремя кейсами, которые дают нам возможность манипулировать нашим State.

  • addWorkout(_ workout: Workout) просто добавляет тренировку, которая передается в качестве параметра.
  • removeWorkout(at: IndexSet) удаляет элемент по указанному индексу.
  • sort (by: SortType) сортирует список тренировок по заданному типу сортировки.

Создадим один из самых сложных компонентов, это Reducer:

func reducer(state: AppState, action: Action) -> AppState {
    var state = state
    
    switch action {
    case .addWorkout(let workout):
        state.workouts.append(workout)
        
    case .removeWorkout(let indexSet):
        state.workouts.remove(atOffsets: indexSet)
    
    case .sort(let type):
        switch type {
        case .distance:
            state.workouts.sort { $0.distance > $1.distance }
            state.sortType = .distance
        case .complexity:
            state.workouts.sort { $0.complexity.rawValue > $1.complexity.rawValue }
            state.sortType = .complexity
        }
    }
    return state
}


Написанная нами функция довольно проста и работает следующим образом:

  1. Копирует текущий State для работы с ним.
  2. Основываясь на Action, мы обновляем наш скопированный State.
  3. Возвращаем обновленный State

Стоит заметить, что вышеуказанная функция является чистой функцией, и именно этого мы и хотели достичь! Функция должна соответствовать двум условиям, чтобы считаться «чистой»:

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

Последний недостающий элемент Redux — это Store, поэтому давайте реализуем его для нашего приложения.

final class Store: ObservableObject {
     @Published private(set) var state: AppState
     
     init(state: AppState = .init(workouts: [Workout]())) {
         self.state = state
     }
     
     public func dispatch(action: Action) {
         state = reducer(state: state, action: action)
     }
 }

В реализаций объекта Store мы используем все преимущества протокола ObservableObject, который позволяет нам исключить написание большого количества шаблонного кода или использование сторонних фреймворков. Свойство State доступно только для чтения и использует обертку свойства @Published, что означает, что всякий раз, когда оно будет изменено, SwiftUI будет получать уведомления. Метод init принимает начальное состояние в качестве параметра с заданным значением по умолчанию в виде пустого массива элементов Workout. Функция dispatch является единственным способом для обновления состояния: она заменяет текущее состояние новым, которое создано функцией reducer, на основе Action, которое передается в качестве параметра.

Теперь, когда мы реализовали все компоненты Redux, мы можем приступить к созданию юзер интерфейса для нашего приложения.

Реализация приложения


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

Реализовывать интерфейс мы будем используя новый фреймворка от Apple под названием SwiftUI. SwiftUI приходит к нам на смену привычного нам UIKit. SwiftUI кардинально отличается от UIKit, в первую очередь тем, что он является декларативным подходом в написании UI элементов кодом. В данной статье я не буду вникать во все тонкости SwiftUI и предполагаю, что у вас есть уже опыт работы с SwiftUI. Если Вы не владеете знаниями SwiftUI, советую обратить внимание на документацию от Apple, а именно посмотреть на их несколько полноценных туториалов с пошаговым добавлением и интерактивным отображением результата на view. Там же есть ссылки на example-проекты. Эти туториалы позволят Вам быстро погрузиться в декларативный мир SwiftUI.

Также стоит заметить, что SwiftUI еще не готов к продакшен проектам, он слишком молодой и еще пройдет не один год пока его можно будет использовать таким образом. Также не стоит забывать, что он поддерживает только iOS 13.0+ версии. Но также стоит отметить, что SwiftUI будет работать на всех платформах Apple, что есть большим преимуществом перед UIKit!

Давайте начнем реализацию с главного экрана нашего приложения. Перейдите в файл ContentView.swift измените текущий код на этот.

struct ContentView: View {
    @EnvironmentObject var store: Store
    @State private var isAddingMode: Bool = false
    
    var body: some View {
        NavigationView {
            WorkoutListView()
                .navigationBarTitle("Workouts diary", displayMode: .inline)
                .navigationBarItems(
                    leading: AddButton(isAddingMode: self.$isAddingMode),
                    trailing: TrailingView()
                )
        }
        .sheet(isPresented: $isAddingMode) {
            AddWorkoutView(isAddingMode: self.$isAddingMode)
                .environmentObject(self.store)
        }
    }
}

Content View — это стандартное представление в SwiftUI. Самая важная часть — с моей точки зрения — это строка кода, которая содержит переменную store. Мы создадим @EnvironmentObject. Это позволит нам использовать данные из Store везде, где это необходимо, и кроме этого, он автоматически обновляем наши представления, если данные будут изменены. Это что-то типа Singleton для нашего Store.

@EnvironmentObject var store: Store

Также стоит отметить следующую строку кода:

@State private var isAddingMode: Bool = false

State — это обертка, которую мы можем использовать для обозначения состояния View. SwiftUI будет хранить ее в специальной внутренней памяти вне структуры View. Только связанный View может получить к ней доступ. Как только значение свойства State изменяется, SwiftUI перестраивает View для учета изменений состояния.

image

Затем перейдем в файл SceneDelegate.swift и добавим в метод код:

func scene(
        _ scene: UIScene,
        willConnectTo session: UISceneSession,
        options connectionOptions: UIScene.ConnectionOptions
    ) {
        let contentView = ContentView().environmentObject(Store())
        
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: contentView)
            self.window = window
            window.makeKeyAndVisible()
        }
    }

Таким же образом любой @EnvironmentObject может быть передан любому дочернему представлению во всем приложении, и все это возможно благодаря Environment. Переменная isAddingMode помечена State и указывает, отображаеться вторичное представление или нет. Переменная store автоматически наследуется WorkoutListView, и нам не нужно явно передавать ее, но это нужно сделать для AddWorkoutView, поскольку она представлена в виде листа, который не является дочерним представлением ContentView.

Теперь создадим WorkoutListView, который будет наследоваться от View. Создадим новый swift файл под названием WorkoutListView.

struct WorkoutListView: View {
    @EnvironmentObject var store: Store
    
    var body: some View {
        List {
            ForEach(store.state.workouts) {
                WorkoutView(workout: $0)
            }
            .onDelete {
                self.store.dispatch(action: .removeWorkout(at: $0))
            }
            .listRowInsets(EdgeInsets())
        }
    }
}

View, которое использует контейнер элемент List для отображения списка тренировок. Функция onDelete служит для удаления тренировки и использует removeWorkout action, который выполняется с помощью функции dispatch, предоставляемым store. Для отображения тренировки в списке используется WorkoutView.

Создадим еще один файл WorkoutView.swift который будет отвечать за отображение нашего элемента в списке.

struct WorkoutView: View {
    let workout: Workout
    
    private var backgroundColor: Color {
        switch workout.complexity {
        case .low:
            return .green
        case .medium:
            return .orange
        case .high:
            return .red
        }
    }
    
    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                Text(workout.name)
                Text("Distance:" + workout.distance + "km")
                    .font(.subheadline)
            }
            Spacer()
            VStack(alignment: .leading) {
                Text(simpleFormat(workout.date))
            }
        }
        .padding()
        .background(backgroundColor)
    }
}

private extension WorkoutView {
    func simpleFormat(_ date: Date) -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "dd MMM yyyy"
        dateFormatter.locale = .init(identifier: "en_GB")
        return dateFormatter.string(from: date)
    }
}

Это представление принимает объект тренировки в качестве параметра и конфигурируется на основе его свойств.

Чтобы добавить новый элемент в список, необходимо изменить параметр isAddingMode на true, чтобы отобразить AddWorkoutView. Эта ответственность ложиться на AddButton.

struct AddButton: View {
    @Binding var isAddingMode: Bool
    
    var body: some View {
        Button(action: { self.isAddingMode = true }) {
            Image(systemName: "plus")
        }
    }
}

AddButton так же стоит вынести в отдельный файл.

Это представление — простая кнопка, которая была извлечена из основного ContentView для лучшей структуры и разделения кода.

Создадим представление для добавления новой тренировки. Создадим новый файл AddWorkoutView.swift:

struct AddWorkoutView: View {
    @EnvironmentObject private var store: Store
    
    @State private var nameText: String = ""
    @State private var distanceText: String = ""
    @State private var complexityField: Complexity = .medium
    @State private var dateField: Date = Date()
    @Binding var isAddingMode: Bool
    
    var body: some View {
        NavigationView {
            Form {
                TextField("Name", text: $nameText)
                TextField("Distance", text: $distanceText)
                Picker(selection: $complexityField, label: Text("Complexity")) {
                    Text("Low").tag(Complexity.low)
                    Text("Medium").tag(Complexity.medium)
                    Text("High").tag(Complexity.high)
                }
                DatePicker(selection: $dateField, displayedComponents: .date) {
                    Text("Date")
                }
            }
            .navigationBarTitle("Workout Details", displayMode: .inline)
            .navigationBarItems(
                leading: Button(action: { self.isAddingMode = false }) {
                    Text("Cancel")
                },
                trailing: Button(action: {
                    let workout = Workout(
                        name: self.nameText,
                        distance: self.distanceText,
                        date: self.dateField,
                        complexity: self.complexityField
                    )
                    self.store.dispatch(action: .addWorkout(workout))
                    self.isAddingMode = false
                }) {
                    Text("Save")
                }
                .disabled(nameText.isEmpty)
            )
        }
    }
}

Это довольно большой контроллер который как и другие контроллеры содержит переменную store. Он также содержит переменные nameText, distanceText, complexityField и isAddingMode. Первые три переменные необходимы для связывания TextField, Picker, DatePicker, которые можно увидеть на этом экране. Панель навигации имеет два элемента. Первая кнопка — это кнопка, которая закрывает экран без добавления новой тренировки, а последняя добавляет новую тренировку в список, что достигается путем отправки addWorkout action. Это действие также закрывает экран добавления новой тренировки.

image

Последнее, но не менее важное это TrailingView.

struct TrailingView: View {
    @EnvironmentObject var store: Store
    
    var body: some View {
        HStack(alignment: .center, spacing: 30) {
            Button(action: {
                switch self.store.state.sortType {
                case .distance:
                    self.store.dispatch(action: .sort(by: .distance))
                default:
                    self.store.dispatch(action: .sort(by: .complexity))
                }
            }) {
                Image(systemName: "arrow.up.arrow.down")
            }
            EditButton()
        }
    }
}

Это представление состоит из двух кнопок, которые отвечают за сортировку списка тренировок и за активацию или деактивацию режима редактирования нашего списка тренировок. Действия сортировки вызываются с помощью функции dispatch, которую мы можем вызвать через свойство store.

image

Результат


Приложение готово и должно работать точно так, как ожидалось. Давайте попробуем скомпилировать и запустить его.

Выводы


Redux и SwiftUI очень хорошо работают вместе. Код, написанный с использованием этих инструментов, прост для понимания и может быть хорошо организован. Еще один хороший аспект этого решения — отличная тестируемость кода. Однако это решение не лишено недостатков, один из них — это большое количество использованной памяти приложением когда State приложения очень сложный, также производительность приложения может быть не идеальной в некоторых конкретных сценариях, поскольку все Views в SwiftUI обновляются при создании нового State. Эти недостатки могут оказать большое влияние на качество приложения и взаимодействие с пользователем, но если мы будем помнить их и готовить state разумным образом, негативное воздействие можно легко минимизировать или даже избежать.

Исходный код проекта можете скачать или посмотреть тут.

Я надеюсь, что вам понравился данная статья и вы узнали для себя что-то новое, до скорых встреч. Дальше будет еще интересней.
Tags:
Hubs:
+8
Comments 0
Comments Leave a comment

Articles