52
votes

Is there a way to iterate through a Dictionary in a ForEach loop? Xcode says

Generic struct 'ForEach' requires that '[String : Int]' conform to 'RandomAccessCollection'

so is there a way to make Swift Dictionaries conform to RandomAccessCollection, or is that not possible because Dictionaries are unordered?

One thing I've tried is iterating the dictionary's keys:

let dict: [String: Int] = ["test1": 1, "test2": 2, "test3": 3]
...
ForEach(dict.keys) {...}

But keys is not an array of Strings, it's type is Dictionary<String, Int>.Keys (not sure when that was changed). I know I could write a helper function that takes in a dictionary and returns an array of the keys, and then I could iterate that array, but is there not a built-in way to do it, or a way that's more elegant? Could I extend Dictionary and make it conform to RandomAccessCollection or something?

9
At WWDC21 Apple announced the Collections package that includes OrderedDictionary which works seamlessly with ForEach - see this answer.pawello2222

9 Answers

84
votes

You can sort your dictionary to get (key, value) tuple array and then use it.

struct ContentView: View {
    let dict = ["key1": "value1", "key2": "value2"]
    
    var body: some View {
        List {
            ForEach(dict.sorted(by: >), id: \.key) { key, value in
                Section(header: Text(key)) {
                    Text(value)
                }
            }
        }
    }
}
20
votes

Since it's unordered, the only way is to put it into an array, which is pretty simple. But the order of the array will vary.

struct Test : View {
let dict: [String: Int] = ["test1": 1, "test2": 2, "test3": 3]
var body: some View {
    let keys = dict.map{$0.key}
    let values = dict.map {$0.value}

    return List {
        ForEach(keys.indices) {index in
            HStack {
                Text(keys[index])
                Text("\(values[index])")
            }
        }
    }
}
}

#if DEBUG
struct Test_Previews : PreviewProvider {
    static var previews: some View {
        Test()
    }
}
#endif
19
votes

Simple answer: no.

As you correctly pointed out, a dictionary is unordered. The ForEach watches its collection for changes. These changes includes inserts, deletions, moves and update. If any of those changes occurs, an update will be triggered. Reference: https://developer.apple.com/videos/play/wwdc2019/204/ at 46:10:

A ForEach automatically watches for changes in his collection

I recommend you watch the talk :)

You can not use a ForEach because:

  1. It watches a collection and monitors movements. Impossible with an unorered dictionary.
  2. When reusing views (like a UITableView reuses cells when cells can be recycled, a List is backed by UITableViewCells, and I think a ForEach is doing the same thing), it needs to compute what cell to show. It does that by querying an index path from the data source. Logically speaking, an index path is useless if the data source is unordered.
6
votes

Xcode: 11.4.1~

    ...
    var testDict: [String: Double] = ["USD:": 10.0, "EUR:": 10.0, "ILS:": 10.0]
    ...
    ForEach(testDict.keys.sorted(), id: \.self) { key in
        HStack {
            Text(key)
            Text("\(testDict[key] ?? 1.0)")
        }
    }
    ...

more detail:

final class ViewModel: ObservableObject {
    @Published
    var abstractCurrencyCount: Double = 10
    @Published
    var abstractCurrencytIndex: [String: Double] = ["USD:": 10.0, "EUR:": 15.0, "ILS:": 5.0]
}

struct SomeView: View {
    @ObservedObject var vm = ViewModel()
    var body: some View {
        NavigationView {
            List {
                ForEach(vm.abstractCurrencytIndex.keys.sorted(), id: \.self) { key in
                    HStack {
                        Text(String(format: "%.2f", self.vm.abstractCurrencyCount))
                        Text("Abstract currency in \(key)")
                        Spacer()
                        Text(NumberFormatter.localizedString(from: NSNumber(value: self.vm.abstractCurrencytIndex[key] ?? 0.0), number: .currency))
                    }
                }
            }
        }
    }
}
5
votes

OrderedDictionary

At WWDC21 Apple announced the Collections package that includes OrderedDictionary (among others).

Now, we just need to replace:

let dict: [String: Int] = ["test1": 1, "test2": 2, "test3": 3]

with:

let dict: OrderedDictionary = ["test1": 1, "test2": 2, "test3": 3]

Alternatively, we can init one from another:

let dict: [String: Int] = ["test1": 1, "test2": 2, "test3": 3]
let orderedDict = OrderedDictionary(uniqueKeys: dict.keys, values: dict.values)

Just note that because dict is unordered, you may want to sort the orderedDict to enforce the consistent order.


Here is an example how we can use it in a SwiftUI View:

import Collections
import SwiftUI

struct ContentView: View {
    let dict: OrderedDictionary = ["test1": 1, "test2": 2, "test3": 3]

    var body: some View {
        VStack {
            ForEach(dict.keys, id: \.self) {
                Text("\($0)")
            }
        }
    }
}

Note: Currently Collections is available as a separate package, so you need to import it to your project.


You can find more information here:

0
votes

I was trying to figure this out as well with a Dictionary of enum/Int pairs. I am effectively converting it to array as Andre suggests, but instead of using map I just cast the keys.

enum Fruits : Int, CaseIterable {
    case Apple = 0
    case Banana = 1
    case Strawberry = 2
    case Blueberry = 3
}

struct ForEachTest: View {
    var FruitBasket : [Fruits: Int] = [Fruits.Apple: 5, Fruits.Banana : 8, Fruits.Blueberry : 20]
    var body: some View {
        VStack {
            ForEach([Fruits](FruitBasket.keys), id:\Fruits.hashValue) { f in
                Text(String(describing: f) + ": \(self.FruitBasket[f]!)")
            }
        }
    }
}

struct ForEachTest_Previews: PreviewProvider {
    static var previews: some View {
        ForEachTest()
    }
}
0
votes

Here is how I implemented this:

struct CartView: View {
    var cart:[String: Int]

    var body: some View {
        List {
            ForEach(cart.keys.sorted()) { key in
                Text(key)
                Text("\(cart[key]!)")
            }
        }
    }
}

The first Text View will output the key which is a String. The second Text View will output the value of the Dict at that key which is an Int. The ! that follows this is to unwrap the Optional which contains this Int. In production you would perform checks on this Optional for a more safe way of unwrapping it, but this is a proof of concept.

0
votes

The syntax errors I encountered using code from some of the answers to this post helped me sort out a solution for my own problem...

Using a dictionary that contained:

  • key = a CoreDataEntity;
  • value = the count of instances of that entity from a relationship (type NSSet).

I highlight the answer by @J.Doe and comments that an unordered / random collection Dictionary may not be the best solution to use with table view cells (AppKit NSTableView / UIKit UITableView / SwiftUI List rows).

Subsequently, I'll be rebuilding my code to instead work with arrays.

But if you must work with a Dictionary, here is my working solution:

struct MyView: View {
    
    var body: some View {
        
        // ...code to prepare data for dictionary...

        var dictionary: [CoreDataEntity : Int] = [:]

        // ...code to create dictionary...

        let dictionarySorted = dictionary.sorted(by: { $0.key.name < $1.key.name })
        // where .name is the attribute of the CoreDataEntity to sort by
        
        VStack {  // or other suitable view builder/s
            ForEach(dictionarySorted, id: \.key) { (key, value) in
                Text("\(value) \(key.nameSafelyUnwrapped)")
            }
        }
    }
}

extension CoreDataEntity {
    var nameSafelyUnwrapped: String {
        guard let name = self.name else {
            return String() // or another default of your choosing
        }
        return name
    }
}
0
votes

No, you can't use the ForEach View with Dictionary. You can try but it will likely crash, especially if you use the id: .\self hack of if you try to loop a different array from the actual data. As shown in the documentation, to use the ForEach View correctly, you need a "collection of identified data" which you can create by make a custom struct that conforms to Identifiable and use an array containing the structs as follows:

private struct NamedFont: Identifiable {
    let name: String
    let font: Font
    var id: String { name } // or let id = UUID()
}

private let namedFonts: [NamedFont] = [
    NamedFont(name: "Large Title", font: .largeTitle),
    NamedFont(name: "Title", font: .title),
    NamedFont(name: "Headline", font: .headline),
    NamedFont(name: "Body", font: .body),
    NamedFont(name: "Caption", font: .caption)
]

var body: some View {
    ForEach(namedFonts) { namedFont in
        Text(namedFont.name)
            .font(namedFont.font)
    }
}