Skip to content

Commit e6cece0

Browse files
authored
fix: Support wrapping selection on keyboard navigation (#14)
This change also addresses a few todos and tidies up the class naming a little, bringing it in line with some of the implementation in Folders which was used as a prototype for this implementation.
1 parent e91b81f commit e6cece0

9 files changed

Lines changed: 683 additions & 109 deletions

Sources/SelectableCollectionView/Environment/SelectionColor.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) 2022 Jason Morley
1+
// Copyright (c) 2022-2024 Jason Morley
22
//
33
// Permission is hereby granted, free of charge, to any person obtaining a copy
44
// of this software and associated documentation files (the "Software"), to deal
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Copyright (c) 2022-2024 Jason Morley
2+
//
3+
// Permission is hereby granted, free of charge, to any person obtaining a copy
4+
// of this software and associated documentation files (the "Software"), to deal
5+
// in the Software without restriction, including without limitation the rights
6+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
// copies of the Software, and to permit persons to whom the Software is
8+
// furnished to do so, subject to the following conditions:
9+
//
10+
// The above copyright notice and this permission notice shall be included in all
11+
// copies or substantial portions of the Software.
12+
//
13+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
// SOFTWARE.
20+
21+
#if os(macOS)
22+
23+
import AppKit
24+
25+
extension NSCollectionView {
26+
27+
func isSelected(_ indexPath: IndexPath?) -> Bool {
28+
guard let indexPath else {
29+
return false
30+
}
31+
return selectionIndexPaths.contains(indexPath)
32+
}
33+
34+
func firstIndexPath() -> IndexPath? {
35+
return self.indexPath(after: IndexPath(item: -1, section: 0))
36+
}
37+
38+
func lastIndexPath() -> IndexPath? {
39+
return self.indexPath(before: IndexPath(item: 0, section: numberOfSections))
40+
}
41+
42+
fileprivate func indexPath(before indexPath: IndexPath) -> IndexPath? {
43+
44+
// Try decrementing the item...
45+
if indexPath.item - 1 >= 0 {
46+
return IndexPath(item: indexPath.item - 1, section: indexPath.section)
47+
}
48+
49+
// Try decrementing the section...
50+
var nextSection = indexPath.section
51+
while true {
52+
nextSection -= 1
53+
guard nextSection >= 0 else {
54+
return nil
55+
}
56+
let numberOfItems = numberOfItems(inSection: nextSection)
57+
if numberOfItems > 0 {
58+
return IndexPath(item: numberOfItems - 1, section: nextSection)
59+
}
60+
}
61+
62+
}
63+
64+
fileprivate func indexPath(after indexPath: IndexPath) -> IndexPath? {
65+
66+
// Try incrementing the item...
67+
if indexPath.item + 1 < numberOfItems(inSection: indexPath.section) {
68+
return IndexPath(item: indexPath.item + 1, section: indexPath.section)
69+
}
70+
71+
// Try incrementing the section...
72+
var nextSection = indexPath.section
73+
while true {
74+
nextSection += 1
75+
guard nextSection < numberOfSections else {
76+
return nil
77+
}
78+
if numberOfItems(inSection: nextSection) > 0 {
79+
return IndexPath(item: 0, section: nextSection)
80+
}
81+
}
82+
83+
}
84+
85+
func indexPathSequence(following indexPath: IndexPath, direction: SequenceDirection) -> IndexPathSequence {
86+
return IndexPathSequence(collectionView: self, indexPath: indexPath, direction: direction)
87+
}
88+
89+
func indexPath(following indexPath: IndexPath, direction: SequenceDirection, distance: Int = 1) -> IndexPath? {
90+
var indexPath: IndexPath? = indexPath
91+
for _ in 0..<distance {
92+
guard let testIndexPath = indexPath else {
93+
return nil
94+
}
95+
switch direction {
96+
case .forwards:
97+
indexPath = self.indexPath(after: testIndexPath)
98+
case .backwards:
99+
indexPath = self.indexPath(before: testIndexPath)
100+
}
101+
}
102+
return indexPath
103+
}
104+
105+
func closestIndexPath(toIndexPath indexPath: IndexPath, direction: NavigationDirection) -> CollectionViewNavigationResult? {
106+
107+
guard let layout = collectionViewLayout else {
108+
return nil
109+
}
110+
111+
let threshold = 20.0
112+
let attributesForCurrentItem = layout.layoutAttributesForItem(at: indexPath)
113+
let currentItemFrame = attributesForCurrentItem?.frame ?? .zero
114+
let targetPoint: CGPoint
115+
let indexPaths: IndexPathSequence
116+
switch direction {
117+
case .up:
118+
targetPoint = CGPoint(x: currentItemFrame.midX, y: currentItemFrame.minY - threshold)
119+
indexPaths = self.indexPathSequence(following: indexPath, direction: .backwards)
120+
case .down:
121+
targetPoint = CGPoint(x: currentItemFrame.midX, y: currentItemFrame.maxY + threshold)
122+
indexPaths = self.indexPathSequence(following: indexPath, direction: .forwards)
123+
case .left:
124+
targetPoint = CGPoint(x: currentItemFrame.minX - threshold, y: currentItemFrame.midY)
125+
indexPaths = self.indexPathSequence(following: indexPath, direction: .backwards)
126+
case .right:
127+
targetPoint = CGPoint(x: currentItemFrame.maxX + threshold, y: currentItemFrame.midY)
128+
indexPaths = self.indexPathSequence(following: indexPath, direction: .forwards)
129+
}
130+
131+
// This takes a really simple approach that either walks forwards or backwards through the cells to find the
132+
// next cell. It will fail hard on sparsely packed layouts or layouts which place elements randomly but feels
133+
// like a reasonable limitation given the current planned use-cases.
134+
//
135+
// A more flexible implementation might compute the vector from our current item to the test item and select one
136+
// with the lowest magnitude closest to the requested direction. It might also be possible to use this approach
137+
// to do wrapping more 'correctly'.
138+
//
139+
// Seeking should probably also be limited to a maximum nubmer of test items to avoid walking thousands of items
140+
// if no obvious match is found.
141+
142+
var intermediateIndexPaths: [IndexPath] = []
143+
for indexPath in indexPaths {
144+
if let attributes = layout.layoutAttributesForItem(at: indexPath),
145+
attributes.frame.contains(targetPoint) {
146+
return CollectionViewNavigationResult(nextIndexPath: indexPath, intermediateIndexPaths: intermediateIndexPaths)
147+
}
148+
intermediateIndexPaths.append(indexPath)
149+
}
150+
return nil
151+
}
152+
153+
func nextIndex(_ direction: NavigationDirection, indexPath: IndexPath?) -> CollectionViewNavigationResult? {
154+
155+
// This implementation makes some assumptions that will work with packed grid-like layouts but are unlikely to
156+
// work well with sparsely packed layouts or irregular layouts. Specifically:
157+
//
158+
// - Left/Right directions are always assumed to selection the previous or next index paths by item and section.
159+
//
160+
// - Up/Down will seek through the index paths in order and return the index path of the first item which
161+
// contains a point immediately above or below the starting index path.
162+
163+
guard let indexPath else {
164+
switch direction.sequenceDirection {
165+
case .forwards:
166+
return CollectionViewNavigationResult(nextIndexPath: firstIndexPath())
167+
case .backwards:
168+
return CollectionViewNavigationResult(nextIndexPath: lastIndexPath())
169+
}
170+
}
171+
172+
switch direction {
173+
case .up, .down:
174+
return closestIndexPath(toIndexPath: indexPath, direction: direction)
175+
case .left:
176+
return CollectionViewNavigationResult(nextIndexPath: self.indexPath(following: indexPath, direction: .backwards))
177+
case .right:
178+
return CollectionViewNavigationResult(nextIndexPath: self.indexPath(following: indexPath, direction: .forwards))
179+
}
180+
}
181+
182+
}
183+
184+
#endif
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright (c) 2022-2024 Jason Morley
2+
//
3+
// Permission is hereby granted, free of charge, to any person obtaining a copy
4+
// of this software and associated documentation files (the "Software"), to deal
5+
// in the Software without restriction, including without limitation the rights
6+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
// copies of the Software, and to permit persons to whom the Software is
8+
// furnished to do so, subject to the following conditions:
9+
//
10+
// The above copyright notice and this permission notice shall be included in all
11+
// copies or substantial portions of the Software.
12+
//
13+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
// SOFTWARE.
20+
21+
import Foundation
22+
23+
struct CollectionViewNavigationResult {
24+
let nextIndexPath: IndexPath
25+
let intermediateIndexPaths: [IndexPath]
26+
27+
init?(nextIndexPath: IndexPath?, intermediateIndexPaths: [IndexPath] = []) {
28+
guard let nextIndexPath else {
29+
return nil
30+
}
31+
self.nextIndexPath = nextIndexPath
32+
self.intermediateIndexPaths = intermediateIndexPaths
33+
}
34+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright (c) 2022-2024 Jason Morley
2+
//
3+
// Permission is hereby granted, free of charge, to any person obtaining a copy
4+
// of this software and associated documentation files (the "Software"), to deal
5+
// in the Software without restriction, including without limitation the rights
6+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
// copies of the Software, and to permit persons to whom the Software is
8+
// furnished to do so, subject to the following conditions:
9+
//
10+
// The above copyright notice and this permission notice shall be included in all
11+
// copies or substantial portions of the Software.
12+
//
13+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
// SOFTWARE.
20+
21+
#if os(macOS)
22+
23+
import AppKit
24+
25+
struct IndexPathSequence: Sequence {
26+
27+
struct Iterator: IteratorProtocol {
28+
29+
let collectionView: NSCollectionView
30+
var indexPath: IndexPath
31+
let direction: SequenceDirection
32+
33+
init(collectionView: NSCollectionView, indexPath: IndexPath, direction: SequenceDirection) {
34+
self.collectionView = collectionView
35+
self.indexPath = indexPath
36+
self.direction = direction
37+
}
38+
39+
mutating func next() -> IndexPath? {
40+
switch direction {
41+
case .forwards:
42+
guard let nextIndexPath = collectionView.indexPath(following: indexPath, direction: direction) else {
43+
return nil
44+
}
45+
indexPath = nextIndexPath
46+
return indexPath
47+
case .backwards:
48+
guard let nextIndexPath = collectionView.indexPath(following: indexPath, direction: direction) else {
49+
return nil
50+
}
51+
indexPath = nextIndexPath
52+
return indexPath
53+
}
54+
}
55+
56+
}
57+
58+
let collectionView: NSCollectionView
59+
let indexPath: IndexPath
60+
let direction: SequenceDirection
61+
62+
init(collectionView: NSCollectionView, indexPath: IndexPath, direction: SequenceDirection = .forwards) {
63+
self.collectionView = collectionView
64+
self.indexPath = indexPath
65+
self.direction = direction
66+
}
67+
68+
func makeIterator() -> Iterator {
69+
return Iterator(collectionView: collectionView, indexPath: indexPath, direction: direction)
70+
}
71+
72+
}
73+
74+
#endif
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (c) 2022-2024 Jason Morley
2+
//
3+
// Permission is hereby granted, free of charge, to any person obtaining a copy
4+
// of this software and associated documentation files (the "Software"), to deal
5+
// in the Software without restriction, including without limitation the rights
6+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
// copies of the Software, and to permit persons to whom the Software is
8+
// furnished to do so, subject to the following conditions:
9+
//
10+
// The above copyright notice and this permission notice shall be included in all
11+
// copies or substantial portions of the Software.
12+
//
13+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
// SOFTWARE.
20+
21+
#if os(macOS)
22+
23+
import AppKit
24+
import Carbon
25+
26+
enum NavigationDirection {
27+
case up
28+
case down
29+
case left
30+
case right
31+
32+
init?(_ keyCode: UInt16) {
33+
switch Int(keyCode) {
34+
case kVK_LeftArrow:
35+
self = .left
36+
case kVK_RightArrow:
37+
self = .right
38+
case kVK_UpArrow:
39+
self = .up
40+
case kVK_DownArrow:
41+
self = .down
42+
default:
43+
return nil
44+
}
45+
}
46+
47+
}
48+
49+
#endif

0 commit comments

Comments
 (0)