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)
}
}
}
}
}
2
Answers
In your project
Dog
is a class. Your arraylist
, in yourDogArray
class, only stores references. When the properties of aDog
change, its reference remains unchanged, the array doesn’t change and thedidSet
is not called.I simplified your project to make the problem more understandable:
Here, when the user taps the heart in the
DogDetail
View, theDog
changed, theDogDetail
‘s body is redrawn (because Dog is observable). But theDogList
is not invalidated. The parent view does not know that the Dog has changed.We have only three things to do to resolve problem.
Dog
astruct
@ObservedObject var dog: Dog
->@Binding var dog: Dog
DogDetail(dog: dogs.list[index])
->DogDetail(dog: $dogs.list[index])
Now it works.
Your problem is a little more complicated than this example because you use
ObservableObject
for three things: your Model (hereDog
: to be avoided), your Store (hereDogArray
), and a ViewModel (which I can’t see well the usefulness).You should choose I think between using a global Store (your
DogArray
) that you inject with EnvironmentObject at the top of your view hierarchy. Views that need it get it. All share the same object.Or really switch to an architecture with ViewModels, and in this case it’s up to each of them to manage access to the Model.
It could go something like this:
Edit (Combine)
You may not like when the "parent" gives a reference of itself to its "children" :
DogDetail.ViewModel(dog: $0, parent: self)
You can also use Combine and make
DogList.ViewModel
subscribe to changes inDogDetail.ViewModel.dog
:You need a
dogDidChange
publisher inDogDetail.ViewModel
:When you adding walk just set explicitly dog to the list like below hope it work fine