skip to Main Content

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


  1. In your project Dog is a class. Your array list, in your DogArray class, only stores references. When the properties of a Dog change, its reference remains unchanged, the array doesn’t change and the didSet is not called.

    I simplified your project to make the problem more understandable:

    struct DogList: View {
        @StateObject var dogs = DogArray(list: ["John", "Bob"].map(Dog.init))
        var body: some View {
            NavigationView {
                List(dogs.list.indices) {index in
                    NavigationLink(destination: DogDetail(dog: dogs.list[index])) {
                        Text(dogs.list[index].name)
                        Image(systemName: "heart.fill")
                            .foregroundColor(dogs.list[index].favorite ? .red : .gray)
                    }
                }
            }
        }
    }
    
    struct DogDetail: View {
        @ObservedObject var dog: Dog
        var body: some View {
            VStack {
                Text(dog.name)
                    
                Button {
                    dog.favorite.toggle()
                } label: {
                    Image(systemName: "heart.fill")
                        .foregroundColor(dog.favorite ? .red : .gray)
                }
            }.font(.largeTitle)
        }
    }
    
    class Dog: ObservableObject {
        let id = UUID()
        var name: String
        @Published var favorite = false
        init(_ name: String) {
            self.name = name
        }
    }
    
    
    class DogArray: ObservableObject {
        @Published var list: [Dog] {
            didSet {
                print("Array changes")
            }
        }
        init(list: [Dog]) {
            self.list = list
        }
    }
    

    Here, when the user taps the heart in the DogDetail View, the Dog changed, the DogDetail‘s body is redrawn (because Dog is observable). But the DogList is not invalidated. The parent view does not know that the Dog has changed.

    We have only three things to do to resolve problem.

    1. Make Dog a struct
    2. @ObservedObject var dog: Dog -> @Binding var dog: Dog
    3. 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 (here Dog : to be avoided), your Store (here DogArray), 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:

    import SwiftUI
    struct DogList: View {
        class ViewModel: ObservableObject {
            @Published var list: [DogDetail.ViewModel]
            init(dogs: [Dog]) {
                list = []
                list = dogs.map { DogDetail.ViewModel(dog: $0, parent: self) }
            }
            func save() {
                print("list saved")
            }
        }
        @StateObject var vm = DogList.ViewModel(dogs:["John", "Bob"].map(Dog.init))
        var body: some View {
            NavigationView {
                List(vm.list, id: .dog.id) {detail in
                    NavigationLink(destination: DogDetail(vm: detail)) {
                        DogRow(vm: detail)
                    }
                }
            }
        }
    }
    
    struct DogRow: View {
        @ObservedObject var vm: DogDetail.ViewModel
        var body: some View {
            Text(vm.dog.name)
            Image(systemName: "heart.fill")
                .foregroundColor(vm.dog.favorite ? .red : .gray)
        }
    }
    
    struct DogDetail: View {
        class ViewModel: ObservableObject {
            let parent: DogList.ViewModel
            @Published var dog: Dog {
                didSet {
                    print("a dog changed")
                    parent.objectWillChange.send()
                    parent.save()
                }
            }
            init(dog: Dog, parent: DogList.ViewModel) {
                self.dog = dog
                self.parent = parent
            }
        }
        
        @ObservedObject var vm: ViewModel
        var dog: Dog { vm.dog }
        var body: some View {
            VStack {
                Text(dog.name)
                Button {
                    vm.dog.favorite.toggle()
                } label: {
                    Image(systemName: "heart.fill")
                        .foregroundColor(dog.favorite ? .red : .gray)
                }
            }.font(.largeTitle)
        }
    }
    

    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 in DogDetail.ViewModel.dog :

    import Combine
    struct DogList: View {
        class ViewModel: ObservableObject {
            @Published var list: [DogDetail.ViewModel] {
                didSet {
                    subscribeToChanges()
                }
            }
            func subscribeToChanges() {
                self.cancellable = list.publisher
                    .flatMap { $0.dogDidChange }
                    .sink { [weak self] _ in
                        self?.objectWillChange.send()
                        self?.save()
                    }
            }
            var cancellable: AnyCancellable?
            init(dogs: [Dog]) {
                list = dogs.map { DogDetail.ViewModel(dog: $0) }
                subscribeToChanges()
            }
            func save() {
                print("list saved :")
                for dogVM in list {
                    print(dogVM.dog.favorite)
                }
            }
        }
    // ....//
    }
    

    You need a dogDidChange publisher in DogDetail.ViewModel :

    struct DogDetail: View {
        class ViewModel: ObservableObject {
            var dogDidChange = PassthroughSubject<Void, Never>()
            @Published var dog: Dog {
                didSet {
                    print("a dog changed")
                    dogDidChange.send()
                }
            }
            init(dog: Dog) {
                self.dog = dog
            }
        }
    //...//
    }
    
    Login or Signup to reply.
  2. When you adding walk just set explicitly dog to the list like below hope it work fine

    Button(action: {
                    dog.walk(time: Date(), firstSelect: dog.firstSelect, secondSelect: dog.secondSelect)
                    dog.firstSelect = false
                    dog.secondSelect = false
                    dog.sortWalks()
                    guard let i = dogData.list.firstIndex(where: {$0.id == dog.id})else{
                        return
                    }
                    dogData.list[i] = dog
                    
                }, label: {
                    Text("Walk")
                        .frame(width: 120, height: 60)
                        .foregroundColor(.white)
                })
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search