Saving Checked / Unchecked Tasks to UserDefaults

Viewed 48

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

1 Answers

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()
        }
    }
}
Related