swiftnscollectionviewnsevent

NSCollectionView type select


I have implemented type selection for my NSCollectionView implementation.

In my collection view subclass, I have added additional delegate methods:

protocol CollectionViewDelegate : NSCollectionViewDelegate {
    func collectionViewDelegateTypeSelect(_ text: String)
    func collectionViewDelegateResetTypeSelect()
}

class CollectionView : NSCollectionView {
    
    override func keyDown(with event: NSEvent) {
        if let del = delegate as? CollectionViewDelegate {
            if (event.characters?.rangeOfCharacter(from: CharacterSet.alphanumerics.inverted) == nil) || (event.characters == " ") {
                del.collectionViewDelegateTypeSelect(event.characters!)
                return
            }
            else {
                del.collectionViewDelegateResetTypeSelect()
            }
        }
        if (event.specialKey == NSEvent.SpecialKey.pageUp) {
            scroll(NSMakePoint(0, max(0, enclosingScrollView!.documentVisibleRect.minY - enclosingScrollView!.documentVisibleRect.height)))
        } else if (event.specialKey == NSEvent.SpecialKey.pageDown) {
            scroll(NSMakePoint(0, min(enclosingScrollView!.documentVisibleRect.maxY, enclosingScrollView!.documentVisibleRect.origin.y + enclosingScrollView!.documentVisibleRect.height)))
        } else if (event.specialKey == NSEvent.SpecialKey.home) {
            scroll(NSMakePoint(0, 0))
        } else if (event.specialKey == NSEvent.SpecialKey.end) {
            scroll(NSMakePoint(0, frame.height))
        } else {
            super.keyDown(with: event)
        }
    }
    
}

Then in my delegate, which is a subclass of NSArrayController, I process the type selection:

    var timer: Timer?
    var typeString: String = ""
    var typeCount: Int = 0
    func collectionViewDelegateTypeSelect(_ text: String) {
        typeCount = 0
        typeString.append(text)
        findItemClosestToString(typeString)
        guard let _ = timer, timer!.isValid else {
            timer = Timer(timeInterval: 0.1, repeats: true, block: {_ in 
                self.typeCount = self.typeCount + 1
                if self.typeCount > 10 {
                    self.typeCount = 0
                    self.typeString = ""
                    self.timer?.invalidate()
                }
            })
            RunLoop.main.add(timer!, forMode: RunLoop.Mode.default)
            return
        }
    }
    
    func collectionViewDelegateResetTypeSelect() {
        if collectionViewDelegateIsTypeSelecting() {
            timer?.invalidate()
            timer = nil
        }
    }
    
    func sortedItems() -> ([NSManagedObject], [String]) {
        guard let items = (arrangedObjects as? [MediaItem]) else { return ([], []) }
        let sorted = (items as NSArray).sortedArray(using: [alphabeticalSortDescriptor]) as! [MediaSeries]
        return (sorted, sorted.map { $0.title })
    }
    
    func findItemClosestToString(_ text: String) {
        let (items, strings) = sortedItems()
        guard items.count > 0 else { return }
        var lo = 0
        var hi = items.count - 1
        var mid = lo
        while lo <= hi {
            mid = (lo + hi)/2
            let c = text.caseInsensitiveCompare(strings[mid])
            if c == .orderedDescending {
                lo = mid + 1
            } else if c == .orderedAscending {
                hi = mid - 1
            } else {
                break
            }
        }
        if strings[mid].localizedLowercase.hasPrefix(text.localizedLowercase) == false, mid + 1 < strings.count, strings[mid + 1].localizedLowercase.hasPrefix(text.localizedLowercase) {
            mid = mid + 1
        }
        selectItem(items, mid: mid)
    }
    
    func selectItem(_ items: [NSManagedObject], mid: Int) {
        let index = (arrangedObjects as! [NSManagedObject]).firstIndex(of: items[mid])!
        let path = IndexPath(item: index, section: 0)
        mainCollection.deselectAll(nil)
        //mainCollection.reloadData() - This was left over debug
        mainCollection.selectItems(at: [path], scrollPosition: .top)
        collectionView(mainCollection, didSelectItemsAt: [path])
    }

This works pretty good, however, as the number of items the collection view displays grows (> 1000), it is possible to type faster than the type selection works, leaving the user to watch a few seconds of the UI catching up.

The selection functions are broken apart to make overriding individual parts easier in subclasses.

The question I have is how to best collate multiple NSEvents so that in the event a few Events are queued, only the character is added, so that the unnecessary mid-selections occur. About the only way I can think to do this is to add an additional timer that collects one or more events before running the main selection algorithm. Hence all that happens between user input, is the typeString grows in length without proceeding further to doing the selection until I'm reasonably sure no additional input will come in.

When I started working through that, I figured there had to be a better approach. I know that NSTableViews and NSOutlineViews support type selection out of the box, but NSCollectionViews do not appear to.

What I'm curious about is if there is a better approach that I've just completely missed. I haven't found anyone else attempting to add type selection to a NSCollectionView.

Edit: To clarify, this code works, but is slow with a large number of items in the collection view. I can make it faster by changing the way collectionViewDelegateTypeSelect() works to wait a few iterations of the timer to collect additional characters, so less selection events are done overall. However, this seemed a bit nasty and made me think there must be a better approach I was missing.

The delegate is a NSArrayController bound to the CollectionView. It is mutable, and can be sorted using multiple different user defined NSSortDescriptors. It is a mutable array and the user can add/remove items to the CollectionView.


Solution

  • Willeke's idea of using DispatchQueue worked well. There is some fine tuning I need to do to work out the best time to delay the future execution.

    var clearTypeSelect: DispatchWorkItem?
    var issueTypeSelect: DispatchWorkItem?
    var typeString: String = ""
    var typeSelect: Bool = false
    override func collectionViewDelegateTypeSelect(_ text: String) {
        clearTypeSelect?.cancel()
        issueTypeSelect?.cancel()
        typeString.append(text)
        let clearWorkItem = DispatchWorkItem {
            self.typeString = ""
        }
        clearTypeSelect = clearWorkItem
        DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(1000), execute: clearWorkItem)
        let selectWorkItem = DispatchWorkItem {
            DispatchQueue.main.async {
                self.findItemClosestToString(self.typeString)
            }
        }
        issueTypeSelect = selectWorkItem
        DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(300), execute: selectWorkItem)
    }
    

    Thanks. There's a bit of clean up I still have to do, but I think this is the answer.