SwiftUI Form tutorial for beginners

Learn how to create a form using the SwiftUI Form. Creating forms is easy using SwiftUI.

SwiftUI Form tutorial for beginners

In todays tutorial we will learn how to use SwiftUI's built in Form view. Forms are one of the most common features of an app, whether it is a login/signup view, profile view or any other view where a user can edit some kind of information.

SwiftUI makes Form creation incredibly simple and quick to build functional forms in no time at all.

In this tutorial we will build user profile form. It will include information such as name, location, reset password etc. This will allow us to use some common views that a user might expect to see when filling in their information in a normal app.

Step 1: Create the Form

As I mentioned in the intro, creating a Form with SwiftUI is incredibly simple. Update code in your ContentView to look like the below:

struct ContentView: View {
    var body: some View {
        NavigationView {
            Form {
                // Form fields go here
            }.navigationBarTitle(Text("Profile"))
        }
    }
}

All that we done here is remove the default "hello world" Text view, and replace it with a NavigationView so that we can set a .navigationBarTitle, and a Form view which is needed for our form fields in the steps that follow.

Step 2: Add First and Last name TextFields to the Form

We can now add some of our first form fields. We will add two TextField views for Firstname and Lastname.

Before we add the TextField, lets add our @State properties that we will use for the Firstname and Lastname.

Add the following code above var body: some View :

@State private var firstname = ""
@State private var lastname = ""

We need these so that we can bind them to our TextField views.

Next we need to add those TextField views to our Form. To do that, update your body property code to look like this:

var body: some View {
    NavigationView {
        Form {
            TextField("Firstname",
                      text: $firstname)
            TextField("Lastname",
                      text: $lastname)
        }.navigationBarTitle(Text("Profile"))
    }
}

If you build and run the app, you will see the following:

Step 3: Add location Picker

We want the user to be able to select a location from a provided data source. This could come from an api request, but for now we will use a struct that has a static property which will contain all the locations.

Let's first create the Location struct. Add the following outside of the main ContentView:

struct Location {
    static let allLocations = [
        "New York",
        "London",
        "Tokyo",
        "Berlin",
        "Paris"
    ]
}

As said before, this is just a static property which a String array which contains all the locations that we need for our Picker view.

Next we need to have another @State property which can store the selected location. To do this, add a new @State property below the lastname property we created before. This is what the property will look like:

@State private var location = ""

Now that we have our data source and location state, let's add our Picker view below the TextField views we added in our previous step.

To do that, add the following code below our Lastname TextField:

Picker(selection: $location,
       label: Text("Location")) {
        ForEach(Location.allLocations, id: \.self) { location in
            Text(location).tag(location)
        }
}

We bind the Picker to our location state. When the Picker's value changes, it will update the location property.

We also have a ForEach that will loop through all the locations that we have in our data source.

If you build and run the app now, you will see this:

If you tap on the Picker view, you will see the following:

When we tap on one of the choices it will update our location property and navigate back to the Profile view. Because the location property has been updated, it will show on the Picker view next to the Location text.

Our next step is to allow the user to accept our terms and conditions.

Step 4: Add Terms and Conditions Toggle view

As with all the other fields that we have added, we need to add some @State so that we can bind the Toggle view to it.

Let's add a termsAccepted property below the location property we added in the previous step:

@State private var termsAccepted = false

This needs to be false because we cannot accept the terms and conditions on behalf of our users.

We now need to add our Toggle view. This will go below the Picker view from the previous step.

To add the Toggle, add the following code:

Toggle(isOn: $termsAccepted,
       label: {
           Text("Accept terms and conditions")
})

As with the previous steps, we bind the view to a property, in this case termsAccepted. We then need to add a label, this is a simple Text view.

If you build and run the app, it will look like this:

Step 5: Add a Stepper view for Age

This is the last input field for this section of Form. Once again, we need to add some state, so lets do that by adding a new property called age

Add the following code below the termsAccepted property:

@State private var age = 20

I have just defaulted this to 20, but the age range will be from 18-100, we will add that in with the next bit of code:

Stepper(value: $age, 
        in: 18...100, 
        label: {
    Text("Current age: \(self.age)")
})

We bind the Stepper view to our age property, we then provide an age range, in this case we will allow the ages from 18 to 100, and lastly we add a Text view that will display the current age value.

If you run the app, you will see the following:

If you click the - and + the age will decrement and increment within the range that we provided.

Step 6: Add "Update Profile" Button

This Button won't really do anything besides print that the profile has been updated.

Add the following code below the Stepper we added previously:

Button(action: {
    print("Updated profile")
}, label: {
    Text("Update Profile")
})

That was simple enough. But we don't want this button to be visible unless our Form is valid, so let's add a validation method to our ContentView.

For our validation we want to ensure that the Firstname and Lastname fields are not empty, we also want to make sure that the user has accepted our terms and conditions and lastly the user needs to select a location.

To do this add the following code below the body property:

private func isUserInformationValid() -> Bool {
    if firstname.isEmpty {
        return false
    }
    
    if lastname.isEmpty {
        return false
    }
    
    if !termsAccepted {
        return false
    }
    
    if location.isEmpty {
        return false
    }
    
    return true
}

If any of the fields are not valid according to the above rules, we will return false, otherwise we will return true.

Let's wrap our Button so that it only shows when our Form is valid.

Update the Button code to look like this:

if self.isUserInformationValid() {
    Button(action: {
        print("Updated profile")
    }, label: {
        Text("Update Profile")
    })
}

Every time one of the fields change, this will check to see if our Form is valid and if it is, it will show the Button otherwise it will not be rendered.

If you build and run the app, you will see the following:

Once you fill in all the fields you will see the "Update Profile" button, like this:

Step 7: Add a Section to the Form

Before we add the reset password section to the Form we need to wrap all the above fields in a Section.

To do this, replace the body property with the following code:

var body: some View {
    NavigationView {
        Form {
            Section(header: Text("User Details")) {
                TextField("Firstname",
                          text: $firstname)
                TextField("Lastname",
                          text: $lastname)
                Picker(selection: $location,
                       label: Text("Location")) {
                        ForEach(Location.allLocations, id: \.self) { location in
                            Text(location).tag(location)
                        }
                }
                
                Toggle(isOn: $termsAccepted,
                       label: {
                        Text("Accept terms and conditions")
                })
                
                Stepper(value: $age,
                        in: 18...100,
                        label: {
                    Text("Current age: \(self.age)")
                })
                
                if self.isUserInformationValid() {
                    Button(action: {
                        print("Updated profile")
                    }, label: {
                        Text("Update Profile")
                    })
                }
            }
        }.navigationBarTitle(Text("Profile"))
    }
}

All we have done now is add the Section view with a header of User Details. So when we run the app now, we will see the following:

It is a very subtle difference, but there is now a User Details heading just below the Profile title.

Step 8: Add the reset password Section

Just below the above Section that we added, add a new Section with the heading "Password". The code will look like this:

Section(header: Text("Password")) {
    // Fields go here
}

We now need to add three @State properties and one local constant.

Add the following properties below the age property that we added in Step 5:

private let oldPasswordToConfirmAgainst = "12345"
@State private var oldPassword = ""
@State private var newPassword = ""
@State private var confirmedPassword = ""

We will bind the three @State properties to SecureField views, and we will use the oldPasswordToConfirmAgainst to mock validate that the oldPassword is our "previous" password.

Inside the new password Section, add the following code:

SecureField("Enter old password", text: $oldPassword)
SecureField("New Password", text: $newPassword)
SecureField("Confirm New Password", text: $confirmedPassword)

Each of the SecureField views have been bound to one of the @State properties that we added.

If you build and run the app now, you will see the following:

The next thing that we need to add is our "Update Password" Button. To do that, add the following code below the SecureField views we just added:

Button(action: {
    print("Updated password")
}, label: {
    Text("Update password")
})

Once again, this Button does nothing besides print "Updated Password". If you build and run the app now you will see the button, but we don't want the button to show unless the password fields are valid.

Let's add the password validation method so that the "Update password" Button only shows when the password fields are valid.

Add the following validation code below the `isUserInformationValid` method that we added for the User Details validation:

private func isPasswordValid() -> Bool {    
    if oldPassword != oldPasswordToConfirmAgainst {
        return false
    }
    
    if !newPassword.isEmpty && newPassword == confirmedPassword {
        return true
    }
    
    return false
}

To ensure that the password fields are valid, we check to see if the oldPassword is not equal to the oldPasswordToConfirmAgainst. If it is not the same we return false.

We will then make sure that newPassword is not empty, and we check to see if newPassword and confirmedPassword is the same, if they are the same, we return true. Otherwise we return false.

Let's update the Button to only show when the password fields are valid. To do that, replace the current Button that you have with the following:

if self.isPasswordValid() {
    Button(action: {
        print("Updated password")
    }, label: {
        Text("Update password")
    })
}

If you build and run the app now, nothing will be visually different from the previous time we you ran the app.

We now need to type "12345" into the "Enter old password" field, and then we need to enter the same password in the "New Password" and "Confirm Password" SecureField views.

Once you have done that, you will see the following:

Step 8: Move text fields when keyboard shows

Ok, in the previous step I did not have a keyboard showing up as I was using my mac keyboard. Unfortunately, doing this leaves a massive flaw in this form. The flaw is that if the user is on a device the keyboard will cover the text fields, so let's fix that.

The first thing we need to do is to wrap the NavigationView in a VStack. I am not going to show all the code for this, but this is the structure now:

var body: some View {
    VStack {
        NavigationView {
            Form {
                ...
            }
        }
    }
 }

Now that we have the VStack added we add in the code that will allow us to see the textfields when the keyboard is showing.

To move the text fields up we are going to need to get the keyboard height, for this functionality we need to add the following property below confirmedPassword:

@State private var keyboardOffset: CGFloat = 0

Now let's set the offset on the NavigationView. To do this, add the offset modifier to the NavigationView:

Your code should look like this:

var body: some View {
    VStack {
        NavigationView {
            Form {
                ...
            }
        }.offset(y: -self.keyboardOffset)
    }
 }

As you can see, the offset modifier uses the keyboardOffset property that we added.

We have two more things to do, firstly we need to add the code that will listen for the notification that the keyboard sends when it shows and hides.

We will add listeners for these in the onAppear modifier, update your code to look like this following:

var body: some View {
    VStack {
        NavigationView {
            Form {
                ...
            }
        }.offset(y: -self.keyboardOffset)
        .onAppear {
                NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidShowNotification,
                                                       object: nil,
                                                       queue: .main) { (notification) in
                                                        NotificationCenter.default.addObserver(
                                                            forName: UIResponder.keyboardDidShowNotification,
                                                            object: nil,
                                                            queue: .main) { (notification) in
                                                                guard let userInfo = notification.userInfo,
                                                                    let keyboardRect = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
                                                                
                                                                self.keyboardOffset = keyboardRect.height
                                                        }
                                                        
                                                        NotificationCenter.default.addObserver(
                                                            forName: UIResponder.keyboardDidHideNotification,
                                                            object: nil,
                                                            queue: .main) { (notification) in
                                                                self.keyboardOffset = 0
                                                        }
                }
            }
    }
 }

This looks like a lot of code, but it really isn't. We are adding observers for two notifications. The first being for when the keyboard shows, UIResponder.keyboardDidShowNotification, and the second for when it hides, UIResponder.keyboardDidHideNotification.

When we listen for UIResponder.keyboardDidShowNotification, we are doing more than when we listen for the keyboard hiding. When the keyboard shows we need to get the userInfo, if that is not nil we will try and get the value for UIResponder.keyboardFrameEndUserInfoKey, which, at the same time we will try and cast as a CGRect. This key holds the rectangle information for the keyboard.

If this data is not nil and we can cast it, we set the keyboardOffset to the height that we get from keyboardRect.

When we listen to see when the keyboard closes, all we need to do is set keyboardOffset to 0.

Lastly we need to change the background color of the VStack. To do that, add the following modifier to the VStack:

.background(Color(UIColor.systemGray6))

This will stop there being a white rectangle showing up just above the keyboard.

Conclusion

Even though this was quite a long post, it is actually very simple to create a Form with SwiftUI.

If you want to find the full source code for this post, you can find it here.