From db386bc64f69f84ccbaae3090c174bbbbca7fccf Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Wed, 26 Jun 2024 17:05:27 +0200 Subject: [PATCH 1/5] feat: Hybrid input field for UIKit/SwiftUI --- Package.swift | 36 +- Sources/GRAsyncImage/GRAsyncImage.swift | 1 + Sources/GRAsyncImage/TypeAliases.swift | 10 - .../Common/InputFieldAppearance.swift | 92 +++ .../Common/InputFieldTraits.swift | 47 ++ .../GRInputField/Common/ValidationError.swift | 73 +++ Sources/GRInputField/Common/Validator.swift | 178 +++++ Sources/GRInputField/SwiftUI/InputField.swift | 279 ++++++++ .../GRInputField/SwiftUI/ValidityGroup.swift | 62 ++ .../GRInputField/UIKit/InputFieldView.swift | 617 ++++++++++++++++++ .../UIKit/ValidableInputFieldView.swift | 77 +++ Sources/GoodSwiftUI/GoodSwiftUI.swift | 6 - Sources/GoodSwiftUI/OptionalBinding.swift | 7 + 13 files changed, 1458 insertions(+), 27 deletions(-) delete mode 100644 Sources/GRAsyncImage/TypeAliases.swift create mode 100644 Sources/GRInputField/Common/InputFieldAppearance.swift create mode 100644 Sources/GRInputField/Common/InputFieldTraits.swift create mode 100644 Sources/GRInputField/Common/ValidationError.swift create mode 100644 Sources/GRInputField/Common/Validator.swift create mode 100644 Sources/GRInputField/SwiftUI/InputField.swift create mode 100644 Sources/GRInputField/SwiftUI/ValidityGroup.swift create mode 100644 Sources/GRInputField/UIKit/InputFieldView.swift create mode 100644 Sources/GRInputField/UIKit/ValidableInputFieldView.swift delete mode 100644 Sources/GoodSwiftUI/GoodSwiftUI.swift diff --git a/Package.swift b/Package.swift index 095eac8..e5414c8 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -9,29 +9,43 @@ let package = Package( .iOS(.v13) ], products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. .library( name: "GoodSwiftUI", - targets: ["GoodSwiftUI"]), + targets: ["GoodSwiftUI", "GRAsyncImage", "GRInputField"] + ), .library( name: "GRAsyncImage", - targets: ["GRAsyncImage"]) + targets: ["GRAsyncImage"] + ), + .library( + name: "GRInputField", + targets: ["GRInputField"] + ) ], dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), + .package(url: "https://github.com/goodrequest/goodextensions-ios", branch: "feature/goodextensions-update") ], targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "GoodSwiftUI", - dependencies: []), + dependencies: [] + ), .target( name: "GRAsyncImage", - dependencies: []), + dependencies: [ + .product(name: "GoodExtensions", package: "GoodExtensions-iOS"), + ] + ), + .target( + name: "GRInputField", + dependencies: [ + .product(name: "GoodExtensions", package: "GoodExtensions-iOS"), + .product(name: "GoodStructs", package: "GoodExtensions-iOS") + ] + ), .testTarget( name: "GoodSwiftUITests", - dependencies: ["GoodSwiftUI"]), + dependencies: ["GoodSwiftUI"] + ), ] ) diff --git a/Sources/GRAsyncImage/GRAsyncImage.swift b/Sources/GRAsyncImage/GRAsyncImage.swift index d6fd387..6a9b2f5 100644 --- a/Sources/GRAsyncImage/GRAsyncImage.swift +++ b/Sources/GRAsyncImage/GRAsyncImage.swift @@ -5,6 +5,7 @@ // Created by Marián Franko on 25/05/2023. // +import GoodExtensions import SwiftUI @available(iOS 14.0, *) diff --git a/Sources/GRAsyncImage/TypeAliases.swift b/Sources/GRAsyncImage/TypeAliases.swift deleted file mode 100644 index 155c8d0..0000000 --- a/Sources/GRAsyncImage/TypeAliases.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// TypeAliases.swift -// -// -// Created by Matus Klasovity on 15/06/2023. -// - -import Foundation - -public typealias VoidClosure = () -> Void diff --git a/Sources/GRInputField/Common/InputFieldAppearance.swift b/Sources/GRInputField/Common/InputFieldAppearance.swift new file mode 100644 index 0000000..e82f6ac --- /dev/null +++ b/Sources/GRInputField/Common/InputFieldAppearance.swift @@ -0,0 +1,92 @@ +// +// InputFieldAppearance.swift +// benu +// +// Created by Maroš Novák on 11/03/2022. +// + +import UIKit + +public struct InputFieldAppearance { + + public var titleFont: UIFont? + public var titleColor: UIColor? + + public var textFieldTintColor: UIColor? + public var textFieldFont: UIFont? + + public var hintFont: UIFont? + + public var borderWidth: CGFloat? + public var cornerRadius: CGFloat? + public var height: CGFloat? + + public var eyeImageHidden: UIImage? + public var eyeImageVisible: UIImage? + + public var enabled: InputFieldViewStateAppearance? = .default + public var selected: InputFieldViewStateAppearance? = .default + public var disabled: InputFieldViewStateAppearance? = .default + public var failed: InputFieldViewStateAppearance? = .default + + public init( + titleFont: UIFont? = .systemFont(ofSize: 12), + titleColor: UIColor? = .black, + textFieldTintColor: UIColor? = .systemBlue, + textFieldFont: UIFont? = .systemFont(ofSize: 12), + hintFont: UIFont? = .systemFont(ofSize: 12), + borderWidth: CGFloat? = 0, + cornerRadius: CGFloat? = 0, + height: CGFloat? = 50, + eyeImageHidden: UIImage? = nil, + eyeImageVisible: UIImage? = nil, + enabled: InputFieldViewStateAppearance? = .default, + selected: InputFieldViewStateAppearance? = .default, + disabled: InputFieldViewStateAppearance? = .default, + failed: InputFieldViewStateAppearance? = .default + ) { + self.titleFont = titleFont + self.titleColor = titleColor + self.textFieldTintColor = textFieldTintColor + self.textFieldFont = textFieldFont + self.hintFont = hintFont + self.borderWidth = borderWidth + self.cornerRadius = cornerRadius + self.height = height + self.eyeImageHidden = eyeImageHidden + self.eyeImageVisible = eyeImageVisible + self.enabled = enabled + self.selected = selected + self.disabled = disabled + self.failed = failed + } + + public static let `default` = InputFieldAppearance() + +} + +public struct InputFieldViewStateAppearance { + + public var placeholderColor: UIColor? + public var contentBackgroundColor: UIColor? + public var textFieldTextColor: UIColor? + public var borderColor: UIColor? + public var hintColor: UIColor? + + public init( + placeholderColor: UIColor? = .lightGray, + contentBackgroundColor: UIColor? = .white, + textFieldTextColor: UIColor? = .black, + borderColor: UIColor? = .black, + hintColor: UIColor? = .black + ) { + self.placeholderColor = placeholderColor + self.contentBackgroundColor = contentBackgroundColor + self.textFieldTextColor = textFieldTextColor + self.borderColor = borderColor + self.hintColor = hintColor + } + + public static let `default` = InputFieldViewStateAppearance() + +} diff --git a/Sources/GRInputField/Common/InputFieldTraits.swift b/Sources/GRInputField/Common/InputFieldTraits.swift new file mode 100644 index 0000000..9640ed6 --- /dev/null +++ b/Sources/GRInputField/Common/InputFieldTraits.swift @@ -0,0 +1,47 @@ +// +// InputFieldTraits.swift +// benu +// +// Created by Filip Šašala on 04/08/2022. +// + +import UIKit + +public struct InputFieldTraits { + + public var textContentType: UITextContentType? + public var autocapitalizationType: UITextAutocapitalizationType = .none + public var autocorrectionType: UITextAutocorrectionType = .default + public var keyboardType: UIKeyboardType = .default + public var returnKeyType: UIReturnKeyType = .default + public var numpadReturnKeyTitle: String? + /// If text field is secure, clear button is always disabled. + public var clearButtonMode: UITextField.ViewMode = .whileEditing + public var isSecureTextEntry: Bool = false + public var isHapticsAllowed: Bool = true + + public init( + textContentType: UITextContentType? = nil, + autocapitalizationType: UITextAutocapitalizationType = .none, + autocorrectionType: UITextAutocorrectionType = .default, + keyboardType: UIKeyboardType = .default, + returnKeyType: UIReturnKeyType = .default, + numpadReturnKeyTitle: String? = nil, + clearButtonMode: UITextField.ViewMode = .whileEditing, + isSecureTextEntry: Bool = false, + isHapticsAllowed: Bool = true + ) { + self.textContentType = textContentType + self.autocapitalizationType = autocapitalizationType + self.autocorrectionType = autocorrectionType + self.keyboardType = keyboardType + self.returnKeyType = returnKeyType + self.numpadReturnKeyTitle = numpadReturnKeyTitle + self.clearButtonMode = clearButtonMode + self.isSecureTextEntry = isSecureTextEntry + self.isHapticsAllowed = isHapticsAllowed + } + + public static let `default` = InputFieldTraits() + +} diff --git a/Sources/GRInputField/Common/ValidationError.swift b/Sources/GRInputField/Common/ValidationError.swift new file mode 100644 index 0000000..d9b5ea1 --- /dev/null +++ b/Sources/GRInputField/Common/ValidationError.swift @@ -0,0 +1,73 @@ +// +// ValidationError.swift +// benu +// +// Created by Filip Šašala on 11/06/2024. +// + +import Foundation + +// MARK: - Validation errors + +public protocol ValidationError: Error, Equatable { + + var localizedDescription: String { get } + +} + +public enum InternalValidationError: ValidationError { + + case alwaysError + case required + case mismatch + + public var localizedDescription: String { + switch self { + case .alwaysError: + String(describing: self) + + case .required: + "Required" + + case .mismatch: + "Elements do not match" + } + } + +} + +// MARK: - Default criteria + +public extension Criterion { + + /// Always succeeds + static let alwaysValid = Criterion { _ in true } + + /// Always fails + static let alwaysError = Criterion { _ in false } + .failWith(error: InternalValidationError.alwaysError) + + /// Accepts any input with length > 0, excluding leading/trailing whitespace + static let nonEmpty = Criterion { !($0 ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .failWith(error: InternalValidationError.required) + + /// Accepts an input if it is equal with another input + static func matches(_ other: String?) -> Criterion { + Criterion { this in this == other } + .failWith(error: InternalValidationError.mismatch) + } + + /// Accepts an empty input, see ``nonEmpty``. + /// + /// - Parameter criterion: Criteria for validation of non-empty input + /// - Returns: `true` if input is empty or valid + /// + /// If input is empty, validation **succeeds** and input is deemed valid. + /// If input is non-empty, validation continues by criterion specified as a parameter. + static func acceptEmpty(_ criterion: Criterion) -> Criterion { + Criterion { Criterion.nonEmpty.validate(input: $0) ? criterion.validate(input: $0) : true } + .failWith(error: criterion.error) + } + +} + diff --git a/Sources/GRInputField/Common/Validator.swift b/Sources/GRInputField/Common/Validator.swift new file mode 100644 index 0000000..04f3aa9 --- /dev/null +++ b/Sources/GRInputField/Common/Validator.swift @@ -0,0 +1,178 @@ +// +// Validator.swift +// benu +// +// Created by Filip Šašala on 04/08/2022. +// + +import Foundation +import GoodExtensions +import GoodStructs + +// MARK: - ValidatorBuilder + +@resultBuilder public struct ValidatorBuilder { + + public static func buildBlock(_ components: CriteriaConvertible...) -> Validator { + var criteria: [Criterion] = [] + components.forEach { criteria.append(contentsOf: $0.asCriteria()) } + + return Validator(criteria: criteria) + } + + public static func buildOptional(_ component: CriteriaConvertible?) -> Validator { + if let component = component?.asCriteria() { + Validator(criteria: component) + } else { + Validator(criteria: []) + } + } + + public static func buildEither(first component: CriteriaConvertible) -> Validator { + Validator(criteria: component.asCriteria()) + } + + public static func buildEither(second component: CriteriaConvertible) -> Validator { + Validator(criteria: component.asCriteria()) + } + + public static func buildArray(_ components: [CriteriaConvertible]) -> Validator { + let criteria = components.reduce(into: [Criterion]()) { partialResult, newElement in + newElement.asCriteria().forEach { partialResult.append($0) } + } + + return Validator(criteria: criteria) + } + +} + +// MARK: - Validator + +public struct Validator: CriteriaConvertible { + + fileprivate var criteria: [Criterion] = [] + + public func isValid(input: String?) -> Bool { + validate(input: input).isNil + } + + public func validate(input: String?) -> Error? { + let failure = criteria + .map { (criterion: $0, result: $0.validate(input: input)) } + .first { _, result in !result } + .map { $0.0 } + + if let failure = failure { + return failure.error + } + + return nil + } + + public func asCriteria() -> [Criterion] { + return Array(criteria) + } + +} + +// MARK: - Criterion + +public final class Criterion: Then, CriteriaConvertible { + + // MARK: - Variables + + private(set) internal var error: any ValidationError = InternalValidationError.alwaysError + + // MARK: - Constants + + private let regex: String? + private let predicate: GoodExtensions.Predicate? + + // MARK: - Initialization + + public init(regex: String) { + self.regex = regex + self.predicate = nil + } + + public init(predicate: @escaping GoodExtensions.Predicate) { + self.regex = nil + self.predicate = predicate + } + + public func failWith(error: any ValidationError) -> Self { + self.error = error + return self + } + + public func asCriteria() -> [Criterion] { + [self] + } + +} + +// MARK: - CriteriaConvertible + +public protocol CriteriaConvertible { + + func asCriteria() -> [Criterion] + +} + +// MARK: - Hashable + +extension Criterion: Hashable { + + public static func == (lhs: Criterion, rhs: Criterion) -> Bool { + lhs.hashValue == rhs.hashValue + } + + public func hash(into hasher: inout Hasher) { + if predicate.isNotNil { hasher.combine(UUID()) } + hasher.combine(regex) + } + +} + +// MARK: - Public + +extension Criterion { + + public func validate(input: String?) -> Bool { + if regex != nil { + guard let input = input else { return false } + + return validateRegex(input: input) + } else if let predicate = predicate { + return predicate(input) + } else { + return false + } + } + +} + +// MARK: - Private + +private extension Criterion { + + func validateRegex(input: String) -> Bool { + guard let pattern = regex else { + assertionFailure("Validator regex equal to nil") + return false + } + + guard let regex = try? NSRegularExpression(pattern: pattern) else { + assertionFailure("Invalid validator regex") + return false + } + + let range = NSRange(input.startIndex..? + + private var traits: InputFieldTraits + @ValidatorBuilder private var criteria: Supplier + private var returnAction: VoidClosure? + private var resignAction: VoidClosure? + + // MARK: - Initialization + + public init( + text: Binding, + title: String? = nil, + placeholder: String? = nil, + hint: String? = " ", + rightButton: Supplier? = nil + ) { + self._text = text + self._validityGroup = Binding.constant([:]) + self.title = title + self.placeholder = placeholder + self.hint = hint + self.rightButton = rightButton + self.traits = InputFieldTraits() + + @ValidatorBuilder func alwaysValidCriteria() -> Validator { + Criterion.alwaysValid + } + + // closures cannot take @ValidationBuilder attribute, must be a function reference + self.criteria = alwaysValidCriteria + } + + // MARK: - Coordinator + + public class Coordinator: NSObject, UITextFieldDelegate { + + let textFieldUUID = UUID() + + var cancellables = Set() + var returnAction: VoidClosure? + + public override init() {} + + /// Prevent SwiftUI from dismissing keyboard for a split of a second + /// when binding to `@FocusState` changes (custom return action) + /// - Parameter textField: Text field in question + /// - Returns: true if keyboard should dismiss + public func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if let returnAction { + returnAction() + return false + } else { + return true + } + } + + } + + // MARK: - UIView representable + + public func makeCoordinator() -> Coordinator { + Coordinator() + } + + public func makeUIView(context: Context) -> ValidableInputFieldView { + let view = ValidableInputFieldView() + let model = InputFieldView.Model( + title: title, + rightButton: rightButton?(), + placeholder: placeholder, + hint: hint, + traits: traits + ) + + view.setup(with: model) + view.attachTextFieldDelegate(context.coordinator) + + view.setValidationCriteria(criteria) + view.setPostValidationAction { validationResult in + if let error = validationResult { + setValidityStateInGroup(to: .invalid(error), in: context) + } else { + setValidityStateInGroup(to: .valid, in: context) + } + } + + context.coordinator.returnAction = returnAction + + let editingChangedCancellable = view.editingChangedPublisher + .removeDuplicates() + .sink { newText in Task { @MainActor in + self.text = newText + invalidateValidityState(in: context) + }} + + let resignCancellable = view.resignPublisher + .sink { _ in resignAction?() } + + context.coordinator.cancellables.insert(editingChangedCancellable) + context.coordinator.cancellables.insert(resignCancellable) + + return view + } + + public func updateUIView(_ uiView: ValidableInputFieldView, context: Context) { + uiView.text = self.text + uiView.isEnabled = context.environment.isEnabled + + updateValidityState(uiView: uiView, context: context) + } + + public static func dismantleUIView(_ uiView: ValidableInputFieldView, coordinator: Coordinator) { + coordinator.cancellables.removeAll() + } + + // MARK: - Layout + + @available(iOS 16.0, *) + public func sizeThatFits(_ proposal: ProposedViewSize, uiView: ValidableInputFieldView, context: Context) -> CGSize? { + let intrinsicSize = uiView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + + var optimalSize = CGSize() + optimalSize.width = proposal.width ?? intrinsicSize.width + optimalSize.height = intrinsicSize.height + + return optimalSize + } + + // MARK: - Validation + + private func updateValidityState(uiView: ValidableInputFieldView, context: InputField.Context) { + let validityState = validityGroup[context.coordinator.textFieldUUID] + switch validityState { + case .valid: + uiView.unfail() + + case .invalid(let error): + uiView.failSilently(with: error.localizedDescription) + + case .none: + setValidityStateInGroup(to: .pending(uiView.validateSilently()), in: context) + + case .pending: + break + } + } + + private func setValidityStateInGroup(to validationState: ValidationState, in context: Context) { + Task { @MainActor in + validityGroup.updateValue( + validationState, + forKey: context.coordinator.textFieldUUID + ) + } + } + + private func invalidateValidityState(in context: Context) { + Task { @MainActor in + validityGroup.removeValue(forKey: context.coordinator.textFieldUUID) + } + } + +} + +// MARK: - Public modifiers + +public extension InputField { + + @available(iOS 15.0, *) + func bindFocusState( + _ focusState: FocusState.Binding, + to field: Focus + ) -> some View where Focus.RawValue == Int { + var modifiedSelf = self + modifiedSelf.returnAction = { focusNextField(in: focusState) } + return modifiedSelf.focused(focusState, equals: field) + } + + func validityGroup(_ binding: Binding) -> Self { + var modifiedSelf = self + modifiedSelf._validityGroup = binding + return modifiedSelf + } + + func onResign(_ action: @escaping VoidClosure) -> Self { + var modifiedSelf = self + modifiedSelf.resignAction = action + return modifiedSelf + } + + func validationCriteria(@ValidatorBuilder _ criteria: @escaping () -> Validator) -> Self { + var modifiedSelf = self + modifiedSelf.criteria = criteria + return modifiedSelf + } + + func inputFieldTraits( + textContentType: UITextContentType? = .none, + autocapitalizationType: UITextAutocapitalizationType = .none, + autocorrectionType: UITextAutocorrectionType = .default, + keyboardType: UIKeyboardType = .default, + returnKeyType: UIReturnKeyType = .default, + numpadReturnKeyTitle: String? = "Done", + clearButtonMode: UITextField.ViewMode = .whileEditing, + isSecureTextEntry: Bool = false, + isHapticsAllowed: Bool = true + ) -> Self { + var modifiedSelf = self + modifiedSelf.traits = InputFieldTraits( + textContentType: textContentType, + autocapitalizationType: autocapitalizationType, + autocorrectionType: autocorrectionType, + keyboardType: keyboardType, + returnKeyType: returnKeyType, + numpadReturnKeyTitle: numpadReturnKeyTitle, + clearButtonMode: clearButtonMode, + isSecureTextEntry: isSecureTextEntry, + isHapticsAllowed: isHapticsAllowed + ) + return modifiedSelf + } + +} + +// MARK: - View focus extension + +@available(iOS 15.0, *) +private extension View { + + func focusNextField(in focusState: FocusState.Binding) where Focus.RawValue == Int { + guard let currentValue = focusState.wrappedValue else { return } + + let nextFieldIndex = currentValue.rawValue + 1 + focusState.wrappedValue = Focus(rawValue: nextFieldIndex) + + // OK to recurse, SwiftUI will update focus state only once to the last valid value + // Used to skip fields that are not in view hierarchy (hidden) and unable to be focused + advanceFocusIfNeeded(nextFieldIndex: nextFieldIndex, in: focusState) + } + + func advanceFocusIfNeeded(nextFieldIndex: Int, in focusState: FocusState.Binding) where Focus.RawValue == Int { + DispatchQueue.main.asyncAfter(wallDeadline: .now() + .milliseconds(10)) { + if focusState.wrappedValue?.rawValue != nextFieldIndex { + let newValue = Focus(rawValue: nextFieldIndex + 1) + focusState.wrappedValue = newValue + + if newValue.isNotNil { + advanceFocusIfNeeded(nextFieldIndex: nextFieldIndex + 1, in: focusState) + } + } + } + } + +} diff --git a/Sources/GRInputField/SwiftUI/ValidityGroup.swift b/Sources/GRInputField/SwiftUI/ValidityGroup.swift new file mode 100644 index 0000000..64c8ddd --- /dev/null +++ b/Sources/GRInputField/SwiftUI/ValidityGroup.swift @@ -0,0 +1,62 @@ +// +// ValidityGroup.swift +// benu +// +// Created by Filip Šašala on 15/04/2024. +// + +import Foundation + +// MARK: - Validity Group + +public typealias ValidityGroup = [UUID: ValidationState] + +public extension ValidityGroup { + + func allValid() -> Bool { + isEmpty ? false : allSatisfy { $0.value.isValid } + } + + mutating func validateAll() { + self = mapValues { $0.deterministicState } + } + + internal mutating func determine(fieldWithId uuid: UUID) { + self[uuid] = self[uuid]?.deterministicState + } + +} + +// MARK: - Validation State + +public enum ValidationState { + + case valid + case pending((any ValidationError)?) + case invalid(any ValidationError) + + var isValid: Bool { + if case .valid = self { + return true + } else if case .pending(let error) = self { + return (error == nil) + } else { + return false + } + } + + var deterministicState: ValidationState { + switch self { + case .valid, .invalid: + return self + + case .pending(let error): + if let error { + return .invalid(error) + } else { + return .valid + } + } + } + +} diff --git a/Sources/GRInputField/UIKit/InputFieldView.swift b/Sources/GRInputField/UIKit/InputFieldView.swift new file mode 100644 index 0000000..b2de011 --- /dev/null +++ b/Sources/GRInputField/UIKit/InputFieldView.swift @@ -0,0 +1,617 @@ +// +// InputFieldView.swift +// benu +// +// Created by Maroš Novák on 10/03/2022. +// + +import GoodExtensions +import UIKit +import Combine + +public class InputFieldView: UIView { + + // MARK: - State + + public enum State: Equatable { + + case enabled + case disabled + case selected + case failed(String?) + + } + + // MARK: - Model + + public struct Model { + + public var title: String? + public var text: String? + public var leftImage: UIImage? + + /// Custom right button. This will be ignored when input field is secure. + public var rightButton: UIButton? + public var placeholder: String? + public var hint: String? + public var traits: InputFieldTraits? + + public init( + title: String? = nil, + text: String? = nil, + leftImage: UIImage? = nil, + rightButton: UIButton? = nil, + placeholder: String? = nil, + hint: String? = nil, + traits: InputFieldTraits? = nil + ) { + self.title = title + self.text = text + self.leftImage = leftImage + self.rightButton = rightButton + self.placeholder = placeholder + self.hint = hint + self.traits = traits + } + + } + + // MARK: - Constants + + private struct C { + + static let emptyString = "" + + } + + private let verticalStackView = UIStackView().then { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.spacing = 4 + $0.axis = .vertical + } + + private let horizontalStackView = UIStackView().then { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.axis = .horizontal + $0.spacing = 8 + } + + private let titleLabel = UILabel().then { + $0.adjustsFontForContentSizeCategory = true + } + + private let contentView = UIView() + + private let leftImageView = UIImageView().then { + $0.contentMode = .scaleAspectFit + $0.setContentHuggingPriority(.defaultHigh, for: .horizontal) + } + + private let textField = UITextField().then { + $0.textAlignment = .left + $0.setContentHuggingPriority(.defaultLow, for: .horizontal) + } + + private lazy var eyeButton = UIButton().then { + $0.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + $0.setContentHuggingPriority(.defaultHigh, for: .horizontal) + } + + private let lineView = UIView().then { + $0.backgroundColor = .lightGray + } + + private let hintLabel = UILabel().then { + $0.adjustsFontForContentSizeCategory = true + $0.numberOfLines = 2 + } + + // MARK: - Variables + + private weak var completionResponder: UIView? + private var isSecureTextEntry = false + private var isHapticsAllowed = true + + public var text: String { + get { + textField.text ?? C.emptyString + } + set { + textField.text = newValue + editingChangedSubject.send(newValue) + } + } + + public var hint: String? + + private(set) public var state: State! { + didSet { + setState(state) + } + } + + open var standardAppearance: InputFieldAppearance = defaultAppearance + + public static var defaultAppearance: InputFieldAppearance = .default + + // MARK: - Combine + + internal var cancellables = Set() + + private let resignSubject = PassthroughSubject() + private(set) public lazy var resignPublisher = resignSubject.eraseToAnyPublisher() + + private let returnSubject = PassthroughSubject() + private(set) public lazy var returnPublisher = returnSubject.eraseToAnyPublisher() + + private let editingChangedSubject = PassthroughSubject() + private(set) public lazy var editingChangedPublisher = editingChangedSubject.eraseToAnyPublisher() + + // MARK: - Initializer + + public override init(frame: CGRect) { + super.init(frame: frame) + + setupLayout() + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +// MARK: - Private + +private extension InputFieldView { + + func setupLayout() { + setupAppearance() + addSubviews() + setupConstraints() + setupActionHandlers() + } + + func setupAppearance() { + titleLabel.font = standardAppearance.titleFont + titleLabel.textColor = standardAppearance.titleColor + + textField.font = standardAppearance.textFieldFont + textField.tintColor = standardAppearance.textFieldTintColor + + hintLabel.font = standardAppearance.hintFont + + contentView.layer.cornerRadius = standardAppearance.cornerRadius ?? 0 + contentView.layer.borderWidth = standardAppearance.borderWidth ?? 0 + + state = .enabled + } + + // MARK: States + + func setState(_ state: State?) { + switch state { + case .enabled: + setEnabled() + + case .selected: + setSelected() + + case .disabled: + setDisabled() + + case .failed(let message): + setFailed(message: message) + + case .none: + fatalError("State cannot be nil") + } + } + + func setEnabled() { + textField.isUserInteractionEnabled = true + contentView.backgroundColor = standardAppearance.enabled?.contentBackgroundColor + contentView.layer.borderColor = standardAppearance.enabled?.borderColor?.cgColor + + textField.textColor = standardAppearance.enabled?.textFieldTextColor + + hintLabel.textColor = standardAppearance.enabled?.hintColor + hintLabel.text = hint + + textField.attributedPlaceholder = NSAttributedString( + string: textField.placeholder ?? C.emptyString, + attributes: [.foregroundColor: standardAppearance.enabled?.placeholderColor ?? .gray] + ) + } + + func setDisabled() { + textField.isUserInteractionEnabled = false + contentView.backgroundColor = standardAppearance.disabled?.contentBackgroundColor + contentView.layer.borderColor = standardAppearance.disabled?.borderColor?.cgColor + + textField.textColor = standardAppearance.disabled?.textFieldTextColor + + hintLabel.textColor = standardAppearance.disabled?.hintColor + hintLabel.text = hint + + textField.attributedPlaceholder = NSAttributedString( + string: textField.placeholder ?? C.emptyString, + attributes: [.foregroundColor: standardAppearance.disabled?.placeholderColor ?? .gray] + ) + } + + func setSelected() { + contentView.backgroundColor = standardAppearance.selected?.contentBackgroundColor + contentView.layer.borderColor = standardAppearance.selected?.borderColor?.cgColor + + textField.textColor = standardAppearance.selected?.textFieldTextColor + + hintLabel.textColor = standardAppearance.selected?.hintColor + hintLabel.text = hint + + textField.attributedPlaceholder = NSAttributedString( + string: textField.placeholder ?? C.emptyString, + attributes: [.foregroundColor: standardAppearance.selected?.placeholderColor ?? .gray] + ) + } + + func setFailed(message: String?) { + contentView.backgroundColor = standardAppearance.failed?.contentBackgroundColor + contentView.layer.borderColor = standardAppearance.failed?.borderColor?.cgColor + + textField.textColor = standardAppearance.failed?.textFieldTextColor + + hintLabel.textColor = standardAppearance.failed?.hintColor + hintLabel.text = (hint == nil) ? (nil) : (message ?? hint) + + textField.attributedPlaceholder = NSAttributedString( + string: textField.placeholder ?? C.emptyString, + attributes: [.foregroundColor: standardAppearance.failed?.placeholderColor ?? .gray] + ) + } + + func setupTraits(traits: InputFieldTraits) { + isSecureTextEntry = traits.isSecureTextEntry + isHapticsAllowed = traits.isHapticsAllowed + setSecureTextEntryIfAllowed(isSecure: isSecureTextEntry) + + textField.textContentType = traits.textContentType + textField.autocapitalizationType = traits.autocapitalizationType + textField.autocorrectionType = traits.autocorrectionType + textField.keyboardType = traits.keyboardType + textField.returnKeyType = traits.returnKeyType + textField.clearButtonMode = isSecureTextEntry ? .never : traits.clearButtonMode + } + + func setupToolbarIfNeeded(traits: InputFieldTraits) { + switch traits.keyboardType { + case .phonePad, .decimalPad, .asciiCapableNumberPad, .namePhonePad, .numberPad: + break + + default: + return + } + + let toolbar = UIToolbar() + toolbar.barStyle = .default + + let space = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + let done: UIBarButtonItem + + if #available(iOS 14.0, *) { + done = UIBarButtonItem( + title: traits.numpadReturnKeyTitle, + primaryAction: UIAction { [weak self] _ in self?.return() } + ) + } else { + done = UIBarButtonItem( + title: traits.numpadReturnKeyTitle, + style: .done, + target: self, + action: #selector(self.return) + ) + } + + toolbar.items = [space, done] + toolbar.sizeToFit() + + textField.inputAccessoryView = toolbar + } + + // MARK: Subviews + + func addSubviews() { + [titleLabel, contentView, hintLabel].forEach { + verticalStackView.addArrangedSubview($0) + } + + [leftImageView, textField].forEach { + horizontalStackView.addArrangedSubview($0) + } + + contentView.addSubview(horizontalStackView) + addSubview(verticalStackView) + + horizontalStackView.isLayoutMarginsRelativeArrangement = true + horizontalStackView.directionalLayoutMargins.leading = 16 + horizontalStackView.directionalLayoutMargins.trailing = 16 + } + + // MARK: Constraints + + func setupConstraints() { + NSLayoutConstraint.activate([ + verticalStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + verticalStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + verticalStackView.topAnchor.constraint(equalTo: topAnchor), + verticalStackView.bottomAnchor.constraint(equalTo: bottomAnchor), + + contentView.heightAnchor.constraint(equalToConstant: standardAppearance.height ?? 50), + + horizontalStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + horizontalStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + horizontalStackView.topAnchor.constraint(equalTo: contentView.topAnchor), + horizontalStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + } + + // MARK: Action handlers + + func setupActionHandlers() { + textField.addTarget(self, action: #selector(onEditingBegin), for: .editingDidBegin) + textField.addTarget(self, action: #selector(onEditingChanged), for: .editingChanged) + textField.addTarget(self, action: #selector(onEditingEnd), for: .editingDidEnd) + textField.addTarget(self, action: #selector(`return`), for: .editingDidEndOnExit) + } + + func setupEyeButtonHandler() { + eyeButton.addTarget(self, action: #selector(onEyeButtonPressed), for: .touchUpInside) + } + + // MARK: Helper methods + + func setSecureTextEntryIfAllowed(isSecure: Bool) { + guard isSecureTextEntry else { return } + + let eyeImage = isSecure + ? standardAppearance.eyeImageHidden ?? UIImage(systemName: "eye") + : standardAppearance.eyeImageVisible ?? UIImage(systemName: "eye.slash") + + eyeButton.isHidden = false + eyeButton.setImage(eyeImage, for: .normal) + textField.isSecureTextEntry = isSecure + } + + func trimWhitespaceIfAllowed() { + guard !isSecureTextEntry else { return } + switch textField.textContentType { + case .password?, .newPassword?: + return + + default: + textField.text = text.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + +} + +// MARK: - Event action handlers + +private extension InputFieldView { + + @objc func onEditingBegin() { + if case .enabled? = state { + state = .selected + } + + DispatchQueue.main.async { [weak self] in + guard let self = self, self.isSecureTextEntry else { return } + + let endPosition = self.textField.endOfDocument + self.textField.selectedTextRange = self.textField.textRange(from: endPosition, to: endPosition) + } + } + + @objc func onEditingChanged() { + if case .failed? = state { + state = .selected + } + editingChangedSubject.send(text) + } + + @objc func onEditingEnd() { + state = .enabled + setSecureTextEntryIfAllowed(isSecure: true) + trimWhitespaceIfAllowed() + editingChangedSubject.send(text) + resignSubject.send(text) + } + + @objc func `return`() { + if textField.delegate?.textFieldShouldReturn?(textField) ?? true { + if let completionResponder = completionResponder { + completionResponder.becomeFirstResponder() + } else { + textField.resignFirstResponder() + } + returnSubject.send(text) + hapticTap() + } + } + + @objc func onEyeButtonPressed() { + textField.becomeFirstResponder() + setSecureTextEntryIfAllowed(isSecure: !textField.isSecureTextEntry) + } + +} + +// MARK: - Public + +public extension InputFieldView { + + override var inputView: UIView? { + get { + textField.inputView + } + set { + textField.inputView = newValue + } + } + + var isEnabled: Bool { + get { + textField.isEnabled + } + set { + state = newValue ? .enabled : .disabled + } + } + + var isSelected: Bool { textField.isSelected } + + func setup(with model: Model) { + /// Traits + setupTraits(traits: model.traits ?? .default) + setupToolbarIfNeeded(traits: model.traits ?? .default) + + /// Left image + if let leftImage = model.leftImage { + leftImageView.image = leftImage + leftImageView.isHidden = false + } else { + leftImageView.isHidden = true + } + + /// Secure entry + if isSecureTextEntry { + setupEyeButtonHandler() + horizontalStackView.addArrangedSubview(eyeButton) + } + + /// Right button + if let rightButton = model.rightButton, !isSecureTextEntry { + rightButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + rightButton.setContentHuggingPriority(.defaultHigh, for: .horizontal) + horizontalStackView.addArrangedSubview(rightButton) + } + + /// Input field title + if let title = model.title { + titleLabel.text = title + titleLabel.isHidden = false + } else { + titleLabel.isHidden = true + } + + /// Placeholder + textField.attributedPlaceholder = NSAttributedString( + string: model.placeholder ?? C.emptyString, + attributes: [.foregroundColor: standardAppearance.enabled?.placeholderColor ?? .gray] + ) + + /// Hint + if let hintText = model.hint { + hintLabel.text = hintText + hint = hintText + } + + /// Text + textField.text = model.text + } + + func fail(with errorMessage: String?) { + state = .failed(errorMessage) + hapticError() + } + + func failSilently(with errorMessage: String?) { + state = .failed(errorMessage) + } + + func unfail() { + if isSelected { + state = .selected + return + } + if isEnabled { + state = .enabled + return + } + state = .disabled + } + + @available(*, deprecated, message: "Assign to `text` instead.") + func setText(_ text: String) { + self.text = text + } + + func beginEditing() { + textField.becomeFirstResponder() + } + + func setNextResponder(_ nextResponder: UIView?) { + completionResponder = nextResponder + } + + func attachTextFieldDelegate(_ delegate: any UITextFieldDelegate) { + textField.delegate = delegate + } + +} + +// MARK: - Tap gesture recognizer + +public extension InputFieldView { + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + textField.becomeFirstResponder() + } + + override var isFirstResponder: Bool { + textField.isFirstResponder + } + + override var canBecomeFirstResponder: Bool { + false + } + + @discardableResult + override func becomeFirstResponder() -> Bool { + super.becomeFirstResponder() + + guard state != .disabled else { + if let completionResponder = completionResponder { + completionResponder.becomeFirstResponder() + } + return false + } + + textField.becomeFirstResponder() + return false + } + + @discardableResult + override func resignFirstResponder() -> Bool { + textField.resignFirstResponder() + } + +} + +// MARK: - Haptics + +private extension InputFieldView { + + func hapticTap() { + if isHapticsAllowed { + GRHapticsManager.shared.playSelectionFeedback() + } + } + + func hapticError() { + if isHapticsAllowed { + GRHapticsManager.shared.playNotificationFeedback(.error) + } + } + +} diff --git a/Sources/GRInputField/UIKit/ValidableInputFieldView.swift b/Sources/GRInputField/UIKit/ValidableInputFieldView.swift new file mode 100644 index 0000000..ce894a5 --- /dev/null +++ b/Sources/GRInputField/UIKit/ValidableInputFieldView.swift @@ -0,0 +1,77 @@ +// +// ValidableInputFieldView.swift +// benu +// +// Created by Filip Šašala on 08/08/2022. +// + +import Combine +import GoodExtensions +import UIKit + +public final class ValidableInputFieldView: InputFieldView { + + // MARK: - Variables + + private var validator: (() -> Validator)? + private var afterValidation: Consumer<(any ValidationError)?>? + + // MARK: - Initialization + + public override init(frame: CGRect) { + super.init(frame: frame) + + setupValidators() + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + + setupValidators() + } + + // MARK: - Public + + public func setValidationCriteria(@ValidatorBuilder _ validator: @escaping () -> Validator) { + self.validator = validator + } + + public func setPostValidationAction(_ action: @escaping Consumer<(any ValidationError)?>) { + self.afterValidation = action + } + + @discardableResult + func validate() -> Bool { + guard let validator = validator?() else { fatalError("Validator not set") } + + if let error = validator.validate(input: self.text) as? any ValidationError { + fail(with: error.localizedDescription) + afterValidation?(error) + return false + } else { + unfail() + afterValidation?(nil) + return true + } + } + + func validateSilently() -> (any ValidationError)? { + guard let validator = validator?() else { return InternalValidationError.alwaysError } + + return validator.validate(input: self.text) as? any ValidationError + } + + // MARK: - Private + + private func setupValidators() { + resignPublisher + .map { [weak self] _ in self?.validator?().validate(input: self?.text) } + .map { $0 as? any ValidationError } + .sink { [weak self] error in + if let error { self?.fail(with: error.localizedDescription) } + self?.afterValidation?(error) // If wrapped in UIViewRepresentable, update SwiftUI state here + } + .store(in: &cancellables) + } + +} diff --git a/Sources/GoodSwiftUI/GoodSwiftUI.swift b/Sources/GoodSwiftUI/GoodSwiftUI.swift deleted file mode 100644 index 8667d28..0000000 --- a/Sources/GoodSwiftUI/GoodSwiftUI.swift +++ /dev/null @@ -1,6 +0,0 @@ -public struct GoodSwiftUI { - public private(set) var text = "Hello, World!" - - public init() { - } -} diff --git a/Sources/GoodSwiftUI/OptionalBinding.swift b/Sources/GoodSwiftUI/OptionalBinding.swift index dfcfcb0..3e7044f 100644 --- a/Sources/GoodSwiftUI/OptionalBinding.swift +++ b/Sources/GoodSwiftUI/OptionalBinding.swift @@ -7,6 +7,13 @@ import SwiftUI +/// Allows binding to optional values in SwiftUI, replacing `nil` with default argument on `rhs`. +/// +/// Example: +/// ```swift +/// @State private var text: String? = nil +/// InputField(text: $text ?? "") +/// ``` public func ??(lhs: Binding>, rhs: T) -> Binding { Binding( get: { lhs.wrappedValue ?? rhs }, From c97ba2f9b059c327bf1c3d71a94e0966ffc2a7dc Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Wed, 26 Jun 2024 17:05:32 +0200 Subject: [PATCH 2/5] task: Sample project --- .../project.pbxproj | 16 ++ .../xcshareddata/swiftpm/Package.resolved | 32 +++ .../InputFieldViewConfiguration.swift | 77 +++++++ .../Screens/InputFieldSampleView.swift | 199 ++++++++++++++++++ .../Screens/SamplesListView.swift | 25 ++- 5 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 GoodSwiftUI-Sample/GoodSwiftUI-Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 GoodSwiftUI-Sample/GoodSwiftUI-Sample/Extensions/InputFieldViewConfiguration.swift create mode 100644 GoodSwiftUI-Sample/GoodSwiftUI-Sample/Screens/InputFieldSampleView.swift diff --git a/GoodSwiftUI-Sample/GoodSwiftUI-Sample.xcodeproj/project.pbxproj b/GoodSwiftUI-Sample/GoodSwiftUI-Sample.xcodeproj/project.pbxproj index 38006fb..1b3e329 100644 --- a/GoodSwiftUI-Sample/GoodSwiftUI-Sample.xcodeproj/project.pbxproj +++ b/GoodSwiftUI-Sample/GoodSwiftUI-Sample.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 09794E3A2C295F4600023CD1 /* InputFieldViewConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09794E392C295F4600023CD1 /* InputFieldViewConfiguration.swift */; }; + 09794E3C2C2ADAFE00023CD1 /* InputFieldSampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09794E3B2C2ADAFE00023CD1 /* InputFieldSampleView.swift */; }; 3F9F28442A3AEC6000D92CA2 /* SamplesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F9F28432A3AEC6000D92CA2 /* SamplesListView.swift */; }; 3F9F28462A3AEC8B00D92CA2 /* GRAsyncImageSampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F9F28452A3AEC8B00D92CA2 /* GRAsyncImageSampleView.swift */; }; 3F9F28482A3AECC700D92CA2 /* GRAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = 3F9F28472A3AECC700D92CA2 /* GRAsyncImage */; }; @@ -17,6 +19,8 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 09794E392C295F4600023CD1 /* InputFieldViewConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputFieldViewConfiguration.swift; sourceTree = ""; }; + 09794E3B2C2ADAFE00023CD1 /* InputFieldSampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputFieldSampleView.swift; sourceTree = ""; }; 3F9F28432A3AEC6000D92CA2 /* SamplesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SamplesListView.swift; sourceTree = ""; }; 3F9F28452A3AEC8B00D92CA2 /* GRAsyncImageSampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRAsyncImageSampleView.swift; sourceTree = ""; }; 5D740EA929B0BE0100975B8C /* GoodSwiftUI-Sample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "GoodSwiftUI-Sample.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -40,11 +44,20 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 09794E382C295F3F00023CD1 /* Extensions */ = { + isa = PBXGroup; + children = ( + 09794E392C295F4600023CD1 /* InputFieldViewConfiguration.swift */, + ); + path = Extensions; + sourceTree = ""; + }; 3F9F28422A3AEC5200D92CA2 /* Screens */ = { isa = PBXGroup; children = ( 3F9F28432A3AEC6000D92CA2 /* SamplesListView.swift */, 3F9F28452A3AEC8B00D92CA2 /* GRAsyncImageSampleView.swift */, + 09794E3B2C2ADAFE00023CD1 /* InputFieldSampleView.swift */, ); path = Screens; sourceTree = ""; @@ -70,6 +83,7 @@ 5D740EAB29B0BE0100975B8C /* GoodSwiftUI-Sample */ = { isa = PBXGroup; children = ( + 09794E382C295F3F00023CD1 /* Extensions */, 3F9F28422A3AEC5200D92CA2 /* Screens */, 5D740EAC29B0BE0100975B8C /* AppDelegate.swift */, 5D740EB729B0BE0200975B8C /* Assets.xcassets */, @@ -169,8 +183,10 @@ buildActionMask = 2147483647; files = ( 5D740EAD29B0BE0100975B8C /* AppDelegate.swift in Sources */, + 09794E3A2C295F4600023CD1 /* InputFieldViewConfiguration.swift in Sources */, 3F9F28462A3AEC8B00D92CA2 /* GRAsyncImageSampleView.swift in Sources */, 3F9F28442A3AEC6000D92CA2 /* SamplesListView.swift in Sources */, + 09794E3C2C2ADAFE00023CD1 /* InputFieldSampleView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/GoodSwiftUI-Sample/GoodSwiftUI-Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/GoodSwiftUI-Sample/GoodSwiftUI-Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..c168429 --- /dev/null +++ b/GoodSwiftUI-Sample/GoodSwiftUI-Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,32 @@ +{ + "pins" : [ + { + "identity" : "combineext", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CombineCommunity/CombineExt.git", + "state" : { + "revision" : "d7b896fa9ca8b47fa7bcde6b43ef9b70bf8c1f56", + "version" : "1.8.1" + } + }, + { + "identity" : "goodextensions-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/goodrequest/goodextensions-ios", + "state" : { + "branch" : "feature/goodextensions-update", + "revision" : "ea9c84a268ef7dc52c884a14f26682c68959aed9" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", + "version" : "509.1.1" + } + } + ], + "version" : 2 +} diff --git a/GoodSwiftUI-Sample/GoodSwiftUI-Sample/Extensions/InputFieldViewConfiguration.swift b/GoodSwiftUI-Sample/GoodSwiftUI-Sample/Extensions/InputFieldViewConfiguration.swift new file mode 100644 index 0000000..114d902 --- /dev/null +++ b/GoodSwiftUI-Sample/GoodSwiftUI-Sample/Extensions/InputFieldViewConfiguration.swift @@ -0,0 +1,77 @@ +// +// InputFieldViewConfiguration.swift +// GoodSwiftUI-Sample +// +// Created by Filip Šašala on 24/06/2024. +// + +import UIKit +import GRInputField + +extension InputFieldView { + + static func configureAppearance() { + let customAppearance = InputFieldAppearance( + titleFont: UIFont.preferredFont(for: .caption1, weight: .regular, defaultSize: 12.0), + titleColor: UIColor.systemBlue, + textFieldTintColor: UIColor.systemBlue, + textFieldFont: UIFont.preferredFont(for: .body, weight: .regular, defaultSize: 17.0), + hintFont: UIFont.preferredFont(for: .caption1, weight: .regular, defaultSize: 12.0), + borderWidth: 1, + cornerRadius: 16, + height: 56, + eyeImageHidden: UIImage(systemName: "eye"), + eyeImageVisible: UIImage(systemName: "eye.slash"), + enabled: InputFieldViewStateAppearance( + placeholderColor: UIColor.darkGray, + contentBackgroundColor: UIColor.white, + textFieldTextColor: UIColor.systemBlue, + borderColor: UIColor.gray, + hintColor: UIColor.darkGray + ), + selected: InputFieldViewStateAppearance( + placeholderColor: UIColor.darkGray, + contentBackgroundColor: UIColor.white, + textFieldTextColor: UIColor.systemBlue, + borderColor: UIColor.gray, + hintColor: UIColor.darkGray + ), + disabled: InputFieldViewStateAppearance( + placeholderColor: UIColor.darkGray, + contentBackgroundColor: UIColor.lightGray, + textFieldTextColor: UIColor.darkGray, + borderColor: UIColor.gray, + hintColor: UIColor.darkGray + ), + failed: InputFieldViewStateAppearance( + placeholderColor: UIColor.darkGray, + contentBackgroundColor: UIColor.white, + textFieldTextColor: UIColor.systemBlue, + borderColor: UIColor.systemRed, + hintColor: UIColor.systemRed + ) + ) + + InputFieldView.defaultAppearance = customAppearance + } + +} + +extension UIFont { + + static func preferredFont(for style: TextStyle, weight: Weight, defaultSize: CGFloat) -> UIFont { + let font = UIFont.systemFont(ofSize: defaultSize, weight: weight).with([.traitUIOptimized]) + return UIFontMetrics(forTextStyle: style).scaledFont(for: font) + } + + func with(_ traits: UIFontDescriptor.SymbolicTraits...) -> UIFont { + if let descriptor = fontDescriptor.withSymbolicTraits( + UIFontDescriptor.SymbolicTraits(traits).union(fontDescriptor.symbolicTraits) + ) { + return UIFont(descriptor: descriptor, size: 0) + } else { + return self + } + } + +} diff --git a/GoodSwiftUI-Sample/GoodSwiftUI-Sample/Screens/InputFieldSampleView.swift b/GoodSwiftUI-Sample/GoodSwiftUI-Sample/Screens/InputFieldSampleView.swift new file mode 100644 index 0000000..2a9eb62 --- /dev/null +++ b/GoodSwiftUI-Sample/GoodSwiftUI-Sample/Screens/InputFieldSampleView.swift @@ -0,0 +1,199 @@ +// +// InputFieldSampleView.swift +// GoodSwiftUI-Sample +// +// Created by Filip Šašala on 25/06/2024. +// + +import SwiftUI +import GRInputField + +struct InputFieldSampleView: View { + + enum RegistrationError: ValidationError { + + case notFilip + case pinTooShort + + var localizedDescription: String { + switch self { + case .notFilip: + "Your name is not Filip" + + case .pinTooShort: + "PIN code must be at least 6 numbers long" + } + } + + } + + enum LoginFields: Int, CaseIterable, Equatable, Hashable { + case name, pin + } + + // MARK: - Wrappers + + // MARK: - View state + + @FocusState private var focusState: LoginFields? + @State private var validityGroup = ValidityGroup() + + @State private var name: String = "" + @State private var password: String = "" + + // MARK: - Properties + + // MARK: - Initialization + + init() { + // Set up appearance during app launch or screen init() + InputFieldView.configureAppearance() + } + + // MARK: - Computed properties + + // MARK: - Body + + var body: some View { + ScrollView { + VStack { + // Text field + InputField(text: $name, title: "Name", placeholder: "Jožko") + + // "Continue" keyboard action button + .inputFieldTraits(returnKeyType: .continue) + + // Validates name to be equal to "Filip", otherwise fails with custom error + .validationCriteria { + Criterion.matches("Filip") + .failWith(error: RegistrationError.notFilip) + } + + // Validity group to check for validity + .validityGroup($validityGroup) + + // Focus state binding to advance focus from keyboard action button (Continue) + .bindFocusState($focusState, to: .name) + + + + // Text field + InputField(text: $password, title: "PIN code", hint: "At least 6 numbers") + + // Multiple custom traits + .inputFieldTraits( + keyboardType: .numberPad, + numpadReturnKeyTitle: "Done", + isSecureTextEntry: true + ) + + // Custom validation criteria closure + .validationCriteria { + Criterion { password in + password?.count ?? 0 >= 6 + } + .failWith(error: RegistrationError.pinTooShort) + } + + .validityGroup($validityGroup) + .bindFocusState($focusState, to: .pin) + + // Input field controls + VStack(spacing: 16) { + + // Checking validity state on fields + // Doesn't reflect state visible to the user, but the actual validity of data + if validityGroup.allValid() { + Text("Internal validation state: ") + Text("valid").foregroundColor(.green) + } else { + Text("Internal validation state: ") + Text("invalid").foregroundColor(.red) + } + + // Invoking validation of the whole validity group + // State visible to the user is changed to reflect validity of data + Button("Force validation on all fields") { + validityGroup.validateAll() + } + + // State visible to the user is reset to default and + // all data is revalidated in the background. + Button("Remove validation metadata") { + validityGroup.removeAll() + } + + Divider() + + Text("Internal metadata:") + .frame(maxWidth: .infinity, alignment: .leading) + + LazyVGrid(columns: [GridItem(.fixed(100)), GridItem()], content: { + Text("Focus state") + Text(String(describing: focusState)) + + ForEach(Array(validityGroup), id: \.key) { id, state in + let uuidString = id.uuidString + let startIndex = uuidString.startIndex + let endIndex = uuidString.index(startIndex, offsetBy: 6) + + Text(String(uuidString[startIndex...endIndex])) + + Text(String(describing: state)) + } + }) + } + .padding(.vertical) + } + .padding() + } + .scrollDismissesKeyboard(.interactively) + } + +} + +// MARK: - SwiftUI preview + +#Preview { + InputFieldSampleView() +} + +// MARK: - UIKit preview + +@available(iOS 17.0, *) +#Preview { + let _: () = InputFieldView.configureAppearance() + + let inputField = InputFieldView(frame: CGRect(x: 0, y: 100, width: 250, height: 56)) + inputField.setup(with: .init( + title: "Title", + text: .placeholder(length: 20), + leftImage: nil, + rightButton: nil, + placeholder: "Placeholder", + hint: " ", + traits: .default + )) + + let validableInputField = ValidableInputFieldView(frame: CGRect(x: 0, y: 100, width: 250, height: 56)) + validableInputField.setup(with: .init( + title: "Title", + text: .placeholder(length: 8), + leftImage: nil, + rightButton: nil, + placeholder: "Placeholder", + hint: " ", + traits: .init(isSecureTextEntry: true) + )) + + inputField.setNextResponder(validableInputField) + validableInputField.setValidationCriteria { Criterion.nonEmpty } + validableInputField.setPostValidationAction { result in print("Hello validation - \(result)") } + + let stackView = UIStackView(arrangedSubviews: [ + inputField, validableInputField + ]) + + stackView.axis = .vertical + stackView.widthAnchor.constraint(equalToConstant: 300).isActive = true + + return stackView +} diff --git a/GoodSwiftUI-Sample/GoodSwiftUI-Sample/Screens/SamplesListView.swift b/GoodSwiftUI-Sample/GoodSwiftUI-Sample/Screens/SamplesListView.swift index 5bc7e79..00f4b54 100644 --- a/GoodSwiftUI-Sample/GoodSwiftUI-Sample/Screens/SamplesListView.swift +++ b/GoodSwiftUI-Sample/GoodSwiftUI-Sample/Screens/SamplesListView.swift @@ -14,20 +14,29 @@ enum Sample: CaseIterable, Identifiable, Hashable { var id: String { UUID().uuidString } case grAsyncImage - + case inputFields + var title: String { switch self { case .grAsyncImage: - return "Async Image" + "Async Image" + + case .inputFields: + "Input fields" } } var view: some View { - switch self { - case .grAsyncImage: - return GRAsyncImageSampleView() - .navigationTitle(self.title) + Group { + switch self { + case .grAsyncImage: + GRAsyncImageSampleView() + + case .inputFields: + InputFieldSampleView() + } } + .navigationTitle(self.title) } } @@ -49,3 +58,7 @@ struct SamplesListView: View { } } + +#Preview { + SamplesListView() +} From b4639ca246dfb51eb89ace151cbaf0d9013ae26d Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Tue, 2 Jul 2024 13:06:08 +0200 Subject: [PATCH 3/5] feat: Input formatting --- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../Screens/InputFieldSampleView.swift | 224 ++++++++++++------ Package.swift | 2 +- Sources/GRInputField/Common/Validator.swift | 2 +- Sources/GRInputField/SwiftUI/InputField.swift | 54 ++++- .../GRInputField/UIKit/InputFieldView.swift | 19 +- .../UIKit/ValidableInputFieldView.swift | 5 +- 7 files changed, 223 insertions(+), 87 deletions(-) diff --git a/GoodSwiftUI-Sample/GoodSwiftUI-Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/GoodSwiftUI-Sample/GoodSwiftUI-Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c168429..b37bbdb 100644 --- a/GoodSwiftUI-Sample/GoodSwiftUI-Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/GoodSwiftUI-Sample/GoodSwiftUI-Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/goodrequest/goodextensions-ios", "state" : { - "branch" : "feature/goodextensions-update", - "revision" : "ea9c84a268ef7dc52c884a14f26682c68959aed9" + "branch" : "main", + "revision" : "0db99c0d3b49828a7d6189786215f3abfeb4de6f" } }, { diff --git a/GoodSwiftUI-Sample/GoodSwiftUI-Sample/Screens/InputFieldSampleView.swift b/GoodSwiftUI-Sample/GoodSwiftUI-Sample/Screens/InputFieldSampleView.swift index 2a9eb62..8b33693 100644 --- a/GoodSwiftUI-Sample/GoodSwiftUI-Sample/Screens/InputFieldSampleView.swift +++ b/GoodSwiftUI-Sample/GoodSwiftUI-Sample/Screens/InputFieldSampleView.swift @@ -10,6 +10,7 @@ import GRInputField struct InputFieldSampleView: View { + // Custom validation error with error descriptions enum RegistrationError: ValidationError { case notFilip @@ -27,6 +28,8 @@ struct InputFieldSampleView: View { } + // Iterable enum with Integer raw values for managing SwiftUI focus state + // and specifying the ordering of input fields enum LoginFields: Int, CaseIterable, Equatable, Hashable { case name, pin } @@ -40,6 +43,10 @@ struct InputFieldSampleView: View { @State private var name: String = "" @State private var password: String = "" + @State private var percent: Double = 0.5 + + @State private var nameEnabled: Bool = true + @State private var passwordEnabled: Bool = true // MARK: - Properties @@ -52,100 +59,177 @@ struct InputFieldSampleView: View { // MARK: - Computed properties - // MARK: - Body + // MARK: - Example var body: some View { ScrollView { VStack { - // Text field - InputField(text: $name, title: "Name", placeholder: "Jožko") + nameInputField + pinCodeInputField + formattedInputField - // "Continue" keyboard action button - .inputFieldTraits(returnKeyType: .continue) + // Input field controls + VStack(spacing: 16) { + validityGroups - // Validates name to be equal to "Filip", otherwise fails with custom error - .validationCriteria { - Criterion.matches("Filip") - .failWith(error: RegistrationError.notFilip) - } + Divider() - // Validity group to check for validity - .validityGroup($validityGroup) + metadata - // Focus state binding to advance focus from keyboard action button (Continue) - .bindFocusState($focusState, to: .name) + Divider() + disableToggles + } + .padding() + .background { Color(uiColor: UIColor.secondarySystemBackground) } + .clipShape(.rect(cornerRadius: 12)) + .padding(.vertical) + } + .padding() + } + .scrollDismissesKeyboard(.interactively) + } +} - // Text field - InputField(text: $password, title: "PIN code", hint: "At least 6 numbers") +// MARK: - Examples - // Multiple custom traits - .inputFieldTraits( - keyboardType: .numberPad, - numpadReturnKeyTitle: "Done", - isSecureTextEntry: true - ) +extension InputFieldSampleView { - // Custom validation criteria closure - .validationCriteria { - Criterion { password in - password?.count ?? 0 >= 6 - } - .failWith(error: RegistrationError.pinTooShort) - } + private var nameInputField: some View { + // Text field + InputField(text: $name, title: "Name", placeholder: "Jožko") - .validityGroup($validityGroup) - .bindFocusState($focusState, to: .pin) + // "Continue" keyboard action button + .inputFieldTraits(returnKeyType: .continue) - // Input field controls - VStack(spacing: 16) { + // Validates name to be equal to "Filip", otherwise fails with custom error + .validationCriteria { + Criterion.matches("Filip") + .failWith(error: RegistrationError.notFilip) + } - // Checking validity state on fields - // Doesn't reflect state visible to the user, but the actual validity of data - if validityGroup.allValid() { - Text("Internal validation state: ") + Text("valid").foregroundColor(.green) - } else { - Text("Internal validation state: ") + Text("invalid").foregroundColor(.red) - } - - // Invoking validation of the whole validity group - // State visible to the user is changed to reflect validity of data - Button("Force validation on all fields") { - validityGroup.validateAll() - } - - // State visible to the user is reset to default and - // all data is revalidated in the background. - Button("Remove validation metadata") { - validityGroup.removeAll() - } + // Validity group to check for validity + .validityGroup($validityGroup) - Divider() + // Focus state binding to advance focus from keyboard action button (Continue) + .bindFocusState($focusState, to: .name) + .disabled(!nameEnabled) + } - Text("Internal metadata:") - .frame(maxWidth: .infinity, alignment: .leading) + private var pinCodeInputField: some View { + // Text field + InputField(text: $password, title: "PIN code", hint: "At least 6 numbers") + + // Multiple custom traits + .inputFieldTraits( + keyboardType: .numberPad, + numpadReturnKeyTitle: "Done", + isSecureTextEntry: true + ) + + // Custom validation criteria closure + .validationCriteria { + Criterion { password in + password?.count ?? 0 >= 6 + } + .failWith(error: RegistrationError.pinTooShort) + } - LazyVGrid(columns: [GridItem(.fixed(100)), GridItem()], content: { - Text("Focus state") - Text(String(describing: focusState)) + .validityGroup($validityGroup) + .bindFocusState($focusState, to: .pin) + .disabled(!passwordEnabled) + } - ForEach(Array(validityGroup), id: \.key) { id, state in - let uuidString = id.uuidString - let startIndex = uuidString.startIndex - let endIndex = uuidString.index(startIndex, offsetBy: 6) + private var percentFormattedInputField: some View { + // Text field with custom formatter + InputField( + value: $percent, + format: .percent.precision(.fractionLength(0..<2)), + title: "Percent (%)", + placeholder: "0 %" + ) + .inputFieldTraits(keyboardType: .numbersAndPunctuation) + } - Text(String(uuidString[startIndex...endIndex])) + private var validityGroups: some View { + Group { + // Checking validity state on fields + // Doesn't reflect state visible to the user, but the actual validity of data + if validityGroup.allValid() { + Text("Internal validation state: ") + Text("valid").foregroundColor(.green) + } else { + Text("Internal validation state: ") + Text("invalid").foregroundColor(.red) + } - Text(String(describing: state)) - } - }) - } - .padding(.vertical) + // Invoking validation of the whole validity group + // State visible to the user is changed to reflect validity of data + Button("Force validation on all fields") { + validityGroup.validateAll() + } + + // State visible to the user is reset to default and + // all data is revalidated in the background. + Button("Remove validation metadata") { + validityGroup.removeAll() } - .padding() } - .scrollDismissesKeyboard(.interactively) + } + +} + +// MARK: - Internal + +extension InputFieldSampleView { + + private var metadata: some View { + Group { + Text("Internal metadata:") + .frame(maxWidth: .infinity, alignment: .leading) + + LazyVGrid(columns: [GridItem(.fixed(100)), GridItem()], content: { + Text("Focus state") + Text(String(describing: focusState)) + + ForEach(Array(validityGroup), id: \.key) { id, state in + let uuidString = id.uuidString + let startIndex = uuidString.startIndex + let endIndex = uuidString.index(startIndex, offsetBy: 6) + + Text(String(uuidString[startIndex...endIndex])) + + Text(String(describing: state)) + } + }) + } + } + + private var disableToggles: some View { + Group { + Toggle("Name enabled", isOn: $nameEnabled) + Toggle("PIN enabled", isOn: $passwordEnabled) + } + } + + private var formattedInputField: some View { + Group { + percentFormattedInputField + + Text("Native input field + percent format style") + .font(.caption2) + .frame(maxWidth: .infinity, alignment: .leading) + TextField( + value: $percent, + format: .percent.precision(.fractionLength(0..<2)), + label: { Text("Percent (%)") } + ) + .keyboardType(.numbersAndPunctuation) + + Text("Current value slider (0 - 1)") + .font(.caption2) + .frame(maxWidth: .infinity, alignment: .leading) + Slider(value: $percent) + } } } diff --git a/Package.swift b/Package.swift index e5414c8..d8ed131 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/goodrequest/goodextensions-ios", branch: "feature/goodextensions-update") + .package(url: "https://github.com/goodrequest/goodextensions-ios", branch: "main") ], targets: [ .target( diff --git a/Sources/GRInputField/Common/Validator.swift b/Sources/GRInputField/Common/Validator.swift index 04f3aa9..0e2da8d 100644 --- a/Sources/GRInputField/Common/Validator.swift +++ b/Sources/GRInputField/Common/Validator.swift @@ -56,7 +56,7 @@ public struct Validator: CriteriaConvertible { validate(input: input).isNil } - public func validate(input: String?) -> Error? { + public func validate(input: String?) -> (any ValidationError)? { let failure = criteria .map { (criterion: $0, result: $0.validate(input: input)) } .first { _, result in !result } diff --git a/Sources/GRInputField/SwiftUI/InputField.swift b/Sources/GRInputField/SwiftUI/InputField.swift index 81e671f..1319648 100644 --- a/Sources/GRInputField/SwiftUI/InputField.swift +++ b/Sources/GRInputField/SwiftUI/InputField.swift @@ -25,6 +25,8 @@ public struct InputField: UIViewRepresentable { public let hint: String? public let rightButton: Supplier? + private var hasFormatting: Bool = false + private var traits: InputFieldTraits @ValidatorBuilder private var criteria: Supplier private var returnAction: VoidClosure? @@ -55,6 +57,31 @@ public struct InputField: UIViewRepresentable { self.criteria = alwaysValidCriteria } + @available(iOS 15.0, *) + public init( + value: Binding, + format: FormatterType, + title: String? = nil, + placeholder: String? = nil, + hint: String? = " ", + rightButton: Supplier? = nil + ) where FormatterType.FormatInput == FormattedType , FormatterType.FormatOutput == String { + let formattedBinding = Binding(get: { + let formattedString = format.format(value.wrappedValue) + return formattedString + }, set: { newString in + do { + let parsedValue = try format.parseStrategy.parse(newString) + value.wrappedValue = parsedValue + } catch { + // skip assigning invalid value + } + }) + + self.init(text: formattedBinding, title: title, placeholder: placeholder, hint: hint, rightButton: rightButton) + self.hasFormatting = true + } + // MARK: - Coordinator public class Coordinator: NSObject, UITextFieldDelegate { @@ -113,13 +140,18 @@ public struct InputField: UIViewRepresentable { let editingChangedCancellable = view.editingChangedPublisher .removeDuplicates() - .sink { newText in Task { @MainActor in + .receive(on: DispatchQueue.main) + .sink { newText in self.text = newText invalidateValidityState(in: context) - }} + } let resignCancellable = view.resignPublisher - .sink { _ in resignAction?() } + .receive(on: DispatchQueue.main) + .sink { [weak view] _ in + applyFormatting(view: view) + resignAction?() + } context.coordinator.cancellables.insert(editingChangedCancellable) context.coordinator.cancellables.insert(resignCancellable) @@ -128,8 +160,12 @@ public struct InputField: UIViewRepresentable { } public func updateUIView(_ uiView: ValidableInputFieldView, context: Context) { - uiView.text = self.text - uiView.isEnabled = context.environment.isEnabled + uiView.updateText(self.text) + + // Equality check to prevent unintended side effects + if uiView.isEnabled != context.environment.isEnabled { + uiView.isEnabled = context.environment.isEnabled + } updateValidityState(uiView: uiView, context: context) } @@ -185,6 +221,14 @@ public struct InputField: UIViewRepresentable { } } + // MARK: - Formatting + + func applyFormatting(view uiView: ValidableInputFieldView?) { + if hasFormatting { + uiView?.text = self.text + } + } + } // MARK: - Public modifiers diff --git a/Sources/GRInputField/UIKit/InputFieldView.swift b/Sources/GRInputField/UIKit/InputFieldView.swift index b2de011..f96c06a 100644 --- a/Sources/GRInputField/UIKit/InputFieldView.swift +++ b/Sources/GRInputField/UIKit/InputFieldView.swift @@ -541,11 +541,6 @@ public extension InputFieldView { state = .disabled } - @available(*, deprecated, message: "Assign to `text` instead.") - func setText(_ text: String) { - self.text = text - } - func beginEditing() { textField.becomeFirstResponder() } @@ -560,6 +555,20 @@ public extension InputFieldView { } +// MARK: - Internal + +internal extension InputFieldView { + + /// Update text in internal textfield when data changes and textfield is currently not being edited + func updateText(_ text: String) { + guard state != .selected else { + return + } + textField.text = text + } + +} + // MARK: - Tap gesture recognizer public extension InputFieldView { diff --git a/Sources/GRInputField/UIKit/ValidableInputFieldView.swift b/Sources/GRInputField/UIKit/ValidableInputFieldView.swift index ce894a5..ad1c9c1 100644 --- a/Sources/GRInputField/UIKit/ValidableInputFieldView.swift +++ b/Sources/GRInputField/UIKit/ValidableInputFieldView.swift @@ -44,7 +44,7 @@ public final class ValidableInputFieldView: InputFieldView { func validate() -> Bool { guard let validator = validator?() else { fatalError("Validator not set") } - if let error = validator.validate(input: self.text) as? any ValidationError { + if let error = validator.validate(input: self.text) { fail(with: error.localizedDescription) afterValidation?(error) return false @@ -58,7 +58,7 @@ public final class ValidableInputFieldView: InputFieldView { func validateSilently() -> (any ValidationError)? { guard let validator = validator?() else { return InternalValidationError.alwaysError } - return validator.validate(input: self.text) as? any ValidationError + return validator.validate(input: self.text) } // MARK: - Private @@ -66,7 +66,6 @@ public final class ValidableInputFieldView: InputFieldView { private func setupValidators() { resignPublisher .map { [weak self] _ in self?.validator?().validate(input: self?.text) } - .map { $0 as? any ValidationError } .sink { [weak self] error in if let error { self?.fail(with: error.localizedDescription) } self?.afterValidation?(error) // If wrapped in UIViewRepresentable, update SwiftUI state here From a2116914355976d63dc19db5d579a4bd2b8d88ab Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Tue, 2 Jul 2024 14:00:13 +0200 Subject: [PATCH 4/5] feat: Common criteria --- .../GRInputField/Common/ValidationError.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Sources/GRInputField/Common/ValidationError.swift b/Sources/GRInputField/Common/ValidationError.swift index d9b5ea1..46bde78 100644 --- a/Sources/GRInputField/Common/ValidationError.swift +++ b/Sources/GRInputField/Common/ValidationError.swift @@ -71,3 +71,21 @@ public extension Criterion { } +// MARK: - Commonly used + +public extension Criterion { + + /// Email validator similar to RFC-5322 standards, modified for Swift compatibility, case-insensitive + static let email = Criterion(regex: """ + (?i)\\A(?=[a-z0-9@.!#$%&'*+\\/=?^_'{|}~-]{6,254}\ + \\z)(?=[a-z0-9.!#$%&'*+\\/=?^_'{|}~-]{1,64}@)\ + [a-z0-9!#$%&'*+\\/=?^_'{|}~-]+(?:\\.[a-z0-9!#$%&'*+\\/=?^_'{|}~-]+)\ + *@(?:(?=[a-z0-9-]{1,63}\\.)[a-z0-9]\ + (?:[a-z0-9-]*[a-z0-9])?\\.)+(?=[a-z0-9-]{1,63}\\z)\ + [a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\z + """) + + /// Accepts only valid zip codes + static let zipCode = Criterion(regex: #/^[0-9]{5}$/#) // Criterion(regex: "^[0-9]{5}$") + +} From 68a69a3d68d933b10a2bfb90650cc6ec2a3bc4b1 Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Tue, 2 Jul 2024 14:00:23 +0200 Subject: [PATCH 5/5] task: Documentation --- Package.resolved | 32 +++++++++++++++++++ .../UIKit/ValidableInputFieldView.swift | 14 ++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 Package.resolved diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..b37bbdb --- /dev/null +++ b/Package.resolved @@ -0,0 +1,32 @@ +{ + "pins" : [ + { + "identity" : "combineext", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CombineCommunity/CombineExt.git", + "state" : { + "revision" : "d7b896fa9ca8b47fa7bcde6b43ef9b70bf8c1f56", + "version" : "1.8.1" + } + }, + { + "identity" : "goodextensions-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/goodrequest/goodextensions-ios", + "state" : { + "branch" : "main", + "revision" : "0db99c0d3b49828a7d6189786215f3abfeb4de6f" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", + "version" : "509.1.1" + } + } + ], + "version" : 2 +} diff --git a/Sources/GRInputField/UIKit/ValidableInputFieldView.swift b/Sources/GRInputField/UIKit/ValidableInputFieldView.swift index ad1c9c1..24e99c2 100644 --- a/Sources/GRInputField/UIKit/ValidableInputFieldView.swift +++ b/Sources/GRInputField/UIKit/ValidableInputFieldView.swift @@ -40,8 +40,14 @@ public final class ValidableInputFieldView: InputFieldView { self.afterValidation = action } + /// Requests validation of this text fields' content. Text field will automatically update the appearance to + /// valid/invalid state, depending on the result of validation. + /// + /// If no validator is set, this function will crash. Please make sure to set validation criteria before + /// requesting validation. + /// - Returns: `true` when content is valid, `false` otherwise @discardableResult - func validate() -> Bool { + public func validate() -> Bool { guard let validator = validator?() else { fatalError("Validator not set") } if let error = validator.validate(input: self.text) { @@ -55,7 +61,11 @@ public final class ValidableInputFieldView: InputFieldView { } } - func validateSilently() -> (any ValidationError)? { + /// Validates the content and returns an appropriate error. + /// + /// Does not update the UI in any way. + /// - Returns: `nil` when content is valid, otherwise validation error from failed criterion. + public func validateSilently() -> (any ValidationError)? { guard let validator = validator?() else { return InternalValidationError.alwaysError } return validator.validate(input: self.text)