Skip to content

Commit

Permalink
refactor: Better separation of generic collection view code and imple…
Browse files Browse the repository at this point in the history
…mentation specifics (#97)

This change moves a lot of the reusable selection management code into
an `NSCollectionView` extension and leaves only the
implementation-specific code in `InteractiveCollectionView`. It also
extracts a couple of utilities and renames a couple of APIs to match the
implementation in SelectableCollectionView which we're currently
tracking.
  • Loading branch information
jbmorley authored Mar 22, 2024
1 parent db96f6b commit 5ecedbe
Show file tree
Hide file tree
Showing 8 changed files with 283 additions and 221 deletions.
8 changes: 8 additions & 0 deletions Folders.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
D8472A6A2B2518600070DB64 /* NSWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8472A692B2518600070DB64 /* NSWorkspace.swift */; };
D8472A6C2B2518900070DB64 /* NSCollectionViewDiffableDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8472A6B2B2518900070DB64 /* NSCollectionViewDiffableDataSource.swift */; };
D8472A6E2B2518D00070DB64 /* InteractiveCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8472A6D2B2518D00070DB64 /* InteractiveCollectionView.swift */; };
D85AC0D72BAE2005003CAE8F /* NSCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85AC0D62BAE2005003CAE8F /* NSCollectionView.swift */; };
D85AC0D92BAE217F003CAE8F /* CollectionViewNavigationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85AC0D82BAE217F003CAE8F /* CollectionViewNavigationResult.swift */; };
D85C07B12B7EBC3A00C8BAA6 /* FolderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85C07B02B7EBC3A00C8BAA6 /* FolderView.swift */; };
D870EC9E2B7EC47B00704688 /* FolderModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D870EC9D2B7EC47B00704688 /* FolderModel.swift */; };
D870ECA12B7ED5D700704688 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = D870ECA02B7ED5D700704688 /* Algorithms */; };
Expand Down Expand Up @@ -95,6 +97,8 @@
D8472A692B2518600070DB64 /* NSWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSWorkspace.swift; sourceTree = "<group>"; };
D8472A6B2B2518900070DB64 /* NSCollectionViewDiffableDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSCollectionViewDiffableDataSource.swift; sourceTree = "<group>"; };
D8472A6D2B2518D00070DB64 /* InteractiveCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractiveCollectionView.swift; sourceTree = "<group>"; };
D85AC0D62BAE2005003CAE8F /* NSCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSCollectionView.swift; sourceTree = "<group>"; };
D85AC0D82BAE217F003CAE8F /* CollectionViewNavigationResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewNavigationResult.swift; sourceTree = "<group>"; };
D85C07B02B7EBC3A00C8BAA6 /* FolderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderView.swift; sourceTree = "<group>"; };
D861A32F2B8EC22900A79214 /* Folders.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = Folders.xctestplan; sourceTree = "<group>"; };
D870EC9D2B7EC47B00704688 /* FolderModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderModel.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -175,6 +179,7 @@
D82B95852B23DC5500C8B6FB /* Utilities */ = {
isa = PBXGroup;
children = (
D85AC0D82BAE217F003CAE8F /* CollectionViewNavigationResult.swift */,
D8DDBCC72B2A1582003EAF4E /* DirectoryScanner.swift */,
D83312E22B76FE4E00B994B3 /* Filter.swift */,
D8A82E3C2BADF38000DD32DA /* IndexPathSequence.swift */,
Expand Down Expand Up @@ -279,6 +284,7 @@
D8472A692B2518600070DB64 /* NSWorkspace.swift */,
D87653E12AC9F39100E8B65D /* URL.swift */,
D8F349B42B8150690037D66A /* UTType.swift */,
D85AC0D62BAE2005003CAE8F /* NSCollectionView.swift */,
);
path = Extensions;
sourceTree = "<group>";
Expand Down Expand Up @@ -486,6 +492,7 @@
D85C07B12B7EBC3A00C8BAA6 /* FolderView.swift in Sources */,
D814EA012B7EB620008BF46C /* Sort.swift in Sources */,
D8DDBCC82B2A1582003EAF4E /* DirectoryScanner.swift in Sources */,
D85AC0D72BAE2005003CAE8F /* NSCollectionView.swift in Sources */,
D87653B42AC9F01600E8B65D /* FoldersApp.swift in Sources */,
D8F349B52B8150690037D66A /* UTType.swift in Sources */,
D8A82E3B2BADF35400DD32DA /* SequenceDirection.swift in Sources */,
Expand All @@ -503,6 +510,7 @@
D83312E92B77132B00B994B3 /* Details.swift in Sources */,
D83312E32B76FE4E00B994B3 /* Filter.swift in Sources */,
D879E3452AD27C7B00A021E0 /* SidebarItem.swift in Sources */,
D85AC0D92BAE217F003CAE8F /* CollectionViewNavigationResult.swift in Sources */,
D8472A662B2517DE0070DB64 /* StoreView.swift in Sources */,
D82B95812B231AD000C8B6FB /* Store.swift in Sources */,
D89622E02ACD3B9A006F7D2E /* FoldersError.swift in Sources */,
Expand Down
182 changes: 182 additions & 0 deletions Folders/Extensions/NSCollectionView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// MIT License
//
// Copyright (c) 2023-2024 Jason Morley
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

import AppKit

extension NSCollectionView {

func isSelected(_ indexPath: IndexPath?) -> Bool {
guard let indexPath else {
return false
}
return selectionIndexPaths.contains(indexPath)
}

func firstIndexPath() -> IndexPath? {
return self.indexPath(after: IndexPath(item: -1, section: 0))
}

func lastIndexPath() -> IndexPath? {
return self.indexPath(before: IndexPath(item: 0, section: numberOfSections))
}

fileprivate func indexPath(before indexPath: IndexPath) -> IndexPath? {

// Try decrementing the item...
if indexPath.item - 1 >= 0 {
return IndexPath(item: indexPath.item - 1, section: indexPath.section)
}

// Try decrementing the section...
var nextSection = indexPath.section
while true {
nextSection -= 1
guard nextSection >= 0 else {
return nil
}
let numberOfItems = numberOfItems(inSection: nextSection)
if numberOfItems > 0 {
return IndexPath(item: numberOfItems - 1, section: nextSection)
}
}

}

fileprivate func indexPath(after indexPath: IndexPath) -> IndexPath? {

// Try incrementing the item...
if indexPath.item + 1 < numberOfItems(inSection: indexPath.section) {
return IndexPath(item: indexPath.item + 1, section: indexPath.section)
}

// Try incrementing the section...
var nextSection = indexPath.section
while true {
nextSection += 1
guard nextSection < numberOfSections else {
return nil
}
if numberOfItems(inSection: nextSection) > 0 {
return IndexPath(item: 0, section: nextSection)
}
}

}

func indexPathSequence(following indexPath: IndexPath, direction: SequenceDirection) -> IndexPathSequence {
return IndexPathSequence(collectionView: self, indexPath: indexPath, direction: direction)
}

func indexPath(following indexPath: IndexPath, direction: SequenceDirection, distance: Int = 1) -> IndexPath? {
var indexPath: IndexPath? = indexPath
for _ in 0..<distance {
guard let testIndexPath = indexPath else {
return nil
}
switch direction {
case .forwards:
indexPath = self.indexPath(after: testIndexPath)
case .backwards:
indexPath = self.indexPath(before: testIndexPath)
}
}
return indexPath
}

func closestIndexPath(toIndexPath indexPath: IndexPath, direction: NavigationDirection) -> CollectionViewNavigationResult? {

guard let layout = collectionViewLayout else {
return nil
}

let threshold = 20.0
let attributesForCurrentItem = layout.layoutAttributesForItem(at: indexPath)
let currentItemFrame = attributesForCurrentItem?.frame ?? .zero
let targetPoint: CGPoint
let indexPaths: IndexPathSequence
switch direction {
case .up:
targetPoint = CGPoint(x: currentItemFrame.midX, y: currentItemFrame.minY - threshold)
indexPaths = self.indexPathSequence(following: indexPath, direction: .backwards)
case .down:
targetPoint = CGPoint(x: currentItemFrame.midX, y: currentItemFrame.maxY + threshold)
indexPaths = self.indexPathSequence(following: indexPath, direction: .forwards)
case .left:
targetPoint = CGPoint(x: currentItemFrame.minX - threshold, y: currentItemFrame.midY)
indexPaths = self.indexPathSequence(following: indexPath, direction: .backwards)
case .right:
targetPoint = CGPoint(x: currentItemFrame.maxX + threshold, y: currentItemFrame.midY)
indexPaths = self.indexPathSequence(following: indexPath, direction: .forwards)
}

// This takes a really simple approach that either walks forwards or backwards through the cells to find the
// next cell. It will fail hard on sparsely packed layouts or layouts which place elements randomly but feels
// like a reasonable limitation given the current planned use-cases.
//
// A more flexible implementation might compute the vector from our current item to the test item and select one
// with the lowest magnitude closest to the requested direction. It might also be possible to use this approach
// to do wrapping more 'correctly'.
//
// Seeking should probably also be limited to a maximum nubmer of test items to avoid walking thousands of items
// if no obvious match is found.

var intermediateIndexPaths: [IndexPath] = []
for indexPath in indexPaths {
if let attributes = layout.layoutAttributesForItem(at: indexPath),
attributes.frame.contains(targetPoint) {
return CollectionViewNavigationResult(nextIndexPath: indexPath, intermediateIndexPaths: intermediateIndexPaths)
}
intermediateIndexPaths.append(indexPath)
}
return nil
}

func nextIndex(_ direction: NavigationDirection, indexPath: IndexPath?) -> CollectionViewNavigationResult? {

// This implementation makes some assumptions that will work with packed grid-like layouts but are unlikely to
// work well with sparsely packed layouts or irregular layouts. Specifically:
//
// - Left/Right directions are always assumed to selection the previous or next index paths by item and section.
//
// - Up/Down will seek through the index paths in order and return the index path of the first item which
// contains a point immediately above or below the starting index path.

guard let indexPath else {
switch direction.sequenceDirection {
case .forwards:
return CollectionViewNavigationResult(nextIndexPath: firstIndexPath())
case .backwards:
return CollectionViewNavigationResult(nextIndexPath: lastIndexPath())
}
}

switch direction {
case .up, .down:
return closestIndexPath(toIndexPath: indexPath, direction: direction)
case .left:
return CollectionViewNavigationResult(nextIndexPath: self.indexPath(following: indexPath, direction: .backwards))
case .right:
return CollectionViewNavigationResult(nextIndexPath: self.indexPath(following: indexPath, direction: .forwards))
}
}

}
36 changes: 36 additions & 0 deletions Folders/Utilities/CollectionViewNavigationResult.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// MIT License
//
// Copyright (c) 2023-2024 Jason Morley
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

import Foundation

struct CollectionViewNavigationResult {
let nextIndexPath: IndexPath
let intermediateIndexPaths: [IndexPath]

init?(nextIndexPath: IndexPath?, intermediateIndexPaths: [IndexPath] = []) {
guard let nextIndexPath else {
return nil
}
self.nextIndexPath = nextIndexPath
self.intermediateIndexPaths = intermediateIndexPaths
}
}
14 changes: 7 additions & 7 deletions Folders/Utilities/IndexPathSequence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,17 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

import Foundation
import AppKit

struct IndexPathSequence: Sequence {

struct Iterator: IteratorProtocol {

let collectionView: InteractiveCollectionView
let collectionView: NSCollectionView
var indexPath: IndexPath
let direction: SequenceDirection

init(collectionView: InteractiveCollectionView, indexPath: IndexPath, direction: SequenceDirection) {
init(collectionView: NSCollectionView, indexPath: IndexPath, direction: SequenceDirection) {
self.collectionView = collectionView
self.indexPath = indexPath
self.direction = direction
Expand All @@ -39,13 +39,13 @@ struct IndexPathSequence: Sequence {
mutating func next() -> IndexPath? {
switch direction {
case .forwards:
guard let nextIndexPath = collectionView.indexPath(after: indexPath) else {
guard let nextIndexPath = collectionView.indexPath(following: indexPath, direction: direction) else {
return nil
}
indexPath = nextIndexPath
return indexPath
case .backwards:
guard let nextIndexPath = collectionView.indexPath(before: indexPath) else {
guard let nextIndexPath = collectionView.indexPath(following: indexPath, direction: direction) else {
return nil
}
indexPath = nextIndexPath
Expand All @@ -55,11 +55,11 @@ struct IndexPathSequence: Sequence {

}

let collectionView: InteractiveCollectionView
let collectionView: NSCollectionView
let indexPath: IndexPath
let direction: SequenceDirection

init(collectionView: InteractiveCollectionView, indexPath: IndexPath, direction: SequenceDirection = .forwards) {
init(collectionView: NSCollectionView, indexPath: IndexPath, direction: SequenceDirection = .forwards) {
self.collectionView = collectionView
self.indexPath = indexPath
self.direction = direction
Expand Down
9 changes: 0 additions & 9 deletions Folders/Utilities/NavigationDirection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,4 @@ enum NavigationDirection {
}
}

var sequenceDirection: SequenceDirection {
switch self {
case .up, .left:
return .backwards
case .down, .right:
return .forwards
}
}

}
13 changes: 13 additions & 0 deletions Folders/Utilities/SequenceDirection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,16 @@ enum SequenceDirection {

}
}

extension NavigationDirection {

var sequenceDirection: SequenceDirection {
switch self {
case .up, .left:
return .backwards
case .down, .right:
return .forwards
}
}

}
14 changes: 8 additions & 6 deletions Folders/Views/GridView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ extension InnerGridView: StoreViewDelegate {

}

extension InnerGridView: InteractiveCollectionViewDelegate {
extension InnerGridView: CollectionViewInteractionDelegate {

@objc func reveal(sender: NSMenuItem) {
guard let identifiers = sender.representedObject as? [Details.Identifier] else {
Expand Down Expand Up @@ -278,10 +278,11 @@ extension InnerGridView: InteractiveCollectionViewDelegate {
}
}

func customCollectionView(_ customCollectionView: InteractiveCollectionView, contextMenuForSelection selection: IndexSet) -> NSMenu? {
func collectionView(_ customCollectionView: InteractiveCollectionView,
contextMenuForSelection selection: Set<IndexPath>) -> NSMenu? {
// TODO: Make this a utility.
let selections = selection.compactMap { index in
dataSource.itemIdentifier(for: IndexPath(item: index, section: 0))
let selections = selection.compactMap { indexPath in
dataSource.itemIdentifier(for: indexPath)
}
guard !selections.isEmpty else {
return nil
Expand All @@ -306,14 +307,15 @@ extension InnerGridView: InteractiveCollectionViewDelegate {
return menu
}

func customCollectionView(_ customCollectionView: InteractiveCollectionView, didDoubleClickSelection selection: Set<IndexPath>) {
func collectionView(_ customCollectionView: InteractiveCollectionView,
didDoubleClickSelection selection: Set<IndexPath>) {
let identifiers = dataSource.itemIdentifiers(for: selection)
for identifier in identifiers {
NSWorkspace.shared.open(identifier.url)
}
}

func customCollectionViewShowPreview(_ customCollectionView: InteractiveCollectionView) {
func collectionViewShowPreview(_ customCollectionView: InteractiveCollectionView) {
showPreview()
}

Expand Down
Loading

0 comments on commit 5ecedbe

Please sign in to comment.