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..b37bbdb --- /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" : "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/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..8b33693 --- /dev/null +++ b/GoodSwiftUI-Sample/GoodSwiftUI-Sample/Screens/InputFieldSampleView.swift @@ -0,0 +1,283 @@ +// +// InputFieldSampleView.swift +// GoodSwiftUI-Sample +// +// Created by Filip Šašala on 25/06/2024. +// + +import SwiftUI +import GRInputField + +struct InputFieldSampleView: View { + + // Custom validation error with error descriptions + 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" + } + } + + } + + // 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 + } + + // 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 = "" + @State private var percent: Double = 0.5 + + @State private var nameEnabled: Bool = true + @State private var passwordEnabled: Bool = true + + // MARK: - Properties + + // MARK: - Initialization + + init() { + // Set up appearance during app launch or screen init() + InputFieldView.configureAppearance() + } + + // MARK: - Computed properties + + // MARK: - Example + + var body: some View { + ScrollView { + VStack { + nameInputField + pinCodeInputField + formattedInputField + + // Input field controls + VStack(spacing: 16) { + validityGroups + + Divider() + + metadata + + Divider() + + disableToggles + } + .padding() + .background { Color(uiColor: UIColor.secondarySystemBackground) } + .clipShape(.rect(cornerRadius: 12)) + .padding(.vertical) + } + .padding() + } + .scrollDismissesKeyboard(.interactively) + } + +} + +// MARK: - Examples + +extension InputFieldSampleView { + + private var nameInputField: some View { + // 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) + .disabled(!nameEnabled) + } + + 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) + } + + .validityGroup($validityGroup) + .bindFocusState($focusState, to: .pin) + .disabled(!passwordEnabled) + } + + 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) + } + + 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) + } + + // 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() + } + } + } + +} + +// 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) + } + } + +} + +// 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() +} 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/Package.swift b/Package.swift index 095eac8..d8ed131 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: "main") ], 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..46bde78 --- /dev/null +++ b/Sources/GRInputField/Common/ValidationError.swift @@ -0,0 +1,91 @@ +// +// 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) + } + +} + +// 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}$") + +} diff --git a/Sources/GRInputField/Common/Validator.swift b/Sources/GRInputField/Common/Validator.swift new file mode 100644 index 0000000..0e2da8d --- /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?) -> (any ValidationError)? { + 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 hasFormatting: Bool = false + + 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 + } + + @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 { + + 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() + .receive(on: DispatchQueue.main) + .sink { newText in + self.text = newText + invalidateValidityState(in: context) + } + + let resignCancellable = view.resignPublisher + .receive(on: DispatchQueue.main) + .sink { [weak view] _ in + applyFormatting(view: view) + resignAction?() + } + + context.coordinator.cancellables.insert(editingChangedCancellable) + context.coordinator.cancellables.insert(resignCancellable) + + return view + } + + public func updateUIView(_ uiView: ValidableInputFieldView, context: Context) { + 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) + } + + 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: - Formatting + + func applyFormatting(view uiView: ValidableInputFieldView?) { + if hasFormatting { + uiView?.text = self.text + } + } + +} + +// 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..f96c06a --- /dev/null +++ b/Sources/GRInputField/UIKit/InputFieldView.swift @@ -0,0 +1,626 @@ +// +// 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 + } + + func beginEditing() { + textField.becomeFirstResponder() + } + + func setNextResponder(_ nextResponder: UIView?) { + completionResponder = nextResponder + } + + func attachTextFieldDelegate(_ delegate: any UITextFieldDelegate) { + textField.delegate = delegate + } + +} + +// 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 { + + 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..24e99c2 --- /dev/null +++ b/Sources/GRInputField/UIKit/ValidableInputFieldView.swift @@ -0,0 +1,86 @@ +// +// 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 + } + + /// 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 + public func validate() -> Bool { + guard let validator = validator?() else { fatalError("Validator not set") } + + if let error = validator.validate(input: self.text) { + fail(with: error.localizedDescription) + afterValidation?(error) + return false + } else { + unfail() + afterValidation?(nil) + return true + } + } + + /// 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) + } + + // MARK: - Private + + private func setupValidators() { + resignPublisher + .map { [weak self] _ in self?.validator?().validate(input: self?.text) } + .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 },