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

Создание формы регистрации с использованием Combine и View Model

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

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

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

Если у вас есть опыт работы с Swift и UIKit, вы знаете, что существует множество способов реализации валидации формы. В этой части мы рассмотрим, как можно использовать фреймворк Combine для выполнения валидации формы.

Разметка формы с использованием SwiftUI

Начнем с упражнения: используйте полученные в предыдущих статьях знания для разметки формы, показанной на скриншотах выше. Для создания текстового поля в SwiftUI можно использовать компонент TextField. Для полей пароля SwiftUI предоставляет защищенное текстовое поле под названием SecureField. (Кстати если будете тестировать на симуляторе ввод пароля зайдите в настройки симулятора и отключите возможность использования auto-fill)

Нажимаем Пароли
Нажимаем Пароли

Чтобы создать текстовое поле, необходимо инициализировать TextField с именем поля и привязкой. Это создаст редактируемое текстовое поле, в котором ввод пользователя хранится в указанной привязке. Как и другие поля формы, его внешний вид можно изменить, применяя соответствующие модификаторы. Пример кода:

TextField("Имя пользователя", text: $username)
    .font(.system(size: 20, weight: .semibold, design: .rounded))
    .padding(.horizontal)

Использование этих двух компонентов очень похоже, за исключением того, что защищенное поле автоматически скрывает ввод пользователя:

SecureField("Пароль", text: $password)
    .font(.system(size: 20, weight: .semibold, design: .rounded))
    .padding(.horizontal)

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

Давайте прежде чем мы продолжим я обьясню свое решение.

Откройте файл ContentView.swift и просмотрите разметку в Canvas. Ваша отображаемая форма должна выглядеть, как показано на скриншотах выше. Теперь кратко рассмотрим код. Начнем с представления RequirementText.

struct RequirementText: View {
    var iconName = "xmark.square"
    var iconColor = Color(red: 251/255, green: 128/255, blue: 128/255)
    var text = ""
    var isStrikeThrough = false

    var body: some View {
        HStack {
            Image(systemName: iconName)
                .foregroundColor(iconColor)
            Text(text)
                .font(.system(.body, design: .rounded))
                .foregroundColor(.secondary)
                .strikethrough(isStrikeThrough)
            Spacer()
        }
    }
}

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

RequirementText(text: "минимум 4 символа")

Этот код отобразит квадрат с крестиком (xmark.square) и текст. В некоторых случаях текст требования должен быть зачеркнут и отображать другую иконку/цвет. Код можно записать так:

RequirementText(iconName: "lock.open", iconColor: Color.secondary, text: "минимум 8 символов", isStrikeThrough: true)

Теперь, когда вы понимаете, как работает представление RequirementText и почему я его создал, давайте рассмотрим представление FormField. Опять же, если вы посмотрите на все текстовые поля, у них общий стиль - текстовое поле с округлым шрифтом. Именно поэтому я выделил общий код и создал представление FormField.

struct FormField: View {
    var fieldName = ""
    @Binding var fieldValue: String
    var isSecure = false

    var body: some View {
        VStack {
            if isSecure {
                SecureField(fieldName, text: $fieldValue)
                    .font(.system(size: 20, weight: .semibold, design: .rounded))
                    .padding(.horizontal)
            } else {
                TextField(fieldName, text: $fieldValue)
                    .font(.system(size: 20, weight: .semibold, design: .rounded))
                    .padding(.horizontal)
            }
            Divider()
                .frame(height: 1)
                .background(Color(red: 240/255, green: 240/255, blue: 240/255))
                .padding(.horizontal)
        }
    }
}

Поскольку универсальное FormField должно обрабатывать как текстовые, так и защищенные поля, оно имеет свойство isSecure. Если оно установлено в true, поле формы будет создано как защищенное поле. В SwiftUI можно использовать компонент Divider для создания линии. В коде мы используем модификатор frame, чтобы изменить его высоту на 1 точку.

Чтобы создать поле логина пользователя, вы пишете код следующим образом:

FormField(fieldName: "Логин", fieldValue: $username)

Для поля пароля код очень похож, за исключением параметра isSecure, который установлен в true:

FormField(fieldName: "Пароль", fieldValue: $password, isSecure: true)

Теперь вернемся к структуре ContentView и посмотрим, как разметить форму.

struct ContentView: View {
	
