Home > Software engineering >  Encoding to JSON format is not encoding the toggled boolean value in Swift
Encoding to JSON format is not encoding the toggled boolean value in Swift

Time:04-12

I am making an app that has information about different woods, herbs and spices, and a few other things. I am including the ability to save their favorite item to a favorites list, so I have a heart button that the user can press to add it to the favorites. Pressing the button toggles the isFavorite property of the item and then leaving the page calls a method that encodes the data to save it to the user's device. The problem that I am running into is that it is not encoding the updated value of the isFavorite property. It is still encoding the value as false, so the favorites list is not persisting after closing and reopening the app.

Here is my Wood.swift code, this file sets up the structure for Wood items. I also included the test data that I was using to make sure that it displayed properly in the Wood extension:

import Foundation

struct Wood: Identifiable, Codable {
    var id = UUID()
    
    var mainInformation: WoodMainInformation
    var preparation: [Preparation]
    var isFavorite = false
    
    init(mainInformation: WoodMainInformation, preparation: [Preparation]) {
        self.mainInformation = mainInformation
        self.preparation = preparation
    }
}

struct WoodMainInformation: Codable {    
    var category: WoodCategory
    var description: String
    var medicinalUses: [String]
    var magicalUses: [String]
    var growZone: [String]
    var lightLevel: String
    var moistureLevel: String
    var isPerennial: Bool
    var isEdible: Bool
}

enum WoodCategory: String, CaseIterable, Codable {
    case oak = "Oak"
    case pine = "Pine"
    case cedar = "Cedar"
    case ash = "Ash"
    case rowan = "Rowan"
    case willow = "Willow"
    case birch = "Birch"
}

enum Preparation: String, Codable {
    case talisman = "Talisman"
    case satchet = "Satchet"
    case tincture = "Tincture"
    case salve = "Salve"
    case tea = "Tea"
    case ointment = "Ointment"
    case incense = "Incense"
}

extension Wood {
    static let woodTypes: [Wood] = [
        Wood(mainInformation: WoodMainInformation(category: .oak,
                                                  description: "A type of wood",
                                                  medicinalUses: ["Healthy", "Killer"],
                                                  magicalUses: ["Spells", "Other Witchy Stuff"],
                                                  growZone: ["6A", "5B"],
                                                  lightLevel: "Full Sun",
                                                  moistureLevel: "Once a day",
                                                  isPerennial: false,
                                                  isEdible: true),
             preparation: [Preparation.incense, Preparation.satchet]),
        Wood(mainInformation: WoodMainInformation(category: .pine,
                                                  description: "Another type of wood",
                                                  medicinalUses: ["Healthy"],
                                                  magicalUses: ["Spells"],
                                                  growZone: ["11G", "14F"],
                                                  lightLevel: "Full Moon",
                                                  moistureLevel: "Twice an hour",
                                                  isPerennial: true,
                                                  isEdible: true),
             preparation: [Preparation.incense, Preparation.satchet])
    ]
}

Here is my WoodData.swift file, this file contains methods that allow the app to display the correct wood in the list of woods, as well as encode, and decode the woods:

import Foundation

class WoodData: ObservableObject {
    @Published var woods = Wood.woodTypes
    
    var favoriteWoods: [Wood] {
        woods.filter { $0.isFavorite }
    }
    
    func woods(for category: WoodCategory) -> [Wood] {
        var filteredWoods = [Wood]()
        
        for wood in woods {
            if wood.mainInformation.category == category {
                filteredWoods.append(wood)
            }
        }
        return filteredWoods
    }
    
    func woods(for category: [WoodCategory]) -> [Wood] {
        var filteredWoods = [Wood]()
        filteredWoods = woods
        
        return filteredWoods
    }
    
    func index(of wood: Wood) -> Int? {
        for i in woods.indices {
            if woods[i].id == wood.id {
                return i
            }
        }
        return nil
    }
    
    private var dataFileURL: URL {
        do {
            let documentsDirectory = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
            return documentsDirectory.appendingPathComponent("evergreenData")
        }
        catch {
            fatalError("An error occurred while getting the url: \(error)")
        }
    }

