10.
More User Input & App Storage
Written by Antonio Bello
In the last two chapters, you learned how to use state and how easy it is to make the UI react to state changes; you also implemented reactivity to your custom reference types.
In this chapter, you’ll meet a few other input controls: lists with sections, steppers, toggles and pickers. To do so, you’ll work on a new Kuchi app section dedicated to its settings.
Since you’ll implement this new feature as a separate new view, you might think you need to add some navigation to the app — and you’d be right. In fact, you’ll add tab-based navigation later on.
For now, you’ll create a new setup view and make it the default view displayed when you launch the app.
You’ll find the starter project, along with the final project, in the materials for this chapter. It’s almost the same final project you left in the previous chapter, so feel free to use your own copy that you’ve worked on so far. However, in this case, you need to manually add the content of the Shared/Utils folder to the project, which contains these three files:
-
Color+Extension: contains some
UIColorextension methods. - LocalNotifications: helper class to create local notifications.
- Appearance: defines an enumeration used to describe the app’s appearance.
Creating the Settings View
Before doing anything else, you must create the new settings view and make it the default view displayed at launch.
Open the starter project or your project from the previous chapter. In the Shared folder, create a new group, call it Settings, then create a new file inside it using the SwiftUI template, and name it SettingsView.
Now, to make Settings the initial view, open KuchiApp and, in body, replace the code that instantiates StarterView, along with its modifiers, with:
SettingsView()
If you now run the app, it’ll show the classic, but never outdated, Hello, World! message that every developer has met at least a hundred times in their developer life.
Now that everything is set up, you can focus on building the settings view. Your goal is to create something that looks like this:
You can see that the view has:
- A Settings title.
- Three sections: Appearance, Game and Notifications.
- One or more items (settings) per section.
To implement this structure in UIKit, you would probably opt for a UITableView with static content or a vertical UIStackView. In AppKit, you’d use a slightly similar way.
In SwiftUI, you’ll use a List, a container view that arranges rows of data in a single column. Additionally, you’ll use a Section for each of the three sections listed above. This is just an implementation-oriented peek, and you’ll learn more about lists in Chapter 14: “Lists”.
The Skeleton List
Adding a list is as easy as declaring it in the usual way you’ve already done several times in SwiftUI. Before starting, resume the preview so you have visual feedback of what you’re doing in real-time, step by step.
In the SettingsView’s body, replace the welcome text with:
List {
}
Next, add the title inside the List:
Text("Settings")
.font(.largeTitle)
.padding(.bottom, 8)
You’re using two modifiers to:
- Select the
largeTitletext style. - Add a bottom padding.
Last, for now, add three sections after the Text, respectively, for appearance, game and notifications:
Section(header: Text("Appearance")) {
}
Section(header: Text("Game")) {
}
Section(header: Text("Notifications")) {
}
The Stepper Component
It’s good practice to start from the beginning; in fact, you’ll start populating the … erm … second section. :]
The Game section contains two settings, the first of which is the number of questions. You remember from the previous chapters that a session is a sequence of challenges, the number of which is set to six in ChallengesViewModel.
Because you like to win easily or because you like to put your name in the Guinness World Records, you might want to model the number of questions per session accordingly to your taste.
So the first setting you’ll add to the Kuchi app is to let you choose how many questions you wish per session.
You could use a text field where you have to input a number manually. Still, you’d need to add validation to ensure that the input is convertible to a positive integer — there’s a better and more elegant control that fits.
As you might have already guessed by reading the title of this section, this control is the stepper, aka a pair of buttons that allow you to increase or decrease an integer value and an associated label. You’ve already briefly met the stepper in Chapter 6: “Controls & User Input”.
First, at the top of SettingsView, add a state variable to hold the number of questions:
@State var numberOfQuestions = 6
Then, in the second section, Game, add this code:
// 1
VStack(alignment: .leading) {
// 2
Stepper(
"Number of Questions: \(numberOfQuestions)",
value: $numberOfQuestions,
// 3
in: 3 ... 20
)
// 4
Text("Any change will affect the next game")
.font(.caption2)
.foregroundColor(.secondary)
}
Here’s what’s going on:
- Along with the stepper, you’re showing an informative label beneath it, so you’re using a vertical stack to stack the stepper and the label, both aligned to the left.
- This is the stepper, which has a label showing the currently selected number of questions and a binding.
- Look how cool this is! You’re forcing the stepper to stay in the 3-20 range, you don’t need to validate it; you just prevent the user from choosing values outside that range.
- This is the informative label, properly stylized, below the stepper.
If you resume the preview, this is what you’ll see:
If you activate the live preview, you can play with the control to amend the property value, and you can easily find that you can’t go beyond the limits defined by the 3-20 range you specified in the control declaration.
Note: Spoiler alert! You’ve added a state property, and it’s not the only one you’ll add in this chapter. Although, for now, it works fine, it’s not the best way to handle a state that must ideally survive app restarts. You’ll look into that later in this chapter when discussing
AppStorage.
The Toggle Component
The second setting you’ll add is a switch that enables or disables the Learning section of the Kuchi app. Before you go and browse all the previous chapters to search for something you might have forgotten, you should be aware that there’s no such section yet, you’ll add it in the next chapter.
You’ve already used the toggle component in **Chapter 6: “Controls & User Input”**to enable the “Remember Me” feature that allows the app to remember the user’s name. So you should already know how to use it.
At the top of SettingsView, add a new piece of state:
@State var learningEnabled: Bool = true
Then, in the Game section, after the vertical stack, add this code:
Toggle("Learning Enabled", isOn: $learningEnabled)
You’re simply creating a toggle with a label and a binding.
Now, if you resume the preview, you’ll see this:
The settings view is taking shape!
The Date Picker Component
The following section you’ll take care of is Notifications. You might be wondering what notifications have to do with Kuchi.
When you’re learning something new, and it requires constant effort, you must dedicate time regularly. You can’t afford to skip practicing, and that mustn’t happen just because you forget it!
So, ask the app to remind you.
To implement it, you only need two controls:
- A toggle to enable or disable the notification.
- A time picker to select the time of the day you want the reminder to show up.
Note: What we’re calling time picker is, in reality, a
DatePickerconfigured to handle the time component only. There’s no standalone time picker component in SwiftUI.
Since both relate to the same settings, you’ll lay them out horizontally, so, you guessed right, you’ll embed them in an HStack
First, in SettingsView add the following state property below the ones previously added:
@State var dailyReminderEnabled = false
Then, in the Notifications section, add the following:
HStack {
Toggle("Daily Reminder", isOn: $dailyReminderEnabled)
}
Here you’re using a toggle component to turn the daily reminder on and off.
Now you can resume the preview and see the new toggle in place.
Currently, it does nothing, which is unsurprising, as you still need to add behavior to its state change. More on that soon.
Now, you can add a time picker. Add this code after the reminder toggle:
DatePicker(
// 1
"",
// 2
selection: $dailyReminderTime
)
DatePicker has a few initializers, differing by whether they use a Text or a custom View for the label and by the inclusion of a validity range or not.
In the version you’ve used above:
- You’re using the
Textlabel, but since you already have aTextfor the label, you added it as part of the daily reminder switch, and you’re passing an empty string. - This is the binding to a state property you must add.
To get it to compile, add the new state property after dailyReminderEnabled:
@State var dailyReminderTime = Date(timeIntervalSince1970: 0)
Resume the preview, and enable live preview. Or, if you prefer, launch the app in the simulator. Notice the two fields after the switch, one for the date and one for the time.
Now, if you tap the date part of the component, it will display a pop-up to let you choose a date. Likewise, tapping the time part will show a pop-up to select a time. And needless to say, if you select a date or a time, it will automatically store to dailyReminderTime.
Date Picker Styles
In iOS, the date picker comes in three different styles, which you can configure using the .datePickerStyle() modifier, similarly to how it works for TextField, which you encountered in Chapter 6: “Controls & User Input”. The three styles are:
-
CompactDatePickerStyle: This is what you’ll use in Kuchi. It consists of two compact fields showing the selected date and time. When you tap one, it will display a pop-up to edit the relevant part.
Compact date picker -
WheelDatePickerStyle: It’s the classic wheel where you can swipe up and down to compose the date and time, field by field. If you’ve ever developed in UIKit, you should know what it is. :]
Wheel date picker -
GraphicalDatePickerStyle: An embedded calendar component.
Graphical date picker
In macOS, there are three styles too:
-
GraphicalDatePickerStyle: This is the macOS counterpart of the iOS style seen above.
Graphical date picker macos -
FieldDatePickerStyle: This is a text field where you can type your date and/or time.
Field date picker -
StepperFieldDatePickerStyle: This is similar to the previous one, but with a stepper that lets you use your mouse to select values.
Stepper date picker
For both platforms, there’s an additional DefaultDatePickerStyle, which is an alias for a style, but different per platform:
- In iOS, the default style is
CompactDatePickerStyle. - In macOS, it’s
StepperFieldDatePickerStyle.
Configuring the Daily Reminder Time Picker
After some theory, it’s time to get back to Kuchi. The date picker with compact style looks great, but there’s one issue: you don’t need the date. This picker is to select a time of the day, but there’s no date component because you want it to remind you every day.
This is very easy to achieve. The initializer takes an additional displayedComponents parameter, which can be either .hourAndMinute, .date, or both. In your case, you want it to be just hourAndMinute, so add it after selection:
DatePicker(
"",
selection: $dailyReminderTime,
// Add this, but don't forget the trailing
// comma in the previous line
displayedComponents: .hourAndMinute
)
Now you can resume the live preview, or run the app if you prefer, and play with the time picker.
You probably noticed another problem while testing the app: if the switch is off, the time picker should disable, but it always stays enabled instead. Thanks to SwiftUI’s reactivity, this is very simple to achieve. Declare that the date picker’s enabled property must follow the value of the switch’s value.
Add the following modifier to DatePicker:
.disabled(dailyReminderEnabled == false)
With it, you’re binding dailyReminderEnabled to the time picker’s disabled property. Try it now; when you turn the switch off, the time picker will automatically disable.
Activating Notifications
Now that the user interface part of the time picker is complete, you need to make it functional. The requirements are pretty simple:
- If the daily notification switch turns on, create the daily notification.
- If the time changes, cancel the previous notification and create a new one with the updated time by selecting with the time picker.
- If the daily notification switch turns off, cancel the current notification.
In UIKit and AppKit Jurassic worlds, you would probably hook to a value-changed event and do the processing in there. You should already know that the SwiftUI way of doing things is different and that, often, you can achieve the same goal in different ways.
Both the switch and the time picker have an associated state variable each, which holds the current selection. When the user changes the switch state, either turning on or off, the component automatically updates the binding, which is the dailyReminderEnabled property.
Adding a Custom Handler to the Toggle
It would be nice if you could intercept when the binding updates and inject a call to a method that creates or removes a local notification. This is exactly what you’re going to do now.
The toggle button is declared as:
Toggle("Daily Reminder", isOn: $dailyReminderEnabled)
Replace the $dailyReminderEnabled binding with an explicit binding, as follows:
Toggle("Daily Reminder", isOn:
// 1
Binding(
// 2
get: { dailyReminderEnabled },
// 3
set: { newValue in
// 4
dailyReminderEnabled = newValue
}
)
)
If you remember when you met bindings a couple of chapters ago, a binding is a property wrapper type that can read and write a value owned by a source of truth. Here, the source of truth is dailyReminderEnabled, and you achieve the read and write via two closures that you pass to the binding initializer:
- This is the binding that you’re creating.
- This is the
getimplementation, a closure that returns the source of truth’s value. - This is the
setcounterpart, where you set the value into the source of truth’s wrapped value. - Here’s where you set the value.
Now if you enable live preview or run the app, you won’t notice any difference. This implementation, left as is, doesn’t add anything new from a functional standpoint.
As mentioned earlier, you only want to inject a method call when a new value is set. In the binding’s set closure, after setting the new value into dailyReminderEnabled, add this method call:
configureNotification()
This method doesn’t exist yet, it will be responsible of creating or removing a notification. Add it after body:
func configureNotification() {
if dailyReminderEnabled {
// 1
LocalNotifications.shared.createReminder(
time: dailyReminderTime)
} else {
// 2
LocalNotifications.shared.deleteReminder()
}
}
Depending on the value of dailyReminderEnabled:
- Create a new reminder with the currently selected time.
- Delete the reminder.
Shared/Utils/LocalNotifications contains the details of how to schedule and cancel a notification. However, the way a custom handler injects into a binding is a little verbose. You could create a Binding extension method that automatically does what you did with the custom getter and setter above. Still, there’s actually another way that SwiftUI already provides: the onChange(of:perform:) modifier.
Restore the previous implementation of the Daily Reminder toggle so you use the state variable and not the custom binding:
Toggle("Daily Reminder", isOn: $dailyReminderEnabled)
Now add to it a call to the modifier mentioned above, passing dailyReminderEnabled as value and a call to configureNotification() as closure:
.onChange(
of: dailyReminderEnabled,
perform: { _ in configureNotification() }
)
This tells SwiftUI: Hey, when dailyReminderEnabled changes, please execute this closure. The value can be any type conforming to Equatable, so it’s not restricted to state or binding only, and the closure takes the new value, of the same type, as a parameter.
Adding a Custom Handler to the Time Picker
Now you need to replicate what you did to the toggle. Still in SettingsView, add the same modifier to the DatePicker:
.onChange(
of: dailyReminderTime,
perform: { _ in configureNotification() }
)
The only difference is that you’re now monitoring dailyReminderTime instead of dailyReminderEnabled.
Note that .onChange(of:perform:) is part of the View protocol so you can use it on any view. You could, for example, move the two uses you’ve done above from their respective components to HStack, Section or List. For example, in case you opt for the section, the code would look like this:
Section(header: Text("Notifications")) {
HStack {
Toggle("Daily Reminder", isOn: $dailyReminderEnabled)
DatePicker(
"",
selection: $dailyReminderTime,
displayedComponents: .hourAndMinute
)
}
}
.onChange(
of: dailyReminderEnabled,
perform: { _ in configureNotification() }
)
.onChange(
of: dailyReminderTime,
perform: { _ in configureNotification() }
)
Testing the Notifications
After all these changes, notifications are fully working. Every time the state of the toggle or the time picker changes, you invoke configureNotification(), which either cancels a schedule or schedules a new notification.
After so much effort, you can see what you’ve achieved! You need to run the app on a simulator or a device, notifications won’t work in live preview. Follow these steps:
- Enable Daily Reminder.
- Take note of your current time, and add one minute.
- Tap on the time picker, and select that time.
- Set the app to the background by going to the home screen.
- Wait for the notification to appear.
The Color Picker Component
Now swift … ehm, shift your focus on the app’s appearance. :]
In the next chapter, you’ll add a learning screen to the app where you can play with swipeable cards. They have a solid background color, which was statically set to red in previous iterations of this book.
Why not prepare a setting that allows the user to select a background color of their choice, instead of defaulting to red?
To achieve that, you’ll use a ColorPicker. To store the selected color, you’re going to need a state variable. Add the following to the top of SettingsView, right after dailyReminderTime:
@State var cardBackgroundColor: Color = .red
Next, in the body under the Appearance section, add the color picker:
ColorPicker(
"Card Background Color",
selection: $cardBackgroundColor
)
And that’s it, very simple. The initializer takes three parameters:
- A label.
- A binding.
- An optional flag stating if opacity is supported, which, by default, is
true.
There are several overloads with minor differences from each other. One that’s worth mentioning allows you to specify a label as a view rather than a string, this is quite common in SwiftUI’s components.
You can run it in a simulator, a device, or live preview. When you tap the small colored circle at the right, a pop-up displays, offering you several ways to choose a color.
It would be superfluous to say that when you select a new color, it’s automatically set in the cardBackgroundColor state property.
The Picker Component
The last setting you’re offering to your users is the ability to select the app appearance, either light or dark, a popular setting among modern apps.
You’ll give the user a set of three options to choose from:
- Light
- Dark
- Automatic
The last option is basically a way to say, “use the same appearance as configured in the Settings app”.
To implement this setting you’ll be using the picker component, which is formally described as a control for selecting a set of mutually exclusive values.
Using it is very simple: you provide a binding that determines the currently selected value and declare a set of mutually exclusive options.
A good way to start is by declaring the state variable. Add it after numberOfQuestions:
@State var appearance: Appearance = .automatic
Appearance is an enum defined in Utils/Appearance, with three cases matching the options mentioned earlier: .light, .dark and .automatic.
Since you’ll add a new component to the Appearance section, which already contains the color picker, you must add a stack view to lay the two components out vertically. So enclose the color picker in a VStack:
VStack(alignment: .leading) {
ColorPicker(
"Card Background Color",
selection: $cardBackgroundColor
)
}
Now, before the color picker, add the new picker component:
// 1
Picker("", selection: $appearance) {
// 2
Text(Appearance.light.name)
Text(Appearance.dark.name)
Text(Appearance.automatic.name)
}
-
The first parameter passed to the picker initializer is a label, which you don’t need here. There’s an initializer overload that accepts a custom view instead of a text, so you’re free to customize the label as much as you like.
You have already figured out that the second parameter is the binding.
-
The content of the picker lists all possible options.
This is how it looks:
Be honest: it doesn’t look good, just a blank line with a disclosure icon. But there are other problems: it needs to be actionable. If you run the app in the simulator, you’ll notice you can’t select a new value.
Styling the Picker
In order to change the style, you have a modifier at your disposal. It’s an established pattern in SwiftUI and should already look familiar to you. In this case, it’s called .pickerStyle(_:).
You can browse the documentation to know all available styles at apple.co/3nyViIG.
If you look at the screenshot at the beginning of this chapter, you’ll see that the desired look for the appearance control is like a segmented control. To achieve that, you can use SegmentedPickerStyle, which displays all options in a segmented control.
Add this modifier to the picker:
.pickerStyle(SegmentedPickerStyle())
This changes the look of the picker to:
Much better. However, if you run the app, you’ll notice that the following:
- It doesn’t highlight its default value, set in the
appearanceproperty initialization. - It’s not actionable: It does nothing if you try to interact with it.
Binding Options to the Picker State
If you look at the picker declaration, you can notice that:
- The currently selected item is bound to the
appearanceproperty. - The list of items is just a list of strings (
Appearance.light.nameresolves to a string).
Picker("Pick", selection: $appearance) {
Text(Appearance.light.name)
Text(Appearance.dark.name)
Text(Appearance.automatic.name)
}
When you select an option, how would the picker know what to put into appearance? Likewise, how does the picker know which corresponding item to select if the code changes the appearance.
So, you need to bind each picker option to a specific value of its selection binding. In the case of this appearance picker, that means binding each option to a case of the Appearance enum.
You can create that binding with the tag(:_) modifier, which differentiates and identifies views in lists and pickers.
The tag modifier takes a value, which can be any type conforming to the Hashable protocol. Enumerations automatically implement it so that you can use enum cases out of the box.
For each of the three cases, add the tag modifier, passing the corresponding enum case:
Text(Appearance.light.name).tag(Appearance.light)
Text(Appearance.dark.name).tag(Appearance.dark)
Text(Appearance.automatic.name).tag(Appearance.automatic)
Now when you run or live preview the app:
- You immediately see that
.automaticis the preselected option. That’s because you initializeappearancewith that value. - Whenever you tap a non-selected option, the selection changes and you have a visual clue.
Iterating Options Programmatically
A keen eye like yours has probably realized that:
- The picker options have the same format: A
Textwith a.tagmodifier, fed with data from enum cases. - Enumerations in Swift are enumerable and iterable.
Even if you haven’t noticed, don’t worry, it’s not that obvious. Why, rather than listing all options explicitly, you can’t iterate over them in a loop or similar?
Of course, the answer is yes, you can. You can leverage the CaseIterable protocol and use the ForEach struct. Appearance already adopts CaseIterable, but if you use this technique in your enumerations, remember to make them conform to that protocol.
Replace the three options in the picker with the following:
ForEach(Appearance.allCases) { appearance in
Text(appearance.name).tag(appearance)
}
It’s now more compact, easier to read and less error-prone. Not to mention that if you decide to add 10 more enum cases, you won’t need to update this view: it’s automatically populated, whichever the number of cases Appearance has.
The Tab Bar
Well done, now you’ve got a working settings view! But, currently, it’s the only view that your app provides access to. At the beginning of this chapter, you replaced StarterView with SettingsView as the only view. Of course, this doesn’t make sense even in the least meaningless apps.
So, you need some navigation, and the tab bar fits perfectly with what you need — also taking into account that, as mentioned earlier, you’ll add a new Learn section in the next chapter.
For what matters in this chapter, your new tab bar needs to handle two views:
StarterViewSettingsView
You need a new view to host the tab bar, which acts as a master view that selects the embedded view to display. There’s already a view in the project called HomeView, located in the Shared folder, which contains an empty view.
Replace its body content with:
// 1
TabView {
EmptyView()
}
// 2
.accentColor(.orange)
This is very simple, you are:
- Creating a tab view; it only has an empty view for now.
- Using the
accentColormodifier, make the icon and text orange when the user selects a tab.
Now you need to add the two tabs. The first is for the new settings view, so inside TabView, replace EmptyView() with:
// 1
SettingsView()
// 2
.tabItem({
// 3
VStack {
Image(systemName: "gear")
Text("Settings")
}
})
// 4
.tag(2)
Adding a tab is pretty straightforward:
- This is the view displayed when the tab is active.
- You use the
tabItemmodifier to configure the tab. - You’re displaying an icon and a label below it, using a
VStackto keep them together. - This is the index of the settings tab. You’re assigning a value of 2 because it will be the rightmost, i.e., the last. Afterward, you’ll add the next two tabs and the other in the next chapter.
If you resume the preview, this is what you’ll see:
To add the second tab, you first need to do some refactoring: In WelcomeView, you have to replace the instance of PracticeView with the new HomeView.
To do so, first, open up WelcomeView. You see that in body the if branch shows PracticeView — cut the following code:
PracticeView(
challengeTest: $challengesViewModel.currentChallenge,
userName: $userManager.profile.name,
numberOfAnswered:
.constant(challengesViewModel.numberOfAnswered)
)
.environment(
\.questionsPerSession,
challengesViewModel.numberOfQuestions
)
And replace it with:
HomeView()
Next, go back to HomeView, and right before the SettingsView tab, paste the code you cut above:
PracticeView(
challengeTest: $challengesViewModel.currentChallenge,
userName: $userManager.profile.name,
numberOfAnswered:
.constant(challengesViewModel.numberOfAnswered)
)
.environment(
\.questionsPerSession,
challengesViewModel.numberOfQuestions
)
Because of missing properties, this creates a few things that you need to correct. You’ll fix these soon. But first, you’ll finish the body of HomeView.
Before the environment modifier of PracticeView, add this code to configure the tab:
.tabItem({
VStack {
Image(systemName: "rectangle.dock")
Text("Challenge")
}
})
.tag(1)
As done previously for the settings tab, this adds a new tab to the tab bar and assigns a tag of 1. Since you order tabs by tag, the practice tab will appear before the settings bar, for which you assigned a value of 2, which is the expected behavior.
To avoid any ambiguity, be sure that body looks like this:
TabView {
PracticeView(
challengeTest: $challengesViewModel.currentChallenge,
userName: $userManager.profile.name,
numberOfAnswered: .constant(challengesViewModel.numberOfAnswered)
)
.tabItem({
VStack {
Image(systemName: "rectangle.dock")
Text("Challenge")
}
})
.tag(1)
.environment(
\.questionsPerSession,
challengesViewModel.numberOfQuestions
)
SettingsView()
.tabItem({
VStack {
Image(systemName: "gear")
Text("Settings")
}
})
.tag(2)
}
.accentColor(.orange)
Next, you’ll fix those errors. HomeView requires two properties that you left in WelcomeView. Go back to it, and copy them:
@EnvironmentObject var userManager: UserManager
@EnvironmentObject var challengesViewModel: ChallengesViewModel
Then paste them at the top of HomeView, before body. Since they are environment objects, if you want to take a peek at how the view looks like using the preview, you need to add them to the HomeView() initializer in HomeView_Previews.
Do so by replacing the contents of previews with:
HomeView()
.environmentObject(UserManager())
.environmentObject(ChallengesViewModel())
You can now resume the preview and see the new Challenge tab added at the left of Settings.
If you want to make things right, in WelcomeView, you notice that challengeViewModel is no longer used so that you can delete the property.
There’s one last thing left, which you can see if you run the app: The settings view displays instead of the HomeView you created earlier. At the beginning of this chapter, you replaced the starter view with the settings view as the default view displayed at launch — it’s time to restore that view.
Open KuchiApp and replace the content of WindowGroup with:
StarterView()
.environmentObject(userManager)
.environmentObject(challengesViewModel)
Now when you run the app, after the welcome view, you’ll see the HomeView with the two tabs you added in this section.
One last adjustment to ensure everything works smoothly, you need to wire the tab view to something so that it can remember what tab is currently active. If you look at the tab view in the code, you’ll notice it has defined tabs. But no place stores the currently selected tab or its index .
You need this because if TabView is re-rendered (or the entire HomeView), it would forget the previously selected tab and just make the first one selected.
To keep track of the currently selected tab index, in HomeView add a state property before userManager:
@State var selectedTab = 0
Next, pass a binding of it to the TabView’s initializer:
TabView(selection: $selectedTab) {
And that’s all. Amazingly simple!
App Storage
The settings view you’ve created in this chapter looks great, but it misses two important points:
- Changes are not persistent. If you change, for example, the number of questions to 4, then you restart the app, the app will forget your change and will reinitialize that value to 6.
- Changes are not functional. If you change the number of questions to 4, then switch to the Challenge tab, it will still display “0/6”, meaning it still uses 6 for the number of questions to ask per session.
You would probably use UserDefaults to store user settings, and that’s what you’ll do, just in a different way.
In fact, SwiftUI has introduced a new property wrapper that works like @State but with the value read from and written to UserDefaults.
The attribute to use is @AppStorage, and you use it just like @State and @Binding, except that you must provide a key representing the name under which you store the value in the UserDefaults.
Storing Settings to UserDefaults
Open SettingView and replace the line that declares where the state variable numberOfQuestions with:
@AppStorage("numberOfQuestions")
var numberOfQuestions = 6
You pass the key as the first unnamed parameter, "numberOfQuestions" — it’s common practice to avoid confusion with the same name for the key and the property name.
You must also provide an initial value, which is stored in UserDefaults if the key doesn’t exist yet, and you’re using the same value as before, which is 6.
You can also optionally pass an instance of UserDefaults, in which case it will be used to read from and store to the handled value.
To verify that it works:
- Launch the app.
- Go to settings and change the number of questions to 4.
- Wait a couple of seconds, writing to user defaults is not synchronous and happens in the background.
- Relaunch the app.
- Go to settings: The number of questions is 4, meaning it remembered the change.
However, this change alone doesn’t fix the second issue. If you switch to the Challenge tab, it still displays 0/6, meaning the number of questions hasn’t changed.
To fix that, open Practice/ChallengesViewModel, locate the numberOfQuestions property, and apply the same changes you did in SettingsView, by turning the state property into an app storage property and initializing it:
@AppStorage("numberOfQuestions")
private(set) var numberOfQuestions = 6
Now you have the same property, but in two different places: ChallengesViewModel and SettingsView. Under the hood, the application stores this property in the user defaults, ensuring that the single source of truth rule is not violated. To make a comparison, @AppStorage looks more like a binding than a state attribute.
However, there are better approaches than this for several reasons, the first of which is that you must provide an initial value in all cases. If you want to change it in the future but forget to update in one place, you’ll have a different initial value depending on which property you reference first.
So it’s better to keep one copy only and always reference that from elsewhere. Since you already declared it there, the most suitable candidate is ChallengesViewModel. So go ahead and remove the numberOfQuestions property from SettingsView.
The compiler will immediately inform you that something is wrong — you need to change where you reference this property in order to point to the updated location. The only control using it is the stepper, replace its code with:
Stepper(
"Number of Questions: \(challengesViewModel.numberOfQuestions)",
value: $challengesViewModel.numberOfQuestions,
in: 3 ... 200
)
The changes you’ve applied consist of replacing the two occurrences of numberOfQuestions with challengesViewModel.numberOfQuestions.
But you also need to add a reference to challengesViewModel to SettingsView. Add this property before learningEnabled:
@EnvironmentObject
var challengesViewModel: ChallengesViewModel
Lastly, for now, you also need to supply to the preview, which would otherwise make previewing to crash. Go to the bottom of the SettingsView file and replace the content of the previews property with:
SettingsView()
.environmentObject(ChallengesViewModel())
You may notice that the compiler is still complaining about numberOfQuestions having no access to the setter — that’s because you declare the property as private(set). Just remove this access modifier so that it looks like this:
@AppStorage("numberOfQuestions")
var numberOfQuestions = 6
It might sound like everything is complete, but you need more. You need to do a few other updates for the app storage variable to work correctly.
If you open Shared/Practice/ScoreView, you’ll notice that it has a numberOfQuestions property, which is immutable, and initialized when the view is instantiated. If you want it to follow the value you can change in the settings view, you need to turn it into a binding.
Replace:
let numberOfQuestions: Int
With:
@Binding var numberOfQuestions: Int
You also need to make the preview view compliant with this change. Add a state property to it:
@State static var numberOfQuestions: Int = 6
Next, still, in the preview, update the value passed to the numberOfQuestions parameter with the state property you’ve just created. previews should now look like this:
ScoreView(
numberOfQuestions: $numberOfQuestions,
numberOfAnswered: $numberOfAnswered
)
You use ScoreView in ChallengeView, so you need to do some work on it too. It has a questionsPerSession property, which is an environment variable:
@Environment(\.questionsPerSession) var questionsPerSession
As done for the settings view above, you need to delete it.
ChallengeView does not have a reference to ChallengesViewModel, so you inject it from the environment. Add the property after verticalSizeClasses:
@EnvironmentObject
var challengesViewModel: ChallengesViewModel
Next, locate the two places where ScoreView is instantiated in the body implementation, and update the reference to questionsPerSession to point to the challenges view model:
ScoreView(
// Update this parameter
numberOfQuestions: $challengesViewModel.numberOfQuestions,
numberOfAnswered: $numberOfAnswered
)
Almost done. In ChallengeView, before replacing with an app storage property, you had an environment variable, and that environment variable must have been injected from elsewhere. That elsewhere is HomeView, open it and delete these lines under PracticeView:
.environment(
\.questionsPerSession,
challengesViewModel.numberOfQuestions
)
Now everything is set up. If available, this change will retrieve’ numberOfQuestion’ from the UserDefaults. Otherwise, it will initialize with the provided initial value. Since in the previous run, you assigned a new value from the settings view, this is what you’ll see in the challenge view if you run the app in the simulator:
Try changing its value again from settings, when you switch back to challenge, you’ll find the new value updated.
Storable Types
If you have ever used UserDefaults, you know you can’t store any arbitrary type. You’re restricted to:
- Basic data types:
Int,Double,StringandBool. - Composite types:
DataandURL. - Any type adopting
RawRepresentable.
To store types that are not explicitly handled by AppStorage, you have two choices:
- Make the type
RawRepresentable - Use a shadow property
Using RawRepresentable
A real example of the former case is appearance, which is of the Appearance enum type, hence not storable by default. However, if you open Shared/Utils/Appearance, you’ll notice that the enumeration implicitly conforms to RawRepresentable, having it as a raw value of Int Type. Remember, if you specify a raw value type for an enum, it will automatically conform to RawRepresentable.
So in SettingsView make appearance an AppStorage property by replacing its declaration line with:
@AppStorage("appearance") var appearance: Appearance = .automatic
Note that even if the setting is permanently stored and remembered across app relaunches, it won’t affect the actual app appearance, you’ll fix that later. Feel free to verify that when you change its value and relaunch the app, it remembers the value you selected and appears as selected in the settings view.
Using a Shadow Property
In cases where a supported type is not an option and so is conforming to RawRepresentable, you can declare a shadow property that is AppStorage friendly.
A real use case in Kuchi is for the dailyReminderTime property. You have already declared it as state property and verified that it works with the date picker, but it’s of Date type, which is not handled by AppStorage.
Without touching it, you add a new property using a type that’s handled by AppStorage. You can convert a date into a double, and vice-versa, so you can use the Double type.
In SettingsView, add this property after dailyReminderTime:
@AppStorage("dailyReminderTime")
var dailyReminderTimeShadow: Double = 0
This property will go to the UserDefaults, whereas dailyReminderTime is what’s bound to the date picker. Now you need to link the two properties so that:
- When you select a new time using the date picker, the new
Datevalue is copied into the shadow property, hence saved toUserDefaults. - When the value is read from
UserDefaultsand stored in the shadow property, thedailyReminderTimeis reinitialized properly.
For the first, DatePicker already has an explicit binding defined via the onChange(of:perform:) modifier, which you needed in order to be able to update the local notification every time you choose a new time.
All you need to do is to convert the new Date value to Double and store it in the shadow property. Do it in the second onChange modifier, the one monitoring dailyReminderTime, so that it looks like this:
.onChange(
of: dailyReminderTime,
perform: { newValue in
dailyReminderTimeShadow = newValue.timeIntervalSince1970
configureNotification()
}
)
This copies the number of seconds since the midnight of Jan 1, 1970, as a double value, into the shadow property.
For the second, you can take advantage of the .onAppear() modifier, taking a closure that is executed every time the view displays. Add it after the onChange modifiers:
.onAppear {
dailyReminderTime = Date(timeIntervalSince1970: dailyReminderTimeShadow)
}
With it, every time the Section displays, the value stored in the shadow property converts to a date and stored into dailyReminderTime.
You need to turn the dailyReminderEnabled from state to app storage property, and replace it with this line:
@AppStorage("dailyReminderEnabled")
var dailyReminderEnabled = false
Now you can verify that it works. Follow these steps:
- Run the app, either in the simulator or device.
- Go to the settings tab.
- Enable daily reminders.
- If it asks you to allow notifications, allow it.
- Choose a time.
- Relaunch the app.
- Go to the settings view again.
You can now see that the daily reminders setting is still enabled, and the date picker shows the time you selected.
Enabling Appearance
The last thing left for this chapter is that you need to make the picker you added at the beginning of this chapter change the app’s appearance. Right now, if you change it, it won’t have any effect.
Earlier, you turned the appearance property into an AppStorage property. That’s just one aspect of it, you also need to react to its changes.
Since this is an app-wide setting, you need to work on the KuchiApp. Open KuchiApp and add this property below userManager:
@AppStorage("appearance")
var appearance: Appearance = .automatic
To apply the appearance, there’s a modifier called .preferredColorScheme(_:). You can apply it to any view, so you’re not limited to applying it to the entire app. But in the case of Kuchi, that’s actually what you want to achieve.
The .preferredColorScheme(:_) modifier accepts a ColorScheme parameter, which is an enum with two cases: .dark and .light — the Appearance modifier defined in Kuchi, which adds a third .automatic case, exposes a getColorScheme() method that converts from Appearance to ColorScheme.
Add this modifier to StarterView(), after the two environment objects:
.preferredColorScheme(appearance.getColorScheme())
Now you can run the app, go to the settings view, and change from light to dark appearance, and vice-versa. Magically, but not surprisingly, the app will immediately turn from light to dark back and forth, as expected.
Note: If you set the appearance to automatic, you might need to relaunch the app for the setting to take effect.
You can change the system appearance on your iPhone from the Settings app in the Display & Brightness section.
If you’re using the simulator, instead, still in the Settings app, you need to look into the Developer section. Alternatively, you can reach out the Feature → Toggle Appearance menu item or its handy ⇧+⌘+E shortcut.
SceneStorage
Alongside AppStorage, SwiftUI also offers a @SceneStorage attribute that works like @AppStorage, except that the persisted storage is limited to a scene instead of being app-wide. This is very useful if you have a multi-scene app. Unfortunately, Kuchi isn’t so you won’t cover it here. But it’s definitely beneficial for you to know! In the Where to Go From Here sections, there’s a resource on learning more about both AppStorage and SceneStorage.
Key Points
- In this chapter, you’ve played with some of the UI components SwiftUI offers by using them to build a settings view in the Kuchi app. There are a few more, and you can use the ones you’ve used here differently. Take, for example, the date picker, which you can use to pick a date, a time, or both.
- You’ve looked at the three different styles of components; the stepper component, the toggle component and the date picker.
- You’ve also witnessed how easy creating a tabbed UI is.
- Lastly, you used
AppStorageto persist settings to the user defaults.
Where to Go From Here?
This is just a short list of documentation you can browse to know more about the components you’ve seen here and what you haven’t.
-
List: apple.co/2IhW0KW -
Section: apple.co/2JNAKOa - SwiftUI Components: apple.co/39vBy50
- Picker and Picker Styles: apple.co/3nyViIG
-
SceneStorageandAppStorage: apple.co/37lgyeG