Sitemap

Enum Driven Text Box ideas…

42 min readSep 8, 2025

--

I went down a rabbit hole with SwiftUI… I am new to the language, and am returning to programming after a long hiatus. When I attended school, Cobol was in, and fortran was up and coming. Basic was still all it, and Object Oriented was a phrase thrown around when professors talked theory amongst themselves.

You can probably guess my age from this which is fine. Suffice it to say as I near retirement age, I want something to occupy my mind. I picked Swift.

As I am learning the language and figuring things out, banging through the 100 days of SwiftUI and forging through all the apple provided tutorials, I really felt like textboxes were… complicated. Right you think. Of all the things that could be complicated, why that.

The reason is simple. You use them all the time, and you always have to fight to get back the right input — currency? Even just a number? What if I want to just enter a date without the use of a date picker? It gets really really involved. Then you have all the validation that goes along with it, and the format is never exactly how I wanted it.

I first ran across Mango byte on Medium… mangobytekw@proton.me. He had three installments of an implementation of enum driven text fields that really intrigued me. I also ran across a video by sucodee on YouTube showing off several unique text fields. I was hooked, and set about recreating what they had done, and molding it to my own idea of what would be simply awesome! Now it is up to you to tell me what you think of the idea.

First, I want to pull from Mango byte the idea of having the state of the text field completely self contained, and controlled by an enum.

import SwiftUI

// The following enums are used for state, and are set by the textfield view TFView

// Textfield state definition...
enum InputState: Equatable, CustomStringConvertible {
case idle //Textfield is either idle - initial state, waiting for interaction
case focused(InputValidity) //has focus, with either empty, valid or invalid entry
case inactive(InputValidity) //lost focus, with either empty, valid or invalid entry

enum InputValidity: Equatable {
case empty
case valid
case invalid(String)
}

var validity: InputValidity {
switch self {
case .idle: return .empty
case .focused(let validity): return validity
case .inactive(let validity): return validity
}
}

var description: String {
switch self {
case .idle: return "idle"
case .focused(let validity): return "focused(\(validity))"
case .inactive(let validity): return "inactive(\(validity))"
}
}
}

We will have three possible states for the text box…. Initial, Focused, or Inactive. Initial state is when it first appears in a parent view. Focused is when it is actively accepting input — the user has clicked into it. And Inactive will be after the user has left the textfield.

Additionally, we are going to use enums to indicate the validity of the input. Input can either be valid or invalid. I had thought about adding a third state — incomplete. I deemed that unnecessary. We will get into why shortly.

Finally, note that the invalid state has an associated string attached. If a result is invalid, this will contain the appropriate error message.

Formatting the text box

Now that we have our state variables defined, we can use them to define some of the behavior of our textbox. We are going to start with the color of the box. I would like to differentiate color between an idle box, a valid box, and an invalid box.

The extension to our InputState enum to the right will provide a variable that does exactly that. So now we can set the state of the textbox and the appearance will transform

extension InputState {
var tintColor: Color {
switch self {
case .idle: return .secondary
case let .focused(validity):
switch validity {
case .empty: return .blue
case .valid: return .blue
case .invalid: return .red
}
case let .inactive(validity):
switch validity {
case .empty: return .secondary
case .valid: return .blue
case .invalid: return .red
}
}
}

var debugDescriptionColor: Color {
switch self {
case .idle: return .gray
case let .focused(validity):
switch validity {
case .empty: return .gray
case .valid: return .blue
case .invalid: return .red
}
case let .inactive(validity):
switch validity {
case .empty: return .gray
case .valid: return .blue.opacity(0.5)
case .invalid: return .red.opacity(0.5)
}
}
}
}

I want to have more than just a little bit of color in the textbox, however. If you glanced at the work of the authors that inspired this, you will notice some of the design choices they made. Specifically, we will implement a floating label that initially shows up inside the box, and as you begin editing, it moves up to the top corner of the box. We will also provide a custom .capsule border to the box. Error messages will appear below the box, which also means the height of the view will have to adjust based on rather or not an error message is being displayed. All of this should happen automatically, with no additional action necessary other than input from the user into the textbox.

Calling the Text Box from your code

Before we get in to the implementation, I want to start with the usage. I want as simple a call as possible from the parent view. I want to control the type of data accepted by the textbox, and I want to control rather it is a required or optional field. My call should look something like what is shown here…

        Tfield($text, type: .phrase, label: "Hobby")
.textInputAutocapitalization(.never)
Tfield($text1, type: .name, required: true, label: "Enter your Full Name")
Tfield($text2)
Tfield($text3, type: .expDate)
Tfield($text4, type: .cvv)
Tfield($text5, type: .credit)
Tfield($text6, type: .credit, required: true)

As you can see, I want to have 1 required parameter — the text data that will be returned. In addition I want to have 3 optional parameters — type, required, and label. Type looks like another enum — and this one will be public to our view, extendable by other users once this is packaged up nicely upon completion. So I will first define a TBType protocol, and then a TType enum with some variables added as extensions to define the default label.

import SwiftUI

public protocol TBType {
/* This is the default description that is used if no label is provided in the function call. It is required to be implemented by the protocol
*/
var description: String { get }
/* This is a text string representing the input filter that may be displayed, such as "(000) 000-0000" If you do not provide it, then none will be displayed. This is represented by an empty string in the implementation, which is why the default is an empty string. Please note, if you provide an input filter, you must also provide a formatInput closure to utilize it, and it is likely you will need to provide an overlay width as well.
*/
var filter: String { get }
/* This is the keyboard type that will be used. The default is a standard keyboard. Override to provide an .email or .numberPad keypad instead
*/
var keyboardType: UIKeyboardType { get }
/* This is the overlay width. My hope is to at some point make this automatic, but not yet. Basically, your input is left justified, your overlay is right justified. The overlay width provides the width of a frame for the overlay so that the digits match up correctly. This is roughtly 11 per digit on my implementation, but this may vary. Please note all input is monospaced to make this work properly
*/
var overlayWidth: CGFloat { get }
}

// Default implementations provided for convenience...
public extension TBType {
var inputFilter: String { "" }
var keyboardType: UIKeyboardType { .default }
var overlayWidth: CGFloat { 0 }
}

So lets explain the thought process here. The TType enum is going to define what type of data my textbox is going to accept, and then based on that enum, I will validate and format the data. Validation should be live on a character by character basis, providing instant feedback to the user if a value is not acceptable.

import SwiftUI

enum TType: TBType, Equatable {
case data //Current Default! single alphanumeric string, no spaces allowed
case dataLength(length: Int) //dataLength(length) single alphanumeric string, specified length //dataRange(min, max) single alphanumeric string, no spaces, within the specified lenth
case name //name Alpha string any length, allowed spaces, capitalized
case phrase //phrase alphanumeric string, spaces are allowed
case credit // 16 digit card number grouped in 4's
case expDate // MM/YY
case cvv // 3 digit numeric number. 3 digits required
case age(min: Int, max: Int) //two digit age within the specified range
}

extension TType {
var description: String {
switch self {
case .data:
return "Data"
case .dataLength(let length):
return "Data (\(length) characters)"
case .name:
return "Name"
case .phrase:
return "Phrase"
case .credit:
return "Credit Card Number"
case .cvv:
return "CVV"
case .expDate:
return "Expiration Date"
case .age(let min, let max):
return "Age (\(min)-\(max))"
}
}
}

extension TType {
var filter: String {
switch self {
case .data:
return ""
case .dataLength(let length):
return String(repeating: "X", count: length)
case .name:
return ""
case .phrase:
return ""
case .credit:
return "0000 0000 0000 0000"
case .expDate:
return "MM/YY"
case .cvv:
return "000"
case .age(_, _):
return "00" // note this restricts age to a 2 digit integer.
}
}
}

extension TType {
var useFilter: Bool {
return self.filter != ""
}
}

extension TType {
var keyboardType: UIKeyboardType {
switch self {
case .data:
return .default
case .dataLength(length: _):
return .default
case .name:
return .default
case .phrase:
return .default
case .credit:
return .numberPad
case .expDate:
return .numberPad
case .cvv:
return .numberPad
case .age(min: _, max: _):
return .numberPad
}
}
}

extension TType { // Used so the the left justified field and the right justified overlay line up
var overlayWidth: CGFloat {
switch self {
case .data:
return 0
case .dataLength(let length):
return CGFloat(Int(11.0 * Double(length)))
case .name:
return 0
case .phrase:
return 0
case .credit:
return 200
case .expDate:
return 55
case .cvv:
return 35
case .age(min: _, max: _):
return 23
}
}
}

Now this is where I started my thought process. And I got things working OK, but it was not a good design. Specifically, there were other things that needed to be pulled out of the main textbox design and into the TType enum, specifically the text string representing the input filter, the width of the overlay that contains the input filter, a format/filter closure that can restrict user input, a live validation closure that will validate the entry, and a final result validation closure that will validate the end result.

As a result, we will add extension to define these variables as well. We will also be modifying our protocol as well to both require these variables, and provide default options for them.

Further, I am going to allow associated values with the TType cases so that ranges or lengths may be specified.

Because I eventually want to package this code, I need to specify everything that is necessary for a new type behavior, and also make sure that any unique data is handled, so if you specify a range like .age(min: Int, max: Int) then you have to actually implement the usage of these values in your validation closures.

Enough about how I want to do it, lets take a look at what I have. This is the first complete and functional version, , but NOT the final implementation.

The Textbox View

I used three separate extensions to define the actual textfield, the label, and the error message. I also used a fourth to define a debugging view that will print out the value of the textbox state in real time as you interact with it.

Two of these were created as variables, and two were created as @ViewBuilder functions. The reason for the difference — the @Viewbuilder function option allows a lot more flexibility in the creation of the view… Specifically using conditionals and potentially returning an EmptyView().

extension Tfield {
var TextFieldView: some View {
TextField("", text: $text)
.font(.system(.body, design: .monospaced))
.keyboardType(type.keyboardType)
.autocorrectionDisabled(true)
.focused($isFocused)
.overlay(alignment: .leading) {

Text("\(TFPrompt)")
.font(.system(.body, design: .monospaced))
.frame(width: type.overlayWidth, alignment: .trailing)
.offset(x: -2)
.foregroundStyle(.gray)
.onTapGesture {
isFocused = true

}
}
.padding(.horizontal)
.frame(height: 55)
.background(
Capsule().stroke(
inputState.tintColor, lineWidth: isFocused ? 2 : 1))
}
}

If you will look at the code for the TextFieldView first, you will see that I define a simple TextField, and specify no label. We set the keyboard type based on the value in our enum, and set the capsule color based on our state value. Also notice the font is specified to be monospaced. This view also contains the overlay. Currently the implementation requires the TextField to be left justified, and the overlay to be right justified, and the width of the overlay to be set so that the leftmost character lines up. So on the screen you may see “0000 0000 0000 0000”, and each placeholder is replaced by data as you type input. We include the onTapGesture to make sure the overlay does not interfere with setting the focus of the text field itself.

The Floating Label

This view will create the label for the text field. The label is colored and positioned based on our text box state variables. If the textfield is .idle or .inactive and empty, and there is no overlay, then the label is displayed where you would expect it, inside the text field. Otherwise, the label will be displayed in a smaller size inline with the top of the text field border, and will remain visible as the user types information into the box. In addition, we allow for simplicity here by loading a default label value based on the type parameter if none is provided.

extension Tfield {
var floatingLabel: some View {
Text(getLabel())
.padding(.horizontal, 5)
.background(.background)
.foregroundStyle(inputState.tintColor)
.padding(.leading)
.offset(y: labelOffset)
.scaleEffect(labelScale)
.onTapGesture {
isFocused = true
}
}
private func getLabel() -> String {
if label.isEmpty {
return type.description
} else {
return label
}
}
}

The Error Message

The Error Message function will create an error message if our text field contains an invalid state. It will display the message in red, and will position it below the text field. Note that if the state is valid (or idle) that no view is created or displayed.

extension Tfield {
@ViewBuilder
func makeErrorMessage() -> some View {
Group {
if case let .invalid(message) = inputState.validity {
Text(message)
.foregroundColor(.red)
.font(.caption)
.padding(.leading)
.offset(y: errorOffset)
.padding(.top, 4)
}
}
}
}

Debugging State Message

The final component is the State Message. This is totally a debugging component, and is controlled by the value of the debugging boolean. I probably should be doing #IF DEBUG here, but I’m not quite to that point yet.

Similar to the floating label view, the state message view will print out the current state, and display it either in the view if it is empty, or just above the top edge of the view if it contains either data or a formatting overlay. Again, color is and location is defined by state.

extension Tfield {
@ViewBuilder
func makeStateMessage() -> some View {
Group {
if debugging {
Text("\(type), Req:\(required) : \(inputState.description)")
.foregroundStyle(inputState.debugDescriptionColor)
.font(.caption)
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.leading)
.offset(y: debugOffset)
.padding(.top, 4)
} else {
EmptyView()
}
}
}
}

Other State Based Variables

At this point, you are probably seeing a few references that I have not yet discussed. The first is TFPrompt. This is the text string that is used as an overlay to format the input. The value of this is specified in our type enum. This is the working version, which will contain the partial mask depending on how many characters the user has typed, and will contain an empty string if no input mask is being used.

We also refer to several other computed variables that are shown below. These variables again are completely defined by the state of the textfield itself, and require no adjustment other than ensuring the state is properly set.

extension Tfield {

var labelOffset: CGFloat {
switch inputState {
case .idle where text.isEmpty && TFPrompt.isEmpty: return 0
default: return -32
}
}

var labelScale: CGFloat {
switch inputState {
case .idle where text.isEmpty && TFPrompt.isEmpty: return 1
default: return 0.85
}
}

var errorOffset: CGFloat {
switch inputState {
case .idle where text.isEmpty: return 0
default: return -10
}
}

var debugOffset: CGFloat {
switch inputState {
case .idle where text.isEmpty && TFPrompt.isEmpty: return 0
case .inactive(.valid) where text.isEmpty && TFPrompt.isEmpty: return 0
case .inactive(.invalid) where text.isEmpty && TFPrompt.isEmpty: return 0

default: return -36
}
}
var mainFrameHeight: CGFloat {
var height: CGFloat = 55
if hasError {
height += 30
}
if debugging {
height += 10
}
return height
}

var hasError: Bool { //Computed Boolean. If an error is detected, this will be true
switch inputState {
case .focused(.invalid): return true
case .inactive(.invalid): return true
default: return false
}
}
}