    func saveWoods() {
        if let encodedData = try? JSONEncoder().encode(woods) {
            do {
                try encodedData.write(to: dataFileURL)
                let string = String(data: encodedData, encoding: .utf8)
                print(string)
            }
            catch {
                fatalError("An error occurred while saving woods: \(error)")
            }
        }

    }

    func loadWoods() {
        guard let data = try? Data(contentsOf: dataFileURL) else { return }
        do {
            let savedWoods = try JSONDecoder().decode([Wood].self, from: data)
            woods = savedWoods
        }
        catch {
            fatalError("An error occurred while loading woods: \(error)")
        }
    }
}

Finally, this is my WoodsDetailView.swift file, this file displays the information for the wood that was selected, as well as calls the method that encodes the wood data:

import SwiftUI

struct WoodsDetailView: View {
    @Binding var wood: Wood
    
    @State private var woodsData = WoodData()

    var body: some View {
        VStack {
            List {
                Section(header: Text("Description")) {
                    Text(wood.mainInformation.description)
                }
                Section(header: Text("Preparation Techniques")) {
                    ForEach(wood.preparation, id: \.self) { technique in
                        Text(technique.rawValue)
                    }
                }
                Section(header: Text("Edible?")) {
                    if wood.mainInformation.isEdible {
                        Text("Edible")
                    }
                    else {
                        Text("Not Edible")
                    }
                }
                Section(header: Text("Medicinal Uses")) {
                    ForEach(wood.mainInformation.medicinalUses.indices, id: \.self) { index in
                        let medicinalUse = wood.mainInformation.medicinalUses[index]
                        Text(medicinalUse)
                    }
                }
                Section(header: Text("Magical Uses")) {
                    ForEach(wood.mainInformation.magicalUses.indices, id: \.self) { index in
                        let magicalUse = wood.mainInformation.magicalUses[index]
                        Text(magicalUse)
                    }
                }
                Section(header: Text("Grow Zone")) {
                    ForEach(wood.mainInformation.growZone.indices, id: \.self) { index in
                        let zone = wood.mainInformation.growZone[index]
                        Text(zone)
                    }
                }
                Section(header: Text("Grow It Yourself")) {
                    Text("Water: \(wood.mainInformation.moistureLevel)")
                    Text("Needs: \(wood.mainInformation.lightLevel)")
                    
                    if wood.mainInformation.isPerennial {
                        Text("Perennial")
                    }
                    else {
                        Text("Annual")
                    }
                }
            }
        }
        .navigationTitle(wood.mainInformation.category.rawValue)
        .onDisappear {
          woodsData.saveWoods()
        }
        .toolbar {
            ToolbarItem {
                HStack {
                    Button(action: {
                        wood.isFavorite.toggle()
                    }) {
                        Image(systemName: wood.isFavorite ? "heart.fill" : "heart")
                    }
                }
            }
        }
    }
}

struct WoodsDetailView_Previews: PreviewProvider {
    @State static var wood = Wood.woodTypes[0]
    
    static var previews: some View {
        WoodsDetailView(wood: $wood)
    }
}

This is my MainTabView.swift file:

import SwiftUI

struct MainTabView: View {
    @StateObject var woodData = WoodData()
            
    var body: some View {
        TabView {
            NavigationView {
                List {
                    WoodsListView(viewStyle: .allCategories(WoodCategory.allCases))
                }
            }
                .tabItem { Label("Main", systemImage: "list.dash")}
            NavigationView {
                    List {
                            WoodsListView(viewStyle: .favorites)

                    }

                .navigationTitle("Favorites")
            }.tabItem { Label("Favorites", systemImage: "heart.fill")}
        }
        .environmentObject(woodData)
        .onAppear {
            woodData.loadWoods()
        }
        .preferredColorScheme(.dark)
        
    }
}

struct MainTabView_Previews: PreviewProvider {
    static var previews: some View {
        MainTabView()
    }
}

This is my WoodListView.swift file:

import SwiftUI

struct WoodsListView: View {
    @EnvironmentObject private var woodData: WoodData
    let viewStyle: ViewStyle
    
    var body: some View {
        ForEach(woods) { wood in
            NavigationLink(wood.mainInformation.category.rawValue, destination: WoodsDetailView(wood: binding(for: wood)))
        }
    }
}

