Home > Net >  ForEach on a dictionary
ForEach on a dictionary

Time:11-11

I need my app to display a table of data. The data looks like ["body": Optional("Go Shopping"), "isDeleted": Optional(false), "_id": Optional("63333b1600ce507b0097e3b3"), "isCompleted": Optional(false)] The column headers for the table would be the keys body, isDeleted, isCompleted, _id. I will have multiple instances of this data that will have the same keys, but different values. I will need to display the values for each data instance under the respective header and each row will belong to one data instance.

Example:

enter image description here

I'm struggling because the only way I can think of doing this is with a dictionary, but run into a lot of problems when using a dictionary in the View.

*** Important Note: The app allows a user to select a certain collection and then the app will load all the data for that collection. Each collection has different keys in its data, so I cannot create a specific struct since I won't actually know the keys/values in the data. The model will have to be dynamic in the sense that I don't know what key/value types will be used in each collection and the table will need to redraw when a different collection is selected.

What I Tried

A document class that would hold a 'value: [String: Any?]` the string would be the key and the Any is the value from the data instance

class Document{
    let value: [String:Any?]
    
    init(value:[String:Any?]) {
        self.value = value
    }
}

in my ViewModel I have a call to a database that uses the selected collection name to return an array of all the documents from that collection. I loop through the array and create a Document obj with the value of the Document looking like ["body": Optional("Go Shopping"), "isDeleted": Optional(false), "_id": Optional("63333b1600ce507b0097e3b3"), "isCompleted": Optional(false)] and I add each Document to an array of Document's

class DocumentsViewModel : ObservableObject {
    @Published var docKeys: [String]?
    @Published var docsList: [Document]?

    func getDocs() {
       ... //Database call to get docs from collection

            for doc in docs {
                // add doc keys to array (used for table header)
                self.docKeys = doc.value.keys.map{$0}
                
                self.docsList?.append(Document(value: doc.value))
    }

Then in my View I tried to first display a header from the docKeys and then use that key to loop through the array of [Document] and access the value var and use the key to get the correct value to display under the header for that document

    var body: some View {
        Text(viewModel.collectionName)
        
        HStack {
            ForEach(viewModel.docKeys ?? [], id: \.description) {key in
                Text(key.name)
                VStack {
                    ForEach(viewModel.docsList ?? [], id: \.value) { doc in
                        Text(doc.value[property.name])
                    }
                }
            }
        }
    }

After doing research I understand why I can't ForEach over an unsorted dictionary.

I will accept any help/guidance on how I can display this table. Also, if there is any other suggestions besides using a dictionary? THANK YOU!

CodePudding user response:

Just a a few hints:

  • If you need "dictionary with order" you can try to use a Key-ValuePairs object which is essentially an array of tuples with labels key and value
    let values: KeyValuePairs = ["key1": "value1", "key2": "value2"] when you print this to the console you'll realize that this is just a tuple! print(values[0]) will display (key: "key1", value: "value1")
  • please take a look at OrderedDictionary from The Swift Collections https://www.kodeco.com/24803770-getting-started-with-the-swift-collections-package
  • have you consider to use an array of simple structs instead?
struct Document {
  let body: String?
  let isDeleted: Bool?
  let id: String?
  let isCompleted: Bool?
...
}

CodePudding user response:

Here is some example code that shows how to deal with different collections data, each having different keys. And still put all the results into an array of [Document], that then gets displayed in a view.

struct ContentView: View {
    @StateObject var viewModel = DocumentsViewModel()
    
    // display one collection
    func listCollection(_ name: String) -> some View {
        VStack {
            if let docs = viewModel.docCollections[name] {
                Text(name).foregroundColor(.blue)
                ScrollView {
                    ForEach(docs) { doc in
                        HStack {
                            ForEach(Array(doc.data.keys), id: \.self) { key in
                                HStack {
                                    Text(key).foregroundColor(.red)
                                    if let val = doc.data[key] as? NSObject {
                                        Text("\(val)")
                                    }
                                }
                            }
                        }
                        Divider()
                    }
                }
            } else {
                Text("no data for collection \(name)")
            }
        }
    }
    
    var body: some View {
        ScrollView {
            listCollection("first collection")
            Divider()
            listCollection("second collection")
        }
        .onAppear {
            viewModel.getDocsFor("first collection")
            viewModel.getDocsFor("second collection")
        }
    }
}

class DocumentsViewModel : ObservableObject {
    // the key as the name of the collection, and [Document] as the values
    @Published var docCollections: [String : [Document]] = [:]
    
    // data for testing, "first collection"
    let jsonData1 = """
 [
 {
 "body": "Go Shopping",
 "isDeleted": false,
 "_id": "6300b3",
 "isCompleted": false
 },
{
 "body": "Go xxxx",
 "isDeleted": false,
 "_id": "1234",
 "isCompleted": true
 },
{
 "body": "Go yyyy",
 "isDeleted": true,
 "_id": "7523",
 "isCompleted": false
 }
 ]
"""
    
    // data for "second collection"
    let jsonData2 = """
 [
 {
 "xbody": "Go X",
 "xbool": false,
 "_id": "1600b3",
 "xopt": "TTTTT"
 },
 {
 "xbody": "XXXXX",
 "xbool": true,
 "_id": "6468",
 "xopt": null
 },
 {
 "xbody": "UUUUUU",
 "xbool": false,
 "_id": "864",
 "xopt": "ertyuu"
 }
 ]
"""
    // simulated database fetching of any collection data
    func getDocsFor(_ collectionName: String) {
        // for testing
        var jsonData = jsonData1    // "first collection"
        if collectionName == "second collection" { jsonData = jsonData2 }
        
        if let data = jsonData.data(using: .utf8) {
            do {
                docCollections[collectionName] = try JSONDecoder().decode([Document].self, from: data)
            } catch {
                print("decoding error: \(error)")
            }
        }
    }

//        var docsList: [Document] = []
//        for doc in docs {
//            docsList.append(Document(data: doc.value))
//        }
//        docCollections[collectionName] = docsList
        
        // or alternatively
        // docCollections[collectionName] = docs.map{Document(data: $0.value)}

    
}

struct Document: Identifiable, Decodable {
    let id = UUID()
    var data: [String: Any?] = [:]
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: DynamicKey.self)
        container.allKeys.forEach { key in
            if let theString = try? container.decode(String.self, forKey: key) {
                self.data[key.stringValue] = theString
            }
            if let theInt = try? container.decode(Int.self, forKey: key) {
                self.data[key.stringValue] = theInt
            }
            if let theDouble = try? container.decode(Double.self, forKey: key) {
                self.data[key.stringValue] = theDouble
            }
            if let theBool = try? container.decode(Bool.self, forKey: key) {
                self.data[key.stringValue] = theBool
            }
        }
    }
    
}

struct DynamicKey: CodingKey {
    var intValue: Int?
    init?(intValue: Int) {
        self.intValue = intValue
        self.stringValue = ""
    }
    
    var stringValue: String
    init?(stringValue: String) {
        self.stringValue = stringValue
        self.intValue = nil
    }
}
  • Related