The first is the labelOffset — where the label is positioned vertically. If you remember from before, this will either be inline with the center of the textbox, or inline with the top border of the text box frame.

Get Tkriggle’s stories in your inbox

Join Medium for free to get updates from this writer.

Next is the labelScale — it will be larger if it is displayed inside the text field, versus on the top border of the text box.

We define the extra size needed for the view to display either an error message below the text field, or a debugging state message above the text field. Note that these do not require extra vertical spacing if displayed inside the box, only if they are displayed above and below the box.

Finally we are defining the vertical size of the main view (all of these chid views wrapped together), again based on each of the above variables, which are based entirely on the state of the text field.

The last computed value is simply a boolean to wrap up rather an error exists or not, regardless of rather the text field is active, inactive, or idle.

Input Validation

At this point I need to talk about how I started doing input validation. I decided to break things down into three different steps… Input filtering, Validation while typing, and Validation upon completion.

The idea was that the input filtering would not allow any unacceptable data — for instance if you were asking for a credit card expiration “mm/yy”, then only digits 0–9 would be allowed. The “/“ would be provided by the overlay mask and would be added automatically at the proper place.

Validation while typing would provide real time feedback as the user typed. For instance, if the first digit entered in that credit card expiration date was a 0 or a 1, then you could have a valid value. If it was any other digit, then the value will be invalid, so flag it as such immediately rather than waiting until the result is completed.

Validation upon completion will do additional evaluation of the final result. For instance in the credit card expiration date, if the card is expired it could flag as an error, or if the card expiration is more than 12 years in the future, it could flag an error.

In my original implementation, I had three different functions that handled this. Each function had a switch statement, and applied the proper verification for each field type. This became… unwieldy very quickly. Additionally, even though I am not there yet, I have visions of this being packaged and extensible. And having this implementation definitely was NOT that.

import SwiftUI

public protocol TBType {
/* This is the default description that is used if no label is provided in the function call. It is required to be implemented by the protocol
*/
var description: String { get }
/* This is a text string representing the input filter that may be displayed, such as "(000) 000-0000" If you do not provide it, then none will be displayed. This is represented by an empty string in the implementation, which is why the default is an empty string. Please note, if you provide an input filter, you must also provide a formatInput closure to utilize it, and it is likely you will need to provide an overlay width as well.
*/
var filter: String { get }
/* This is the keyboard type that will be used. The default is a standard keyboard. Override to provide an .email or .numberPad keypad instead
*/
var keyboardType: UIKeyboardType { get }
/* This is the overlay width. My hope is to at some point make this automatic, but not yet. Basically, your input is left justified, your overlay is right justified. The overlay width provides the width of a frame for the overlay so that the digits match up correctly. This is roughtly 11 per digit on my implementation, but this may vary. Please note all input is monospaced to make this work properly
*/
var overlayWidth: CGFloat { get }
/*
formatInput is a closure that is responsible for filtering as you type, and formatting based on the provided filter. It should be implemented in the following fashion...

switch self {
case .newCase:
return {text in
code to filter and format
return formattedText
}
}

if you just wanted to limit input to numbers, you could use... return String (input.filter {$0.isNumber})
if you have a more complex filter like "(000) 000 - 0000" Then you will have to take several steps...
1. filter out the placeholders... (, ), -, and space.
2. verify that your digitsOnly string is actually digits (filter for numbers only)
3. reformat your string back into the placeholder - again I hope to make this automatic at some point, but not yet
4. you are only formatting the portion of the string that exists... if only three digits were entered, you would return "(000". If four digits were entered, you would return "(000) 0" and so forth. Any additional overlay characters still exist in the overlay.
5. The key here is that as you type, the insertion point is immediately following the last character typed. This is also the current content of the text field. All entered digits plus any formatting characters to the left of the insertion point. This is what the formatInput closure needs to return.
*/
var formatInput: (String) -> String { get }
/* This is a closure of similar form to above. It is able to do real time character by character error checking. for instance, if you are entering a two digit month, you could verify that the first digit is either 0 or 1. If not, set the error string (the inout string) and return false. Otherwise, return true The inout string is not used at all in the case of a true result. Note that in some cases, this is not necessary if filtered input does not allow any improper characters. Thus the optional value of true if this is not implemented
switch self {
case .newCase:
return {text, errorMessage in
code to validate
If Valid {
errorMessage = "This is an Error"
return False
} else {
return true
}
}
}
*/
var validateLive: (String, inout String) -> Bool { get }
/* This is a closure of similar form to above. It will verify the final entry when the box looses focus. If you are entering a date, for instance, you could use formatInput to verify that each digit makes sense... "mm/dd/yyyy" First digit of month makes sense (0 or 1) then second digit (first two digits are 01 - 12) then next digit is 0-3, then next two digits are 1-31, then when the year is entered and the focus moves on, you can use this closure to verify that the date actually exists - ie 02/31 is an error. 02/29 is sometimes an error, sometimes not depending on year. You do not need to test for required. That functionality is baked in to the view already. The default if not provided is true - no error checking.

switch self {
case .newCase:
return {text, errorMessage in
code to validate
If Valid {
errorMessage = "This is an Error"
return False
} else {
return true
}
}
}
*/

var validateResult: (String, inout String) -> Bool { get }
}

// Default implementations provided for convenience...
public extension TBType {
var inputFilter: String { "" }
var keyboardType: UIKeyboardType { .default }
var overlayWidth: CGFloat { 0 }
var formatInput: (String) -> String { { $0 } }
var validateLive: (String, inout String) -> Bool { { _, _ in true } }
var validateResult: (String, inout String) -> Bool { { _, _ in true } }
}

So instead, the idea of adding closures as variables in the original type enum came about. The type was already specified, so type.formatInput, type.validateLive, and type.validateResult would execute the desired code. If no closures were listed, then no validation would be performed…. This is probably obvious to a lot of you, but for me this was a HUGE Eureka moment.

Type.formatInput

extension TType {  // This will handle any filtering on data entry (like restricting to numbers only or length)
var formatInput: (String) -> String {
switch self {
case .data: // Any data is allowed... just trim off the whitespace and limit to single line
return { text in
text.replacingOccurrences(of: "\\s+", with: "", options: .regularExpression)
}
case .dataLength(let length):
return { text in
String(text.replacingOccurrences(of: "\\s+", with: "", options: .regularExpression).prefix(length))
}
case .name:
return {text in
text.capitalized
}
case .phrase:
return {text in
text
}

case .credit:
return { text in
let inputText = text.filter { $0.isNumber || $0 == " " }
var digitsOnly = inputText.replacingOccurrences(of: " ", with: "")
digitsOnly = String(digitsOnly.prefix(16))
var formattedText = ""
for (index, character) in digitsOnly.enumerated() {
if index > 0 && index % 4 == 0 {
formattedText.append(" ")
}
formattedText.append(character)
}
return formattedText
}
case .expDate: // filter to only allow two sets of two digits, separated by a slash
return { text in
let inputText = text.filter { $0.isNumber }
var digitsOnly = inputText.replacingOccurrences(of: "/", with: "")
if digitsOnly.count > 4 {
digitsOnly = String(digitsOnly.prefix(4))
}
if digitsOnly.count <= 2 {
return "\(digitsOnly)"
} else {
return "\(digitsOnly.prefix(2))/\(digitsOnly.dropFirst(2).prefix(2))"
}
}

case .cvv: // Only a three digit numeric string
return { text in
String(text.prefix(3)).filter { $0.isNumber }
}
case .age(min: _, max: _): // Only a two or three digit numeric string
return { text in
String(text.prefix(3)).filter { $0.isNumber }
}

}

}
}

