肇鑫的技术博客

业精于勤,荒于嬉

NSCollectionView的选中与恢复

在使用NSCollectionView中发现,如果有选中的项目,那么在重新加载后,选中的项目可能会出现随机的错位。如图:

seleted ite

已经选中了中间的项目,点击底部的Reload按钮后,选中的背景跑到右边的项目上去了。

seleted item reload

代码如下:

import Cocoa

class ViewController: NSViewController {
    @IBOutlet weak var collectionView: NSCollectionView!
    
    private var sources:[String] = [
        "step one",
        "step two",
        "step three",
        "step four",
        "step five",
        "step six",
        "step seven",
        "step eight"
    ]
    
    override func viewDidLoad() {
        super.viewDidLoad()

        registerCollectionViewItem()
    }
    
    private func registerCollectionViewItem() {
        collectionView.register(Item.self, forItemWithIdentifier: NSUserInterfaceItemIdentifier("Item"))
    }

    override var representedObject: Any? {
        didSet {
        // Update the view, if already loaded.
        }
    }

    @IBAction func reloadButtonClicked(_ sender: Any) {
        collectionView.reloadData()
    }
}

extension ViewController:NSCollectionViewDataSource {
    func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int {
        return sources.count
    }
    
    func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
        
        let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier("Item"), for: indexPath)
        item.textField?.stringValue = self.sources[indexPath.item]
         
        return item
    }
}

extension ViewController:NSCollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: NSCollectionView, didSelectItemsAt indexPaths: Set<IndexPath>) {
        print("select")
    }
    
    func collectionView(_ collectionView: NSCollectionView, didDeselectItemsAt indexPaths: Set<IndexPath>) {
        print("deselect")
    }
}

注意:上面提到,跑偏的是“选中的背景”,而“非选中的状态”。因为在collectionView.reloadData()之后,所有的选中状态都清空了。

@IBAction func reloadButtonClicked(_ sender: Any) {
    print(collectionView.selectionIndexPaths.count) // print 1
    
    collectionView.reloadData()
    
    print(collectionView.selectionIndexPaths.count) // print 0
}

根据苹果文档,重新加载的过程是

Call this method when the data in your data source object changes or when you want to force the collection view to update its contents. When you call this method, the collection view discards any currently visible items and views and redisplays them.

也就是说,在数据源改变的时候,重新加载。而如果我们的数据源没有包含选中的信息,大多数情况,我们不会将选中信息保存到数据源,那么重新加载之后,选中的状态就消失了。

分析

既然状态是清空的,为什么还会有错位显示“选中的背景”的问题呢?这是因为,苹果在重新加载的时候,为了效率,会重复使用之前创建的项目,同时还采用了并行的处理方式进行生成,因此,会导致显示背景的错乱。

解决思路有两个,一个是手动恢复选择。一个是将选中状态写到数据源。

方法一:手动恢复选择

思路:先保存选中的数据,然后重新加载,最后恢复。代码如下:

@IBAction func reloadButtonClicked(_ sender: Any) {
    let indexPaths = collectionView.selectionIndexPaths
    
    collectionView.reloadData()
    
    collectionView.selectItems(at: indexPaths, scrollPosition: .init())
}

func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
    
    let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier("Item"), for: indexPath)
    item.textField?.stringValue = self.sources[indexPath.item]
    
    item.highlightState = .none
     
    return item
}

注意看一下第14行,即item.highlightState = .none,因为在应用中恢复选中,并不会激发选中的状态改变,所以我们必须手动更新选中状态。这里相应的代码如下。

import Cocoa

class Item: NSCollectionViewItem {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.view.wantsLayer = true
        self.highlightState = .none
    }
    
    override var highlightState: NSCollectionViewItem.HighlightState {
        didSet {
            switch highlightState {
            case .none:
                self.view.layer?.backgroundColor = isSelected ? NSColor.systemBlue.withAlphaComponent(0.8).cgColor : NSColor.systemGreen.withAlphaComponent(0.8).cgColor
            case .forSelection:
                self.view.layer?.backgroundColor = NSColor.systemBlue.withAlphaComponent(0.5).cgColor
            case .forDeselection:
                self.view.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.5).cgColor
            case .asDropTarget:
                fatalError()
            @unknown default:
                fatalError()
            }
        }
    }
}

那么问题来了,我们的思路是先重新加载,之后恢复选中。既然是这样的顺序,为什么在重新加载的时候,项目会是已经选中的状态呢?不应该是重新加载完成,才选中的吗?这是因为,所有的重新加载,都不是立即重新加载的。而是先恢复状态,然后在下一个事件循环才加载。所以实际的运行类似如下的伪代码:

public func reloadData() {
    // 恢复状态
    DispatchQueue.main.async {
        // 重新加载
    }
}

因此,实际运行的顺序是:

  1. 恢复状态
  2. 恢复选中
  3. 重新加载

这样在实际的重新加载中,项目本身已经是选中的状态了。

另外一个小问题

我们知道,在macOS中,我们可以使用快捷键来进行一些常用的操作。比如利用cmd+a,我们可以进行全部选中。但是如果现在我们使用全部选中,虽然在控制台,我们看到的确是选中了。但是实际上,选中的背景并没有应用。这是因为NSCollectionViewItem.highlightState是针对鼠标操作的,直接用键盘操作,没有鼠标操作的过程,因此背景颜色就没有改变。

分析

highlightState没有改变的,但是选中状态改变了。所以我们在选中状态改变之后,进行背景颜色的改变。代码如下:

