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 own custom reference types.
In this chapter you’re going to meet a few other input controls, namely; lists with sections, steppers, toggles and pickers. To do so, you’ll work on a new section of the Kuchi app, dedicated to its settings.
Since you’ll implement this new feature as a separate new view, you might think that you need to add some navigation to the app — and you’d be right; in fact, you’ll add a tab-based navigation later on.
For now, you’ll create a new setup view, and you’ll make it the default view that’s displayed when the app is launched.
You’ll find the starter project, along with the final, 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 you worked on so far if you prefer — but in this case, you need to manually add the content of the Shared/Utils folder to the project, which contains these 3 files:
-
Color+Extension.swift: contains some
UIColorextension methods. - LocalNotifications.swift: helper class to create local notifications.
- Appearance.swift: defines an enumeration used to describe the app appearance.
Creating the Settings View
Before doing anything else, you need to create the new settings view and make it the default view displayed at launch.
Open the starter project or your own project you brought from the previous chapter. In the Shared folder create a new group, and call it Settings, then create a new file in it, using the SwiftUI template, and name it SettingsView.swift.
Now, to make Settings the initial view, open KuchiApp.swift and, in body, replace the code that instantiates StarterView, along with its modifiers, with:
SettingsView()
If you now run the app, it will show the classic, but never outdated, Hello, World! message that every developer has already met at least a hundred times in his 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, and in AppKit you’d use a differently 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 — 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 that 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 always start from the beginning, and 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 composed by a sequence of challenges, the number of which is set to 6 in ChallengesViewModel.
Maybe because you like to win easy, or because you like to put your name in the Guinness World Record, you might want to model the number of questions per session accordingly to your taste.
So the first setting you’re going to add to the Kuchi app is a setting to let you choose how many questions you wish per session.
Yes, you could use a text field where you have to manually put a number, but 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 allows 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 current selected number of questions, and a binding.
- How cool is this? You’re forcing the stepper to stay in the 3-20 range — no boring validation needed, you just prevent the user from choosing values outside that range.
- This is the informative label shown below the stepper, properly stylized.
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 cannot go beyond the limits defined by the 3-20 range you specified in the control declaration.
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 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’re going to add is a switch that enables or disables the Learning section of the Kuchi app. Before you go and start browsing 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 this is what you’ll see:
The settings view is getting shape!
The Date Picker Component
The next section you’re going to take care of is Notifications. You might be wondering: what do notifications have to do with Kuchi?
When you’re learning something new, and it requires a constant effort, you must dedicate time regularly. You can’t afford to skip practicing, and that must not happen just because you forget it!
So, why not ask the app to remind you? No sooner said than done!
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 time picker component in SwiftUI.
Since both are related to the same settings, you will 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, and that’s not surprising, as you haven’t added any 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 a Text or a custom View is used 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), you’re passing an empty string. - This is the binding to a state property that you need to add.
And 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 2 fields after the switch, one for the date part, and one for the time part.
Now if you tap any of the two, a popup to let you choose a date and time will be displayed. Needless to say, if you select a date and/or a time, it will automatically be stored to dailyReminderTime.
Date Picker Styles
In iOS, the date picker comes in three different flavors, which you can configure using the .datePickerStyle() modifier, in a similar way to how it works for TextField, which you encountered in Chapter 6: Controls & User Input. The three styles are:
-
CompactDatePickerStyle: it’s what you’re using in Kuchi, as it’s the default style in iOS — it consists of two compact fields showing the selected date and time, tapping on which will display a full-screen pop-up.
Compact date picker -
WheelDatePickerStyle: it’s the classic wheel where you can swipe up and down to compose the data 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 platform, 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, let’s 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.
There’s another problem, which you probably have noticed while testing the app: if the switch is off, the time picker should be disabled, 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 be disabled.
Activating Notifications
Now the user interface part of the time picker is done, you need to make it functional. The requirements are pretty simple:
- If the daily notification switch is turned on, create the daily notification.
- If the time is changed (by selecting with the time picker), cancel the previous notification and create a new one with the updated time.
- If the daily notification switch is turned 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 the SwiftUI-y 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 is updated, and inject a call to a method that creates or removes a local notification. Turns out, this is exactly what you’re gonna do.
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 the read and write are achieved 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, all you want to do is 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
The details of how a notification is scheduled and canceled are in Shared/Utils/LocalNotifications.swift.
Adding a Custom Handler to the Time Picker
Now you need to replicate what you did to the time picker. Still in SettingsView, in the DatePicker’s selection parameter replace $dailyReminderTime with:
Binding(
get: { dailyReminderTime },
set: { newValue in
dailyReminderTime = newValue
configureNotification()
}
)
With this code you’re doing the same you did for the switch — the only difference is the source of truth, which now is dailyReminderTime.
After this change, notifications are fully working. Every time the state of the toggle or the time picker changes, configureNotification() is invoked, which either cancels a schedule, or schedules a new notification.
Now, after so much effort, you can see with your eyes what you’ve achieved! You need to run the app on either 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. :]
Spoiler Alert: In the next chapter, you’ll add a learning screen to the app wherein you can play with swipeable cards. They have a solid background color, which, in previous iterations of this book, was statically set to red.
So why not prepare a setting that lets you select a background color of your choice, which will unquestionably be red by default?
To achieve that you’ll use a ColorPicker. To store the selected color you’re gonna need a state variable — add to 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 as label a view rather than a string — this is quite common in SwiftUI’s components.
You can run it in either a simulator or a device, or in live preview. When you tap the small colored circle at the right, a popup is displayed, offering you several ways to choose a color.
It would be superfluous to say that when you select a new color, it is automatically set in the cardBackgroundColor state property.
The picker component
The last setting that you’re offering to your users is the ability to select the app appearance, either light or dark — a pretty 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, which determines what the currently selected value is, 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.swift, with 3 cases matching the list of 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 need to 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 like:
So far so good — but there are two major issues:
- The component looks like an elephant in a doll’s house — it’s too big.
- The component doesn’t remember the selected state. If you tap on any of the three segments, you have visual feedback, but when you pull your finger the segment looks the same.
Tackle one problem at a time.
Styling the Picker
It’s an established pattern in SwiftUI, and it should already look familiar to you: In order to change the style, you have a modifier at your disposal; 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 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:
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, if appearance is changed from code, how does the picker know which is the corresponding item to select?
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 is used to differentiate and identify 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 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 becauseappearanceis initialized with that value). - Whenever you tap a non selected option, the selection is changed, and you have a visual clue of that.
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 coming from enum cases. - Enumerations in Swift are enumerable and iterable.
Even if you haven’t noticed, don’t worry — it’s not that obvious. The question probably arises: Rather than listing all options explicitly, can’t you iterate over them in a loop or similar?
Of course, the answer is yes, you can, you can leverage CaseIterable and use the ForEach struct. Appearance already adopts CaseIterable, but if you use this technique in your own enumerations remember that you need to make them conform to that protocol.
Replace the three options in the picker with:
ForEach(Appearance.allCases) { appearance in
Text(appearance.name).tag(appearance)
}
You agree that it’s more compact, easier to read, and less error-prone, right? :]
The tab bar
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 of the apps!
So you need some kind of navigation, and the tab bar fits perfectly with what you need — also taking into account that, as mentioned earlier, in the next chapter you’ll add a new Learn section.
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; for now, it only has an empty view.
- Using the
accentColormodifier, making the icon and text an orange color when the tab is selected.
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 that’s 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 learn tab. You’re assigning a value of 2 because it will be the rightmost (i.e. the last), after you’ll add the other two tabs.
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.swift. 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 is creating a few errors. You’ll fix these in a bit. 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 tabs are ordered by tag, the practice tab will appear before the settings bar, 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. PracticeView 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. 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 you’ll 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 you can delete the property.
There’s one last thing left, which you can see if you run the app: The settings view is displayed, instead of the HomeView you created earlier. At the beginning of this chapter you replaced the starter view with the settings view — it’s time to restore that view.
Open KuchiApp.swift and replace SettingsView() with:
StarterView()
.environmentObject(userManager)
.environmentObject(ChallengesViewModel())
Now when you run the app, after the welcome view, you’ll be brought to HomeView, with the two tabs that you added in this section.
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. Keeping the same example, if you change the number of questions to 4, then you switch to the Challenge tab, it will still display “0/6”, meaning that it still uses 6 for the number of questions to ask per session.
To store user settings you would probably use UserDefaults, and that’s what you’ll actually do. 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 like @State and @Binding, with the exception that you must provide a key, representing the name under which the value is stored in the UserDefaults.
Storing settings to UserDefaults
Open SettingView.swift and replace the line where the state variable numberOfQuestions is declared with:
@AppStorage("numberOfQuestions") var numberOfQuestions = 6
You pass the key as the first unnamed parameter, which is "numberOfQuestions" — it’s common practice to use the same name for the key and the property name, to avoid confusion.
You must also provide an initial value, which is stored to 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.
- Relaunch the app.
- Go to settings: The number of questions is 4, which means that 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, which means the actual number of questions hasn’t changed.
To fix that, open Practice/ChallengesViewModel.swift, 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
That’s not enough though, you need to do a few updates for the app storage variable to work properly.
If you open Shared/Practice/ScoreView.swift, you 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 that 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:
ScoreView(
numberOfQuestions: $numberOfQuestions,
numberOfAnswered: $numberOfAnswered
)
ScoreView is used in ChallengeView, so you need to do some work on it. There’s a questionsPerSession property, which is an environment variable:
@Environment(\.questionsPerSession) var questionsPerSession
As done above in ChallengesViewModel.swift, you must turn it into an AppStorage variable. Open up ChallengeView and replace questionsPerSession with:
@AppStorage("numberOfQuestions") var numberOfQuestions = 6
And then update the two places where it is used, replacing questionsPerSession with $numberOfQuestions, so that it looks like:
ScoreView(
numberOfQuestions: $numberOfQuestions,
numberOfAnswered: $numberOfAnswered
)
Almost done. In ChallengeView you replaced an environment variable, so it must have been added to the environment elsewhere. That elsewhere is HomeView.swift, open it and delete these lines under PracticeView:
.environment(
\.questionsPerSession,
challengesViewModel.numberOfQuestions
)
Now everything is set up. With this change, numberOfQuestion will be retrieved from the UserDefaults, if available, 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.
Try changing its value again from settings, when you’ll switch back to challenge you’ll find the new value displayed.
Storable types
If you have ever used UserDefaults, you know you can’t store any arbitrary type — you are restricted to:
- Basic data types:
Int,Double,String,Bool - Composite types:
Data,URL - 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.swift you notice that the enumeration implicitly conforms to RawRepresentable, having it a raw value of Int Type — remember, if you specify a raw value type for an enum it will automatically conform to RawRepresentable.
So 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.
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 a 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 is the property that 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 a new time is selected using the date picker, the new
Datevalue is copied into the shadow property, hence saved to UserDefaults. - When the value is read from UserDefaults and stored in the shadow property, the
dailyReminderTimeis reinitialized properly.
For the first, DatePicker already has an explicit binding defined, which you needed in order to be able to update the local notification every time a new time is chosen. This looks as follows:
DatePicker(
"",
selection: Binding(
get: { dailyReminderTime },
set: { newValue in
dailyReminderTime = newValue
configureNotification()
}
),
displayedComponents: .hourAndMinute
)
All you need to do is to convert the new Date value to Double and store it in the shadow property. In the set closure, before setting dailyReminderTime, add this line:
dailyReminderTimeShadow = newValue.timeIntervalSince1970
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 which is executed every time the view is displayed. Add it to the outer view, which is the List:
.onAppear {
dailyReminderTime = Date(timeIntervalSince1970: dailyReminderTimeShadow)
}
With it, every time the List is displayed, the value stored in the shadow property is converted to a date and stored into dailyReminderTime.
Last thing left, you need to turn the dailyReminderEnabled from state to app storage property — 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 the 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
Last thing left for this chapter, you need to make the picker you added at the beginning of this chapter actually change the appearance of the app — right now if you change it, it won’t have any effect.
Early you turned the appearance property into an AppStorage property. That’s just one side of the coin, you also need to react to its changes.
Since this is an app-wide setting, you need to work on the KuchiApp. Open KuchiApp.swift and add this property below userManager:
@AppStorage("appearance")
var appearance: Appearance = .automatic
To apply the appearance, there’s, guess what, 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 accept 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.
SceneStorage
Alongside AppStorage, SwiftUI also offers a @SceneStorage attribute that works the same as @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 it won’t be covered here. But it’s definitely good and useful 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 that SwiftUI offers, by using them to build a settings view in the Kuchi app.
There are a few more, and the ones you’ve used here can also be used in different other ways — take for example the date picker, which can be used to pick a date, a time, or both.
You’ve also witnessed how easy creating a tabbed UI is.
Last, you used AppStorage to persist settings to the user defaults.
Where to go from here?
This is just a short list of documentation that 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