@SectionedFetchRequest with hierarchical data structure (maybe OutlineGroup?)

I am working on a couple of Multiplatform apps that use a very similar 3 column layout - sidebar menu (or tabs for sizeClassHorizontal == .compact), with a master list under each menu option and detail view based upon master list selection.

The apps use Core Data framework and NSPersistentCloudKitContainer for backup and syncing across a user's devices. The apps use Codegen = Class Definition and I write extensions for each entity to prepare convenience methods, properties, comply with generic protocol conformance.

I am learning how to retrofit sectioned fetch requests into each master List. Generally this is straightforward and I'm really happy with the result.

However, one particular master list is based upon an hierarchical data structure and I have investigated using the children: parameter in List to successfully create an hierarchical list.

So to be clear, the following code is based upon @FetchRequest var gardens: FetchedResults<Garden> (not a sectioned fetch request) and works well...

extension Garden {
    var arrayGardenChildren: [Garden]? {
        let setGardenChildren = self.children as? Set<Garden> ?? []
        return setGardenChildren.sorted(by: { $0.name ?? " NO NAME" < $1.name ?? " NO NAME" })
    }
}

and

@FetchRequest(sortDescriptors: [
                SortDescriptor(\.name, order: .forward),
                SortDescriptor(\.timeStampCreated, order: .reverse)
              ],
              animation: .default
) var gardens: FetchedResults<Garden>

and

private func arrayGardens() -> [Garden] {
    return Array(gardens)
}

and

List(arrayGardens(), children: \.arrayGardenChildren) { garden in
    NavigationLink(destination: GardenDetail(...),
                   tag: garden.uuID!.uuidString,
                   selection: $appData.selectedGardens
    ) {
        GardenRow(garden: garden,
                  selected: garden.uuID?.uuidString == appData.selectedGardens)
    }
}

... perhaps obviously from this code, I am forced to convert the FetchedResults to an Array and the entity's children from an NSSet to a sorted Array - no big deal - a little extra code but it works fine and I'm happy.

On first impressions, the lists look great on macOS and iOS, however there are a few downsides to this implementation:

  • expansion state cannot be set as @SceneStorage or therefore controlled as per the sidebar menu in the Apple Developer "Garden" sample app for the WWDC21 code-along "Building a Great Mac App with SwiftUI";
  • for both iOS and macOS, there are no section headers, which are my preference when the list becomes longer;
  • for both iOS and macOS, the bottom level items in each hierarchy tree include a disclosure chevron despite that those items have no children;
  • on iOS, due to the navigation link for the detail view, there are double chevrons at the trailing edge of each row... the inner chevron is the link to the detail view and the outer chevron is the disclosure indicator. This appearance is not ideal and there are a few of ways I can adjust the UI to suit, but include the raw / unedited constraint here for clarity.

So I have continued experimenting with a sectioned fetch request.

As a starting point, the following code works well...

extension Garden {
    @objc var sectionNameParentNil: String {
        return self.name?.filter({ _ in self.parent == nil }) ?? "NO NAME"
    }
}

where each instance of the entity Garden has two relationships that allow the parent child arrangement, a To-One relationship named parent and a To-Many relationship named children (both with destination = Garden).

...and...

SectionedFetchRequest(sectionIdentifier: \.sectionNameParentNil,
                      sortDescriptors: [
                        SortDescriptor(\.name, order: .forward),
                        SortDescriptor(\.timeStampCreated, order: .reverse)
                      ],
                      animation: .default)
) var gardens: SectionedFetchResults<String, Garden>

...and...

List {
    ForEach(gardens) { section in
        Section(header: Text(section.id)) {
            ForEach(section) { garden in
                NavigationLink(destination: GardenDetail(...),
                               tag: garden.uuID!.uuidString,
                               selection: $appData.selectedGardens
                ) {
                    GardenRow(garden: garden,
                              selected: garden.uuID?.uuidString == appData.selectedGardens)
                }
            }
        }
    }
}

