Last active
May 29, 2023 10:18
-
-
Save myurieff/72e62b689e4524f83a9e70f12bc339a5 to your computer and use it in GitHub Desktop.
SwiftUI TCA Validated Input Field
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public enum InputField<Value: Equatable> { | |
/// The state of current input validation | |
public enum InputValidation: Equatable { | |
/// A value that has undergone validation and is found to be valid. | |
case valid(Value) | |
/// A value that has undergone validation and is found to be invalid. | |
/// Optionally, a feedback message can be displayed to the user | |
/// to let them know what the issue with their input is. | |
/// For example: "Please enter a value between 10 and 100." | |
case invalid(Value, feedback: String?) | |
} | |
public struct State: Equatable { | |
/// Localized string that will be displayed within | |
/// the field when there's no input. | |
public var placeholder: String | |
/// Raw string that corresponds to the input field text value. | |
/// This should only be used for internal SwiftUI + TCA mechanics. | |
/// For an output value, use `inputValidation` or `nonValidatedInput`. | |
var rawInput: String | |
/// The state of current input validation as long as there's any | |
/// raw text input to invalidate. | |
public var inputValidation: InputValidation? | |
/// Helper computed value indicating wether or not the input | |
/// is valid. | |
public var inputIsValid: Bool { | |
(/InputValidation.valid).extract(from: inputValidation) != nil | |
} | |
/// Transformed input value regardless of its validation state. | |
public var nonValidatedInput: Value? { | |
(/InputValidation.valid).extract(from: inputValidation) ?? | |
(/InputValidation.invalid).extract(from: inputValidation)?.0 | |
} | |
/// Optional message to display to the user in case their input | |
/// is not valid. | |
public var feedbackMessage: String? { | |
(/InputValidation.invalid).extract(from: inputValidation)?.1 | |
} | |
/// Specifies the keyboard type to use for text entry. | |
/// Defaults to `UIKeyboardType.default`. | |
public var keyboardType: UIKeyboardType | |
public var supportsMultilineInput: Bool | |
public init(placeholder: String) { | |
self.placeholder = placeholder | |
self.rawInput = String() | |
self.keyboardType = .default | |
self.supportsMultilineInput = false | |
} | |
public init( | |
placeholder: String, | |
rawInput: String, | |
inputValidation: InputField<Value>.InputValidation? = nil, | |
keyboardType: UIKeyboardType = .default, | |
supportsMultilineInput: Bool = false | |
) { | |
self.placeholder = placeholder | |
self.rawInput = rawInput | |
self.inputValidation = inputValidation | |
self.keyboardType = keyboardType | |
self.supportsMultilineInput = supportsMultilineInput | |
} | |
} | |
public enum Action: Equatable { | |
// The user has entered a new text in the field. | |
case didChange(String) | |
// Input text has been sanitized. Note that sanitization | |
// does not guarantee a valid value. Its role is to | |
// strip off disallowed characters, trim lenghts, etc. | |
// For example, if you have a number input field, the | |
// sanitization step should remove any non-digit characters. | |
case sanitizationResponse(String) | |
// Input text has been validated and transformed to a `Value`. | |
case validationResponse(InputValidation) | |
} | |
public struct Environment { | |
var mainQueue: AnySchedulerOf<DispatchQueue> | |
var sanitize: (String) -> Effect<String, Never> | |
var validate: (String) -> Effect<InputValidation, Never> | |
public init( | |
mainQueue: AnySchedulerOf<DispatchQueue>, | |
sanitize: @escaping (String) -> Effect<String, Never>, | |
validate: @escaping (String) -> Effect<InputField<Value>.InputValidation, Never> | |
) { | |
self.mainQueue = mainQueue | |
self.sanitize = sanitize | |
self.validate = validate | |
} | |
} | |
public static var reducer: Reducer<State, Action, Environment> { | |
.init { state, action, environment in | |
switch action { | |
case let .didChange(rawInput): | |
guard rawInput != state.rawInput else { return .none } | |
state.rawInput = rawInput | |
return environment | |
.sanitize(rawInput) | |
.map(Action.sanitizationResponse) | |
.receive(on: environment.mainQueue) | |
.eraseToEffect() | |
case let .sanitizationResponse(response): | |
state.rawInput = response | |
return environment | |
.validate(response) | |
.map(Action.validationResponse) | |
.receive(on: environment.mainQueue) | |
.eraseToEffect() | |
case let .validationResponse(response): | |
state.inputValidation = response | |
return .none | |
} | |
} | |
} | |
public struct Component: View { | |
let store: Store<State, Action> | |
@ObservedObject var viewStore: ViewStore<State, Action> | |
@SwiftUI.Environment(\.isEnabled) | |
var isEnabled: Bool | |
public var body: some View { | |
VStack(alignment: .leading) { | |
TextField( | |
LocalizedStringKey(viewStore.placeholder), | |
text: viewStore.binding( | |
get: \.rawInput, | |
send: Action.didChange | |
) | |
) | |
.keyboardType(viewStore.keyboardType) | |
.foregroundColor(isEnabled ? .primary : .secondary) | |
.textStyle(.label) | |
.padding(.horizontal) | |
.frame(minHeight: 50) | |
.background( | |
RoundedRectangle(cornerRadius: 4, style: .continuous) | |
.strokeBorder( | |
!viewStore.inputIsValid ? | |
Color.red : | |
Color(.separator).opacity(isEnabled ? 1 : 0.6) | |
) | |
) | |
if let feedbackMessage = viewStore.feedbackMessage, !viewStore.rawInput.isEmpty { | |
Text(LocalizedStringKey(feedbackMessage)) | |
.foregroundColor(.secondary) | |
.transition(.opacity) | |
} | |
} | |
.animation(.easeInOut, value: viewStore.feedbackMessage) | |
} | |
public init(_ store: Store<State, Action>) { | |
self.store = store | |
self.viewStore = ViewStore(store) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment