EP 131 · Concise Forms · Jan 18, 2021 ·Members

Video #131: Concise Forms: SwiftUI

smart_display

Loading stream…

Video #131: Concise Forms: SwiftUI

Episode: Video #131 Date: Jan 18, 2021 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep131-concise-forms-swiftui

Episode thumbnail

Description

For simple forms, vanilla SwiftUI does a really good job: you can quickly build a form with many components in minimal code and boilerplate. But as a form becomes more complex, SwiftUI stops being so helpful. Let’s explore the problems that emerge and how we can work around them.

Video

Cloudflare Stream video ID: 6acab47a12332bda4e8a7782a4e8f475 Local file: video_131_concise-forms-swiftui.mp4 *(download with --video 131)*

Transcript

0:05

Today we are going to focus on a small, but common problem when building applications and that is building forms. By forms we just mean a screen with lots editable fields, such as text fields, toggles, pickers, sliders and more. Such screens are very common for things like settings, log in, sign up and more.

0:29

It turns out that for simple, straightforward forms, vanilla SwiftUI does a really good job. There’s basically no boilerplate, and you can handle many form components at once with very little code. However, as the behavior of the form gets more complex, SwiftUI stops being as helpful, and you are forced to do some workarounds that are less than ideal.

0:51

These new complexities are what will drive us to look at what the Composable Architecture has to say about forms, but unfortunately the architecture has a few problems of its own when it comes to forms. Most notably, there is a bit of boilerplate involved.

1:04

However, it doesn’t have to be that way. Turns out there are some fancy tricks we can employ to regain all the conciseness that SwiftUI gives us while allowing us to build the feature in the Composable Architecture so that we get all of its benefits, such as explicit side effects and testability. Form view hierarchy

1:21

Let’s start by exploring the problem space of forms by just using plain, vanilla SwiftUI.

1:33

We have a fresh SwiftUI Xcode project waiting for us, and we’re going to create a new file for the vanilla SwiftUI version of a form. We can get some basic scaffolding in place for the view: import SwiftUI struct VanillaSwiftUIFormView: View { var body: some View { } }

1:46

At the root of this view we will use a Form view, which according to the SwiftUI documentation is: Note A container for grouping controls used for data entry, such as in settings or inspectors. struct VanillaSwiftUIFormView: View { var body: some View { Form { } } }

2:01

Any UI controls you put in this view will be automatically laid out in a nice, settings-style format without you having to do any extra work. For example, we can throw in a text field: TextField("Display name", text: .constant("")) For right now we are just using a constant binding because we don’t have anywhere to send the user’s changes, but we will get to that soon.

2:35

We could also throw in a few toggles: Toggle("Protect my posts", isOn: .constant(false)) Toggle("Send notifications", isOn: .constant(false))

2:55

SwiftUI has nicely formatted our UI into a grouped list of UI controls, and it’s even automatically put everything in a scroll view so that we can scroll this content around.

3:13

On the other hand, if we had just use a simple VStack for our root view we would see that we get something much less appealing: VStack { … }

3:33

So this is really impressive. Even cooler, there are certain views that change their behavior based on what kind of view they are embedded in. One such example is the Section view. According to the docs its a view that provides: Note An affordance for creating hierarchical view content.

3:58

There are a few places this can be used, such as context menus that show when you long press a UI element. The Section view allows you to group actions in the menu and provide little headers and footers for those sections.

4:13

When used in forms you get something similar. For example, we could wrap our UI controls in a few sections: Section(header: Text("Profile")) { TextField("Display name", text: .constant("")) Toggle("Protect my posts", isOn: .constant(false)) } Section(header: Text("Communication")) { Toggle("Send notifications", isOn: .constant(false)) }

4:42

And we immediately get an even nicer UI with more contextual information. These sections allow us to break up a long list of settings into something easier for the user to scan and find the things they are interested in.

4:55

Let’s add one more UI control this settings screen, something a little more complicated. Suppose that when you turn notifications on you are further given an option to get a periodical digest of activity. This could be a on a daily or weekly basis, or perhaps you don’t want the digest at all. We can model this with an enum: enum Digest { case daily case weekly case off }

5:24

Then, there is a UI control that allows us to present the user with a finite set of values to choose from. It’s called a Picker view, and we can start by giving the picker a label and we need to provide a binding, which we can start with just a constant binding that is stuck on Digest.off : Picker( "Top posts digest", selection: .constant(Digest.off) ) { }

5:54

And then in this Picker closure we just need to create a view that represents each option the user can choose form. In particular, the enum has three options, so we can just write them out: Text("daily") Text("weekly") Text("off")

6:11

Something interesting happens when we do this. A new row in the settings screen is shown, and on the left it has the label of our picker control, and on the right it has the currently selected digest along with a little disclosure chevron, which typically indicates that there is more content that can be accessed by tapping on that row.

6:30

However, clicking on this row seems to do nothing, and the row even looks a little greyed out, as if it is disabled.

6:39

Well, turns out when you embed a picker view inside a Form it automatically gives you the functionality of being able to drill down to make a selection, but that machinery only works if your form is further embedded in a NavigationView . So let’s wrap our preview in one: struct VanillaSwiftUIForm_Previews: PreviewProvider { static var previews: some View { NavigationView { VanillaSwiftUIFormView() } } }

7:02

Now the button is magically enabled, and if you click on it you will drill down to a screen that allows you to choose from the digest options.

7:09

We can even give the form a title: var body: some View { Form { … } .navigationTitle("Settings") }

7:29

It’s worth taking a moment to revel at just how awesome this is. With very few lines of code we have built a decently complex form that would be an absolute pain to recreate in UIKit. Even better, SwiftUI views are really smart about the context they are embedded in. As soon as you put a picker view inside a form it immediately acts as a navigation link so that you can drill down for the choices. It’s pretty impressive.

7:52

Form state management

7:52

But, currently tapping on any of these doesn’t do anything. That’s because although we have taken care of the view hierarchy part, we haven’t done any state management so that changes to these UI controls actually update a model that drives this UI. This is what all those bindings are about, which are currently hard coded to constant bindings. So, let’s look at what it takes to put in something better for those bindings.

8:28

To do this we need to introduce some state to our application. There are multiple ways of doing this in SwiftUI, such as using @State , @StateObject , @ObservedObject and more, but the one that is most appropriate for this screen is @ObservedObject . This is because this screen does not own this settings state. It wants to make changes to the state, but other parts of the application could need access to this state.

8:55

So, we can start by getting some scaffolding in place for a basic view model that conforms to the ObservableObject protocol: class SettingsViewModel: ObservableObject { }

9:18

In this class we want to hold all the state that represents our settings screen, and we want to mark them with the @Published property wrapper so that any changes to to state are automatically reflected in the view: @Published var digest = Digest.off @Published var displayName = "" @Published var protectMyPosts = false @Published var sendNotifications = false

9:52

Then we can use this view model in our view by requiring that an instance be passed in upon constructing the view: struct VanillaSwiftUIFormView: View { @ObservedObject var viewModel: SettingsViewModel … }

10:06

This breaks our preview, but it’s easy enough to fix: VanillaSwiftUIFormView( viewModel: SettingsViewModel() )

10:16

Now we have a view model injected into our view, and that view model can encapsulate the behavior of our view, but we haven’t hooked anything up yet. The primary way in which we feed user actions into the view model in order to update state, such as typing into the text field or switching a toggle, is via bindings. They are a two-way communication mechanism that allow changes to state to be instantly reflected in the view, and conversely user actions to instantly make changes to the model.

10:44

SwiftUI comes with a very convenient API for deriving bindings from an observable object. If we type self.$viewModel we will get access to the underlying object that sits inside the ObservableObject property wrapper. Then, when we chain onto that object we get bindings for the field: self.$viewModel.displayName . This is the binding that can be handed to the TextField view for the display name: TextField("Display name", text: self.$viewModel.displayName)

11:15

This little bit of code makes sure that our view and model are always in sync with each other. Any change made in one will instantly be reflected in the other, and that’s an amazing power that SwiftUI has given us. Typically coordinating synchronization can be very difficult to get right.

11:28

We can do this binding trick with our other controls too, such as the protected posts toggle: Toggle("Protect my posts", isOn: self.$viewModel.protectMyPosts)

11:38

As well as the notifications toggle: Toggle("Send notifications", isOn: self.$viewModel.sendNotifications)

11:44

And finally the digest picker view: Picker( "Activity digest", selection: self.$viewModel.digest ) { Text("daily") Text("weekly") Text("off") }

11:49

And just like that we will see that if we run the preview we can now type into the text field, and toggle the switches, but the picker doesn’t seem to work. This is because we need to do a little extra work to be able to associate each of these views with an actual piece of data that we can use. This can be done explicitly by using the .tag view modifier: Text("daily") .tag(Digest.daily) Text("weekly") .tag(Digest.weekly) Text("off") .tag(Digest.off)

12:45

This tag is what is used to update the binding that we handed the Picker view upon creation. And when we run things, we can see the digest setting updated when we pick a new one.

13:00

But before moving on, there is a trick we can employ to make this code much shorter and more succinct. There is a view called ForEach that allows you to render a view for each item in a collection. The collection we want is the array that holds all of the cases of the Digest enum, which we can get immediate access to if we just have the enum conform to the CaseIterable protocol: enum Digest: CaseIterable { … }

13:24

Then we can do the following to create a view representing each digest option: ForEach(Digest.allCases, id: \.self) { digest in }

14:00

In here we’ll just construct a text view to represent the item, but in order to do that we need to be able to turn a digest value into a string. That can also be immediately done by having the enum be backed by a string: enum Digest: String, CaseIterable { … }

14:14

And now we can do the following: Picker( "Top posts digest", selection: self.$viewModel.digest ) { ForEach(Digest.allCases, id: \.self) { digest in Text(digest.rawValue) } }

14:27

Now that we’ve seen that user actions can drive the state of our view model, let’s make sure it works the other way, that is changes to the model are coordinated with the UI. Let’s create a button that will reset the state of the form. We can add it to the bottom of the form, outside of any section: Button("Reset") { }

14:48

In this action closure we could just reset all the fields of the view model, but better might be to invoke a method on the view model so that it can encapsulate that logic: func reset() { self.displayName = "" self.protectMyPosts = false self.sendNotifications = false self.digest = .daily } … Button("Reset") { self.viewModel.reset() }

15:12

And now when we press the reset button everything resets back to its default state. One strange thing is that the toggles reset immediately with no animation. When we tap on the toggles they animate to the on state, but when we reset they don’t seem to have an animation.

15:30

This is definitely a problem that can be solved, but we’ll save the animations discussion for a future episode.

15:37

One last thing we could do to our form to improve the user experience is to only show the digest row when notifications are turned on: if self.viewModel.sendNotifications { Picker( "Activity digest", selection: self.$viewModel.digest ) { ForEach(Digest.allCases, id: \.self) { digest in Text(digest.rawValue) } } }

16:02

Now by default the picker control is hidden, but if we flip on notifications we will see it comes into view, but without any animation.

16:09

We again want to reiterate how amazing it is that we were able to build this settings screen with such little code. SwiftUI is really carrying a lot of weight for us, and gives us some really nice tools for state management and view construction. Advanced form logic

16:19

But things break down a little bit when the logic for the screen isn’t so straightforward. Right now we are pretty lucky each control simply mutates a field directly with no additional logic. But what if we want to further validate or mutate the model after the binding did its work? Or what if we wanted to execute some side effects, such as asking the user for push notification permissions when they flip on the notifications toggle? Let’s explore how some of that functionality can be added to our current application.

17:03

Let’s start with some data model validation. We are going to do something simple. We are going to force the display name to be at most 16 characters. So as soon as a mutation is made to the displayName field we will want to truncate it.

17:19

Perhaps the simplest way to achieve this is to tap into the didSet callback on our displayName property so that we can do the truncation logic: @Published var displayName = "" { didSet { self.displayName = String(self.displayName.prefix(16)) } }

17:51

However, when we run this the preview crashes!

17:58

This is because we have accidentally snuck in an infinite loop, where the displayName is changed, causing the didSet to be executed, causing the displayName to be changed, causing the didSet to be executed, and so on…

18:11

So, we have to be extra careful when making mutations inside a didSet . A workaround for this situation is to put in a guard before doing the mutation: guard self.displayName.count > 16 else { return } self.displayName = String(self.displayName.prefix(16))

18:30

And now it works as we expect.

18:36

Let’s amp the complexity up a bit more. Let’s actually ask the user for notification permissions when they toggle that setting on. And if they user tries turning on that setting after having previous denied us, then we should show a prompt letting them know what they can do to remedy the situation.

19:08

Adding notifications to an application is quite complex, so we aren’t going to fully implement this feature in this episode, but we’ll get the beginnings of it. We want to start by tapping into when the sendNotifications value is toggled. In particular, when it is set to true we want to request authorization to the user’s notifications. So let’s start by implementing the didSet callback for that field, and guarding to make sure that notifications were flipped on: @Published var sendNotifications = false { didSet { guard self.sendNotifications else { return } } }

20:10

If we make it past the guard it means the user wants to give us access to notifications, and so we can request it: import UserNotifications … UNUserNotificationCenter.current() .requestAuthorization(options: .alert) { granted, error in }

20:53

Then inside here we need to handle two situations. If the user did not grant us access or if there was an error, we should reset the sendNotifications boolean back to false : if !granted || error != nil { self.sendNotifications = false }

21:32