This is a good start and works well but only presents the top level elements of the hierarchical data set (where parent = nil).

I want to be able to include all children for each top level "Garden" and also mimic the expandable / collapsable function demonstrated in the above noted sample app.

I've done some googling and read a few articles, including "Displaying recursive data using OutlineGroup in SwiftUI" by one of my favourite SwiftUI bloggers.

Based upon my research and reading, I begun by replacing the second ForEach line in the above block with...

            OutlineGroup(section, children: \Garden.children ?? nil) { garden in

The compiler complains

"Cannot convert value of type 'ReferenceWritableKeyPath<Garden, NSSet?>?' to expected argument type 'KeyPath<SectionedFetchResults<String, Garden>.Section.Element, SectionedFetchResults<String, Garden>.Element?>' (aka 'KeyPath<Garden, Optional<SectionedFetchResults<String, Garden>.Section>>')".

My compiler interpretation skills are intermediate at best, but this is beyond me.

I've attempted a few different combinations to attempt to provide a suitable type, but all my attempts to date have caused compiler errors that I am not able to resolve. I'll try to include a few more below.

            OutlineGroup(section, children: \.arrayGardenChildren) { garden in

causes the following compiler error

Key path value type '[Garden]?' cannot be converted to contextual type 'SectionedFetchResults<String, Garden>.Element?' (aka 'Optional<SectionedFetchResults<String, Garden>.Section>')

So from these messages I somehow need to provide a key path for SectionedFetchResults<String, Garden>.Element.

The question for me is how?

Any suggestions please?

  • Or is OutlineGroup the means to achieve this? Maybe there is a more suitable API?

Add a Comment

Replies

I just encountered and implemented this today actually. Your arrayGardenChildren shouldn’t return an optional per the compiler error. So that should be easy to fix. So I setup a SectionedFetchRequest<Folder, Note>. My example is similar but trickier. So I think if you wanted to do it on the children where its the same object type.

Also your SectionedFetchRequest, you can try looking at it from a different perspective. You’re going down the right path of handling the optional parent. But instead of filtering via the sectionIdentifier, maybe you should just add a predicate.

@SectionedFetchRequest private var notes: SectionedFetchResults<Folder, Note>
ForEach(notes) { folder in
    DisclosureGroup(content: {                        
        ForEach(section) { note in                            
            NavigationLink(destination: NoteView(note: note), 
                                       label: { NoteRow(note: note)})}}, 
                                  label: { FolderRow(folder: folder.id)
})
  • Thanks for your answer, very helpful and appreciated, however unfortunately does not provide the visual appearance and function that I was hoping for... primarily a cascading style disclosure indicator at every level / for each child.

Add a Comment

Using the very helpful answer by @nocsi I have the following working solutions...

List {
    ForEach(gardens) { section in
        DisclosureGroup(section.id, content: {
            ForEach(section) { garden in
                NavigationLink(destination: GardenDetail(...),
                               tag: garden.uuID!.uuidString,
                               selection: $appData.selectedGardens
                ) {
                    GardenRow(garden: garden,
                              selected: garden.uuID?.uuidString == appData.selectedGardens)
                }
            }
        })
    }
}

OR

List {
    ForEach(gardens) { section in
        DisclosureGroup(section.id, content: {
            ForEach(section) { garden in
                NavigationLink(destination: GardenDetail(...),
                               tag: garden.uuID!.uuidString,
                               selection: $appData.selectedGardens
                ) {
                    GardenRow(garden: garden,
                              selected: garden.uuID?.uuidString == appData.selectedGardens)
                }
            }
        },
            label: { Text(section.id)
        })
    }
}

However this solution...

  • provides only a top level disclosure group, not the cascading outline group style that I was hoping to achieve;
  • does not provide section headers for iOS and macOS;
  • in my humble opinion, does not work as well as the disclosure indicators for iOS and sizeClassHorizontal == .compact that are automatically provided for each section.