	@State private var username = ""
	@State private var password = ""
	@State private var passwordConfirm = ""
	
	var body: some View {
		VStack {
			Text("Создать аккаунт")
				.font(.system(.largeTitle, design: .rounded))
				.bold()
				.padding(.bottom, 30)
			
			FormField(fieldName: "Логин", fieldValue: $username)
			RequirementText(text: "минимум 4 символа")
				.padding()
			
			FormField(fieldName: "Пароль", fieldValue: $password, isSecure: true)
			VStack {
				RequirementText(iconName: "lock.open", text: "Минимум 8 символов", isStrikeThrough: false)
				RequirementText(iconName: "lock.open", text: "Один символ с большой буквы", isStrikeThrough: false)
			}
			.padding()
			
			FormField(fieldName: "Подтвердите пароль", fieldValue: $passwordConfirm, isSecure: true)
			RequirementText(text: "Пароль должен совпадать с введенным ранее", isStrikeThrough: false)
				.padding()
				.padding(.bottom, 50)
			
			Button(action: {
				// Нажатие на кнопку регистрации
			}) {
				Text("Зарегистрироваться")
					.font(.system(.body, design: .rounded))
					.foregroundColor(.white)
					.bold()
					.padding()
					.frame(minWidth: 0, maxWidth: .infinity)
					.background(LinearGradient(gradient: Gradient(colors: [Color(red: 251/255, green: 128/255, blue: 128/255), Color(red: 253/255, green: 193/255, blue: 104/255)]), startPoint: .leading, endPoint: .trailing))
					.cornerRadius(10)
					.padding(.horizontal)
					
			}
			
			HStack {
				Text("Уже есть аккаунт?")
					.font(.system(.body, design: .rounded))
					.bold()
					
				Button(action: {
					// Нажатие на кнопку войти
				}) {
					Text("Войти")
						.font(.system(.body, design: .rounded))
						.bold()
						.foregroundColor(Color(red: 251/255, green: 128/255, blue: 128/255))
				}
			}.padding(.top, 50)
			
			Spacer()
		}
		.padding()
	}
}

У нас есть VStack, чтобы держать все элементы формы в вертикальном виде. Начинаем с заголовка, затем следуют все поля формы и текст требований. Мы уже объяснили, как создаются поля формы и текст требований, поэтому не будем останавливаться на этом. В полях добавлен модификатор padding, чтобы добавить немного пространства между текстовыми полями.

Кнопка "Зарегистрироваться" создана с использованием компонента Button и имеет пустое действие. Я намеренно оставил action пустым, потому что наше внимание в рамках этой части статей сосредоточено на валидации формы. Текст "Уже есть аккаунт?" и кнопка "Войти" являются полностью опциональными.
Это имитация макета обычной формы регистрации.

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

Понимание Combine

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

Что означает "обработка значений во времени"? Какие такие значения?

Используем форму регистрации в качестве примера. Приложение продолжает генерировать UI-события, когда взаимодействует с пользователем. Каждое нажатие клавиши, которое пользователь вводит в текстовое поле, вызывает событие. Это становится потоком значений.

Эти UI-события — один из типов "значений", о которых говорится в фреймворке. Другим примером этих значений являются сетевые события (например, загрузка файла с удаленного сервера).

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

Publisher и Subscriber являются двумя основными элементами фреймворка. С помощью Combine Publisher отправляет события, а Subscriber подписывается, чтобы получать значения от этого Publisher. Вновь используем текстовое поле в качестве примера. Используя Combine, каждое нажатие клавиши, которое пользователь вводит в текстовое поле, вызывает событие изменения значения. Подписчик, заинтересованный в мониторинге этих значений, может подписаться, чтобы получать эти события и выполнять дальнейшие операции (например, валидацию).

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

class FormValidator: ObservableObject {
    @Published var isReadySubmit: Bool = false
}

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

Вы можете подумать, что @Published работает примерно так же, как @State в SwiftUI. Хотя оно работает практически одинаково для этого примера, @State применяется только к свойствам, принадлежащим конкретному представлению SwiftUI. Если вы хотите создать пользовательский тип, который не принадлежит конкретному представлению или который может использоваться между несколькими представлениями, нужно создать класс, который соответствует протоколу ObservableObject, и отметить эти свойства аннотацией @Published.

Combine и MVVM

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

  1. Создадим модель представления для формы регистрации пользователя.

  2. Реализуем валидацию формы в модели представления.

