@FocusState, TextFields, TextEditor in SwiftUI 3.0

I've recently been working with SwiftUI on a couple of projects. One major area of improvement in SwiftUI 3.0 (the Autumn 2021 release) is around form handling. We now have a PropertyWrapper @FocusState which allows us to track the focus state of a form elements. We also have a bunch of handy new View extensions which now means you can actually create somewhat decent form handling. That this, at least out of the box/without lots of ugly hacks, is now possible is a huge step forwards. SwiftUI still might be pretty buggy but at least we can build most standard kinds of apps with it now (assuming iOS 15+, MacOS 12+).

How to create a form you won't hate with SwiftUI

Like most SwiftUI things it is hard to find documentation. Apple's is lamentably bad and much of the community stuff refers to older versions of SwiftUI (which you would probably need to use for many enterprise projects, in order to support iOS 14). Here is an approach I ended up with recently that handles next-on-enter, validation and the correct on-screen keyboard enter button labelling (i.e. the basics of a decent form). I still didn't find a good approach for focusing an item by default (the obvious things didn't seem reliable) but that isn't such a bad thing in my case as the on-screen keyboard might obscure the intro text.

Inside your View struct create something like:

struct AddCourse: View {
  enum Field: Hashable {
    case title, author, description

Then for handling next fields add something like this property for next focus (you could optimise slightly by making the enum CaseIterable and choosing next item if available, for 3 items this is not worth it(?) but if you want to generalise I'd probably go with it):


    var nextField: Field? {
      switch self {
      case .title:
        return .author
      case .author:
        return .description
      case .description:
        return nil
      }
    }

We'll also want a label for the enter button. You can do it like (again with CaseIterable you could generalise):

    var label: SubmitLabel {
      switch self {
      case .description:
        return .done
      default:
        return .next
      }
    }
  }

Okay, so how to use? For TextFields I created a method on the View struct:

func ACTextField(title: String, text: Binding<String>, field: Field, focus: FocusState<AddCourse.Field?>.Binding) -> some View {
    TextField(title, text: text)
      .onSubmit {
        focus.wrappedValue = field.nextField
      }
      .submitLabel(field.label)
      .focused($focused, equals: field)
      .niceTextField(invalid: text.wrappedValue.isEmpty && didTryToSave)
  }

This is doing all the key things, on enter go to next field (or clear focus):

.onSubmit {
  focus.wrappedValue = field.nextField
}

Use the appropriate enter button label:

.submitLabel(field.label)

Determine whether is is focused:

.focused($focused, equals: field)

The visual helper niceTextField is just:

  func niceTextField(invalid: Bool = true) -> some View {
    padding(8)
      .background(Colours.g050)
      .rounded()
      .overlay(RoundedRectangle(cornerRadius: Config.cornerRadius).stroke(invalid ? Colours.a400 : Color.clear))
      .padding(.horizontal)
  }

You could create a TextFieldStyle however this is specific to TextFields and I wanted some multiline text editing, so a View extension seems a little better.

TextEditor

There are lots of minor visual inconsistencies with the built in thing for multiline text editing. And worse it applies a background based on theme, that messes up any attempts to customise. To stop that at least, put this somewhere:

UITextView.appearance().backgroundColor = .clear

Now to create the field:

TextEditor(text: $description)
  .focused($focused, equals: .description)
  .niceTextField(invalid: description.isEmpty && didTryToSave)
  .frame(height: 120)

It is one off. By default it will expand vertically (so the frame height constrains this). I wanted to use the same appearance so used the View extension again.

The validation uses everywhere is pretty simple (checking the user entered something); probably you would want to both extract this to a separate function and add useful messages to the user. I also added something simple to focus on the first field with a validation issue, if any on attempting to save. That is all pretty basic stuff but prior to Swift 3.0 there was no good way to do it. The validation should also be done in a way that is accessible; this is not.