It’s day 36 of the 100 Days of SwiftUI! Yesterday, we had a consolidation day to review what we’ve learned over the past week and a half and also create a new app from scratch. Today, we’re starting a new project called iExpense. We’re going to be learning lots of new skills, so let’s dive in!
iExpense SwiftUI app introduction
Our seventh project will be a SwiftUI app called iExpense. It’s an expense tracker that separates personal costs from business costs. In order to make this app with the necessary functionality, we need to learn how to :
- Present and dismiss a second screen of data.
- Delete rows from a list
- Save and load user data
And even more.
Sharing SwiftUI state with @stateobject
Up until now, we’ve only really worked with structs and not with classes, even though we learned about classes all the way back during day 12. That is about to change!
When using a @State
property, SwiftUI knows what value to watch and will reinvoke the body
every time a change to that property occurs, which allows the changes to be reflected in our app.
This does not work when using a class, however. Instead of using @State
, we need to create an instance of our class as a @StateObject
. And that’s not all. Check out the example below, which includes explanations of the meaning of the new types we’re seeing.
import SwiftUI
class User: ObservableObject { // other things can monitor this class for changes
@Published var firstName = "Bilbo" // @Published announces a change to a value, so that the body can be reinvoked when changes occur
@Published var lastName = "Baggins"
}
struct ContentView: View {
@StateObject var user = User() //@StateObject is used when you create the data, for example a new instance of a class. Everywhere else where we are reading or modifying it, but not creating a new one, we use @ObservedObject instead
var body: some View {
VStack {
Text("Your name is \(user.firstName) \(user.lastName)")
TextField("First name", text: $user.firstName)
TextField("Last name", text: $user.lastName)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Showing and hiding views
We can show new views to users using a sheet
. A sheet is essentially a new view
that slides over the current view
that can be dismissed either by swiping down, or by adding a button to dismiss it.
import SwiftUI
struct SecondView: View {
@Environment(\.dismiss) var dismiss
let name: String // our view must have a name property passed in
var body: some View {
VStack {
Text("Hello, \(name)")
.padding(20)
Button("Dismiss") {
dismiss()
}
}
}
}
struct ContentView: View {
@State private var showingSheet = false
var body: some View {
Button("Show Sheet") {
showingSheet.toggle()
}
.sheet(isPresented: $showingSheet) { // present the sheet
SecondView(name: "Darryl")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Deleting items with onDelete()
SwiftUI gives us the onDelete()
modifier, which can be applied to ForEach
views only. This modifier allows us to remove data from a collection and is almost exclusively used with a ForEach
inside a List
.
import SwiftUI
struct ContentView: View {
@State private var numbers = [Int]()
@State private var currentNumber = 1
var body: some View {
NavigationView {
VStack {
List {
ForEach(numbers, id: \.self) { // .onDelete can only be called on a ForEach
Text("Row \($0)")
}
.onDelete(perform: removeRows) // Calls onDelete and performs the remvoeRows function
}
Button("Add Number") {
numbers.append(currentNumber) // Each tap on the button adds a new row and increments the current number by 1
currentNumber += 1
}
}
.navigationTitle("onDelete()")
.toolbar {
EditButton() // adds an edit button so rows can quickly be deleted
}
}
}
func removeRows(at offsets: IndexSet) { // An IndexSet tells us the position of all the items in the ForEach that should be removed.
numbers.remove(atOffsets: offsets)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Storing user settings with UserDefaults
Most users expect apps to store their data, which is only logical. There are various ways to accomplish this in SwiftUI, but today, we’re looking at UserDefaults
. UserDefaults
allows us to store some data that get loaded each time our app boots.
import SwiftUI
struct ContentView: View {
@State private var tapCount = UserDefaults.standard.integer(forKey: "Tap") // Reads the tapCount back from UserDefaults. It takes a few seconds for the app to write this to permanent storage, so data might not be saved if the app is closed very quickly after the value has updated. It might not have been written to storage.
var body: some View {
Button("Tap count: \(tapCount)") {
tapCount += 1
UserDefaults.standard.set(tapCount, forKey: "Tap") // We create UserDefaults and put in our tapCount. We assign tapCount to a key named "Tap".
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
SwiftUI also provides a property wrapper called @AppStorage
that wraps around and effectively ignores UserDefaults
. Like a State property, @AppStorage
checks if a value changes and will reinvoke the body
when a change occurs in the property.
import SwiftUI
struct ContentView: View {
@AppStorage("tapCount") private var tapCount = 0
var body: some View {
Button("Tap count: \(tapCount)") {
tapCount += 1
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Apple does not recommend storing a lot of data in @AppStorage
, so we should be careful with that. It’s useful for storing simple values like Integers and Booleans but it’s less useful for complex data, like a Swift struct
.
Archiving Swift objects with Codable
When working with simple data like Integers, Booleans, Strings, etc. Swift provides us with a protocol called Codeable
: a protocol specifically for archiving and unarchiving data, which is a fancy way of saying “converting objects into plain text and back again.”
import SwiftUI
struct User: Codable {
let firstName: String
let lastName: String
}
struct ContentView: View {
@State private var user = User(firstName: "Taylor", lastName: "Swift")
var body: some View {
Button("Save User") {
let encoder = JSONEncoder() // Create an instance of a JSON encoder. If you want to unarchive, you can use JSONDecoder()
if let data = try? encoder.encode(user) { // data is a new datatype called Data (with a capital D). This might throw errors, so we have to use try?
UserDefaults.standard.set(data, forKey: "UserData") // Stores our user, which has been encoded as Data, in UserDefaults
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
And that’s it for day 36! It’s high time to recharge as tomorrow, we’ll be building our iExpense app. Onwards and upwards we go.
100 Days of SwiftUI – Day 36 – iExpense