We can also test this logic to make sure it works, but unfortunately we can no longer use previews. Turns out the UserNotifications framework largely does not work in Xcode previews, and so we must run it in the simulator. There’s even certain functionality of the UserNotifications framework that doesn’t work in the simulator and so must even be run on a real device.

22:02

So let’s set up our application to render this form view: @main struct ConciseFormsApp: App { var body: some Scene { WindowGroup { NavigationView { VanillaSwiftUIFormView(viewModel: SettingsViewModel()) } } } }

22:35

When we run this on the simulator we will see that if we toggle notifications on we get a prompt asking us for permissions, so it seems to be working, and when we allow permissions it stays in its “on” state.

22:50

Let’s try the unhappy path, where we deny access. In order to get that prompt back we have to delete and reinstall the app since we’ve already allowed permission.

23:04

When we tap “Don’t Allow” it untoggles things, but we also get a runtime warning letting us know that we have made an update to our observable object from a background thread, and that is not allowed: Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

23:19

This must mean that requestAuthorization invokes its callback on a background thread, and so we’ve got to further wrap this work in a DispatchQueue.main.async to get it back on the main thread: if !granted || error != nil { DispatchQueue.main.async { self.sendNotifications = false } }

23:39

Now we want to test this again, so we’ll delete and reinstall the app once again.

23:47

And now when we run and toggle the field we can deny permissions and we no longer get the warning, and the toggle switched back to the off state.

23:52

If we try to toggle the setting on again we will see a funny little quirk where the digest picker starts to animate into view, then the toggle is immediately switched off, and the digest picker peeks into view.

24:07

This is happening because the sendNotifications binding is being set to true and then is being almost immediately set to false a tick later.

24:33

A better user experience would be to not update that binding till we know what’s going on, like whether or not the user has previously denied authorization. But then even further we should also show a prompt to the user letting them know they have not given us permissions for notifications and so they can only fix this by going into iOS settings.

24:58

Let’s tackle this second problem first. When the push notification setting is flipped on we should check if the user has previously denied permissions so that we can show an alert. The API that does this is .getNotificationSettings , which works in an async fashion by invoking a callback you hand to it with the settings. This means we need to wrap all of our notification code in another scope for this callback: UNUserNotificationCenter.current() .getNotificationSettings { settings in UNUserNotificationCenter.current() .requestAuthorization(options: .alert) { granted, error in … } }

25:43

It’s a bummer that we have to incur yet another indentation for this logic, but that’s the reality when dealing with callback closures.

25:49

