Part 2: Swift UI custom Text Field with floating label animation and a validation error prompt.

Simeon Rumyannikov
5 min readJan 27, 2023

--

If you did not read part one, take a look at what we are building:

Note the fist time we type the is no error prompt straight away. We do not want to be as annoying as Xcode! It is only after the user stops editing for the first time do we start to provide instant feedback.

1. Refactor State properties and inject validation logic

Before we kept the user input as a local variable. Now we remove that and pass in a binding. This allows us to delegate text validation to some higher level component.

struct TextInputField: View {

private var title: String
private var prompt: String
private var isValid: Bool

@Binding private var text: String
@State private var height: CGFloat = 0
@State private var isEditing = false

init(_ title: String, prompt: String = "", text: Binding<String>, isValid: Bool) {
self.title = title
self._text = text
self.prompt = prompt
self.isValid = isValid
}

var body: some View {
ZStack.....
}

}

An alternative implementation is to pass a closure or an object that can take the local state as an input and return a bool.

Additionally, we inject a prompt to show the user in case of validation failure.

2. Define Edit State

To handle complex validation logic we will need to track more state, beyond the edditing flag we added in part one.

struct TextInputField: View {

enum EditState {
case idle
case firstTime
case secondOrMore
}

@State private var isEditing = false
@State private var height: CGFloat = 0
@State private var editState: EditState = .idle

var body: some View {
ZStack.....
}

}

The EditState enum is used to keep track of the state of the text field as the user interacts with it. It has three cases: idle, firstTime, and secondOrMore.

I define this enum as a nested enum to keep the namespace clean. We will only need to use it within this view.

ZStack(alignment: .leading) {
Text(title)
.....
.....
TextField("", text: $text) { _ in
withAnimation(.default) { isEditing.toggle() }
switch editState {
case .idle:
editState = .firstTime
case .firstTime:
editState = .secondOrMore
case .secondOrMore:
break
}
}
.padding()
.overlay(...)
.background(... )
  • When the user first starts editing the text field, the editState is set to .idle. The first time the user finishes editing the text field, the editState is updated to .firstTime.
  • When the user starts editing the text field again, the editState is set to .firstTime. The second time the user finishes editing the text field, the editState is updated to .secondOrMore.
  • From then on, regardless of how many times the user starts and stops editing the text field, the editState remains .secondOrMore.

This allows the TextInputField to keep track of how many times the user has edited the text field, and to adjust the behavior of the text field accordingly, for example, show the validation error prompt only after the second or more time the user has edited the text field.

This is acheived using the showValidationErrorPrompt computed property.

struct TextInputField: View {

enum EditState {
case idle
case firstTime
case secondOrMore
}

@State private var isEditing = false
@State private var height: CGFloat = 0
@State private var editState: EditState = .idle


// New stuff
var showValidationErrorPrompt: Bool {
!isValid && (editState == .secondOrMore)
}

var body: some View {
ZStack.....
}

}

The computed property uses a ternary operator to check if the input is not valid and the user has edited the text field for the second time or more, if so the computed property returns true.

Reminder: the isValid property is returned by a higher level component which owns the text state. The isValid variable itself is not marked as a state property. But it is updated every time a new char is entered as the view has a binding to the text property.

3. Add VStack with Text prompt

VStack(alignment: .leading) {
ZStack(alignment: .leading) {
Text(title)
.foregroundColor(text.isEmpty ? Color(.placeholderText) : .accentColor)
.offset(x: text.isEmpty ? 0 : -16, y: text.isEmpty ? 0 : -height * 0.85)
.scaleEffect(text.isEmpty ? 1: 0.9, anchor: .leading)
.padding()
.font(text.isEmpty ? .body: .body.bold())
TextField("", text: $text) { _ in
withAnimation(.default) { isEditing.toggle() }
switch editState {
case .idle:
editState = .firstTime
case .firstTime:
editState = .secondOrMore
case .secondOrMore:
break
}
}
.padding()
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(isEditing ? Color.accentColor : Color(.secondarySystemBackground), lineWidth: 2)
)
.overlay( // new stuff
RoundedRectangle(cornerRadius: 5)
.stroke(showValidationErrorPrompt ? Color.red : Color.clear, lineWidth: 2)
)
.background(
GeometryReader { geometry in
Color(.clear).onAppear {
height = geometry.size.height
}
}
)
}
.background {
Color(.secondarySystemBackground)
.cornerRadius(5.0)
.shadow(radius: 5.0)
}
.animation(.default, value: text.isEmpty)
.animation(.default, value: showValidationErrorPrompt)
// new stuff
if showValidationErrorPrompt {
Text(prompt)
.padding(.leading, 2)
.font(.footnote)
.foregroundColor(Color(.systemRed))
}
}

The error prompt Text view is added as a child view of the VStack. If the showValidationErrorPrompt property is true, the validation error prompt is visible and added to the VStack as a child view, otherwise, it's hidden.

We also add RounderRectangle to the background which is set to red when a validation error occurs.

4. Use the view

We create a form field model to hold the state of the field. You can inject arbitrary validation logic into it.

The Validatable protocol defines two properties, text and validate, and a default implementation of a computed property isValid which calls the validate function with the text as input. None of this is necessary. I am using POP to facilitate code reuse.

struct FormField: Validatable {

var validate: (String) -> Bool

var text: String
let placeholder: String
let prompt: String

init(text: String = "", placeholder: String, prompt: String, validate: @escaping (String) -> Bool) {
self.text = text
self.validate = validate
self.prompt = prompt
self.placeholder = placeholder
}
}

protocol Validatable {
var text: String { get }
var validate: (String) -> Bool { get }
}

extension Validatable {

var isValid: Bool {
validate(text)
}
}

extension Collection where Element: Validatable {
var isValid: Bool {
self.allSatisfy { $0.isValid }
}
}

Initialise the FormField struct and use it to populate the view.

struct InputTextField_Previews: PreviewProvider {

static let emailRegex = /^([a-zA-Z0-9._%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6})*$/

@State private static var emailForm: FormField = FormField(placeholder: "Email", prompt: "Enter a valid email", validate: { $0.validate(with: emailRegex)})

static var previews: some View {
TextInputField(emailForm.placeholder, prompt: emailForm.prompt, text: $emailForm.text, isValid: emailForm.isValid)
}
}

5. Final Tip

Did you notice that the SecureTextField does not have the onEditingChanged parameter? We can use FocusState instead (available from iOS15).

@FocusState private var isFocused: Bool

Focus is assigned like so:

SecureField("", text: $text)
.focused($isFocused)

Full code:

--

--