Above you see another extension to the TType enum. This is my current implementation of the input filtering. Note that this does not do any real error checking. It only handles any forced formatting (such as capitalization or trimming) and any filtering (such as numbers only). It accepts the user input, and returns the filtered user input, formatted properly. The logic here is a little important to make sure everything works. If you were to have a more complex input mask, such as “(000) 000–0000”, and the user has not entered any data, then you would return an empty string. If the user had entered, for instance, three digits, you would return “(515”. If the user entered a fourth digit, you would return “(515) 3” and so on. So you take the user data, if the entered a letter or other non digit you filter it out, and then return the partially complete, formatted result. I already have Ideas for how to make this better, but we will save that for version 2.

Type.validateLive

extension TType {  // This will handle any data verification as numbers are being entered
var validateLive: (_ text: String, _ errorMessage: inout String) -> Bool {
// Each closure should return a Bool based on the intermediate validity, and if their is an error, set the errorMessage to the proper error description. Note the value entered is not updated. It is up to the user to delete and enter valid data

switch self {
case .data: // Any data is allowed, except no spaces...
return { text, errorMessage in
return true
}
case .dataLength(_):
return { text, errorMessage in
return true
}
case .name:
return { text, errorMessage in
return true
}
case .phrase:
return { text, errorMessage in
return true
}

case .credit: // Input filter handles all live error controls
return { text, errorMessage in
return true
}
case .expDate:
return { text, errorMessage in
var digitsOnly = text.replacingOccurrences(of: "/", with: "")
if digitsOnly.count > 4 {
digitsOnly = String(digitsOnly.prefix(4))
}
switch digitsOnly.count {
case 0: // dont errorcheck empty string
return true
case 1: // Must be 0 or 1 to be valid month
errorMessage = "Invalid Month"
return digitsOnly.prefix(1) == "0" || digitsOnly.prefix(1) == "1"
case 2: // first two digits must be between 1 and 12
if let month = Int(digitsOnly.prefix(2)) {
errorMessage = "Invalid Month"
return month > 0 && month < 13
} else {
errorMessage = "Invalid Month/Year #" // This should not be possible
return false
}
case 3:
if let month = Int(digitsOnly.prefix(2)) {
if month < 1 || month > 12 {
errorMessage = "Invalid Month"
return false
} else { //month valid, test year
// +/-12 year window around current...
let thisYear = Calendar.current.component(.year, from: Date())
let min = String(thisYear - 12).dropFirst(2).prefix(1)
let max = String(thisYear + 12).dropFirst(2).prefix(1)
if let minInt = Int(min), let maxInt = Int(max), let yearInt = Int(digitsOnly.dropFirst(2).prefix(1)) {
if (yearInt < minInt || yearInt > maxInt) {
errorMessage = "Year out of range"
return false
} else {
return true
}
}else {
errorMessage = "Invalid Month/Year #" // This should not be possible
return false
}
}


} else {
errorMessage = "Invalid Month/Year #" // This should not be possible
return false
}
case 4:
if let month = Int(digitsOnly.prefix(2)) {
if month < 1 || month > 12 {
errorMessage = "Invalid Month"
return false
} else { //month valid, test year
// +/-12 year window around current...
let thisYear = Calendar.current.component(.year, from: Date())
let min = String(thisYear - 12).dropFirst(2).prefix(2)
let max = String(thisYear + 12).dropFirst(2).prefix(2)
if let minInt = Int(min), let maxInt = Int(max), let yearInt = Int(digitsOnly.dropFirst(2).prefix(2)) {
if (yearInt < minInt || yearInt > maxInt) {
errorMessage = "Year out of range"
return false
} else {
return true
}
}else {
errorMessage = "Invalid Month/Year #" // This should not be possible
return false
}
}


} else {
errorMessage = "Invalid Month/Year #" // This should not be possible
return false
}
default:
return false // this should not be possible with input filtering
}
}
case .cvv: // Input filter handles all error controls
return { text, errorMessage in
return true
}
case .age(let min, let max): // Only a two or three digit numeric string, between the min and max values
return { text, errorMessage in
if text.count == 0 { //Dont error check an empty field
return true
}
guard let value = Int(text) else {
errorMessage = "LOGIC ERROR VALIDATE LIVE" // This should never happen because of input filtering
return false
}
if text.count >= 2 {
switch value {
case ..<min:
errorMessage = "Value is smaller than /(min)"
return false
case (max+1)...:
errorMessage = "Value is larger than /(max)"
return false
default: return true
}
}
if text.count == 1{
switch value {
case let n where n*10 < min:
if (n*10) + 9 < min {
errorMessage = "Value is smaller than /(min)"
return false
} else {
return true
}
case let n where (n*10)+9 > max:
if n*10 > max {
errorMessage = "Value is larger than /(max)"
return false
} else {
return true
}
default: return true
}

} else {
return true // partially complete or empty number that still has the potential to be within the specified range
}
}
}
}
}

The validate live concept is to me an absolute necessity. However input filtering in a lot of cases eliminates the need for live validation. When you are entering a credit card number, for instance, if your input can only be numeric, and you cannot enter more than 16 digits, then the only validation is did you enter a complete or incomplete number at the end — result verification, not live validation. This is not the case for the credit card expiration date, however. This gives you an example of how truly powerful this validation can become.

As you can see, we validate on a digit by digit basis. If only one digit is entered, it must be either a zero or one. If two digits are entered, it must be a value 1–12. As the third digit is entered, it must be the first digit of a year within 12 years of the current year. A little arbitrary I know, but I didn’t want to take a chance on eliminating a valid date. I left a 12 year window in either direction, but this could easily be modified to disallow an expired card. The only reason I can give you for why I chose this range, is because in version 2, I fully expect to implement warning messages as well — like the ability to accept or display an expired card, but warn the user it is expired as opposed to flagging it as invalid. The key difference being a warning value can be accepted, but an invalid value will not be accepted as a final result.

There are several instances in the closure that say

errorMessage = "Invalid Month/Year #"  // This should not be possible
return false

This should truly be impossible, but of course the case statement requires us to be exhaustive. The reason for so many “not possibles” is mainly because of the necessity to unwrap the integer value from the text string. Our input filter makes sure that this text string is between 0 and 4 numeric digits, but we still have to account for the cases where it might not be, and I am always scared to !unwrap in a more extreme fashion. Especially when I am still working through some kinks in code.

As you can see above, live validation can be as exacting as you choose to make it. One specific point to notice, however, is that live validation by design does not remove the offending input. Unlike filtering, the last character is left in place, and simply flagged as invalid. It is up to the user to backspace and delete it, and replace it with a valid character. Hence neither validateLive nor validateResult will modify the value that is in the text field. Additionally, while errorMessage is defined as an inout value to be played with, it is only relevant in the case of an invalid result. If the closure returns false, then errorMessage holds meaningful information. Otherwise if true is returned, then errorMessage does not matter. I played with a little more elegant version that used my InputValidity enum directly, with an associated value for the errorMessage. And while this might be a little more elegant, I felt like it complicated things if I wanted anyone to be able to package this and have it be extensible. It was to me simpler and more straight forward to just set a value for errorMessage, and return a boolean to indicate validity.

ValidateResult

Result validation may not always be necessary as well. You do not have to test for required status — that is baked in automatically. If you want to ensure a complete result, however, then you will need to check for that. In the case of a .cvv code for instance, you are required to have 3 digits in your final value.

Just like live validation above, the text value is not modified. Only an error message and a boolean return value are specified.

extension TType {  // This will handle any data verification as numbers are being entered
var validateResult: (_ text: String, _ errorMessage: inout String) -> Bool {
// Each closure should return a Bool based on the intermediate validity, and if their is an error, set the errorMessage to the proper error description. Note the value entered is not updated. It is up to the user to delete and enter valid data

switch self {
case .data: // Any data is allowed, except no spaces...
return { text, errorMessage in
return true
}
case .dataLength(let length):
return { text, errorMessage in
errorMessage = "Not Long Enough"
return text.count >= length
}

case .name:
return { text, errorMessage in
return true
}
case .phrase:
return { text, errorMessage in
return true
}
case .credit:
return { text, errorMessage in
errorMessage = "Card Number Incomplete"
return text.count >= self.filter.count
}
case .expDate:
return { text, errorMessage in
if text.count != 5 {
errorMessage = "Incomplete Date"
return false
}
let digitsOnly = text.replacingOccurrences(of: "/", with: "")
if let month = Int(digitsOnly.prefix(2)) {
if month < 1 || month > 12 {
errorMessage = "Invalid Month"
return false
} else { //month valid, test year
// +/-12 year window around current...
let thisYear = Calendar.current.component(.year, from: Date())
let min = String(thisYear - 12).dropFirst(2).prefix(2)
let max = String(thisYear + 12).dropFirst(2).prefix(2)
if let minInt = Int(min), let maxInt = Int(max), let yearInt = Int(digitsOnly.dropFirst(2).prefix(2)) {
if (yearInt < minInt || yearInt > maxInt) {
errorMessage = "Year out of range"
return false
} else {
return true
}
}else {
errorMessage = "Invalid Month/Year #" // This should not be possible
return false
}
}


} else {
errorMessage = "Invalid Month/Year #" // This should not be possible
return false
}

}
case .cvv: // input filter handles numeric input. Final number acceptable if 3 digits
return { text, errorMessage in
errorMessage = "CVV Incomplete"
return text.count >= self.filter.count
}
case .age(let min, let max): // Only a two digit numeric string, between the min and max values
return { text, errorMessage in
guard let value = Int(text) else {
errorMessage = "LOGIC ERROR VALIDATE LIVE" // This should never happen because of input filtering
return false
}
switch value {
case ..<min:
errorMessage = "Value is smaller than /(min)"
return false
case (max + 1)... :
errorMessage = "Value is larger than /(max)"
return false
default: return true
}

}
}
}
}

At this point I would like to mention that you can add associated values to your type enum’s, and use those values for validation. For instance, you might add an age type by adding case age (min: Int, max: Int). You would then have access to a min and max value when you validate.

State Control…

We are almost ready to wrap up this code. Perhaps the most important part is of course updating the state of the text field. I handle this in a private function attached to the main view. This function is called any time the focus of the textfield changes, and also any time a .onChange event occurs. Each time it executes, it will reset the errorMessage to empty, set the state to .idle, .focused, or .inactive depending on the focus, and also execute either .validateLive or .validateResult, depending on the focus.

    private func updateState() {
var errorMessage: String = ""
if isFocused {
if type.validateLive(text, &errorMessage) {
inputState = .focused(.valid)
} else {
inputState = .focused(.invalid(errorMessage))
}
} else {
if text.isEmpty {
if required { // required and empty
inputState = .inactive(.invalid("Required Entry"))
} else { //optional and empty
inputState = .idle
}
} else {
if type.validateResult(text, &errorMessage) {
inputState = .inactive(.valid)
} else {
inputState = .inactive(.invalid(errorMessage))
}
}
}
}

Additionally, this function will handle verifying .required input. One decision I made was that if a text field was optional, and lost focus while empty, it would go back to .idle state with no errors — as if it had never had focus in the first place. If a text field was .required and empty, it would initially be idle, but once it gained and then lost focus, it would go to .invalid(“Required Entry”).

Main View

And finally it is time to show the implementation of the main view.

import SwiftUI

struct Tfield: View {
@Binding var text: String
var label: String
var required: Bool
var type: TType
@State private var deletedCharacters: [Character] = []
@State var inputState: InputState = .idle
@State private var TFPrompt: String
@FocusState var isFocused: Bool

private let debugging: Bool = true

init(
_ text: Binding<String>, type: TType = .data, required: Bool = false,
label: String = ""
) {
self._text = text
self.type = type
self.required = required
self.label = label
_TFPrompt = State(initialValue: type.filter)
}

var body: some View {

VStack(alignment: .leading) {
ZStack(alignment: .leading) {
TextFieldView
floatingLabel
makeStateMessage()
}
makeErrorMessage()
}
.frame(height: mainFrameHeight)
.animation(.spring(duration: 0.2), value: inputState)
.onChange(of: isFocused) { _, _ in updateState() }
.onChange(of: text) { old, newInput in
updateState()
formatInputText()
if type.useFilter {
if newInput.count > old.count {
if !TFPrompt.isEmpty {

deletedCharacters.append(TFPrompt.first!)
TFPrompt.removeFirst()
}
} else if newInput.count < old.count {

if text.count < type.filter.count,
let lastDeleted = deletedCharacters.popLast()
{
TFPrompt.insert(lastDeleted, at: TFPrompt.startIndex)
}
}
}
}
}
private func formatInputText() { //this will handle any input filtering (like only numbers, or only 3 digits)
text = type.formatInput(text)
print("text: \(text), TFPrompt: \(TFPrompt)")
}
private func updateState() {
}
}

One of the things I had the most trouble with was getting the view call the way I wanted it. I wanted the binding text to be listed first, as an unnamed parameter, and I wanted all the other parameters to have default values. This was accomplished with the view init function.

Also in the init, you will notice that we pull the value for TFPrompt from the type enum. In the current implementation, TFTPrompt will change as each digit is entered, with one digit either being added to the input as a formatting character, or being removed from the overlay and added to the deletedCharacters array so that if a character is then backspaced over, the overlay can be reconstructed, and also so that the overlay is not displayed on top of any input that is entered.

The current implementation is what was used by sucobee as mentioned at the beginning of this string, but again, one of the things I am hoping to improve on with version 2 is for the input mask to instead have spaces entered, so that it can be left justified instead of right justified.

So each time their is a change in the text field, the input is filtered and validated, and the state is updated. Then the input length is compared. If input length changes, then either a character valid character was added, or a character was deleted. If a character is added, we will delete one character from TFPrompt and add it to the deleted characters array. Remember, any formatting characters would have already been added to your string by formatInput. So if you stopped right at that point, you would see the leftmost character in your input mask and the rightmost character of your input overlapping.

Similarly, if the length of the input decreases, then we will pop out the last character of our input mask and display it again, to replace the user input that was removed.

The full code is included as follows, in five different files. A protocols file, a State Enums file, a Type Enums File, a main view file, and a test view file. Save the test view code in your contentView to see the fields in action and try them out.

import SwiftUI

public protocol TBType {
/* This is the default description that is used if no label is provided in the function call. It is required to be implemented by the protocol
*/
var description: String { get }
/* This is a text string representing the input filter that may be displayed, such as "(000) 000-0000" If you do not provide it, then none will be displayed. This is represented by an empty string in the implementation, which is why the default is an empty string. Please note, if you provide an input filter, you must also provide a formatInput closure to utilize it, and it is likely you will need to provide an overlay width as well.
*/
var filter: String { get }
/* This is the keyboard type that will be used. The default is a standard keyboard. Override to provide an .email or .numberPad keypad instead
*/
var keyboardType: UIKeyboardType { get }
/* This is the overlay width. My hope is to at some point make this automatic, but not yet. Basically, your input is left justified, your overlay is right justified. The overlay width provides the width of a frame for the overlay so that the digits match up correctly. This is roughtly 11 per digit on my implementation, but this may vary. Please note all input is monospaced to make this work properly
*/
var overlayWidth: CGFloat { get }
/*
formatInput is a closure that is responsible for filtering as you type, and formatting based on the provided filter. It should be implemented in the following fashion...

switch self {
case .newCase:
return {text in
code to filter and format
return formattedText
}
}

if you just wanted to limit input to numbers, you could use... return String (input.filter {$0.isNumber})
if you have a more complex filter like "(000) 000 - 0000" Then you will have to take several steps...
1. filter out the placeholders... (, ), -, and space.
2. verify that your digitsOnly string is actually digits (filter for numbers only)
3. reformat your string back into the placeholder - again I hope to make this automatic at some point, but not yet
4. you are only formatting the portion of the string that exists... if only three digits were entered, you would return "(000". If four digits were entered, you would return "(000) 0" and so forth. Any additional overlay characters still exist in the overlay.
5. The key here is that as you type, the insertion point is immediately following the last character typed. This is also the current content of the text field. All entered digits plus any formatting characters to the left of the insertion point. This is what the formatInput closure needs to return.
*/
var formatInput: (String) -> String { get }
/* This is a closure of similar form to above. It is able to do real time character by character error checking. for instance, if you are entering a two digit month, you could verify that the first digit is either 0 or 1. If not, set the error string (the inout string) and return false. Otherwise, return true The inout string is not used at all in the case of a true result. Note that in some cases, this is not necessary if filtered input does not allow any improper characters. Thus the optional value of true if this is not implemented
switch self {
case .newCase:
return {text, errorMessage in
code to validate
If Valid {
errorMessage = "This is an Error"
return False
} else {
return true
}
}
}
*/
var validateLive: (String, inout String) -> Bool { get }
/* This is a closure of similar form to above. It will verify the final entry when the box looses focus. If you are entering a date, for instance, you could use formatInput to verify that each digit makes sense... "mm/dd/yyyy" First digit of month makes sense (0 or 1) then second digit (first two digits are 01 - 12) then next digit is 0-3, then next two digits are 1-31, then when the year is entered and the focus moves on, you can use this closure to verify that the date actually exists - ie 02/31 is an error. 02/29 is sometimes an error, sometimes not depending on year. You do not need to test for required. That functionality is baked in to the view already. The default if not provided is true - no error checking.

switch self {
case .newCase:
return {text, errorMessage in
code to validate
If Valid {
errorMessage = "This is an Error"
return False
} else {
return true
}
}
}
*/

var validateResult: (String, inout String) -> Bool { get }
}

// Default implementations provided for convenience...
public extension TBType {
var inputFilter: String { "" }
var keyboardType: UIKeyboardType { .default }
var overlayWidth: CGFloat { 0 }
var formatInput: (String) -> String { { $0 } }
var validateLive: (String, inout String) -> Bool { { _, _ in true } }
var validateResult: (String, inout String) -> Bool { { _, _ in true } }
}
import SwiftUI

// The following enums are used for state, and are set by the textfield view TFView

// Textfield state definition...
enum InputState: Equatable, CustomStringConvertible {
case idle //Textfield is either idle - initial state, waiting for interaction
case focused(InputValidity) //has focus, with either empty, valid or invalid entry
case inactive(InputValidity) //lost focus, with either empty, valid or invalid entry

enum InputValidity: Equatable {
case empty
case valid
case invalid(String)
}

var validity: InputValidity {
switch self {
case .idle: return .empty
case .focused(let validity): return validity
case .inactive(let validity): return validity
}
}

var description: String {
switch self {
case .idle: return "idle"
case .focused(let validity): return "focused(\(validity))"
case .inactive(let validity): return "inactive(\(validity))"
}
}
}


enum FloatingLabelLocation: Equatable {
case hidden
case inline
case top
}

// UI Properties driven by the above enum combinations...

extension InputState {
var tintColor: Color {
switch self {
case .idle: return .secondary
case let .focused(validity):
switch validity {
case .empty: return .blue
case .valid: return .blue
case .invalid: return .red
}
case let .inactive(validity):
switch validity {
case .empty: return .secondary
case .valid: return .blue
case .invalid: return .red
}
}
}

var debugDescriptionColor: Color {
switch self {
case .idle: return .gray
case let .focused(validity):
switch validity {
case .empty: return .gray
case .valid: return .blue
case .invalid: return .red
}
case let .inactive(validity):
switch validity {
case .empty: return .gray
case .valid: return .blue.opacity(0.5)
case .invalid: return .red.opacity(0.5)
}
}
}
}
import SwiftUI

enum TType: TBType, Equatable {
case data //Current Default! single alphanumeric string, no spaces allowed
case dataLength(length: Int) //dataLength(length) single alphanumeric string, specified length //dataRange(min, max) single alphanumeric string, no spaces, within the specified lenth
case name //name Alpha string any length, allowed spaces, capitalized
case phrase //phrase alphanumeric string, spaces are allowed
case credit // 16 digit card number grouped in 4's
case expDate // MM/YY
case cvv // 3 digit numeric number. 3 digits required
case age(min: Int, max: Int) //two digit age within the specified range
}

extension TType {
var description: String {
switch self {
case .data:
return "Data"
case .dataLength(let length):
return "Data (\(length) characters)"
case .name:
return "Name"
case .phrase:
return "Phrase"
case .credit:
return "Credit Card Number"
case .cvv:
return "CVV"
case .expDate:
return "Expiration Date"
case .age(let min, let max):
return "Age (\(min)-\(max))"
}
}
}

extension TType {
var filter: String {
switch self {
case .data:
return ""
case .dataLength(let length):
return String(repeating: "X", count: length)
case .name:
return ""
case .phrase:
return ""
case .credit:
return "0000 0000 0000 0000"
case .expDate:
return "MM/YY"
case .cvv:
return "000"
case .age(_, _):
return "00" // note this restricts age to a 2 digit integer.
}
}
}

extension TType {
var useFilter: Bool {
return self.filter != ""
}
}

extension TType {
var keyboardType: UIKeyboardType {
switch self {
case .data:
return .default
case .dataLength(length: _):
return .default
case .name:
return .default
case .phrase:
return .default
case .credit:
return .numberPad
case .expDate:
return .numberPad
case .cvv:
return .numberPad
case .age(min: _, max: _):
return .numberPad
}
}
}

extension TType { // Used so the the left justified field and the right justified overlay line up
var overlayWidth: CGFloat {
switch self {
case .data:
return 0
case .dataLength(let length):
return CGFloat(Int(11.0 * Double(length)))
case .name:
return 0
case .phrase:
return 0
case .credit:
return 200
case .expDate:
return 55
case .cvv:
return 35
case .age(min: _, max: _):
return 23
}
}
}

extension TType { // This will handle any filtering on data entry (like restricting to numbers only or length)
var formatInput: (String) -> String {
switch self {
case .data: // Any data is allowed... just trim off the whitespace and limit to single line
return { text in
text.replacingOccurrences(of: "\\s+", with: "", options: .regularExpression)
}
case .dataLength(let length):
return { text in
String(text.replacingOccurrences(of: "\\s+", with: "", options: .regularExpression).prefix(length))
}
case .name:
return {text in
text.capitalized
}
case .phrase:
return {text in
text
}

case .credit:
return { text in
let inputText = text.filter { $0.isNumber || $0 == " " }
var digitsOnly = inputText.replacingOccurrences(of: " ", with: "")
digitsOnly = String(digitsOnly.prefix(16))
var formattedText = ""
for (index, character) in digitsOnly.enumerated() {
if index > 0 && index % 4 == 0 {
formattedText.append(" ")
}
formattedText.append(character)
}
return formattedText
}
case .expDate: // filter to only allow two sets of two digits, separated by a slash
return { text in
let inputText = text.filter { $0.isNumber }
var digitsOnly = inputText.replacingOccurrences(of: "/", with: "")
if digitsOnly.count > 4 {
digitsOnly = String(digitsOnly.prefix(4))
}
if digitsOnly.count <= 2 {
return "\(digitsOnly)"
} else {
return "\(digitsOnly.prefix(2))/\(digitsOnly.dropFirst(2).prefix(2))"
}
}

case .cvv: // Only a three digit numeric string
return { text in
String(text.prefix(3)).filter { $0.isNumber }
}
case .age(min: _, max: _): // Only a two or three digit numeric string
return { text in
String(text.prefix(3)).filter { $0.isNumber }
}

}

}
}

extension TType { // This will handle any data verification as numbers are being entered
var validateLive: (_ text: String, _ errorMessage: inout String) -> Bool {
// Each closure should return a Bool based on the intermediate validity, and if their is an error, set the errorMessage to the proper error description. Note the value entered is not updated. It is up to the user to delete and enter valid data

switch self {
case .data: // Any data is allowed, except no spaces...
return { text, errorMessage in
return true
}
case .dataLength(_):
return { text, errorMessage in
return true
}
case .name:
return { text, errorMessage in
return true
}
case .phrase:
return { text, errorMessage in
return true
}

case .credit: // Input filter handles all live error controls
return { text, errorMessage in
return true
}
case .expDate:
return { text, errorMessage in
var digitsOnly = text.replacingOccurrences(of: "/", with: "")
if digitsOnly.count > 4 {
digitsOnly = String(digitsOnly.prefix(4))
}
switch digitsOnly.count {
case 0: // dont errorcheck empty string
return true
case 1: // Must be 0 or 1 to be valid month
errorMessage = "Invalid Month"
return digitsOnly.prefix(1) == "0" || digitsOnly.prefix(1) == "1"
case 2: // first two digits must be between 1 and 12
if let month = Int(digitsOnly.prefix(2)) {
errorMessage = "Invalid Month"
return month > 0 && month < 13
} else {
errorMessage = "Invalid Month/Year #" // This should not be possible
return false
}
case 3:
if let month = Int(digitsOnly.prefix(2)) {
if month < 1 || month > 12 {
errorMessage = "Invalid Month"
return false
} else { //month valid, test year
// +/-12 year window around current...
let thisYear = Calendar.current.component(.year, from: Date())
let min = String(thisYear - 12).dropFirst(2).prefix(1)
let max = String(thisYear + 12).dropFirst(2).prefix(1)
if let minInt = Int(min), let maxInt = Int(max), let yearInt = Int(digitsOnly.dropFirst(2).prefix(1)) {
if (yearInt < minInt || yearInt > maxInt) {
errorMessage = "Year out of range"
return false
} else {
return true
}
}else {
errorMessage = "Invalid Month/Year #" // This should not be possible
return false
}
}


} else {
errorMessage = "Invalid Month/Year #" // This should not be possible
return false
}
case 4:
if let month = Int(digitsOnly.prefix(2)) {
if month < 1 || month > 12 {
errorMessage = "Invalid Month"
return false
} else { //month valid, test year
// +/-12 year window around current...
let thisYear = Calendar.current.component(.year, from: Date())
let min = String(thisYear - 12).dropFirst(2).prefix(2)
let max = String(thisYear + 12).dropFirst(2).prefix(2)
if let minInt = Int(min), let maxInt = Int(max), let yearInt = Int(digitsOnly.dropFirst(2).prefix(2)) {
if (yearInt < minInt || yearInt > maxInt) {
errorMessage = "Year out of range"
return false
} else {
return true
}
}else {
errorMessage = "Invalid Month/Year #" // This should not be possible
return false
}
}


} else {
errorMessage = "Invalid Month/Year #" // This should not be possible
return false
}
default:
return false // this should not be possible with input filtering
}
}
case .cvv: // Input filter handles all error controls
return { text, errorMessage in
return true
}
case .age(let min, let max): // Only a two or three digit numeric string, between the min and max values
return { text, errorMessage in
if text.count == 0 { //Dont error check an empty field
return true
}
guard let value = Int(text) else {
errorMessage = "LOGIC ERROR VALIDATE LIVE" // This should never happen because of input filtering
return false
}
if text.count >= 2 {
switch value {
case ..<min:
errorMessage = "Value is smaller than /(min)"
return false
case (max+1)...:
errorMessage = "Value is larger than /(max)"
return false
default: return true
}
}
if text.count == 1{
switch value {
case let n where n*10 < min:
if (n*10) + 9 < min {
errorMessage = "Value is smaller than /(min)"
return false
} else {
return true
}

case let n where (n*10)+9 > max:
if n*10 > max {
errorMessage = "Value is larger than /(max)"
return false
} else {
return true
}
default: return true
}

} else {
return true // partially complete or empty number that still has the potential to be within the specified range
}

}
}

}
}
extension TType { // This will handle any data verification as numbers are being entered
var validateResult: (_ text: String, _ errorMessage: inout String) -> Bool {
// Each closure should return a Bool based on the intermediate validity, and if their is an error, set the errorMessage to the proper error description. Note the value entered is not updated. It is up to the user to delete and enter valid data

switch self {
case .data: // Any data is allowed, except no spaces...
return { text, errorMessage in
return true
}
case .dataLength(let length):
return { text, errorMessage in
errorMessage = "Not Long Enough"
return text.count >= length
}

case .name:
return { text, errorMessage in
return true
}
case .phrase:
return { text, errorMessage in
return true
}
case .credit:
return { text, errorMessage in
errorMessage = "Card Number Incomplete"
return text.count >= self.filter.count
}
case .expDate:
return { text, errorMessage in
if text.count != 5 {
errorMessage = "Incomplete Date"
return false
}
let digitsOnly = text.replacingOccurrences(of: "/", with: "")
if let month = Int(digitsOnly.prefix(2)) {
if month < 1 || month > 12 {
errorMessage = "Invalid Month"
return false
} else { //month valid, test year
// +/-12 year window around current...
let thisYear = Calendar.current.component(.year, from: Date())
let min = String(thisYear - 12).dropFirst(2).prefix(2)
let max = String(thisYear + 12).dropFirst(2).prefix(2)
if let minInt = Int(min), let maxInt = Int(max), let yearInt = Int(digitsOnly.dropFirst(2).prefix(2)) {
if (yearInt < minInt || yearInt > maxInt) {
errorMessage = "Year out of range"
return false
} else {
return true
}
}else {
errorMessage = "Invalid Month/Year #" // This should not be possible
return false
}
}


} else {
errorMessage = "Invalid Month/Year #" // This should not be possible
return false
}

}
case .cvv: // input filter handles numeric input. Final number acceptable if 3 digits
return { text, errorMessage in
errorMessage = "CVV Incomplete"
return text.count >= self.filter.count
}
case .age(let min, let max): // Only a two digit numeric string, between the min and max values
return { text, errorMessage in
guard let value = Int(text) else {
errorMessage = "LOGIC ERROR VALIDATE LIVE" // This should never happen because of input filtering
return false
}
switch value {
case ..<min:
errorMessage = "Value is smaller than /(min)"
return false
case (max + 1)... :
errorMessage = "Value is larger than /(max)"
return false
default: return true
}

}
}
}
}
import SwiftUI

struct Tfield: View {
@Binding var text: String
var label: String
var required: Bool
var type: TType
@State private var deletedCharacters: [Character] = []
@State var inputState: InputState = .idle
@State private var TFPrompt: String
@FocusState var isFocused: Bool

private let debugging: Bool = true

init(
_ text: Binding<String>, type: TType = .data, required: Bool = false,
label: String = ""
) {
self._text = text
self.type = type
self.required = required
self.label = label
_TFPrompt = State(initialValue: type.filter)
}

var body: some View {

VStack(alignment: .leading) {
ZStack(alignment: .leading) {
TextFieldView
floatingLabel
makeStateMessage()
}
makeErrorMessage()
}
.frame(height: mainFrameHeight)
.animation(.spring(duration: 0.2), value: inputState)
.onChange(of: isFocused) { _, _ in updateState() }
.onChange(of: text) { old, newInput in
updateState()
formatInputText()
if type.useFilter {
if newInput.count > old.count {
if !TFPrompt.isEmpty {

deletedCharacters.append(TFPrompt.first!)
TFPrompt.removeFirst()
}
} else if newInput.count < old.count {

if text.count < type.filter.count,
let lastDeleted = deletedCharacters.popLast()
{
TFPrompt.insert(lastDeleted, at: TFPrompt.startIndex)
}
}
}

}

}
private func formatInputText() { //this will handle any input filtering (like only numbers, or only 3 digits)
text = type.formatInput(text)
print("text: \(text), TFPrompt: \(TFPrompt)")
}
private func updateState() {
var errorMessage: String = ""
if isFocused {
if type.validateLive(text, &errorMessage) {
inputState = .focused(.valid)
} else {
inputState = .focused(.invalid(errorMessage))
}
} else {
if text.isEmpty {
if required { // required and empty
inputState = .inactive(.invalid("Required Entry"))
} else { //optional and empty
inputState = .idle
}
} else {
if type.validateResult(text, &errorMessage) {
inputState = .inactive(.valid)
} else {
inputState = .inactive(.invalid(errorMessage))
}
}
}
}
}

extension Tfield {
var TextFieldView: some View {
TextField("", text: $text)
.font(.system(.body, design: .monospaced))
.keyboardType(type.keyboardType)
.autocorrectionDisabled(true)
.focused($isFocused)
.overlay(alignment: .leading) {

Text("\(TFPrompt)")
.font(.system(.body, design: .monospaced))
.frame(width: type.overlayWidth, alignment: .trailing)
.offset(x: -2)
.foregroundStyle(.gray)
.onTapGesture {
isFocused = true

}
}
.padding(.horizontal)
.frame(height: 55)
.background(
Capsule().stroke(
inputState.tintColor, lineWidth: isFocused ? 2 : 1))
}
} // TextFieldView

extension Tfield {
var floatingLabel: some View {
Text(getLabel())
.padding(.horizontal, 5)
.background(.background)
.foregroundStyle(inputState.tintColor)
.padding(.leading)
.offset(y: labelOffset)
.scaleEffect(labelScale)
.onTapGesture {
isFocused = true
}
}
private func getLabel() -> String {
if label.isEmpty {
return type.description
} else {
return label
}
}
} // floatingLabel

extension Tfield {
@ViewBuilder
func makeErrorMessage() -> some View {
Group {
if case let .invalid(message) = inputState.validity {
Text(message)
.foregroundColor(.red)
.font(.caption)
.padding(.leading)
.offset(y: errorOffset)
.padding(.top, 4)
}
}
}
} // makeErrorMessage

extension Tfield {
@ViewBuilder
func makeStateMessage() -> some View {
Group {
if debugging {
Text("\(type), Req:\(required) : \(inputState.description)")
.foregroundStyle(inputState.debugDescriptionColor)
.font(.caption)
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.leading)
.offset(y: debugOffset)
.padding(.top, 4)
} else {
EmptyView()
}
}
}
} // makeStateMessage (if debugging)

#Preview {
TTestView()
}

extension Tfield {

var labelOffset: CGFloat {
switch inputState {
case .idle where text.isEmpty && TFPrompt.isEmpty: return 0
default: return -32
}
}

var labelScale: CGFloat {
switch inputState {
case .idle where text.isEmpty && TFPrompt.isEmpty: return 1
default: return 0.85
}
}

var errorOffset: CGFloat {
switch inputState {
case .idle where text.isEmpty: return 0
default: return -10
}
}

var debugOffset: CGFloat {
switch inputState {
case .idle where text.isEmpty && TFPrompt.isEmpty: return 0
case .inactive(.valid) where text.isEmpty && TFPrompt.isEmpty: return 0
case .inactive(.invalid) where text.isEmpty && TFPrompt.isEmpty: return 0

default: return -36
}
}
var mainFrameHeight: CGFloat {
var height: CGFloat = 55
if hasError {
height += 30
}
if debugging {
height += 10
}
return height
}

var hasError: Bool { //Computed Boolean. If an error is detected, this will be true
switch inputState {
case .focused(.invalid): return true
case .inactive(.invalid): return true
default: return false
}
}
} // offset calculations for floating elements
import SwiftUI

struct TTestView: View {
@State private var text: String = ""
@State private var text1: String = ""
@State private var text2: String = ""
@State private var text3: String = ""
@State private var text4: String = ""
@State private var text5: String = ""
@State private var text6: String = ""
@State private var text7: String = ""
@State private var text8: String = ""
var body: some View {
Tfield($text, type: .phrase, label: "Hobby")
.textInputAutocapitalization(.never)
Tfield($text1, type: .name, required: true, label: "Enter your Full Name")
.textInputAutocapitalization(.never)
Tfield($text2)
Tfield($text3, type: .expDate)
Tfield($text4, type: .cvv)
Tfield($text5, type: .credit)
Tfield($text6, type: .credit, required: true)

}
}

#Preview {
TTestView()
}

Please let me know what you think. This is my first attempt at anything truly useful, and also my first Medium content, so any feedback would be appreciated.

--

--