import Cocoa

class Item: NSCollectionViewItem {
    
    override var isSelected: Bool {
        didSet {
            self.view.layer?.backgroundColor = isSelected ? NSColor.systemBlue.withAlphaComponent(0.8).cgColor : NSColor.systemGreen.withAlphaComponent(0.8).cgColor
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.view.wantsLayer = true
        self.isSelected = false
    }
    
    override var highlightState: NSCollectionViewItem.HighlightState {
        didSet {
            switch highlightState {
            case .none:
                break
            case .forSelection:
                self.view.layer?.backgroundColor = NSColor.systemBlue.withAlphaComponent(0.5).cgColor
            case .forDeselection:
                self.view.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.5).cgColor
            case .asDropTarget:
                fatalError()
            @unknown default:
                fatalError()
            }
        }
    }
}
func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
    
    let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier("Item"), for: indexPath)
    item.textField?.stringValue = self.sources[indexPath.item]
     
    return item
}

这里,我们移除了.none状态对于背景颜色的改变,而是采用isSelecteddidSet。并且在viewDidLoad()中恢复状态。这样,我们就不必在collectionView(_:itemForRepresentedObjectAt:)恢复状态了。

方法二:将选中状态写到数据源

单纯的将选中状态写入到数据源很简单,就不详细叙述了。这里介绍的是在不将选中状态写入到数据源的情况下,利用中间对象来进行处理的办法。

我们创建了一个中间对象StateObject<Element>来保存真正的数据和是否选中的状态。这样,就可以不必在真正的数据源中记录选中信息了。

class ViewController: NSViewController {
    @IBOutlet weak var collectionView: NSCollectionView!
    
    private var sources:[String] = [
        "step one",
        "step two",
        "step three",
        "step four",
        "step five",
        "step six",
        "step seven",
        "step eight"
    ]
    
    lazy private var elements:[StateObject<String>] = self.sources.map { StateObject($0) }
    
    override func viewDidLoad() {
        super.viewDidLoad()

        registerCollectionViewItem()
    }
    
    private func registerCollectionViewItem() {
        collectionView.register(Item.self, forItemWithIdentifier: NSUserInterfaceItemIdentifier("Item"))
    }

    override var representedObject: Any? {
        didSet {
        // Update the view, if already loaded.
        }
    }

    @IBAction func reloadButtonClicked(_ sender: Any) {
        collectionView.reloadData()
    }
}

extension ViewController:NSCollectionViewDataSource {
    func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int {
        return elements.count
    }
    
    func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
        
        let dataObject = elements[indexPath.item]
        let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier("Item"), for: indexPath)
        item.textField?.stringValue = dataObject.object
        
        if dataObject.isSelected {
            item.isSelected = true
            collectionView.selectionIndexPaths.insert(indexPath)
        } else {
            item.isSelected = false
        }
         
        return item
    }
}

extension ViewController:NSCollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: NSCollectionView, didSelectItemsAt indexPaths: Set<IndexPath>) {
        print("select")
        
        indexPaths.forEach {
            elements[$0.item].isSelected = true
        }
    }
    
    func collectionView(_ collectionView: NSCollectionView, didDeselectItemsAt indexPaths: Set<IndexPath>) {
        print("deselect")
        
        indexPaths.forEach {
            elements[$0.item].isSelected = false
        }
    }
}

class StateObject<Element> {
    var isSelected:Bool
    var object:Element
    
    init(_ object:Element) {
        self.object = object
        isSelected = false
    }
}
import Cocoa

class Item: NSCollectionViewItem {
    
    override var isSelected: Bool {
        didSet {
            self.view.layer?.backgroundColor = isSelected ? NSColor.systemBlue.withAlphaComponent(0.8).cgColor : NSColor.systemGreen.withAlphaComponent(0.8).cgColor
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.view.wantsLayer = true
    }
    
    override var highlightState: NSCollectionViewItem.HighlightState {
        didSet {
            switch highlightState {
            case .none:
                break
            case .forSelection:
                self.view.layer?.backgroundColor = NSColor.systemBlue.withAlphaComponent(0.5).cgColor
            case .forDeselection:
                self.view.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.5).cgColor
            case .asDropTarget:
                fatalError()
            @unknown default:
                fatalError()
            }
        }
    }
}

总结

  1. 项目的选中,从鼠标点击角度看,一个分成三步:
    1. 项目原始状态,此时的NSCollectionViewItem.HighlightStatenone
    2. 鼠标按压,此时的NSCollectionViewItem.HighlightStateforSelection
    3. 鼠标放开,此时的NSCollectionViewItem.HighlightStatenone
  2. 而如果之前有选中的项目,并且只允许单选的话,那么已选中项目,对应上面2的是forDeselection
  3. 我们可以通过状态none结合isSelected来判断应该设置何种的背景颜色。但是这种方式不如直接在isSelecteddidSet中设置更为方便。这是因为,只有鼠标交互才会改变NSCollectionViewItem.HighlightState,而无论何种交互方式,都会改变isSelected,后者才是确认项目是否已经选中的方式。
  4. 每次重新加载,NSCollectionView的状态都会立即重置,并且在下一个主线程周期进行实际加载。
  5. 我们可以通过暂存并手动恢复的方式来恢复选择,也可以直接将选中状态直接写入到数据源。如果后者不方便,我们可以创建中间对象,来进行处理。
  6. 相对于手动恢复的方式,使用中间对象来进行处理,更加自然,代码也更加集中,更方便今后的修改。