Support item expanding and collapsing#146
Support item expanding and collapsing#146bmarkowitz wants to merge 4 commits intojessesquires:mainfrom
Conversation
1787986 to
238a869
Compare
| self.apply(destinationSectionSnapshot, to: $0.id, animatingDifferences: animated) { [weak self] in | ||
| self?._applySnapshotCompletion(source: source, | ||
| destination: destination, | ||
| completion) |
There was a problem hiding this comment.
Execute completion multiple times?
There was a problem hiding this comment.
ah, yup 🤦 Great catch.
Any ideas? Thinking a dispatch group might work here?
There was a problem hiding this comment.
This should all be greatly simplified and easier after I merge #158
|
Related to the above, found this super interesting response from Apple:
Also goes on to say that "applying snapshots from a background queue is almost always unnecessary in general." |
|
@jessesquires hey! Wanted to see if you had a chance to check this out? |
|
Hey @bmarkowitz! Sorry for the delay here. I really appreciate your work here and I do want to get this reviewed and merged. I've been very busy, then the holidays, now some travel. I'll try to review soon! |
No need to apologize at all, totally understood! Hope you're doing well! |
harrisonbay
left a comment
There was a problem hiding this comment.
@bmarkowitz I'm building an app that has disclosable views and want to use this library. Thanks for the starting point on supporting items expanding and collapsing; attempted to tackle some of the TODOs detailed in your PR's summary. Haven't tested thoroughly or with dynamic data, but the straightforward example app seems to work, including preserving expansion state!
Disclaimer: not an experienced iOS dev
There was a problem hiding this comment.
We need to replace allCellsByIdentifier:
func allCellsByIdentifier() -> [UniqueIdentifier: AnyCellViewModel] {
var allCellsDict = [UniqueIdentifier: AnyCellViewModel]()
var cellsToProcess: [AnyCellViewModel] = []
for section in self.sections {
cellsToProcess.append(contentsOf: section.cells)
}
while let cell = cellsToProcess.popLast() {
allCellsDict[cell.id] = cell
cellsToProcess.append(contentsOf: cell.children)
}
return allCellsDict
}
Note: maybe a place we can optimize performance? You have to call allCellsByIdentifier for both source and dest in _findItemsToReconfigure which size-wise can be >>> the # of cells you actually care about--visible items--which is probably around ~10 on mobile depending on ur design
| // If so, nesting is desired and we must use section snapshots, since UIKit only supports | ||
| // this behavior through the use of NSDiffableDataSourceSectionSnapshots. | ||
| // Otherwise, use a standard snapshot. | ||
| let childrenExist = destination.sections.first?.contains(where: \.children.isNotEmpty) ?? false |
There was a problem hiding this comment.
| let childrenExist = destination.sections.first?.contains(where: \.children.isNotEmpty) ?? false | |
| let childrenExist = destination.sections.contains { section in | |
| section.cells.contains(where: \.children.isNotEmpty) | |
| } |
|
@jessesquires hope all's well! Saw some activity pick up here lately and am interested in picking this back if it's worthwhile - thoughts? |
Hey @bmarkowitz! Yes! Definitely worthwhile 😄 Let's get this rebased on |
There was a problem hiding this comment.
Let's revert all these changes to the Example app.
I want to leave the existing tabs and models as-is and have a new example that's fully independent of everything else.
What I'd prefer to do is:
- Create a new "Outline" tab in the example app to exercise this functionality
- Create new models types to use in this new tab. Here's one idea using sections with emoji:
Produce >
Fruit >
🍌
🍎
Vegetables >
🌽
🫑
Junk Food >
🍕
This gives us multiple opportunities for various layers of nesting.
Alternatively, we could do something similar with SF Symbols.
There was a problem hiding this comment.
If you're up for it, it would be nice to get the basic ground work for this done in a separate PR -- just getting the basic scaffolding setup.
| /// Returns an array of children for the cell. | ||
| /// These children correspond to the items that have been appended to this item as part of a `NSDiffableDataSourceSectionSnapshot`. |
There was a problem hiding this comment.
| /// Returns an array of children for the cell. | |
| /// These children correspond to the items that have been appended to this item as part of a `NSDiffableDataSourceSectionSnapshot`. | |
| /// Returns an array of subitems for this cell. | |
| /// This results in this cell becoming the parent item for this array of children items. |
| /// Default implementation. Returns `true`. | ||
| public var shouldHighlight: Bool { true } | ||
|
|
||
| /// Default implementation. Returns `[]`. |
There was a problem hiding this comment.
| /// Default implementation. Returns `[]`. | |
| /// Default implementation. Returns an empty array, `[]`. |
| /// | ||
| /// - Precondition: The specified `indexPath` must be valid. | ||
| public func cellViewModel(at indexPath: IndexPath) -> AnyCellViewModel { | ||
| public func cellViewModel(at indexPath: IndexPath, in collectionView: UICollectionView) -> AnyCellViewModel { |
There was a problem hiding this comment.
All of the changes here seem wrong. I'm very weary and skeptical of referencing the collection view, datasource, and snapshot here. This breaks a lot of encapsulation.
My first thought is that this method shouldn't change.
But that raises the question: what is the index path of a nested child cell? How does that work?
There was a problem hiding this comment.
per the video here, an item's index path changes depending on if there are expanded items preceding it.
I think this was part of why I went with the approach I did, because we need to know what's visible at the moment. Still need to look around some more to refresh myself.
There was a problem hiding this comment.
Another thing we can do is get the section from the collection view model, then ask the data source for the snapshot of that section, then grab the visibleItems off that and use those to get the cell view model.
something like:
let section = self.viewModel.sectionViewModel(at: sectionIndex)
let snapshot = self._dataSource.snapshot(for: section.id)
let visibleItems = Array(snapshot.visibleItems)
// ...
let identifier = visibleItems[indexPath.item]
if let cell = self.cellViewModel(for: identifier) {
return cell
}There was a problem hiding this comment.
This behavior seems to be mostly undocumented, as mentioned here in the first section:
https://swiftsenpai.com/development/undocumented-section-snapshot-facts/
There was a problem hiding this comment.
per the video here, an item's index path changes depending on if there are expanded items preceding it.
I think this was part of why I went with the approach I did, because we need to know what's visible at the moment. Still need to look around some more to refresh myself.
Ahh thanks. Super helpful.
Another thing we can do is get the section from the collection view model, then ask the data source for the snapshot of that section, then grab the
visibleItemsoff that and use those to get the cell view model.
I am still extremely apprehensive about having CollectionViewModel know anything about any other components. That really breaks a lot of paradigms and encapsulation.
The entire idea behind CollectionViewModel is that it is a full representation of the collection view state. This means it should model the item expand/collapse behavior. So here's what I'm thinking — since CellViewModel now has a children property, it should also have an isExpanded property. Once this is part of the model, we should be able to figure everything out regarding visibility and index paths without having to muck with dataSource, etc. Then everything makes sense. We know that if an item is expanded, then its children are "visible" (here visible means not necessarily on screen, but having a valid index path).
I have not experimented with any code yet, so the open questions on the feasibility of this approach are:
Do we have to keep isExpanded in sync with the data source somehow? Can we do that? It seems like there are callbacks for this, UICollectionViewDiffableDataSourceSectionSnapshotHandlers.
How exactly would this work? The Driver generates a new model and applies it?
How does all of this work with UI interactions?
There was a problem hiding this comment.
If I'm understanding right, I think the DiffableDataSource would need a way to access the CollectionViewModel upon expand/collapse events, so it can then get the right cellViewModel and update its property. Is that what you had in mind, some sort of way for the data source to communicate up (or otherwise) that those events have happened?
| } | ||
|
|
||
| /// Recursively traverse the children array of each child to locate a matching cell view model | ||
| private func cellViewModel(in viewModel: AnyCellViewModel, with id: UniqueIdentifier) -> AnyCellViewModel? { |
There was a problem hiding this comment.
We should push some of this functionality to CellViewModel. We should be able to ask a cell for a specific child.
cellViewModel.childWithIdentifier(id)
| self.apply(destinationSectionSnapshot, to: $0.id, animatingDifferences: animated) { [weak self] in | ||
| self?._applySnapshotCompletion(source: source, | ||
| destination: destination, | ||
| completion) |
There was a problem hiding this comment.
This should all be greatly simplified and easier after I merge #158
|
Did a review pass. Let's also be sure to add tests. |
|
Sweeeet, thanks! I'll need to reacquaint myself with what I did and how things work so might take a bit to respond/adjust! |
|
@bmarkowitz No worries! No rush. |

Issue #33
Describe your changes
This is a WIP of an implementation to support nested items (i.e. nested CellViewModels), enabled by
NSDiffableDataSourceSectionSnapshots.What's been done so far
CellViewModelnow includes achildrenproperty, which allows us to provide an array ofAnyCellViewModels. Each of those can havechildren, and so on.DiffableSectionSnapshot, which is a typealias forNSDiffableDataSourceSectionSnapshot. The ability to append items to other items is only supported through section snapshots. The logic to do so is recursive - appending the items for each top level cell, then for each of those going into all of their children, and so on.DiffableDataSource, I added a check to see if we have at least one section with at least one cell that has non-empty children. If that's the case, we split off into creating section snapshots. Otherwise, we proceed with the existing standard snapshot approach.Details
Simulator.Screen.Recording.-.iPhone.16.Pro.-.2024-11-16.at.12.06.50.mp4
What still needs to be done
self.snapshot(), then reconfigure from there.CollectionViewModelto ensure that we're referencing the snapshot'svisibleItemswhen we're grabbing aCellViewModelfor a given index path. The index paths all change when you expand/collapse an item, so we have to account for that.