Skip to content

Instantly share code, notes, and snippets.

@myurieff
Last active May 29, 2023 10:18
Show Gist options
  • Save myurieff/72e62b689e4524f83a9e70f12bc339a5 to your computer and use it in GitHub Desktop.
Save myurieff/72e62b689e4524f83a9e70f12bc339a5 to your computer and use it in GitHub Desktop.
SwiftUI TCA Validated Input Field
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