extension WoodsListView {
    enum ViewStyle {
        case favorites
        case singleCategory(WoodCategory)
        case allCategories([WoodCategory])
    }
    
    private var woods: [Wood] {
        switch viewStyle {
        case let .singleCategory(category):
            return woodData.woods(for: category)
        case let  .allCategories(category):
            return woodData.woods(for: category)
        case .favorites:
            return woodData.favoriteWoods
        }
    }
    
    func binding(for wood: Wood) -> Binding<Wood> {
        guard let index = woodData.index(of: wood) else {
            fatalError("Wood not found")
        }
        return $woodData.woods[index]
    }
}

struct WoodsListView_Previews: PreviewProvider {
    static var previews: some View {
        WoodsListView(viewStyle: .singleCategory(.ash))
            .environmentObject(WoodData())
    }
}

Any assistance into why it is not encoding the toggled isFavorite property will be greatly appreciated.

CodePudding user response:

Your problem is that structs are value types in Swift. Essentially this means that the instance of Wood that you have in WoodsDetailView is not the same instance that is in your array in your model (WoodData); It is a copy (Technically, the copy is made as soon as you modify the isFavourite property).

In SwiftUI it is important to maintain separation of responsibilities between the view and the model.

Changing the favourite status of a Wood is something the view should ask the model to do.

This is where you have a second issue; In your detail view you are creating a separate instance of your model; You need to refer to a single instance.

You have a good start; you have put your model instance in the environment where views can access it.

First, change the detail view to remove the binding, refer to the model from the environment and ask the model to do the work:

struct WoodsDetailView: View {
    var wood: Wood
    
    @EnvironmentObject private var woodsData: WoodData
    
    var body: some View {
        VStack {
            List {
                Section(header: Text("Description")) {
                    Text(wood.mainInformation.description)
                }
                Section(header: Text("Preparation Techniques")) {
                    ForEach(wood.preparation, id: \.self) { technique in
                        Text(technique.rawValue)
                    }
                }
                Section(header: Text("Edible?")) {
                    if wood.mainInformation.isEdible {
                        Text("Edible")
                    }
                    else {
                        Text("Not Edible")
                    }
                }
                Section(header: Text("Medicinal Uses")) {
                    ForEach(wood.mainInformation.medicinalUses, id: \.self) { medicinalUse in
                        Text(medicinalUse)
                    }
                }
                Section(header: Text("Magical Uses")) {
                    ForEach(wood.mainInformation.magicalUses, id: \.self) { magicalUse in
                        Text(magicalUse)
                    }
                }
                Section(header: Text("Grow Zone")) {
                    ForEach(wood.mainInformation.growZone, id: \.self) { zone in
                        Text(zone)
                    }
                }
                Section(header: Text("Grow It Yourself")) {
                    Text("Water: \(wood.mainInformation.moistureLevel)")
                    Text("Needs: \(wood.mainInformation.lightLevel)")
                    
                    if wood.mainInformation.isPerennial {
                        Text("Perennial")
                    }
                    else {
                        Text("Annual")
                    }
                }
            }
        }
        .navigationTitle(wood.mainInformation.category.rawValue)
        .onDisappear {
            woodsData.saveWoods()
        }
        .toolbar {
            ToolbarItem {
                HStack {
                    Button(action: {
                        self.woodsData.toggleFavorite(for: wood)
                    }) {
                        Image(systemName: wood.isFavorite ? "heart.fill" : "heart")
                    }
                }
            }
        }
    }
}

struct WoodsDetailView_Previews: PreviewProvider {
    static var wood = Wood.woodTypes[0]
    
    static var previews: some View {
        WoodsDetailView(wood: wood)
    }
}

I also got rid of the unnecessary use of indices when listing the properties.

Now, add a toggleFavorite function to your WoodData object:

func toggleFavorite(for wood: Wood) {
     guard let index = self.woods.firstIndex(where:{ $0.id == wood.id }) else {
         return
     }
        
     self.woods[index].isFavorite.toggle()
}

You can also remove the index(of wood:Wood) function (which was really just duplicating Array's firstIndex(where:) function) and the binding(for wood:Wood) function.

Now, not only does your code do what you want, but you have hidden the mechanics of toggling a favorite from the view; It simply asks for the favorite status to be toggled and doesn't need to know what this actually involves.

  • Related