Skip to content

Support item expanding and collapsing#146

Open
bmarkowitz wants to merge 4 commits intojessesquires:mainfrom
bmarkowitz:feature/expandable-items
Open

Support item expanding and collapsing#146
bmarkowitz wants to merge 4 commits intojessesquires:mainfrom
bmarkowitz:feature/expandable-items

Conversation

@bmarkowitz
Copy link
Contributor

@bmarkowitz bmarkowitz commented Nov 16, 2024

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

  1. As discussed in the issue, CellViewModel now includes a children property, which allows us to provide an array of AnyCellViewModels. Each of those can have children, and so on.
  2. Created DiffableSectionSnapshot, which is a typealias for NSDiffableDataSourceSectionSnapshot. 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.
  3. Inside 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.
    • I believe we'll want the same completion logic, so I tried to separate that into a method so as to not repeat all of the comments in there.
  4. The final commit is purely for demo purposes, which implements a rough version of some things described below that are still needed + in order to be able to show a quick demo in the example app.
Details
Simulator.Screen.Recording.-.iPhone.16.Pro.-.2024-11-16.at.12.06.50.mp4

What still needs to be done

  1. We need to figure out an approach for reconfiguring items for the section snapshots. Section snapshots don't have the same reconfigure APIs as standard snapshots, so my thinking is we'll need to apply all of the section snapshots, then at the end grab the current full snapshot via self.snapshot(), then reconfigure from there.
  2. We'll need additional recursive logic in CollectionViewModel to ensure that we're referencing the snapshot's visibleItems when we're grabbing a CellViewModel for a given index path. The index paths all change when you expand/collapse an item, so we have to account for that.
  3. Example app work - not sure if want that in this PR or a separate one.
  4. Perhaps some additional/updated comments
  5. Tests

@bmarkowitz bmarkowitz force-pushed the feature/expandable-items branch from 1787986 to 238a869 Compare November 16, 2024 17:19
self.apply(destinationSectionSnapshot, to: $0.id, animatingDifferences: animated) { [weak self] in
self?._applySnapshotCompletion(source: source,
destination: destination,
completion)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Execute completion multiple times?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, yup 🤦 Great catch.

Any ideas? Thinking a dispatch group might work here?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should all be greatly simplified and easier after I merge #158

@bmarkowitz
Copy link
Contributor Author

One other note - this warning appears whenever collapsing or expanding an item, and then any time the collection view updates after that. Only way I've been able to "fix" it is by disabling diffing on a background queue, but not sure why.

Screenshot 2024-11-16 at 4 39 27 PM

@bmarkowitz
Copy link
Contributor Author

Related to the above, found this super interesting response from Apple:

Currently, you do need to apply snapshots on the main queue when using certain features (such as outline disclosure expand/collapse support, or interactive item reordering) where the user is directly manipulating the collection view and an immediate UI update is expected in response.

Also goes on to say that "applying snapshots from a background queue is almost always unnecessary in general."

https://forums.developer.apple.com/forums/thread/757270

@bmarkowitz
Copy link
Contributor Author

@jessesquires hey! Wanted to see if you had a chance to check this out?

@jessesquires
Copy link
Owner

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!

@bmarkowitz
Copy link
Contributor Author

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!

Copy link

@harrisonbay harrisonbay left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let childrenExist = destination.sections.first?.contains(where: \.children.isNotEmpty) ?? false
let childrenExist = destination.sections.contains { section in
section.cells.contains(where: \.children.isNotEmpty)
}

@bmarkowitz
Copy link
Contributor Author

@jessesquires hope all's well! Saw some activity pick up here lately and am interested in picking this back if it's worthwhile - thoughts?

@jessesquires
Copy link
Owner

@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 main. I'll do a quick review pass right now as well.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Create a new "Outline" tab in the example app to exercise this functionality
  2. 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.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +41 to +42
/// 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`.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// 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 `[]`.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// 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 {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This behavior seems to be mostly undocumented, as mentioned here in the first section:

https://swiftsenpai.com/development/undocumented-section-snapshot-facts/

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 visibleItems off 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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? {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should all be greatly simplified and easier after I merge #158

@jessesquires
Copy link
Owner

Did a review pass. Let's also be sure to add tests. ☺️

@bmarkowitz
Copy link
Contributor Author

Sweeeet, thanks! I'll need to reacquaint myself with what I did and how things work so might take a bit to respond/adjust!

@jessesquires
Copy link
Owner

@bmarkowitz No worries! No rush.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants