I'm making a pretty complex SwiftUI app which lets you register walks.
The UI itself works great but I can't for the life of me figure out how to properly store data locally with UserDefaults.
I am trying to store a User object, with a child Profile object.
I'm separately trying to store a DogArray object which has a list of Dog objects.
Like this:
- User Defaults
- User
- Profile
- DogArray
- Dog
- Walk
- Walk
- Dog
- Walk
...
I am able to store the User and Profile objects but I first have to navigate back from the ProfileView before closing the app for them to be stored. I can also store the Dog objects in the DogArray but it doesn't seem to update the UserDefaults when I change a Dog in the DogDetailView, and their data isn't stored when the app is relaunched, only the DogArray.
I realize it would be much easier to clone the project than try to puzzle together my code snippets. I've uploaded a minimum viable example to github where you can try it yourself. Thanks
https://github.com/kimnordin/WalkApp
If you run it and change something you'll see it's not stored properly, or at all.
Here are my DogArray, User, and Walk objects (walks can be stored in a Dog).
DogArray
class DogArray: ObservableObject, Codable {
enum CodingKeys: CodingKey {
case list
}
@Published var list: [Dog] {
didSet {
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(list) {
UserDefaults.standard.set(encoded, forKey: "Dogs")
}
}
}
init() {
if let dog = UserDefaults.standard.data(forKey: "Dogs") {
let decoder = JSONDecoder()
if let decoded = try? decoder.decode([Dog].self, from: dog) {
self.list = decoded
return
}
}
self.list = []
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
list = try container.decode([Dog].self, forKey: .list)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(list, forKey: .list)
}
var count : Int {
return list.count
}
func addDog(dog: Dog) {
list.append(dog)
}
func clearDogs() {
list.removeAll()
}
func deleteDog(index: Int){
list.remove(at: index)
}
func entry(index: Int) -> Dog? {
if index >= 0 && index <= list.count {
return list[index]
}
return nil
}
}
class Dog: ObservableObject, Identifiable, Codable {
enum CodingKeys: CodingKey {
case name, image, startDisplayDate, walkArray, firstSelect, secondSelect
}
let id = UUID()
var name: String
var image: UIImage
var startDisplayDate: Date? = Date()
@Published var walkArray = [Walk]()
@Published var firstSelect: Bool = false
@Published var secondSelect: Bool = false
init(name: String, image: UIImage, startDisplayDate: Date? = Date(), walkArray: [Walk] = [Walk](), firstSelect: Bool = false, secondSelect: Bool = false) {
self.name = name
self.image = image
self.startDisplayDate = startDisplayDate
self.walkArray = walkArray
self.firstSelect = firstSelect
self.secondSelect = secondSelect
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decode(String.self, forKey: .name)
let dataImage = try container.decode(Data.self, forKey: .image)
if let uiImage = dataImage.toImage() {
image = uiImage
}
else {
image = UIImage(systemName: "questionmark.circle.fill")!
}
startDisplayDate = try container.decode(Date.self, forKey: .startDisplayDate)
walkArray = try container.decode([Walk].self, forKey: .walkArray)
firstSelect = try container.decode(Bool.self, forKey: .firstSelect)
secondSelect = try container.decode(Bool.self, forKey: .secondSelect)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name)
try container.encode(image.toData(), forKey: .image)
try container.encode(startDisplayDate, forKey: .startDisplayDate)
try container.encode(walkArray, forKey: .walkArray)
try container.encode(firstSelect, forKey: .firstSelect)
try container.encode(secondSelect, forKey: .secondSelect)
}
func walk(time: Date, firstSelect: Bool, secondSelect: Bool) {
let walk = Walk(time: time, firstAction: firstSelect, secondAction: secondSelect)
walkArray.append(walk)
sortWalks()
}
func sortWalks() {
walkArray = walkArray.sortWalksByDates()
}
}
User
class User: ObservableObject {
@Published var profile: Profile {
didSet {
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(profile) {
UserDefaults.standard.set(encoded, forKey: "Profile")
}
}
}
init() {
if let profile = UserDefaults.standard.data(forKey: "Profile") {
let decoder = JSONDecoder()
if let decoded = try? decoder.decode(Profile.self, from: profile) {
self.profile = decoded
return
}
}
self.profile = Profile()
}
}
struct Profile: Identifiable, Codable { // <-- Profile As Struct
enum CodingKeys: CodingKey {
case walkColor, firstColor, secondColor
}
let id = UUID()
var walkColor: Color? = Color.orange
var firstColor: Color? = Color.blue
var secondColor: Color? = Color.pink
init() {}
init(walkColor: Color? = Color.orange, firstColor: Color? = Color.blue, secondColor: Color? = Color.pink) {
self.walkColor = walkColor
self.firstColor = firstColor
self.secondColor = secondColor
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let walkData = try container.decode(Data.self, forKey: .walkColor)
let firstData = try container.decode(Data.self, forKey: .firstColor)
let secondData = try container.decode(Data.self, forKey: .secondColor)
walkColor = dataToColor(walkData)
firstColor = dataToColor(firstData)
secondColor = dataToColor(secondData)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
let walkUiColor = walkColor?.toUiColor() ?? .orange
let firstUiColor = firstColor?.toUiColor() ?? .blue
let secondUiColor = secondColor?.toUiColor() ?? .systemPink
let walkData = toData(walkUiColor)
let firstData = toData(firstUiColor)
let secondData = toData(secondUiColor)
try container.encodeIfPresent(walkData, forKey: .walkColor)
try container.encodeIfPresent(firstData, forKey: .firstColor)
try container.encodeIfPresent(secondData, forKey: .secondColor)
}
func dataToColor(_ data: Data) -> Color? {
do {
if let dataColor = try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? UIColor {
return Color(dataColor)
}
} catch {
print(error)
}
return nil
}
func toData(_ uiColor: UIColor) -> Data? {
do {
let colorData = try NSKeyedArchiver.archivedData(withRootObject: uiColor, requiringSecureCoding: false)
return colorData
} catch {
print(error)
}
return nil
}
}
enum SelectedColor {
case walk
case first
case second
case none
}
Walk
struct Walk: Codable {
var time: Date
var firstAction: Bool?
var secondAction: Bool?
}
Here's where I introduce the User and DogArray objects when the App launches
import SwiftUI
@main
struct WalkAppApp: App {
@StateObject private var dogData = DogArray()
@StateObject private var user = User()
var body: some Scene {
WindowGroup {
DogListView(viewModel: DogListViewModel())
.environmentObject(user)
.environmentObject(dogData)
}
}
}
And here's the first view that's displayed in the App
struct DogListView: View {
@EnvironmentObject var user: User
@EnvironmentObject var dogs: DogArray
@State private var presentNew = false
@State private var presentProfile = false
var body: some View { //MARK: View
NavigationView {
List {
ForEach(0..<dogs.list.count, id: \.self) { dog in
ZStack(alignment: .leading) {
NavigationLink(destination: DogDetailView(dog: dogs.list[dog])) {
}
.opacity(0)
DogListRow(dog: dogs.list[dog])
}
}
.onMove(perform: move)
.onDelete(perform: delete)
}
.background(NavigationLink(
destination: NewDogView(),
isActive: $presentNew) {
})
.background(NavigationLink(
destination: ProfileView(),
isActive: $presentProfile) {
})
.navigationBarTitle(Text("Dogs"))
.navigationBarItems(leading:
HStack {
Button("Profile") {
presentProfile = true
}
}
, trailing:
HStack {
Button("+") {
presentNew = true
}
}
)
}
}
private func move(at indexSet: IndexSet, to destination: Int) {
dogs.list.move(fromOffsets: indexSet, toOffset: destination)
}
func delete(at indexSet: IndexSet) {
dogs.list.remove(atOffsets: indexSet)
}
}
The DogListRow View
struct DogListRow: View {
@EnvironmentObject var user: User
@ObservedObject var dog: Dog
var body: some View {
HStack(spacing: 10) {
Image(uiImage: dog.image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipShape(Circle())
.padding()
VStack(spacing: 4) {
Text(dog.name)
.font(.title)
.lineLimit(2)
if !dog.walkArray.isEmpty {
if let firstWalk = dog.walkArray.first {
if let time = firstWalk.time.timeToString() {
VStack {
Text("Latest Walk")
.font(.footnote)
Text(time)
}
}
}
}
}
VStack(spacing: 3) {
if dog.walkArray.first?.firstAction == true {
Text("1")
.frame(width: 65, height: 35)
.background(user.profile.firstColor)
.cornerRadius(15)
}
if dog.walkArray.first?.secondAction == true {
Text("2")
.frame(width: 65, height: 35)
.background(user.profile.secondColor)
.cornerRadius(15)
}
}
}
}
}
The NewDogView to make a new Dog
struct NewDogView: View {
@Environment(\.presentationMode) var presentation
@EnvironmentObject var dogs: DogArray
@State var alertItem: AlertItem?
@State var dogCreated: Bool = false
@State var name: String = ""
@State var uiImage: UIImage? = nil
@State var showAction: Bool = false
@State var showImagePicker: Bool = false
var body: some View {
ScrollView(showsIndicators: false) {
if (uiImage == nil) {
Image(systemName: "camera.circle.fill")
.resizable()
.frame(width: 130, height: 130)
.onTapGesture {
displayImagePicker()
}
} else {
Image(uiImage: uiImage!)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200, height: 200)
.cornerRadius(15)
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.onTapGesture {
displayActionSheet()
}
}
TextField("Enter the Dogs name", text: $name)
.multilineTextAlignment(.center)
.padding(EdgeInsets(top: 40, leading: 20, bottom: 20, trailing: 20))
}
.fixFlickering()
.alert(item: $alertItem, content: { alertItem in
Alert(title: alertItem.title,
message: alertItem.message,
dismissButton: .default(alertItem.buttonTitle))
})
.sheet(isPresented: $showImagePicker, onDismiss: {
dismissImagePicker()
}, content: {
ImagePicker(presenting: $showImagePicker, uiImage: $uiImage)
})
.actionSheet(isPresented: $showAction) {
sheet
}
Group {
Button(action: {
createDog(name: name, image: uiImage)
if dogCreated {
self.presentation.wrappedValue.dismiss()
}
}, label: {
Text("Add dog")
.frame(width: 120, height: 60)
.foregroundColor(.white)
})
.padding(.horizontal, 8).lineLimit(1).minimumScaleFactor(0.4)
.background(Color.orange)
.cornerRadius(30)
}
}
private func createDog(name: String, image: UIImage? = nil) {
if isDogValid(name: name, image: image) {
dogs.addDog(dog: Dog(name: name, image: image!))
dogCreated = true
}
else {
dogCreated = false
}
}
var sheet: ActionSheet {
ActionSheet(
title: Text("Action"),
message: Text("Update Image"),
buttons: [
.default(Text("Change"), action: {
self.dismissActionSheet()
self.displayImagePicker()
}),
.cancel(Text("Close"), action: {
self.dismissActionSheet()
}),
.destructive(Text("Remove"), action: {
self.dismissActionSheet()
self.uiImage = nil
})
])
}
func displayActionSheet() {
showAction = true
}
func dismissActionSheet() {
showAction = false
}
func displayImagePicker() {
showImagePicker = true
}
func dismissImagePicker() {
showImagePicker = false
}
func isDogValid(name: String, image: UIImage?) -> Bool {
if name != "" && image != nil {
return true
}
else if name == "" && image != nil {
alertItem = AlertContext.NewDog.noName
}
else if name != "" && image == nil {
alertItem = AlertContext.NewDog.noImage
}
else {
alertItem = AlertContext.NewDog.noNameNoImage
}
return false
}
}
The Profile View where you can edit the color of the Buttons
struct ProfileView: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var user: User
@State var permProfile: Profile = Profile(walkColor: Color.orange, firstColor: Color.blue, secondColor: Color.pink)
@State private var presentModal = false
@State var selectedColor = SelectedColor.none
let colors = [Color.red, Color.blue, Color.green, Color.orange, Color.pink, Color.yellow, Color.red, Color.white, Color.gray, Color.black]
@ViewBuilder func ColorView(color: Color) -> some View {
(color)
.cornerRadius(10)
.onTapGesture {
switch selectedColor {
case .walk:
permProfile.walkColor = color
case .first:
permProfile.firstColor = color
case .second:
permProfile.secondColor = color
default: break
}
}
}
var body: some View {
ZStack {
VStack(spacing: 8) {
Text("Edit Selection Items")
.font(.title)
.padding(.bottom)
.padding(5)
Text("Change the button color")
.font(.title3)
.padding(EdgeInsets(top: 12, leading: 0, bottom: 5, trailing: 0))
HStack {
Button(action: {
presentModal = true
selectedColor = .first
}) {
Text("1")
.frame(width: 100, height: 50, alignment: .center)
.foregroundColor(.white)
}
.background(permProfile.firstColor)
.cornerRadius(30)
Button(action: {
presentModal = true
selectedColor = .walk
}) {
Text("Walk")
.frame(width: 100, height: 50, alignment: .center)
.foregroundColor(.white)
}
.background(permProfile.walkColor)
.cornerRadius(30)
Button(action: {
presentModal = true
selectedColor = .second
}) {
Text("2")
.frame(width: 100, height: 50, alignment: .center)
.foregroundColor(.white)
}
.background(permProfile.secondColor)
.cornerRadius(30)
}
Spacer()
}
.padding()
if presentModal {
ModalView() {
ScrollView {
VStack(spacing: 40) {
ForEach(colors, id: \.self) { color in
ColorView(color: color)
.frame(minWidth: 20, maxWidth: .infinity, minHeight: 20, maxHeight: .infinity)
.padding([.leading, .trailing], 20)
}
}
}
} closeModal: {
presentModal = false
}
}
}
.onAppear() {
permProfile = user.profile
}
.onDisappear() {
user.profile = permProfile
}
}
}
DogDetailView that is shown when you select a dog in the list
struct DogDetailView: View {
@ObservedObject var dog: Dog
var body: some View { //MARK: View
// Dog Section
ZStack {
VStack {
HStack {
Image(uiImage: dog.image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 110, height: 110)
.clipShape(Circle())
VStack(alignment: .leading) {
Text(dog.name)
.font(.title)
}
.padding(.leading, 8)
Spacer()
}
.padding(EdgeInsets(top: 0, leading: 15, bottom: 8, trailing: 10))
List {
ForEach(0..<dog.walkArray.count, id: \.self) { walk in
ZStack(alignment: .leading) {
TimeRow(walk: dog.walkArray[walk])
}
}
}
Group {
WalkSectionView(dog: dog)
}
}
}
}
}
Dogis a class. YourlistinDogArrayonly stores references. When the properties of a Dog change, its reference remains unchanged, thelistdoesn't change and thedidSetis not called. MakeDogastructand useBindingfor your DogDetailView.