У вас может возникнуть несколько вопросов. Во-первых, зачем создавать модель представления? Можно ли добавить свойства формы и выполнить валидацию формы в ContentView?

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

"Разделение обязанностей" — это фундаментальный принцип написания хорошей программы. Вместо того чтобы помещать все в одно представлении, мы можем разделить представление на два компонента: представление и его модель. Само представление отвечает за разметку UI, в то время как модель хранит состояния и данные, которые должны отображаться в представлении. Модель также обрабатывает валидацию и преобразование данных. Некоторые разработчики узнают в этом широко известный шаблон проектирования под названием MVVM (Model-View-ViewModel).

Какие данные будет содержать эта модель представления?

Посмотрите еще раз на форму регистрации. У нас есть три текстовых поля:

  • Логин

  • Пароль

  • Подтверждение пароля

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

  • Минимум 4 символа (имя пользователя)

  • Минимум 8 символов (пароль)

  • Одна заглавная буква (пароль)

  • Подтверждение пароля должно совпадать с паролем введенным ранее

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

class UserRegistrationViewModel: ObservableObject {
    // Ввод
    @Published var userLogin = ""
    @Published var password = ""
    @Published var passwordConfirm = ""

    // Вывод
    @Published var isLoginLengthValid = false
    @Published var isPasswordLengthValid = false
    @Published var isPasswordCapitalLetter = false
    @Published var isPasswordConfirmValid = false
}

Это модель данных для представления формы. Свойства userLogin, password и passwordConfirm содержат значение полей ввода логина пользователя, пароля и подтверждения пароля соответственно. Этот класс должен соответствовать протоколу ObservableObject. Все эти свойства аннотированы @Published, потому что мы хотим уведомлять подписчиков всякий раз, когда происходит изменение значения, и выполнять валидацию соответственно.

Валидация имени пользователя с использованием Combine

Теперь, когда у нас есть модель данных, но мы еще не обработали валидацию формы. Как выполнить валидацию имени пользователя, пароля и подтверждения пароля в соответствии с требованиями?

С Combine нужно развить мышление издателя/подписчика для ответа на этот вопрос. Рассмотрим имя пользователя, у нас фактически есть два издателя: userLogin и isLoginLengthValid. Издатель username излучает изменения значения всякий раз, когда пользователь вводит символ в поле имени пользователя. Издатель isLoginLengthValid информирует подписчика о статусе валидации ввода пользователя. Почти все элементы управления в SwiftUI являются подписчиками, поэтому представление текста требований будет слушать изменение результата валидации и обновлять свой стиль (например, зачеркнутый или нет) соответственно.

Что здесь отсутствует, так это нечто, что соединяет эти два издателя. И это "нечто" должно выполнять следующие задачи:

  • Слушать изменения userLogin

  • Валидировать имя пользователя и возвращать результат валидации (true/false)

  • Назначать результат isLoginLengthValid

Если преобразовать эти требования в код, получится следующий фрагмент кода:

$userLogin
    .receive(on: RunLoop.main)
    .map { userLogin in
        return userLogin.count >= 4
    }
    .assign(to: \.isLoginLengthValid, on: self)

Фреймворк Combine предоставляет два встроенных подписчика: sink и assign. Для sink он создает универсального подписчика для получения значений. assign позволяет создать другой тип подписчика, который может обновлять конкретное свойство объекта. Например, он назначает результат валидации (true/false) непосредственно свойству isUsernameLengthValid.

Давайте погрузимся в код выше по строчкам. $userLogin — это источник изменения значений, который мы хотим слушать. Поскольку мы подписываемся на изменения событий UI, вызываем функцию receive(on:), чтобы убедиться, что подписчик получает значения на основном потоке (т.е. RunLoop.main).

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

  1. Берем логин пользователя как ввод.

  2. Затем проверяем, имеет ли логин пользователя как минимум 4 символа.

  3. Наконец, возвращаем результат валидации как логическое значение (true/false) подписчику.

С результатом валидации подписчик просто устанавливает результат свойству isLoginLengthValid. Напомним, что isLoginLengthValid также является издателем, мы можем даже обновить элемент управления RequirementText следующим образом, чтобы подписаться на изменение и обновить UI соответственно:

RequirementText(iconColor: userRegistrationViewModel.isLoginLengthValid ? Color.secondary : Color(red: 251/255, green: 128/255, blue: 128/255), text: "Минимум 4 символа", isStrikeThrough: userRegistrationViewModel.isLoginLengthValid)

Как цвет значка, так и статус зачёркивания зависят от результата валидации (т.е. isLoginLengthValid).

Вот как использовать Combine для валидации поля формы. Мы еще не вносили изменения в проект и я хочу, чтобы вы поняли концепцию издателя/подписчика и как выполнять валидацию с использованием этого подхода. Теперь мы применим полученные знания и внесем изменения в код.

Валидация паролей с использованием Combine

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

Для поля пароля есть два требования:

  1. Длина пароля должна быть не менее 8 символов.

  2. Должен содержать хотя бы одну заглавную букву.

Чтобы удовлетворить эти требования, мы создадим двух подписчиков следующим образом:

$password
    .receive(on: RunLoop.main)
    .map { password in
        return password.count >= 8
    }
    .assign(to: \.isPasswordLengthValid, on: self)

$password
    .receive(on: RunLoop.main)
    .map { password in
        let pattern = "[A-Z]"
        if let _ = password.range(of: pattern, options: .regularExpression) {
            return true
        } else {
            return false
        }
    }
    .assign(to: \.isPasswordCapitalLetter, on: self)

Первый подписчик проверяет длину пароля и назначает результат свойству isPasswordLengthValid. Второй подписчик проверяет наличие заглавной буквы. Мы используем метод range, чтобы проверить, есть ли в пароле хотя бы одна заглавная буква. Подписчик назначает результат валидации непосредственно свойству isPasswordCapitalLetter.

Теперь осталось выполнить валидацию поля подтверждения пароля. Для этого поля требование заключается в том, что подтверждение пароля должно совпадать с паролем. Оба свойства password и passwordConfirm являются издателями. Чтобы проверить, имеют ли оба издателя одно и то же значение, используем Publisher.combineLatest для получения и объединения последних значений от издателей. Мы можем затем проверить, совпадают ли два значения. Вот фрагмент кода:

Publishers.CombineLatest($password, $passwordConfirm)
    .receive(on: RunLoop.main)
    .map { (password, passwordConfirm) in
        return !passwordConfirm.isEmpty && (passwordConfirm == password)
    }
    .assign(to: \.isPasswordConfirmValid, on: self)

Таким образом, мы назначаем результат валидации свойству isPasswordConfirmValid.

Реализация UserRegistrationViewModel

Теперь, когда я объяснил реализацию, давайте объединим все в проекте. Сначала создайте новый файл Swift под названием UserRegistrationViewModel.swift. Замените все содержимое файла следующим кодом:

import Foundation
import Combine

class UserRegistrationViewModel: ObservableObject {
	// Ввод
	@Published var userLogin = ""
	@Published var password = ""
	@Published var passwordConfirm = ""

	// Вывод
	@Published var isLoginLengthValid = false
	@Published var isPasswordLengthValid = false
	@Published var isPasswordCapitalLetter = false
	@Published var isPasswordConfirmValid = false

	private var cancellableSet: Set<AnyCancellable> = []

	init() {
		$userLogin
			.receive(on: RunLoop.main)
			.map { userLogin in
				return userLogin.count >= 4
			}
			.assign(to: \.isLoginLengthValid, on: self)
			.store(in: &cancellableSet)

		$password
			.receive(on: RunLoop.main)
			.map { password in
				return password.count >= 8
			}
			.assign(to: \.isPasswordLengthValid, on: self)
			.store(in: &cancellableSet)

		$password
			.receive(on: RunLoop.main)
			.map { password in
				let pattern = "[A-Z]"
				if let _ = password.range(of: pattern, options: .regularExpression) {
					return true
				} else {
					return false
				}
			}
			.assign(to: \.isPasswordCapitalLetter, on: self)
			.store(in: &cancellableSet)

		Publishers.CombineLatest($password, $passwordConfirm)
			.receive(on: RunLoop.main)
			.map { (password, passwordConfirm) in
				return !passwordConfirm.isEmpty && (passwordConfirm == password)
			}
			.assign(to: \.isPasswordConfirmValid, on: self)
			.store(in: &cancellableSet)
	}
}

Код почти такой же, как и фрагменты кода, которые мы обсуждали ранее. Единственное, что вы можете заметить, это переменная cancellableSet. Кроме того, для каждого из подписчиков мы вызываем функцию store в самом конце.

Что делают функция store и переменная cancellableSet?

Функция assign, которая создает подписчика, возвращает экземпляр, который можно отменить. Вы можете использовать этот экземпляр, чтобы отменить подписку в нужное время. Функция store позволяет сохранить ссылку на экземпляр, который можно отменить, в набор для последующей очистки. Если не сохранить ссылку, приложение может столкнуться с проблемами утечки памяти.

Когда произойдет очистка для этого демо? Поскольку cancellableSet определен как свойство класса, очистка и отмена подписки произойдут, когда класс будет деинициализирован.

Теперь вернитесь к файлу ContentView.swift и обновите элементы управления UI. Сначала замените следующие переменные состояния:

@State private var username = ""
@State private var password = ""
@State private var passwordConfirm = ""

на модель представления, назвав её userRegistrationViewModel:

@ObservedObject private var userRegistrationViewModel = UserRegistrationViewModel()

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

			FormField(fieldName: "Логин", fieldValue: $userRegistrationViewModel.userLogin)
			RequirementText(
				iconColor: userRegistrationViewModel.isLoginLengthValid ? Color.secondary
				: Color(red: 251/255, green: 128/255, blue: 128/255),
				text: "Минимум 4 символа",
				isStrikeThrough: userRegistrationViewModel.isLoginLengthValid
			)

				.padding()

Параметр fieldName теперь изменен на $userRegistrationViewModel.userLogin.

Для текста требования SwiftUI отслеживает свойство userRegistrationViewModel.isLoginLengthValid и обновляет текст требования соответственно.

Аналогично обновите код UI для полей пароля и подтверждения пароля следующим образом:

			FormField(fieldName: "Пароль", fieldValue: $userRegistrationViewModel.password, isSecure: true)
			VStack {
				RequirementText(
					iconName: "lock.open",
					iconColor: userRegistrationViewModel.isPasswordLengthValid ? Color.secondary : Color(red: 251/255, green: 128/255, blue: 128/255),
					text: "Минимум 8 символов",
					isStrikeThrough: userRegistrationViewModel.isPasswordLengthValid
				)

				RequirementText(
					iconName: "lock.open",
					iconColor: userRegistrationViewModel.isPasswordLengthValid ? Color.secondary : Color(red: 251/255, green: 128/255, blue: 128/255),
					text: "Один символ с большой буквы",
					isStrikeThrough: userRegistrationViewModel.isPasswordLengthValid
				)
			}
			.padding()

И подтверждение пароля

			FormField(fieldName: "Подтвердите пароль", fieldValue: $userRegistrationViewModel.passwordConfirm, isSecure: true)
			RequirementText(text: "Пароль должен совпадать с введенным ранее", isStrikeThrough: userRegistrationViewModel.isPasswordConfirmValid)

Вот и всё! Теперь можно протестировать приложение. Если вы внесли все изменения правильно, приложение теперь должно валидировать ввод пользователя.

Надеюсь, вы теперь получили базовые знания о фреймворке Combine. Введение SwiftUI и Combine полностью меняет подход к созданию приложений. Функциональное реактивное программирование (FRP) становится все более популярным в последние годы и не только в рамках Apple, взгляните что делает Google со своим JetPack Compose.

Как и при внедрении любой новой технологии, будет кривая обучения, местами будет сложно и появится не принятие. Поэтому даже если вы уже программируете на iOS, потребуется время, чтобы перейти от методологии программирования с делегатами к издателям и подписчикам.

Однако, как только вы освоите фреймворк Combine, вы будете очень довольны, так как это поможет вам добиться более поддерживаемого и модульного кода. Как теперь видно, вместе со SwiftUI, коммуникация между представлением и его моделью становится легкой и с минимальным количеством кода.

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

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

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

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


  1. Bardakan
    08.06.2024 15:15

    Функциональное реактивное программирование (FRP) становится все более популярным в последние годы и не только в рамках Apple, взгляните что делает Google со своим JetPack Compose.

    Однако, как только вы освоите фреймворк Combine, вы будете очень довольны, так как это поможет вам добиться более поддерживаемого и модульного кода. Как теперь видно, вместе со SwiftUI, коммуникация между представлением и его моделью становится легкой и с минимальным количеством кода.

    у вас устаревшая информация - Apple отказалась от Combine в пользу Concurrency