14

Im facing issues with displaying my Core Data inside of SwiftUI because of relationships being Sets. What is the best way to go about displaying a many relationship set inside of a SwiftUI ForEach loop?

For instance, I have two entities in core date: Entry & Position. Entry can contain many positions, and each position can only belong to one entry.

I have the Entry as a @binding var on the view that is suppose to display the entry's Positions. I would ideally like to access the positions directly from this entry variable, but because positions are a Set, I get the following error:

error 'ForEach' requires that 'Set' conform to 'RandomAccessCollection'

@Binding private var entry: Entry?

ForEach(entry?.positions) { position in
    Text("position here")
}

Solution One:

Or, I could do a fetch request for all positions, then filter out all the ones that do not belong to the entity, I do not like this idea as I would be fetching potentially thousands of Positions to only get a few. (or am i thinking of this wrong? Im new to core data, coming from realm because of swiftui)

Although this could work if I could do a fetch ONLY on the @binding entry var, and fetch all its positions as sorted fetched results, but I'm not sure there is a way to do this.

Maybe like this, or would this be a performance issue if there was potentially thousands of entry's each with 10-20+ positions? and could objectID be used this way, and would it still be unique if the entry was moved into another journal?:

@Binding private var entry: Entry?

@FetchRequest(
    entity: Position.entity(),
    sortDescriptors: [],
    predicate: NSPredicate(formate: "entry.objectID == %@", self.entry.objectID)
) var positions: FetchedResults<Position>

Solution Two:

I thought of adding an attribute to positions like 'date', this way positions could be compared and sorted? But not sure how this could be updated with SwiftUI, as it would be done only once in the init().

let list = entry.wrappedValue?.positions?.sorted()

Core Data Models:

public class Entry: NSManagedObject, Identifiable {

    // MARK: - ATTRIBUTES
    @NSManaged public var date: Date

    // MARK: - RELATIONSHIPS
    @NSManaged public var journal: Journal?
    @NSManaged public var positions: Set<Position>?
}

public class Position: NSManagedObject, Identifiable {

    // MARK: - RELATIONSHIPS
    @NSManaged public var entry: Entry?
}

How would you go about solving this problem? Keep in mind on the view where the positions are being listed, that this view can add, delete, and modify positions, so the solution should allow SwiftUI to reload the view and update the positions when changes are made.

Eye of Maze
  • 1,327
  • 8
  • 20
  • I have the exact same problem. If you figure out a solution please post an answer. – kdion4891 Nov 07 '19 at 02:14
  • 1
    Have you tried `ForEach(Array(entry?.positions))`? – Joakim Danielson Nov 07 '19 at 18:38
  • @JoakimDanielson Brilliantly simple. Although optional need to be forced unwrapped, but to my surprise even with no positions inside of entry this does not cause a crash `ForEach(Array(entry.positions!))`... The side effect is that the array will be in a random order each time it changes, so this paired with positions conforming to the Comparable protocol works like a charm. Thank you! – Eye of Maze Nov 07 '19 at 20:28
  • You can add a sort to if you want... – Joakim Danielson Nov 07 '19 at 20:31
  • Right, conforming to Comparable allows `ForEach(Array(entry.positions!).sorted())` – Eye of Maze Nov 07 '19 at 20:45

3 Answers3

1

@JoakimDanielson comment works like a charm, but requires a few tweaks.

Wrapping the set in the array initializer works like this, but requires optional sets to be unwrapped. I was surprised to find force unwrapping does not cause a crash even if the set is nil? Maybe someone could explain this?

ForEach(Array(entry.positions!)) { position in
   Text("Position")
}

The next issue was that the array would be randomized everytime the set of positions changed due to sets being unordered. So by conforming Position to the Comparable Protocol solved this. I decided it made the most sense to sort positions by date, so I updated the model like so:

public class Position: NSManagedObject, Identifiable {

    // MARK: - ATTRIBUTES
    @NSManaged public var date: Date

    // MARK: - RELATIONSHIPS
    @NSManaged public var entry: Entry?
}

extension Position: Comparable {

    static func < (lhs: Position, rhs: Position) -> Bool {
        lhs.date < rhs.date
    }
}

Then the ForEach could be sorted and looks like this:

ForEach(Array(entry.positions!).sorted()) { position in
   Text("\(position.date)")
}

Some other solutions I found but are not ideal for reasons mentioned in original post, but they do work, is to either use a fetch request customized inside the view init like so:

@FetchRequest var positions: FetchedResults<Position>

init(entry: Entry) {

    var predicate: NSPredicate?

    // Do some kind of control flow for customizing the predicate here.
    predicate = NSPredicate(formate: "entry == %@", entry)

    self._positions = FetchRequest(
        entity: Position.entity(),
        sortDescriptors: [],
        predicate: predicate
    )
}

or create an "middle man" View Model bound to @ObservedObject that converts core data managed objects to useable view data. Which to me makes the least sense because it will basically contain a bunch of redundant information already found inside the core data managed object, and I like the idea of the core data managed object also being the single source of truth.

Eye of Maze
  • 1,327
  • 8
  • 20
  • An option to using Comparable is to sort using a closure, it could be a good option if date isn't the the natural sort order for the class. `.sorted(by: { $0.date < $1.date })` – Joakim Danielson Nov 07 '19 at 20:53
  • I see, good to know. In this case though, date does make a lot of sense. Thank you again! – Eye of Maze Nov 07 '19 at 21:03
  • Can you post your code for adding/editing etc. too? I need to learn how to add and edit relationships. – kdion4891 Nov 09 '19 at 06:45
  • I tried each version of your solution above but received various errors. For the second part with @FetchRequest, I received "Return from initializer without initializing all stored properties". – FontFamily Nov 12 '20 at 01:46
  • yea this solution doesn't work. position is returned as a NSSet not a Position. the code compiles but try accessing any of attributes on position and the compiler will tell you its an NSSet – ngb Jan 31 '21 at 05:30
0

I found myself using this solution frequently so I added an extension to the CoreData object (see below for an example using Entry/Position types). This also has the added benefit of handling optional relationships and simply returning an empty array in that case.

extension Entry {

    func arrayOfPositions() -> [Position] {
        if let items = self.positions as? Set<Position> {
            return items.sorted(by: {$0.date < $1.date})
        } else {
            return []
        }
    }

}

So that instead of unsafely and cumbersomely writing:

ForEach(Array(entry.positions! as! Set<Position>).sorted(by: {$0.date < $1.date})) { item in
    Text(item.description)  
}

You can safely use:

ForEach(entry.arrayOfPositions()) { item in
    Text(item.description)  
}
-2

Simply pass a customised FetchRequest param to the View containing the @FetchRequest property wrapper.

struct PositionsView: View {

    let entry: Entry

    var body: some View {
        PositionsList(positions: FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Positions.date, ascending: true)], predicate: NSPredicate(format: "entry = %@", entry)))
    }
}

struct PositionsList : View {

    @FetchRequest var positions: FetchedResults<Positions>

    var body: some View {
        ForEach(positions) { position in
             Text("\(position.date!, formatter: positionFormatter)")
        }
    }
}

For more detail, see this answer.

malhal
  • 26,330
  • 7
  • 115
  • 133