Chapters

Hide chapters

SwiftUI by Tutorials

Third Edition · iOS 14 · Swift 5.3 · Xcode 12

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

10. More User Input & App Storage
Written by Antonio Bello

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

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 UIColor extension 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.

New setting group
New setting group

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.

Empty settings view
Empty settings view

Now that everything is set up, you can focus on building the settings view. Your goal is to create something that looks like this:

Final settings view
Final settings view

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.

List {
}
Text("Settings")
  .font(.largeTitle)
  .padding(.bottom, 8)
Section(header: Text("Appearance")) {
}

Section(header: Text("Game")) {
}

Section(header: Text("Notifications")) {
}
Sections
Mapkaegz

The Stepper Component

It’s good practice to always start from the beginning, and in fact, you’ll start populating the… erm… second section. :]

Stepper
Ctovwuc

@State var numberOfQuestions = 6
// 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)
}
Number of questions
Feygiv il ruavnuext

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.

@State var learningEnabled: Bool = true
Toggle("Learning Enabled", isOn: $learningEnabled)
Learning enabled
Rauhcoxv imefwew

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?

@State var dailyReminderEnabled = false
HStack {
  Toggle("Daily Reminder", isOn: $dailyReminderEnabled)
}
Notifications toggle
Qenelexoqeehd kafbxo

DatePicker(
  // 1
  "",
  // 2
  selection: $dailyReminderTime
)
@State var dailyReminderTime = Date(timeIntervalSince1970: 0)
Date picker
Qume lofpas

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:

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.

DatePicker(
  "",
  selection: $dailyReminderTime,
  // Add this, but don't forget the trailing
  // comma in the previous line
  displayedComponents: .hourAndMinute
)

.disabled(dailyReminderEnabled == false)

Activating Notifications

Now the user interface part of the time picker is done, you need to make it functional. The requirements are pretty simple:

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.

Toggle("Daily Reminder", isOn: $dailyReminderEnabled)
Toggle("Daily Reminder", isOn:
  // 1
  Binding(
    // 2
    get: { dailyReminderEnabled },
    // 3
    set: { newValue in
      // 4
      dailyReminderEnabled = newValue
    }
  )
)
configureNotification()
func configureNotification() {
  if dailyReminderEnabled {
    // 1
    LocalNotifications.shared.createReminder(
      time: dailyReminderTime)
  } else {
    // 2
    LocalNotifications.shared.deleteReminder()
  }
}
Binding(
  get: { dailyReminderTime },
  set: { newValue in
    dailyReminderTime = newValue
    configureNotification()
  }
)
Local notification
Juwuw javurejiteez

The Color picker component

Now swift… ehm, shift your focus on the app’s appearance. :]

@State var cardBackgroundColor: Color = .red
ColorPicker(
  "Card Background Color",
  selection: $cardBackgroundColor
)
Color picker
Jupuc leksez

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.

@State var appearance: Appearance = .automatic
VStack(alignment: .leading) {
  ColorPicker(
    "Card Background Color",
    selection: $cardBackgroundColor
  )
}
// 1
Picker("", selection: $appearance) {
  // 2
  Text(Appearance.light.name)
  Text(Appearance.dark.name)
  Text(Appearance.automatic.name)
}
Default picker
Zusoovd zogzuj

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(_:).

.pickerStyle(SegmentedPickerStyle())
Picker segmented
Tuhpus wuvjagvur

Binding options to the picker state

If you look at the picker declaration, you can notice that:

Picker("Pick", selection: $appearance) {
  Text(Appearance.light.name)
  Text(Appearance.dark.name)
  Text(Appearance.automatic.name)
}
Text(Appearance.light.name).tag(Appearance.light)
Text(Appearance.dark.name).tag(Appearance.dark)
Text(Appearance.automatic.name).tag(Appearance.automatic)
Settings view
Sumdotrh soon

Iterating options programmatically

A keen eye like yours has probably realized that:

ForEach(Appearance.allCases) { appearance in
  Text(appearance.name).tag(appearance)
}

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!

// 1
TabView {
  EmptyView()
}
// 2
.accentColor(.orange)
// 1
SettingsView()
  // 2
  .tabItem({
    // 3
    VStack {
      Image(systemName: "gear")
      Text("Settings")
    }
  })
  // 4
  .tag(2)
Settings tab
Yexsecgm nah

PracticeView(
  challengeTest: $challengesViewModel.currentChallenge,
  userName: $userManager.profile.name,
  numberOfAnswered:
    .constant(challengesViewModel.numberOfAnswered)
)
  .environment(
    \.questionsPerSession,
    challengesViewModel.numberOfQuestions
  )
HomeView()
PracticeView(
  challengeTest: $challengesViewModel.currentChallenge,
  userName: $userManager.profile.name,
  numberOfAnswered:
    .constant(challengesViewModel.numberOfAnswered)
)
  .environment(
    \.questionsPerSession,
    challengesViewModel.numberOfQuestions
  )
.tabItem({
  VStack {
    Image(systemName: "rectangle.dock")
    Text("Challenge")
  }
})
.tag(1)
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)
@EnvironmentObject var userManager: UserManager
@EnvironmentObject var challengesViewModel: ChallengesViewModel
HomeView()
  .environmentObject(UserManager())
  .environmentObject(ChallengesViewModel())
Challenge settings tab
Bfojsobzi tirvonww cul

StarterView()
  .environmentObject(userManager)
  .environmentObject(ChallengesViewModel())

App storage

The settings view you’ve created in this chapter looks great, but it misses two important points:

Storing settings to UserDefaults

Open SettingView.swift and replace the line where the state variable numberOfQuestions is declared with:

@AppStorage("numberOfQuestions") var numberOfQuestions = 6
@AppStorage("numberOfQuestions")
private(set) var numberOfQuestions = 6
let numberOfQuestions: Int
@Binding var numberOfQuestions: Int
@State static var numberOfQuestions: Int = 6
ScoreView(
  numberOfQuestions: $numberOfQuestions,
  numberOfAnswered: $numberOfAnswered
)
@Environment(\.questionsPerSession) var questionsPerSession
@AppStorage("numberOfQuestions") var numberOfQuestions = 6
ScoreView(
  numberOfQuestions: $numberOfQuestions,
  numberOfAnswered: $numberOfAnswered
)
.environment(
  \.questionsPerSession,
  challengesViewModel.numberOfQuestions
)
Number of questions updated
Viglet uk liemtooyk axguziy

Storable types

If you have ever used UserDefaults, you know you can’t store any arbitrary type — you are restricted to:

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.

@AppStorage("appearance") var appearance: Appearance = .automatic

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.

@AppStorage("dailyReminderTime")
var dailyReminderTimeShadow: Double = 0
DatePicker(
  "",
  selection: Binding(
    get: { dailyReminderTime },
    set: { newValue in
      dailyReminderTime = newValue
      configureNotification()
    }
  ),
  displayedComponents: .hourAndMinute
)
dailyReminderTimeShadow = newValue.timeIntervalSince1970
.onAppear {
  dailyReminderTime = Date(timeIntervalSince1970: dailyReminderTimeShadow)
}
@AppStorage("dailyReminderEnabled")
var dailyReminderEnabled = false
Testing daily reminder
Zihkovg wearn juloyqer

@AppStorage("appearance")
var appearance: Appearance = .automatic
.preferredColorScheme(appearance.getColorScheme())
Testing appearance
Xortaqz odbaekuxcu

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.

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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now