From 952034fcfaa67a7e0c5458fb7c09e477a6942528 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Mon, 8 Dec 2025 21:44:30 +0400 Subject: [PATCH 1/2] feat: add UITextView validation support --- .../Extensions/UITextView+Validation.swift | 48 +++++++++++++ .../UnitTests/UITextViewTests.swift | 72 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 Sources/ValidatorUI/Classes/UIKit/Extensions/UITextView+Validation.swift create mode 100644 Tests/ValidatorUITests/UnitTests/UITextViewTests.swift diff --git a/Sources/ValidatorUI/Classes/UIKit/Extensions/UITextView+Validation.swift b/Sources/ValidatorUI/Classes/UIKit/Extensions/UITextView+Validation.swift new file mode 100644 index 0000000..deef938 --- /dev/null +++ b/Sources/ValidatorUI/Classes/UIKit/Extensions/UITextView+Validation.swift @@ -0,0 +1,48 @@ +// +// Validator +// Copyright © 2025 Space Code. All rights reserved. +// + +#if os(iOS) + import UIKit + + extension UITextView: IUIValidatable { + /// The value of the text view to validate. + /// Returns an empty string if `text` is nil. + public var inputValue: String { text ?? "" } + + /// The type of input for validation. + public typealias Input = String + + /// Enables or disables automatic validation when the text changes. + /// + /// - Parameter isEnabled: If true, adds an observer for text changes. + /// If false, removes the observer. + public func validateOnInputChange(isEnabled: Bool) { + if isEnabled { + NotificationCenter.default.addObserver( + self, + selector: #selector(textViewDidChangeNotification(_:)), + name: UITextView.textDidChangeNotification, + object: self + ) + } else { + NotificationCenter.default.removeObserver( + self, + name: UITextView.textDidChangeNotification, + object: self + ) + } + } + + // MARK: Private + + /// Called automatically when the text view changes via NotificationCenter. + @objc + private func textViewDidChangeNotification(_ notification: Notification) { + guard let textView = notification.object as? UITextView, textView === self else { return } + + validate(rules: validationRules) + } + } +#endif diff --git a/Tests/ValidatorUITests/UnitTests/UITextViewTests.swift b/Tests/ValidatorUITests/UnitTests/UITextViewTests.swift new file mode 100644 index 0000000..b4b90d2 --- /dev/null +++ b/Tests/ValidatorUITests/UnitTests/UITextViewTests.swift @@ -0,0 +1,72 @@ +// +// Validator +// Copyright © 2025 Space Code. All rights reserved. +// + +import ValidatorCore +import ValidatorUI +import XCTest + +#if canImport(UIKit) + import UIKit +#endif + +#if os(iOS) + final class UITextViewTests: XCTestCase { + // MARK: Tests + + @MainActor + func test_thatTextViewValidationReturnsValid_whenInputValueIsValid() { + // given + let textView = UITextView() + + textView.validateOnInputChange(isEnabled: true) + textView.add(rule: LengthValidationRule(max: .max, error: String.error)) + + // when + textView.text = String(String.text.prefix(.max)) + + var result: ValidationResult? + + textView.validationHandler = { result = $0 } + textView.validate(rules: textView.validationRules) + + // when + if case .valid = result {} + else { XCTFail("The result must be equal to the valid value") } + } + + @MainActor + func test_thatTextViewValidationReturnsInvalid_whenInputValueIsInvalid() { + // given + let textView = UITextView() + + textView.validateOnInputChange(isEnabled: true) + textView.add(rule: LengthValidationRule(max: .max, error: String.error)) + + // when + textView.text = .text + + var result: ValidationResult? + + textView.validationHandler = { result = $0 } + textView.validate(rules: textView.validationRules) + + // when + if case let .invalid(errors) = result { + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors.first?.message, .error) + } else { XCTFail("The result must be equal to the invalid value") } + } + } +#endif + +private extension String { + static let text: String = "lorem ipsum lorem ipsum lorem ipsum" + static let error: String = "error" +} + +private extension Int { + static let min = 0 + static let max = 10 +} From f17969d2fb2cb84cd3ed735d69c679e6ccf6b48c Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Thu, 11 Dec 2025 12:29:17 +0400 Subject: [PATCH 2/2] docs: update examples --- .../UIKitExample/Base.lproj/Main.storyboard | 37 ++- .../UIKitExample/Extensions/UIView+.swift | 16 ++ .../UIKit/UIKitExample/ViewController.swift | 246 +++--------------- ...eedbackTextViewExampleViewController.swift | 211 +++++++++++++++ .../LoginTextFieldExampleViewController.swift | 232 +++++++++++++++++ 5 files changed, 528 insertions(+), 214 deletions(-) create mode 100644 Examples/UIKit/UIKitExample/Extensions/UIView+.swift create mode 100644 Examples/UIKit/UIKitExample/ViewControllers/FeedbackTextViewExampleViewController.swift create mode 100644 Examples/UIKit/UIKitExample/ViewControllers/LoginTextFieldExampleViewController.swift diff --git a/Examples/UIKit/UIKitExample/Base.lproj/Main.storyboard b/Examples/UIKit/UIKitExample/Base.lproj/Main.storyboard index 25a7638..d8c260f 100644 --- a/Examples/UIKit/UIKitExample/Base.lproj/Main.storyboard +++ b/Examples/UIKit/UIKitExample/Base.lproj/Main.storyboard @@ -1,24 +1,51 @@ - + + - + + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/UIKit/UIKitExample/Extensions/UIView+.swift b/Examples/UIKit/UIKitExample/Extensions/UIView+.swift new file mode 100644 index 0000000..530a8e1 --- /dev/null +++ b/Examples/UIKit/UIKitExample/Extensions/UIView+.swift @@ -0,0 +1,16 @@ +// +// Validator +// Copyright © 2025 Space Code. All rights reserved. +// + +import UIKit + +extension UIView { + func findFirstResponder() -> UIView? { + if isFirstResponder { return self } + for sub in subviews { + if let found = sub.findFirstResponder() { return found } + } + return nil + } +} diff --git a/Examples/UIKit/UIKitExample/ViewController.swift b/Examples/UIKit/UIKitExample/ViewController.swift index 478b50e..d3b388b 100644 --- a/Examples/UIKit/UIKitExample/ViewController.swift +++ b/Examples/UIKit/UIKitExample/ViewController.swift @@ -10,235 +10,63 @@ import ValidatorUI // MARK: - ViewController final class ViewController: UIViewController { - // MARK: - Properties + private let examples: [ExampleItem] = [ + ExampleItem(title: "UITextField Example", controller: LoginTextFieldExampleViewController()), + ExampleItem(title: "UITextView Example", controller: FeedbackTextViewExampleViewController()), + ] - // UI - private let scrollView: UIScrollView = { - let scrollView = UIScrollView() - scrollView.translatesAutoresizingMaskIntoConstraints = false - return scrollView - }() - - private let contentView: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - private lazy var firstNameTextField: UITextField = { - let textField = makeField("First Name") - textField.validationRules = [ - LengthValidationRule(min: 2, max: 50, error: "First name should be 2–50 characters long"), - ] - return textField - }() - - private lazy var lastNameTextField: UITextField = { - let textField = makeField("Last Name") - textField.validationRules = [ - LengthValidationRule(min: 2, max: 50, error: "Last name should be 2–50 characters long"), - ] - return textField - }() - - private lazy var emailTextField: UITextField = { - let textField = makeField("Email") - textField.keyboardType = .emailAddress - textField.validationRules = [ - EmailValidationRule(error: "Please enter a valid email address"), - ] - return textField - }() - - private lazy var submitButton: UIButton = { - let button = UIButton(type: .system) - button.setTitle("Sign Up", for: .normal) - button.layer.cornerRadius = 10 - button.backgroundColor = .systemGray4 - button.setTitleColor(.white, for: .normal) - button.translatesAutoresizingMaskIntoConstraints = false - button.addTarget(self, action: #selector(submit), for: .touchUpInside) - return button - }() - - private let stackView: UIStackView = { - let stackView = UIStackView() - stackView.axis = .vertical - stackView.spacing = 16 - stackView.translatesAutoresizingMaskIntoConstraints = false - return stackView - }() - - // Private properties - private var isValid: Bool { - [firstNameTextField, lastNameTextField, emailTextField] - .allSatisfy { $0.validationResult == .valid } - } - - // MARK: - Lifecycle + private let tableView = UITableView(frame: .zero, style: .insetGrouped) override func viewDidLoad() { super.viewDidLoad() + title = "Examples" view.backgroundColor = .systemBackground - configure() - } - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - registerKeyboardNotifications() + setupTableView() } - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - unregisterKeyboardNotifications() - } + private func setupTableView() { + tableView.dataSource = self + tableView.delegate = self + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") + tableView.translatesAutoresizingMaskIntoConstraints = false - // MARK: - UI Setup - - private func configure() { - for item in [firstNameTextField, lastNameTextField, emailTextField] { - item.validationHandler = { [weak self] _ in - guard let self else { return } - updateSubmitButtonState() - } - } - - [firstNameTextField, lastNameTextField, emailTextField, submitButton] - .forEach(stackView.addArrangedSubview) - - view.addSubview(scrollView) - scrollView.addSubview(contentView) - contentView.addSubview(stackView) + view.addSubview(tableView) NSLayoutConstraint.activate([ - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - scrollView.topAnchor.constraint(equalTo: view.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - - contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), - contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), - contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), - contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), - contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), - contentView.heightAnchor.constraint(equalTo: scrollView.heightAnchor), - - stackView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), - stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), - stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), - - submitButton.heightAnchor.constraint(equalToConstant: 48), + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) } +} - private func updateSubmitButtonState() { - submitButton.isEnabled = isValid - UIView.animate(withDuration: 0.25) { - self.submitButton.backgroundColor = self.isValid ? .systemBlue : .systemGray4 - } - } - - // MARK: Private - - private func makeField(_ placeholder: String) -> UITextField { - let textField = UITextField() - textField.placeholder = placeholder - - textField.layer.cornerRadius = 10 - textField.layer.borderWidth = 1 - textField.layer.borderColor = UIColor.systemGray4.cgColor - textField.backgroundColor = UIColor.secondarySystemBackground - textField.textColor = .label - - textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 0)) - textField.leftViewMode = .always - - textField.heightAnchor.constraint(equalToConstant: 56.0).isActive = true - textField.translatesAutoresizingMaskIntoConstraints = false - textField.validateOnInputChange(isEnabled: true) - - return textField - } - - // MARK: Notifications - - private func registerKeyboardNotifications() { - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillShow(_:)), - name: UIResponder.keyboardWillShowNotification, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillHide(_:)), - name: UIResponder.keyboardWillHideNotification, - object: nil - ) - } - - private func unregisterKeyboardNotifications() { - NotificationCenter.default.removeObserver(self) - } - - // MARK: - Actions - - @objc - private func submit() { - guard isValid else { - let alert = UIAlertController( - title: "The form is invalid", - message: "Please check the highlighted fields and correct the entered information.", - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "Got it", style: .default)) - - present(alert, animated: true) - return - } - - let alert = UIAlertController( - title: "Success 🎉", - message: "Your account has been successfully created.", - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "OK", style: .default)) +// MARK: UITableViewDataSource, UITableViewDelegate - present(alert, animated: true) +extension ViewController: UITableViewDataSource, UITableViewDelegate { + func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { + examples.count } - @objc - private func keyboardWillShow(_ notification: Notification) { - guard - let userInfo = notification.userInfo, - let frameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue - else { return } - - let keyboardFrame = frameValue.cgRectValue - let bottomInset = keyboardFrame.height - view.safeAreaInsets.bottom - - scrollView.contentInset.bottom = bottomInset - - if let firstResponder = view.findFirstResponder(), - !scrollView.frame.contains(firstResponder.frame) - { - scrollView.scrollRectToVisible(firstResponder.frame, animated: true) - } + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) + let example = examples[indexPath.row] + cell.textLabel?.text = example.title + cell.accessoryType = .disclosureIndicator + return cell } - @objc - private func keyboardWillHide(_: Notification) { - scrollView.contentInset.bottom = .zero + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let example = examples[indexPath.row] + navigationController?.pushViewController(example.controller, animated: true) } } -extension UIView { - func findFirstResponder() -> UIView? { - if isFirstResponder { return self } - for sub in subviews { - if let found = sub.findFirstResponder() { return found } - } - return nil - } +// MARK: - ExampleItem + +struct ExampleItem { + let title: String + let controller: UIViewController } diff --git a/Examples/UIKit/UIKitExample/ViewControllers/FeedbackTextViewExampleViewController.swift b/Examples/UIKit/UIKitExample/ViewControllers/FeedbackTextViewExampleViewController.swift new file mode 100644 index 0000000..37be276 --- /dev/null +++ b/Examples/UIKit/UIKitExample/ViewControllers/FeedbackTextViewExampleViewController.swift @@ -0,0 +1,211 @@ +// +// Validator +// Copyright © 2025 Space Code. All rights reserved. +// + +import UIKit +import ValidatorCore +import ValidatorUI + +final class FeedbackTextViewExampleViewController: UIViewController { + // MARK: - UI + + private let scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + return scrollView + }() + + private let contentView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var feedbackTextView: UITextView = { + let textView = makeTextView() + textView.validationRules = [ + LengthValidationRule(min: 10, max: 500, error: "Feedback must be 10–500 characters long"), + ] + return textView + }() + + private lazy var submitButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle("Submit Feedback", for: .normal) + button.layer.cornerRadius = 10 + button.backgroundColor = .systemGray4 + button.setTitleColor(.white, for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + button.addTarget(self, action: #selector(submit), for: .touchUpInside) + return button + }() + + private let stackView: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 16 + stack.translatesAutoresizingMaskIntoConstraints = false + return stack + }() + + // MARK: - Validation + + private var isValid: Bool { + feedbackTextView.validationResult == .valid + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + title = "UITextView Demo" + view.backgroundColor = .systemBackground + + configure() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + registerKeyboardNotifications() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + unregisterKeyboardNotifications() + } + + // MARK: - UI Setup + + private func configure() { + feedbackTextView.validationHandler = { [weak self] _ in + self?.updateSubmitButtonState() + } + + [feedbackTextView, submitButton].forEach(stackView.addArrangedSubview) + + view.addSubview(scrollView) + scrollView.addSubview(contentView) + contentView.addSubview(stackView) + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), + + stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), + stackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 40), + stackView.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor), + + feedbackTextView.heightAnchor.constraint(equalToConstant: 160), + submitButton.heightAnchor.constraint(equalToConstant: 48), + ]) + } + + private func updateSubmitButtonState() { + UIView.animate(withDuration: 0.25) { + self.submitButton.backgroundColor = self.isValid ? .systemBlue : .systemGray4 + } + } + + // MARK: - Helpers + + private func makeTextView() -> UITextView { + let textView = UITextView() + + textView.text = "" + textView.font = .systemFont(ofSize: 16) + textView.layer.cornerRadius = 10 + textView.layer.borderWidth = 1 + textView.layer.borderColor = UIColor.systemGray4.cgColor + textView.backgroundColor = UIColor.secondarySystemBackground + textView.textColor = .label + + textView.isScrollEnabled = false + textView.translatesAutoresizingMaskIntoConstraints = false + + // Enable validation on text changes + textView.validateOnInputChange(isEnabled: true) + + return textView + } + + // MARK: - Keyboard Handling + + private func registerKeyboardNotifications() { + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillShow(_:)), + name: UIResponder.keyboardWillShowNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillHide(_:)), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + } + + private func unregisterKeyboardNotifications() { + NotificationCenter.default.removeObserver(self) + } + + @objc + private func keyboardWillShow(_ notification: Notification) { + guard + let userInfo = notification.userInfo, + let frameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue + else { return } + + let keyboardFrame = frameValue.cgRectValue + let bottomInset = keyboardFrame.height - view.safeAreaInsets.bottom + + scrollView.contentInset.bottom = bottomInset + + if let firstResponder = view.findFirstResponder(), + !scrollView.frame.contains(firstResponder.frame) + { + scrollView.scrollRectToVisible(firstResponder.frame, animated: true) + } + } + + @objc + private func keyboardWillHide(_: Notification) { + scrollView.contentInset.bottom = .zero + } + + // MARK: - Actions + + @objc + private func submit() { + guard isValid else { + let alert = UIAlertController( + title: "Feedback is invalid", + message: "Please write at least 10 characters.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + return + } + + let alert = UIAlertController( + title: "Thank you! 🎉", + message: "Your feedback has been successfully submitted.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Great", style: .default)) + present(alert, animated: true) + } +} diff --git a/Examples/UIKit/UIKitExample/ViewControllers/LoginTextFieldExampleViewController.swift b/Examples/UIKit/UIKitExample/ViewControllers/LoginTextFieldExampleViewController.swift new file mode 100644 index 0000000..936913d --- /dev/null +++ b/Examples/UIKit/UIKitExample/ViewControllers/LoginTextFieldExampleViewController.swift @@ -0,0 +1,232 @@ +// +// Validator +// Copyright © 2025 Space Code. All rights reserved. +// + +import UIKit +import ValidatorCore +import ValidatorUI + +final class LoginTextFieldExampleViewController: UIViewController { + // MARK: - Properties + + // UI + private let scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + return scrollView + }() + + private let contentView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var firstNameTextField: UITextField = { + let textField = makeField("First Name") + textField.validationRules = [ + LengthValidationRule(min: 2, max: 50, error: "First name should be 2–50 characters long"), + ] + return textField + }() + + private lazy var lastNameTextField: UITextField = { + let textField = makeField("Last Name") + textField.validationRules = [ + LengthValidationRule(min: 2, max: 50, error: "Last name should be 2–50 characters long"), + ] + return textField + }() + + private lazy var emailTextField: UITextField = { + let textField = makeField("Email") + textField.keyboardType = .emailAddress + textField.validationRules = [ + EmailValidationRule(error: "Please enter a valid email address"), + ] + return textField + }() + + private lazy var submitButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle("Sign Up", for: .normal) + button.layer.cornerRadius = 10 + button.backgroundColor = .systemGray4 + button.setTitleColor(.white, for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + button.addTarget(self, action: #selector(submit), for: .touchUpInside) + return button + }() + + private let stackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 16 + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + // Private properties + private var isValid: Bool { + [firstNameTextField, lastNameTextField, emailTextField] + .allSatisfy { $0.validationResult == .valid } + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + configure() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + registerKeyboardNotifications() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + unregisterKeyboardNotifications() + } + + // MARK: - UI Setup + + private func configure() { + for item in [firstNameTextField, lastNameTextField, emailTextField] { + item.validationHandler = { [weak self] _ in + guard let self else { return } + updateSubmitButtonState() + } + } + + [firstNameTextField, lastNameTextField, emailTextField, submitButton] + .forEach(stackView.addArrangedSubview) + + view.addSubview(scrollView) + scrollView.addSubview(contentView) + contentView.addSubview(stackView) + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), + contentView.heightAnchor.constraint(equalTo: scrollView.heightAnchor), + + stackView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), + + submitButton.heightAnchor.constraint(equalToConstant: 48), + ]) + } + + private func updateSubmitButtonState() { + submitButton.isEnabled = isValid + UIView.animate(withDuration: 0.25) { + self.submitButton.backgroundColor = self.isValid ? .systemBlue : .systemGray4 + } + } + + // MARK: Private + + private func makeField(_ placeholder: String) -> UITextField { + let textField = UITextField() + textField.placeholder = placeholder + + textField.layer.cornerRadius = 10 + textField.layer.borderWidth = 1 + textField.layer.borderColor = UIColor.systemGray4.cgColor + textField.backgroundColor = UIColor.secondarySystemBackground + textField.textColor = .label + + textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 0)) + textField.leftViewMode = .always + + textField.heightAnchor.constraint(equalToConstant: 56.0).isActive = true + textField.translatesAutoresizingMaskIntoConstraints = false + textField.validateOnInputChange(isEnabled: true) + + return textField + } + + // MARK: Notifications + + private func registerKeyboardNotifications() { + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillShow(_:)), + name: UIResponder.keyboardWillShowNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillHide(_:)), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + } + + private func unregisterKeyboardNotifications() { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Actions + + @objc + private func submit() { + guard isValid else { + let alert = UIAlertController( + title: "The form is invalid", + message: "Please check the highlighted fields and correct the entered information.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Got it", style: .default)) + + present(alert, animated: true) + return + } + + let alert = UIAlertController( + title: "Success 🎉", + message: "Your account has been successfully created.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + + present(alert, animated: true) + } + + @objc + private func keyboardWillShow(_ notification: Notification) { + guard + let userInfo = notification.userInfo, + let frameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue + else { return } + + let keyboardFrame = frameValue.cgRectValue + let bottomInset = keyboardFrame.height - view.safeAreaInsets.bottom + + scrollView.contentInset.bottom = bottomInset + + if let firstResponder = view.findFirstResponder(), + !scrollView.frame.contains(firstResponder.frame) + { + scrollView.scrollRectToVisible(firstResponder.frame, animated: true) + } + } + + @objc + private func keyboardWillHide(_: Notification) { + scrollView.contentInset.bottom = .zero + } +}