Once we get the settings we need to check if we were denied, for if we were we can early out of the function and switch the sendNotifications boolean back to false : guard settings.authorizationStatus != .denied else { // TODO: show alert self.sendNotifications = false return } And it’s inside this guard where we can put the logic for the alert.

26:16

To start, we need to introduce some state to the view model that represents the alert being shown. This state will be optional, because nil represents that there is no alert to show and a non- nil value tells SwiftUI to show an alert. Also the type of value held in this state needs to conform to Identifiable , which means most of the time we will have to create a custom type to represent this data.

26:30

Let’s introduce a simple struct that just holds a string for the title of the alert we want to display: struct AlertState: Identifiable { var title: String var id: String { self.title } } If the alert needed more information, like a message, then we would add more fields to this struct.

27:04

Then we will hold onto another @Published property in our view model to represent the alert: @Published var alert: AlertState?

27:10

In the view model we can flip the alert to a non- nil value when we detect that permissions were previously denied: self.alert = .init( title: "You need to enable permissions from iOS settings" )

27:20

Then in the view we can use the .alert modifier to tell SwiftUI to show an alert whenever this field flips from nil to non- nil : .alert(item: self.$viewModel.alert) { alert in Alert(title: Text(alert.title)) }

27:45

If we run this in the simulator and try to turn notifications on we will see an alert appear immediately letting us know that we need to enable notifications in iOS settings. An even better user experience would be if we provided a button to automatically bump them to settings, which is straightforward but not really our focus right now.

28:03

Although the alert works, there’s still that weird glitch. The alert showing helps distract us from it a bit, but it’s still definitely visible. The reason this is happening is because the sendNotifications boolean is flipped to true when the user taps on the toggle, and then a brief moment later we get the callback from UNUserNotifications telling us what the settings are. That little bit of asynchrony is just enough time for SwiftUI to start doing an animation.

28:27

Let’s scroll back to our view model to fix this, but it looks like we have another problem, when we check the user’s notification settings, this block too is run on a background thread, so we need to dispatch any mutations to the view model to the main queue. guard settings.authorizationStatus != .denied else { DispatchQueue.main.async { self.alert = .init( title: "You need to enable permissions from iOS settings" ) self.sendNotifications = false } return }

28:51

We could try using forcing that API call to be synchronous using semaphores or something, but that’s probably dangerous because we don’t actually know how long that API takes to execute. It’s definitely pretty fast, but we just don’t know for sure.

29:08

The only other way we know how to address this is to forgo the nice binding helpers that SwiftUI gives us from observable objects, and instead write the toggle’s binding from scratch. This will give us an opportunity to execute some view model logic before turning the switch on, which will avoid that glitch.

29:35

So let’s go back to where we pass a view model-derived binding to the toggle and instead write one from scratch. A binding is quite simple. It only has a get and set closure that needs to be provided. So we could recreate the current binding by explicitly getting and setting a property under the hood: isOn: Binding( get: { self.viewModel.sendNotifications }, set: { isOn in self.viewModel.sendNotifications = isOn } ) // self.$viewModel.sendNotifications

30:08

But we don’t want to eagerly do this work. Instead, we can invoke a method on the view model that does some extra logic before flipping sendNotifications to true : isOn: Binding( get: { self.viewModel.sendNotifications }, set: { self.viewModel.attemptToggleSendNotifications(isOn: $0) } )

30:36

Now we just need to implement a method on the view model: func attemptToggleSendNotifications(isOn: Bool) { }

30:42

A lot of the logic is the same, so we’ll cut and paste it from the didSet block. We no longer need to guard that the current value is true, but we do need to guard that isOn is: guard isOn else { self.sendNotifications = false return }

31:11

We also no longer need to flip sendNotifications to false when permission has been previously denied, because we no longer eagerly set it to true : guard settings.authorizationStatus != .denied else { DispatchQueue.main.async { self.alert = .init( title: "You need to enable permissions from iOS settings." ) } return }

31:32

Once that check is done we can optimistically flip the sendNotifications field to true since we are in the process of trying to get permission from the user. However, that too must be done on the main thread and in an animation block: DispatchQueue.main.async { self.sendNotifications = true }

31:48

And everything else can remain the same.

32:11

When we run this in the simulator and try turning on notifications we will see that we still get an alert, but there is now no glitch. The toggle doesn’t budge at all.

32:33

Before moving on let’s finish our authorization logic, because if authorization is granted we should register for remote notifications: UNUserNotificationCenter.current() .requestAuthorization(options: .alert) { granted, error in if !granted || error != nil { DispatchQueue.main.async { self.sendNotifications = false } } else { UIApplication.shared.registerForRemoteNotifications() } }

33:06

So that’s the basics of building a moderately complex settings. We are leveraging the power of SwiftUI and the Form view to get a ton done for us basically for free. It’s super easy to get a form up on the screen, and easy for changes to that form to be instantly reflected in a backing model.

33:28

However, we did uncover a few complexities that arise when dealing with something a little bit more realistic, and not just a simple demo. For one thing, we saw that if we need to react to a change in the model, such as wanting to truncate the display name to be at most 16 characters, then we had to tap into the didSet of that field. However, it is very dangerous to make mutations in a didSet . As we saw it can lead to infinite loops, and it can be very difficult to understand all the code paths that can lead to an infinite loop. It’s possible for the didSet to call a method, which calls another method, which then mutates the property, and then bam… you’ve got an infinite loop on your hands.

34:06

Another complexity is that if you want to do something a little more complicated when the form changes, such as hook into notification permissions, then you are forced to leave the nice confines of ergonomic magic that SwiftUI provides for us. We needed to write a binding from scratch just so that we could call out to a view model method instead of mutating the model directly. Doing that wasn’t terrible, we just think it’s worth pointing out that working with more real world, complex examples you often can’t take advantage of SwiftUI’s nice features, and gotta get your hands dirty. Next time: the Composable Architecture

34:40

So, we’ve learned a lot about what SwiftUI has to say about forms, but what about the Composable Architecture?

34:46

As most of our viewers probably already know, the Composable Architecture is a library for building applications with a focus on modularity, side effects and testing, and it’s something we built from scratch over the course of many episodes and then finally open sourced more than 6 months ago.

35:03

We would love to say that the Composable Architecture makes dealing with complex forms a breeze, but unfortunately that isn’t quite right. While the Composable Architecture can improve on some of the pain points that we saw with the vanilla SwiftUI code, it sadly is more tedious when it comes to the things that SwiftUI excels at.

35:22

However, it doesn’t have to be that way! Let’s start by naively converting this feature over to the Composable Architecture to see where the tedium comes from, and then see how we can fix it…next time! Downloads Sample code 0131-concise-forms-pt1 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .