0

How to save "Checked / Unchecked Tasks" to persist data with UserDefaults?

In my ListViewModel, I'm attempting to save the checkmarks Bool isCompleted state to UserDefaults. When you relaunch the App, the checkmarks reset to false.

Not sure if I need to add an EnvironmentObject or ObservableObject to the ListView

ListViewModel.swift

//
//  ListViewModel.swift
//

import Foundation

class ListViewModel: ObservableObject {
    
    @Published var items: [ItemModel] = [] {
        didSet {
            saveItems()
        }
    }
    
    let itemsKey: String = "task_items_list"
    
    init() {
        myTaskItems()
    }
    
    func myTaskItems() {
        let taskItems = [
            ItemModel(title: "This is the first task!", isCompleted: false),
            ItemModel(title: "This is the second task!", isCompleted: false),
            ItemModel(title: "This is the third task!", isCompleted: false),
            ItemModel(title: "This is the fourth task!", isCompleted: false),
            ItemModel(title: "This is the fifth task!", isCompleted: false)
        ]
        items.append(contentsOf: taskItems)
        
        guard
            let data = UserDefaults.standard.data(forKey: itemsKey),
            let savedTaskItems = try? JSONDecoder().decode([ItemModel].self, from: data)
        else { return }
        
        self.items = savedTaskItems
    }
    // Update TaskdItems Toggle
    func updateItem(item:ItemModel) {
        if let index = items.firstIndex(where: { $0.id == item.id }) {
            items[index] = item.updateCompletion()
        }
    }
    // Save to UserDefaults
    func saveItems() {
        if let encodedData = try? JSONEncoder().encode(items) {
            UserDefaults.standard.set(encodedData, forKey: itemsKey)
        }
    }
}


ItemModel.swift

//  ItemModel.swift
//

import Foundation

struct ItemModel:Identifiable, Codable {
    let id: String
    let title: String
    let isCompleted: Bool
    
    init(id: String = UUID().uuidString, title: String, isCompleted: Bool) {
        self.id = UUID().uuidString
        self.title = title
        self.isCompleted = isCompleted
    }
    
    func updateCompletion() -> ItemModel {
        return ItemModel(id: id, title: title, isCompleted: !isCompleted)
    }
    
}

ListView.swift

//
//  ListView.swift
//

import SwiftUI

struct ListView: View {
    
    @EnvironmentObject var listViewModel: ListViewModel
    
    var body: some View {
        List {
            ForEach(listViewModel.items) { item in
                ListRowView(item: item)
                    .onTapGesture {
                        listViewModel.updateItem(item: item)
                    }
            }
        }
        .listStyle(PlainListStyle())
        .navigationTitle("My Task List")
    }
    
    
    struct ListView_Previews: PreviewProvider {
        static var previews: some View {
            NavigationView {
                ListView()
            }
            .environmentObject(ListViewModel())
        }
    }
    
}

ListRowView.swift

//
//  ListRowView.swift
//

import SwiftUI

struct ListRowView: View {
    
    let item: ItemModel
    
    var body: some View {
        HStack {
            Image(systemName: item.isCompleted ? "checkmark.circle" : "circle")
                .foregroundColor(item.isCompleted ? .green : .gray)
            Text(item.title)
            Spacer()
        }
        .font(.title2)
        .padding(.vertical, 8)
    }
}

struct ListRowView_Previews: PreviewProvider {
    
    static var item1 = ItemModel(title: "First item!", isCompleted: false)
    static var item2 = ItemModel(title: "Second item!", isCompleted: true)
    
    static var previews: some View {
        
        Group {
            ListRowView(item: item1)
            ListRowView(item: item2)
        }
        .previewLayout(.sizeThatFits)
    }
}

TaskListApp.swift

//
//  TaskListApp.swift
//

import SwiftUI

@main
struct TaskListApp: App {

    @StateObject var listViewModel: ListViewModel = ListViewModel()

    var body: some Scene {
        WindowGroup {
            NavigationView {
                ListView()
            }
            .environmentObject(listViewModel)
        }
    }
}

ScreenShot of My Task List

burnsi
  • 6,194
  • 13
  • 17
  • 27

1 Answers1

0

The issue here is your myTask function. Everytime you initialize the ListViewmodel you are overwriting the UserDefaults with your initial array. Move that code into the else block of the guard statement.

func myTaskItems() {        
    guard
        let data = UserDefaults.standard.data(forKey: itemsKey),
        let savedTaskItems = try? JSONDecoder().decode([ItemModel].self, from: data)
    else {
        let taskItems = [
            ItemModel(title: "This is the first task!", isCompleted: false),
            ItemModel(title: "This is the second task!", isCompleted: false),
            ItemModel(title: "This is the third task!", isCompleted: false),
            ItemModel(title: "This is the fourth task!", isCompleted: false),
            ItemModel(title: "This is the fifth task!", isCompleted: false)
        ]
        items.append(contentsOf: taskItems)
        return
    }

This does fix the issue.


But I would ask you to consider a different approach that makes your code easier. You could use @Appstorage in combination with @Binding. Use the @Appstorage in the viewmodel and pass the ItemsModel via Binding down to the ListRowView.

I have commented the code as far as I thought it might help. If you have questions about this implementation feel free to ask.

//needed to use array in @Appstorage
extension Array: RawRepresentable where Element: Codable{
    public init?(rawValue: String) {
            guard let data = rawValue.data(using: .utf8),
                  let result = try? JSONDecoder().decode([Element].self, from: data)
            else {
                return nil
            }
            self = result
        }

        public var rawValue: String {
            guard let data = try? JSONEncoder().encode(self),
                  let result = String(data: data, encoding: .utf8)
            else {
                return ""
            }
            return result
        }
    
}

class ListViewModel: ObservableObject {
    // this loads and saves the items automatic to UserDefaults
    @AppStorage("task_items_list") var items: [ItemModel] = []
    
    
    init() {
        //fill the collection only if it is empty
        if items.isEmpty{
            myTaskItems()
        }
    }
    
    func myTaskItems() {
        items = [
            ItemModel(title: "This is the first task!", isCompleted: false),
            ItemModel(title: "This is the second task!", isCompleted: false),
            ItemModel(title: "This is the third task!", isCompleted: false),
            ItemModel(title: "This is the fourth task!", isCompleted: false),
            ItemModel(title: "This is the fifth task!", isCompleted: false)
        ]
        
    }
    
    //The rest of the functions are not needed
}
        

struct ItemModel: Identifiable, Codable {
    var id: String = UUID().uuidString
    var title: String
    var isCompleted: Bool
}

struct ListView: View {
    
    @EnvironmentObject var listViewModel: ListViewModel
    
    var body: some View {
        List {
            //Use the $ syntax to pass a binding on to the subviews
            ForEach($listViewModel.items) { $item in
                ListRowView(item: $item)
            }
        }
        .listStyle(PlainListStyle())
        .navigationTitle("My Task List")
    }
}

struct ListRowView: View {
    //this binding will assure changes will bubble up into the viewmodel and will be saved in UserDefaults
    @Binding var item: ItemModel
    
    var body: some View {
        HStack {
            Image(systemName: item.isCompleted ? "checkmark.circle" : "circle")
                .foregroundColor(item.isCompleted ? .green : .gray)
            Text(item.title)
            Spacer()
        }
        .font(.title2)
        .padding(.vertical, 8)
        // this is enough to toggle the checkmark
        .onTapGesture {
            item.isCompleted.toggle()
        }
    }
}
burnsi
  • 6,194
  • 13
  • 17
  • 27
  • How do I go about the implementation using @Appstorage and @Binding I tried calling ListView() in ContentView but got a fatal error message. `ListView()` – Kraig Kistler Sep 11 '22 at 01:45
  • @KraigKistler what is the error messsage precisely? I´ve tested the code and it should work. – burnsi Sep 11 '22 at 01:50
  • AppTest crashed due to missing environment of type: ListViewModel. To resolve this add .environmentObject(ListViewModel(...))` to the appropriate preview. Show Crash Logs – Kraig Kistler Sep 11 '22 at 02:34
  • SwiftUI/EnvironmentObject.swift:70: Fatal error: No ObservableObject of type ListViewModel found. A View.environmentObject(_:) for ListViewModel may be missing as an ancestor of this view. 2022-09-10 20:16:24.269126-0700 ListView[59612:1765909] SwiftUI/EnvironmentObject.swift:70: Fatal error: No ObservableObject of type ListViewModel found. A View.environmentObject(_:) for ListViewModel may be missing as an ancestor of this view. (lldb) – Kraig Kistler Sep 11 '22 at 03:17
  • @KraigKistler the error message seems pretty obvious. You are not passing you viewmodel to you Child views. Look at the code you posted in the question. In `TaskListApp` you are injecting you viewmodel into the environment. Why you removed that is unknown to me. – burnsi Sep 11 '22 at 09:23
  • I have added the `extension Array` and `class ListViewModel` into ListViewModel.swift. I'm now getting ths one Error message for`@AppStorage("task_items_list") var items: [ItemModel] = []` ERROR MESSAGE **Unknown attribute 'AppStorage'** – Kraig Kistler Sep 11 '22 at 16:35
  • @KraigKistler You would need to `import SwiftUI` in your ViewModel file. – burnsi Sep 11 '22 at 19:13
  • Would you look at my post I can't figure out how to load data classes to multiple Lists it is a the code you help with in this post. https://stackoverflow.com/questions/73942323/get-data-from-model-for-multiple-list-views-observableobject-class-and-app-stora – Kraig Kistler Oct 04 '22 at 01:49