ссылка на 12ю часть

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

Что мы будем изучать сегодня:

  1. Как использовать перечисления (enum) для лучшей организации кода.

  2. Как сохранять пользовательские настройки с помощью UserDefaults.

  3. Как делиться данными с использованием Combine и @EnvironmentObject.

Рефакторинг кода с использованием Enum

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

Перечисление (Enum) определяет общий тип для группы связанных значений и позволяет работать с этими значениями безопасным с точки зрения типа способом в вашем коде.

Поскольку эта группа фиксированных значений связана с порядком отображения, мы можем использовать Enum для их хранения, и каждому случаю можно присвоить целочисленное значение, давайте создадим новый файл и назовем его DisplayOrderType.swift

В него добавьте следующий код:

enum DisplayOrderType: Int, CaseIterable {
	case alphabetical = 0
	case favoriteFirst = 1
	case bookedFirst = 2

	init(type: Int) {
		switch type {
		case 0: self = .alphabetical
		case 1: self = .favoriteFirst
		case 2: self = .bookedFirst
		default: self = .alphabetical
		}
	}

	var text: String {
		switch self {
		case .alphabetical: return "Алфавитный порядок"
		case .favoriteFirst: return "Показывать сначала избранные"
		case .bookedFirst: return "Показывать сначала забронированные"
		}
	}
}

Мы определили перечисление DisplayOrderType, которое имеет три возможных значения:

  • alphabetical (0): Алфавитный порядок.

  • favoriteFirst (1): Сначала показывать избранные.

  • bookedFirst (2): Сначала показывать забронированные.

Инициализатор этого перечисления позволяет создать экземпляр DisplayOrderType из целого числа. Если число не соответствует ни одному из определенных значений, по умолчанию выбирается alphabetical.

И наконец свойство text, возвращает текстовое описание для каждого типа порядка отображения.

Почему это удобно?

  1. Ясность и читаемость кода:

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

  2. Безопасность типов:

    • Использование enum предотвращает использование недопустимых значений, так как перечисление строго ограничивает возможные варианты.

  3. Удобство инициализации:

    • Инициализатор позволяет легко создавать экземпляры DisplayOrderType из целых чисел, что полезно при работе с данными, приходящими из внешних источников, таких как API или пользовательские настройки.

  4. Поддержка всех вариантов:

    • Протокол CaseIterable позволяет получить все возможные случаи перечисления, что может быть полезно, например, для отображения всех вариантов в меню настроек.

Этот подход делает код более структурированным и облегчает его поддержку и расширение в будущем.

Теперь давайте удалим наше старое свойство displayOrders из SettingView, а свойство selectedOrder обновим следующим образом.

@State private var selectedOrder = DisplayOrderType.alphabetical

У вас теперь проявились ошибки которые подсвечивают что Пикер больше не знает что за свойство такое displayOrders. На самом деле за счет того что наш DisplayOrderType подписан под CaseIterable мы можем использовать массив возможных вариантов нашего перечисления также как мы пользовались displayOrders

Давайте заменим код Picker следующим образом:

				Picker(selection: $selectedOrder, label: Text("Формат отображения")) {
					ForEach(DisplayOrderType.allCases, id: \.self) {
						Text($0.text)
					}
				}

Итак мы сделали рефакторинг и можно теперь опять попробовать поработать с экраном настроек, все должно работать как и раньше.

Сохранение пользовательских настроек в UserDefaults

Чтобы сохранить выбранные пользователем параметры в памяти, мы воспользуемся классом UserDefaults. Этот класс позволяет хранить простые типы данных, такие как строки, числа, даты и массивы, и автоматически сохраняет их на диск с дальнейшим доступом к ним по ключу.

В текущем проекте нам нужно сохранить только один параметр: порядок отображения автомобилей. Мы будем использовать ключ "displayOrderType" для сохранения и извлечения этого значения.

Давайте создадим еще один файл и назовем его SettingsStore.swift

И добавим в него следующий код:

final class SettingStore {
	init() {
		UserDefaults.standard.register(defaults: [
			"view.preferences.showBookedOnly" : false,
			"view.preferences.displayOrder" : 0,
			"view.preferences.maxPriceLevel" : 5
		]) }
	
	var showBookedOnly: Bool = UserDefaults.standard.bool(forKey: "view.preferences.showBookedOnly") {
		didSet {
			UserDefaults.standard.set(showBookedOnly, forKey: "view.preferences.showBookedOnly")
		}
	}

	var displayOrder: DisplayOrderType = DisplayOrderType(
		type: UserDefaults.standard.integer(forKey: "view.preferences.displayOrder")
	) {
		didSet {
			UserDefaults.standard.set(displayOrder.rawValue, forKey: "view.preferences.displayOrder")
		}
	}

	var maxPriceLevel: Int = UserDefaults.standard.integer(forKey: "view.preferences.maxPriceLevel") {
		didSet {
			UserDefaults.standard.set(maxPriceLevel, forKey: "view.preferences.maxPriceLevel")
		}
	}
}

Мы сделали класс SettingStore, который управляет настройками пользователя, используя UserDefaults для хранения и извлечения данных. Этот класс облегчает работу с настройками, так как автоматически синхронизирует изменения между приложением и хранилищем.

Детали реализации:

  1. Инициализация:

    • В конструкторе init() мы регистрируем значения по умолчанию для трёх настроек: showBookedOnly, displayOrder и maxPriceLevel.

    • Это обеспечивает наличие значений по умолчанию, если пользователь ещё не задал свои настройки.

  2. Свойства:

    • showBookedOnly: Булевое значение, указывающее, нужно ли показывать только забронированные элементы.

      • При изменении значения, оно сохраняется в UserDefaults.

    • displayOrder: Значение типа DisplayOrderType, представляющее порядок отображения.

      • Мы используем инициализацию через значение по умолчанию из UserDefaults.

      • При изменении значения, оно сохраняется в UserDefaults.

    • maxPriceLevel: Целое число, определяющее максимальный уровень цены.

      • Значение берётся из UserDefaults при инициализации.

      • При изменении значения, оно сохраняется в UserDefaults.

Почему это удобно:

  1. Автоматическая синхронизация: Все изменения в свойствах showBookedOnly, displayOrder и maxPriceLevel автоматически сохраняются в UserDefaults. Это исключает необходимость вручную управлять сохранением и загрузкой настроек, снижая вероятность ошибок и упрощая код.

  2. Значения по умолчанию: Регистрация значений по умолчанию гарантирует, что приложение всегда будет иметь корректные значения для настроек, даже если пользователь их ещё не установил. Это улучшает пользовательский опыт, предоставляя разумные начальные настройки.

  3. Типизация: Использование типизированных свойств, таких как DisplayOrderType, упрощает работу с настройками и снижает вероятность ошибок, связанных с неправильными типами данных.

В результате, класс SettingStore обеспечивает удобный и надёжный способ управления пользовательскими настройками в приложении.

Теперь давайте вернемся в наш SettingView и объявим там следующее свойство:

	var settingStore: SettingStore

У нас появилось несколько ошибок в проекте, мы их поправим, но сначала давайте добавим следующую логику в action нашей кнопки сохранения

				ToolbarItem(placement: .navigationBarTrailing) {
					Button(action: {
						self.settingStore.showBookedOnly = self.showBookedOnly
						self.settingStore.displayOrder = self.selectedOrder
						self.settingStore.maxPriceLevel = self.maxPriceLevel
						dismiss()
					}) {
						Text("Сохранить")
							.foregroundStyle(.black)
					}

Также, чтобы при загрузке этого экрана мы получали уже установленные настройки давайте добавим к NavigationStack логику присвоения значений во время появления с помощью модификатора .onAppear

.onAppear {
			self.showBookedOnly = self.settingStore.showBookedOnly
			self.selectedOrder = self.settingStore.displayOrder
			self.maxPriceLevel = self.settingStore.maxPriceLevel
		}

Теперь давайте поправим первую ошибку непосредственно в SettingView

struct SettingView_Previews: PreviewProvider {
	static var previews: some View {
		SettingView(settingStore: SettingStore())
	}
}

Также, я предлагаю продлить этот флоу реверсом буквально до SUIFormApp, мы с вами добавим такое же свойство в ContentView и прокинем его в .sheet, чтобы он его сам прикидывал в экран настроек при показе модального окна.

var settingStore: SettingStore
			.sheet(isPresented: $showSettings) {
				SettingView(settingStore: settingStore)
			}
struct ContentView_Previews: PreviewProvider {
	static var previews: some View {
		ContentView(settingStore: SettingStore())
	}
}

Так это должно выглядеть в итоге

Ну и наконец в SUIFormApp давайте добавим это свойство которое и будет делить свой функционал между этими двумя экранами

@main
struct SUIFormApp: App {
	let settingStore = SettingStore()

    var body: some Scene {
        WindowGroup {
			ContentView(settingStore: settingStore)
        }
    }
}

Отлично, на этом мы завершили логику сохранения и загрузки настроек. Давайте проверим работоспособность этого функционала.

Обмен данными между экранами с использованием @EnvironmentObject

Теперь, когда пользовательские настройки сохраняются в UserDefaults, нам нужно, чтобы список автомобилей изменялся в соответствии с этими настройками. Существует несколько способов решения этой проблемы.

Давайте вспомним, что у нас есть на данный момент. Когда пользователь нажимает кнопку "Сохранить" на экране настроек - мы сохраняем выбранные опции в UserDefaults. Экран настроек затем закрывается, и приложение возвращает пользователя к списку. Таким образом, либо мы указываем списку перезагрузить настройки, либо список должен уметь отслеживать изменения в UserDefaults и автоматически обновлять себя.

С введением SwiftUI, Apple также выпустила новую фреймворк под названием Combine. Combine предоставляет декларативный API для обработки значений с течением времени. В контексте этого демо, Combine позволяет отслеживать один объект и получать уведомления об изменениях. Мы можем инициировать обновление вью без написания строки кода. Все обрабатывается за кулисами SwiftUI и Combine.

Итак, как список узнает, что настройки пользователя изменились, и инициирует обновление сам?

Давайте посмотрим на три ключевых понятия:

  1. @EnvironmentObject - Технически, это считается оберткой свойства, но вы можете рассматривать это ключевое слово как специальный маркер. Когда вы объявляете свойство как объект окружения, SwiftUI отслеживает значение свойства и аннулирует соответствующее представление всякий раз, когда происходят изменения. @EnvironmentObject работает почти так же, как @State. Но когда свойство объявляется как объект окружения, оно становится доступным для всех представлений во всем приложении. Например, если ваше приложение имеет много представлений, которые разделяют один и тот же кусок данных (например, настройки пользователя), объекты окружения отлично подходят для этого. Вам не нужно передавать свойство между представлениями, вместо этого вы можете получить к нему доступ автоматически со всех экранов.

  2. ObservableObject - это протокол от Combine. Когда вы объявляете свойство как объект окружения, тип этого свойства должен реализовывать этот протокол. Возвращаясь к нашему вопросу: как мы можем дать понять списку, что настройки пользователя изменились? Реализуя этот протокол, объект может служить издателем, который выпускает измененные значения. Подписчики, отслеживающие изменение значения, получат уведомление.

  3. @Published - это обертка свойства, которая работает вместе с ObservableObject. Когда свойство помечено как @Published, это указывает на то, что издатель должен информировать всех подписчиков всякий раз, когда значение свойства изменяется.

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

Начнем с SettingStore.swift.

Так как и представление настроек, и представление списка должны отслеживать изменения пользовательских предпочтений, SettingStore должен реализовывать протокол ObservableObject и объявлять изменения свойства defaults. В файле SettingStore.swift сначала нужно импортировать фреймворк Combine:

import Combine

Затем необходимо подписать наш SettingStore под ObservableObject чтобы в последствии он быть объектом отправляющим события

final class SettingStore: ObservableObject

Ну и наконец, что уже более верно мы сдаем публикующими события как раз сами свойства этого класса c помощью @Published:

import Foundation
import Combine

final class SettingStore: ObservableObject {
	init() {
		UserDefaults.standard.register(defaults: [
			"view.preferences.showBookedOnly" : false,
			"view.preferences.displayOrder" : 0,
			"view.preferences.maxPriceLevel" : 5
		]) }
	
	@Published var showBookedOnly: Bool = UserDefaults.standard.bool(forKey: "view.preferences.showBookedOnly") {
		didSet {
			UserDefaults.standard.set(showBookedOnly, forKey: "view.preferences.showBookedOnly")
		}
	}

	@Published var displayOrder: DisplayOrderType = DisplayOrderType(
		type: UserDefaults.standard.integer(forKey: "view.preferences.displayOrder")
	) {
		didSet {
			UserDefaults.standard.set(displayOrder.rawValue, forKey: "view.preferences.displayOrder")
		}
	}

	@Published var maxPriceLevel: Int = UserDefaults.standard.integer(forKey: "view.preferences.maxPriceLevel") {
		didSet {
			UserDefaults.standard.set(maxPriceLevel, forKey: "view.preferences.maxPriceLevel")
		}
	}
}

Используя обертку свойства @Published, издатель уведомляет подписчиков всякий раз, когда происходит изменение значения свойства (например, обновление displayOrder).
Как видите, уведомление об изменении значения с помощью Combine довольно просто. На самом деле, мы не написали никакого нового кода, а просто приняли необходимый протокол и вставили маркер.
Теперь давайте переключимся на SettingView.swift. Переменная settingStore теперь должна быть объявлена как environmentObject, чтобы мы могли делиться данными с другими представлениями. Обновите переменную settingStore следующим образом:

	@EnvironmentObject var settingStore: SettingStore

Разумеется из за этого наш превью, начнет ругаться. Давайте поправим и это.

struct SettingView_Previews: PreviewProvider {
	static var previews: some View {
		SettingView()
			.environmentObject(SettingStore())
	}
}

Вам не нужно обновлять какой-либо код, связанный с кнопкой «Сохранить». Однако, когда вы устанавливаете новое значение для хранилища настроек (например, обновляете showCheckInOnly с true на false), это обновление будет опубликовано и сообщение будет проброшено всем подписчикам.

Хорошо, вся наша работа была на стороне Паблишера. А что насчет подписчика?
В этом проекте список — это сторона подписчика. Ему необходимо отслеживать изменения хранилища настроек и повторно отображать себя таким образом, чтобы отразить настройки пользователя.

Теперь давайте откроем ContentView.swift, чтобы внести некоторые изменения. Подобно тому, что мы только что сделали, хранилище настроек теперь должно быть объявлено как EnvironmentObject:

@EnvironmentObject var settingStore: SettingStore

Разумеется сразу исправляем наш превью

struct ContentView_Previews: PreviewProvider {
	static var previews: some View {
		ContentView()
			.environmentObject(SettingStore())
	}
}

Ну и разумеется теперь мы должны передать SettingView с объектом среды при открытии модального окна

			.sheet(isPresented: $showSettings) {
				SettingView().environmentObject(self.settingStore)
			}

Ну и наконец нам нужно поправить только лишь точку входу в приложение, SUIFormApp:

import SwiftUI

@main
struct SUIFormApp: App {
	let settingStore = SettingStore()

    var body: some Scene {
        WindowGroup {
			ContentView().environmentObject(settingStore)
        }
    }
}

Здесь мы внедряем хранилище настроек в environmentObject, вызывая модификатор EnvironmentObject. Теперь экземпляр хранилища настроек доступен для всех представлений в приложении. Другими словами, оба представления «Настройки» и «Список» могут получить к нему автоматический доступ.

Реализация параметров фильтрации

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

$showBookedOnly и $maxPriceLevel

В ContentView.swift давайте создадим новую функцию shouldShowItem для обработки фильтрации:

	private func shouldShowItem(car: Car) -> Bool {
		return (!self.settingStore.showBookedOnly || car.isBooked)
		&& (car.priceLevel <= self.settingStore.maxPriceLevel)
	}

Эта функция принимает объект Car и сообщает вызывающему объекту, следует ли отображать машину. В приведенном выше коде мы проверяем, выбрана ли опция «showBookedOnly», и проверяем уровень цен данной машины.

Далее давайте обернем BasicImageRow так чтобы он срабатывал только если наш shouldShowItem вернет true

ForEach(cars) { car in
					if shouldShowItem(car: car) {
						BasicImageRow(car: car)

и более полная версия чтобы было яснее:

			List {
				ForEach(cars) { car in
					if shouldShowItem(car: car) {
						BasicImageRow(car: car)
							.contextMenu {
								
								Button(action: {
									// Бронируем выбранный автомобиль
									self.book(item: car)
								}) {
									HStack {
										Text("Забронировать")
										Image(systemName: "checkmark.seal.fill")
									}
								}
								
								Button(action: {
									// Удаляем выбранный автомобиль
									self.delete(item: car)
								}) {
									HStack {
										Text("Удалить")
										Image(systemName: "trash")
									}
								}
												 
								Button(action: {
									// Отмечаем выбранный автомобиль как любимый
									self.setFavorite(item: car)
									
								}) {
									HStack {
										Text("Любимый")
										Image(systemName: "star")
									}
								}
							}
							.onTapGesture {
								self.selectedCar = car
							}
					}

				}
				.onDelete { (indexSet) in
					self.cars.remove(atOffsets: indexSet)
				}
			}

Получается мы сначала вызываем функцию shouldShowItem, которую мы только что реализовали, чтобы проверить, должна ли отображаться машина.


Теперь запустите приложение в симуляторе и проведите быстрый тест. На экране настроек установите для параметра «Показывать только забронированные» значение «ВКЛ» и настройте параметр «Отобразить автомобили в ценновой категории», чтобы отображались машины с уровнем цен 3 (т. е. $$$) или ниже. Как только вы нажмете кнопку «Сохранить», список должен автоматически обновиться (с анимацией) и отобразить отфильтрованные записи.

Делаем метод сортировки

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

В Swift вы можете сортировать последовательность элементов с помощью метода sort(by:). Когда вы используете этот метод, вам необходимо предоставить ему предикат, который возвращает true - когда первый элемент должен быть упорядочен перед вторым.

Например, чтобы отсортировать массив машин в алфавитном порядке вы можете использовать sort(by:) следующим образом:

cars.sorted { $0.name < $1.name }

Соответственно таким же образом можно отфильтровать машины как показывать сначала забронированные, а потом уже не забронированные и также с избранными

		cars.sorted { $0.isFavorite && !$1.isFavorite }
		cars.sorted { $0.isBooked && !$1.isBooked }

Мы можем организовать наш код таким образом, чтобы использовать эти функции высшего порядка в едином предикате, давайте и создадим функцию предиката внутри нашего перечисления DisplayOrderType

	func predicate() -> ((Car, Car) -> Bool) {
		switch self {
		case .alphabetical: return { $0.name < $1.name }
		case .favoriteFirst: return { $0.isFavorite && !$1.isFavorite } 
		case .bookedFirst: return { $0.isBooked && !$1.isBooked }
		}
	}

Метод predicate определяет способ сравнения объектов типа Car на основе перечисления, к которому он принадлежит. Как этот метод работает:

  1. Объявление метода:

    func predicate() -> ((Car, Car) -> Bool) {

    Этот метод не принимает аргументов и возвращает функцию типа ((Car, Car) -> Bool). Это означает, что возвращаемая функция принимает два объекта типа Car и возвращает Bool, который определяет порядок следования этих объектов (в нашем случае для сортировки).

  2. Переключение (switch) по значению self:

    switch self {
    

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

  3. Разные случаи перечисления:

    case .alphabetical: return { $0.name < $1.name }
    case .favoriteFirst: return { $0.isFavorite && !$1.isFavorite }
    case .bookedFirst: return { $0.isBooked && !$1.isBooked }
    

    В зависимости от значения self, метод predicate возвращает различную функцию сравнения:

    • alphabetical: возвращает функцию, которая сравнивает два объекта Car по их свойству name (по алфавиту).

      { $0.name < $1.name }
      

      Эта функция возвращает true, если имя первой машины ($0) меньше имени второй машины ($1).

    • favoriteFirst: возвращает функцию, которая сортирует машины так, чтобы машины с флагом isFavorite шли перед машинами без этого флага.

      { $0.isFavorite && !$1.isFavorite }
      

      Эта функция возвращает true, если первая машина ($0) является избранной (isFavorite), а вторая ($1) - нет.

    • bookedFirst: возвращает функцию, которая сортирует машины так, чтобы машины с флагом isBooked шли перед машинами без этого флага.

      { $0.isBooked && !$1.isBooked }
      

Таким образом, в зависимости от значения self, метод predicate возвращает одну из трёх функций, каждая из которых реализует свой способ сравнения объектов Car.

Ну и наконец мы можем использовать этот предикат в нашем списке, давайте это сделаем.

ForEach(cars.sorted(by: settingStore.displayOrder.predicate()))

И полный код ContentView если вдруг не очень понятно куда пришла эта строчка

import SwiftUI

struct ContentView: View {
	
	@State var cars = FakeData.cars
	@State private var selectedCar: Car?
	@State private var showSettings: Bool = false

	@EnvironmentObject var settingStore: SettingStore

	var body: some View {
		NavigationStack {
			List {
				ForEach(cars.sorted(by: settingStore.displayOrder.predicate())) { car in
					if shouldShowItem(car: car) {
						BasicImageRow(car: car)
							.contextMenu {
								
								Button(action: {
									// Бронируем выбранный автомобиль
									self.book(item: car)
								}) {
									HStack {
										Text("Забронировать")
										Image(systemName: "checkmark.seal.fill")
									}
								}
								
								Button(action: {
									// Удаляем выбранный автомобиль
									self.delete(item: car)
								}) {
									HStack {
										Text("Удалить")
										Image(systemName: "trash")
									}
								}
												 
								Button(action: {
									// Отмечаем выбранный автомобиль как любимый
									self.setFavorite(item: car)
									
								}) {
									HStack {
										Text("Любимый")
										Image(systemName: "star")
									}
								}
							}
							.onTapGesture {
								self.selectedCar = car
							}
					}

				}
				.onDelete { (indexSet) in
					self.cars.remove(atOffsets: indexSet)
				}
			}
			
			.navigationTitle("Аренда Авто")
			.toolbar {
				ToolbarItem(placement: .navigationBarTrailing) {
					Button(action: {
						self.showSettings = true
					}, label: {
						Image(systemName: "gear").font(.title2)
							.foregroundColor(.black)
					})
				}
			}
			.sheet(isPresented: $showSettings) {
				SettingView().environmentObject(self.settingStore)
			}

		}
	}
	
	private func delete(item car: Car) {
		if let index = self.cars.firstIndex(where: { $0.id == car.id }) {
			self.cars.remove(at: index)
		}
	}
	
	private func setFavorite(item car: Car) {
		if let index = self.cars.firstIndex(where: { $0.id == car.id }) {
			self.cars[index].isFavorite.toggle()
		}
	}
	
	private func book(item car: Car) {
		if let index = self.cars.firstIndex(where: { $0.id == car.id }) {
			self.cars[index].isBooked.toggle()
		}
	}

	private func shouldShowItem(car: Car) -> Bool {
		return (!self.settingStore.showBookedOnly || car.isBooked)
		&& (car.priceLevel <= self.settingStore.maxPriceLevel)
	}
}

struct ContentView_Previews: PreviewProvider {
	static var previews: some View {
		ContentView()
			.environmentObject(SettingStore())
	}
}

Как вы видите мы будем будем отдавать массив таким образом каким он зафиксирован в нашем settingStore, а он у нас либо имеет дефолтное значение которого мы зарегистрировали, либо то, которое пользователь установит в ходе пользования приложением.

Давайте теперь проверим как все это работает вместе

На этом для этой части всё, в следующих частях мы погрузимся чуть глубже к Combine который отлично работает в сочетании с SwiftUI, хотя и на базовом примере но вы сами в этом убедились, после добавления буквальны пары модификаторов мы получили реализацию логики подписчика и публикатора, разве это не удобно?

Поэтому как и прежде подписывайтесь на мой телеграм канал - https://t.me/swiftexplorer

И приходите за новыми статьями, буду рад вашим комментариям и лайкам!

Спасибо за прочтение!

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