// // KMThumbnailView.swift // PDF Reader Pro // // Created by tangchao on 2023/5/4. // import Cocoa @objc enum KMThumbnailViewDragInfoKey: Int { case dropOperation = 0 case draggingInfo = 1 } @objc protocol KMThumbnailViewDelegate : NSObjectProtocol { // layout @objc optional func thumbnailView(thumbanView: KMThumbnailView, minimumLineSpacingForSectionAt section: Int) -> CGFloat @objc optional func thumbnailView(thumbanView: KMThumbnailView, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat @objc optional func thumbnailView(thumbanView: KMThumbnailView, insetForSectionAt section: Int) -> NSEdgeInsets @objc optional func thumbnailView(thumbanView: KMThumbnailView, sizeForItemAt indexpath: IndexPath) -> NSSize @objc optional func thumbnailView(thumbanView: KMThumbnailView, numberOfItemsInSection section: Int) -> Int @objc optional func thumbnailView(thumbanView: KMThumbnailView, itemForRepresentedObjectAt indexpath: IndexPath) -> NSCollectionViewItem // Drag & Drop @objc optional func thumbnailView(thumbanView: KMThumbnailView, draggingSession session: NSDraggingSession, willBeginAt screenPoint: NSPoint, forItemsAt indexes: IndexSet) @objc optional func thumbnailView(thumbanView: KMThumbnailView, draggingSession session: NSDraggingSession, endedAt screenPoint: NSPoint, dragOperation operation: NSDragOperation) @objc optional func thumbnailView(thumbanView: KMThumbnailView, canPasteboardWriterForItemAt indexPath: IndexPath) -> Bool @objc optional func thumbnailView(thumbanView: KMThumbnailView, shouldPasteboardWriterForItemAt indexPath: IndexPath) -> Bool @objc optional func thumbnailView(thumbanView: KMThumbnailView, pasteboardWriterForItemAt indexPath: IndexPath) -> NSPasteboardWriting? @objc optional func thumbnailView(thumbanView: KMThumbnailView, canDragItemsAt indexPaths: Set, with event: NSEvent) -> Bool @objc optional func thumbnailView(thumbanView: KMThumbnailView, shouldAcceptDrop draggingInfo: NSDraggingInfo, indexPath: IndexPath, dropOperation: NSCollectionView.DropOperation) -> Bool // 本地拖拽 @objc optional func thumbnailView(thumbanView: KMThumbnailView, didDrag dragedIndexPaths: [IndexPath], indexpath: IndexPath, dragInfo:[KMThumbnailViewDragInfoKey.RawValue:Any]) // 外部拖拽 @objc optional func thumbnailView(thumbanView: KMThumbnailView, didDragAddFiles files: [URL], indexpath: IndexPath) @objc optional func thumbnailView(thumbanView: KMThumbnailView, didSelectItemAt indexpath: IndexPath, object: AnyObject?) @objc optional func thumbnailView(thumbanView: KMThumbnailView, rightMouseDidClick indexpath: IndexPath, item: NSCollectionViewItem?, object: AnyObject?) } private let _KMThumbnailView_collectionViewDrop_lineViewClassName = "NSCollectionViewDropTargetGapIndicator" @objc class KMThumbnailView_CollectionView: NSCollectionView { // 是否点击空白取消选中 默认取消选中 var mouseDownCancelSelected: Bool = true var hasDragLineView: Bool = true { didSet { if (!self.hasDragLineView) { // 隐藏视图 for view in self.subviews { guard let lineClass = NSClassFromString(_KMThumbnailView_collectionViewDrop_lineViewClassName) else { continue } if (view.isKind(of: lineClass)) { // view.frame = NSZeroRect view.isHidden = true } } } } } override func addSubview(_ view: NSView) { if (self.hasDragLineView) { return super.addSubview(view) } if let lineClass = NSClassFromString(_KMThumbnailView_collectionViewDrop_lineViewClassName) { if (view.isKind(of: lineClass)) { Swift.debugPrint("Find Line View......") return } else { super.addSubview(view) } } super.addSubview(view) } override func mouseDown(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) if self.mouseDownCancelSelected { super.mouseDown(with: event) } else { if let indexPath = indexPathForItem(at: point) { // 用户点击了一个项,处理点击事件 super.mouseDown(with: event) } else { // 用户点击了空白区域,不取消选中项 } } } } @objc class KMThumbnailView: NSView { open weak var delegate: KMThumbnailViewDelegate? internal let localForDraggedTypes = kKMLocalForDraggedTypes internal var dragedIndexPaths: [IndexPath] = [] var kmAllowedFileTypes: [String]? // 记录拖拽移动后的位置 fileprivate var _dragMoveFlagIndexpath: IndexPath? fileprivate var _dragMoveFlagIndexs: IndexSet? // 是否隐藏拖放线 dragMoveEffectAnimated 属性为false才有效 var hasDragLineView: Bool = true { didSet { if (!self.dragMoveEffectAnimated) { self.collectionView_.hasDragLineView = self.hasDragLineView } else { self.collectionView_.hasDragLineView = false } } } // 是否显示拖放移动动效 为true时 hasDragLineView属性设置是 var dragMoveEffectAnimated: Bool = false { didSet { self.hasDragLineView = !self.dragMoveEffectAnimated } } override init(frame frameRect: NSRect) { super.init(frame: frameRect) self.initDefaultValue() self.initSubViews() } required public init?(coder: NSCoder) { super.init(coder: coder) self.initDefaultValue() self.initSubViews() } open var minimumLineSpacing: CGFloat = 0.0 { didSet { self.reloadData() } } open var minimumInteritemSpacing: CGFloat = 0.0 { didSet { self.reloadData() } } open var itemSize: NSSize = NSMakeSize(60, 80) { didSet { self.reloadData() } } open var sectionInset: NSEdgeInsets = NSEdgeInsetsZero { didSet { self.reloadData() } } open var numberOfSections: Int = 0 { didSet { self.reloadData() } } var selectionIndexPaths: Set { get { return self.collectionView.selectionIndexPaths } set { var indexpaths: Set = [] for indexpath in newValue { if (indexpath.section >= self.collectionView.numberOfSections) { continue } if indexpath.section < 0 { continue } if (indexpath.item >= self.collectionView.numberOfItems(inSection: indexpath.section)) { continue } if indexpath.item < 0 { continue } indexpaths.insert(indexpath) } self.collectionView.selectionIndexPaths = indexpaths } } // MARK: - Publick Methods public func initDefaultValue() { self.collectionView.registerForDraggedTypes([self.localForDraggedTypes, .fileURL,.string,.pdf]) } public func initSubViews() { self.addSubview(self.scrollView) self.scrollView.frame = self.bounds self.scrollView.autoresizingMask = [.width, .height] self.scrollView.documentView = self.collectionView } // MARK: - register ItemClass open func register(_ itemClass: AnyClass?) { guard let itemClass_ = itemClass else { return } self.register(itemClass_, forItemWithIdentifier: NSStringFromClass(itemClass_)) } open func register(_ itemClass: AnyClass?, forItemWithIdentifier identifier: String) { self.collectionView.register(itemClass, forItemWithIdentifier: NSUserInterfaceItemIdentifier(rawValue: identifier)) } // MARK: - 刷新UI public func reloadData(indexs: Set = []) { if (indexs.count == 0) { if (Thread.isMainThread) { self.collectionView.reloadData() } else { DispatchQueue.main.async { self.collectionView.reloadData() } } } else { var indexpaths: Set = [] for index in indexs { if (index.section >= self.collectionView.numberOfSections) { continue } if (index.item >= self.collectionView.numberOfItems(inSection: index.section)) { continue } indexpaths.insert(index) } if (indexpaths.count == 0) { return } if (Thread.isMainThread) { self.collectionView.reloadItems(at: indexpaths) } else { DispatchQueue.main.async { self.collectionView.reloadItems(at: indexpaths) } } } } // MARK: - 属性 【懒加载】 internal lazy var scrollView_: NSScrollView = { let view = NSScrollView() view.hasHorizontalScroller = true view.hasVerticalScroller = true view.autohidesScrollers = true view.minMagnification = 1.0 view.scrollerStyle = .overlay view.wantsLayer = true view.layer?.backgroundColor = NSColor.clear.cgColor view.wantsLayer = true view.contentView.layer?.backgroundColor = .white return view }() var scrollView: NSScrollView { get { return self.scrollView_ } } internal lazy var collectionView_: KMThumbnailView_CollectionView = { let view = KMThumbnailView_CollectionView() view.autoresizingMask = [.width, .height] let layout = NSCollectionViewFlowLayout() layout.sectionInset = NSEdgeInsetsMake(8, 15, 8, 15) layout.minimumLineSpacing = 0 layout.minimumInteritemSpacing = 0 view.collectionViewLayout = layout view.delegate = self view.dataSource = self view.register(NSCollectionViewItem.self, forItemWithIdentifier: NSUserInterfaceItemIdentifier(rawValue: "KMPDFThumbnailItem")) view.isSelectable = true view.wantsLayer = true view.layer?.backgroundColor = NSColor.km_init(hex: "#F7F8FA").cgColor return view }() var collectionView: KMThumbnailView_CollectionView { get { return self.collectionView_ } } // MARK: - Private Methods private func _moveItemForDrag(to toIndexPath: IndexPath) -> Bool { if (self.dragedIndexPaths.isEmpty) { Swift.debugPrint("not find selected items.\(self.dragedIndexPaths)") return false } if (self.dragedIndexPaths.contains(toIndexPath) == false) { // 目标位置不在拖放数组里 if (self._dragMoveFlagIndexpath == nil) { // 第一次移动 self.collectionView.animator().moveItem(at: self.dragedIndexPaths.first!, to: toIndexPath) Swift.debugPrint("moveBegin, \(self.dragedIndexPaths.first!.item)->\(toIndexPath.item)") // 标记位置 self._dragMoveFlagIndexpath = toIndexPath return true } else { // 已有移动 if (self._dragMoveFlagIndexpath! != toIndexPath) { self.collectionView.animator().moveItem(at: self._dragMoveFlagIndexpath!, to: toIndexPath) Swift.debugPrint("move..., \(self._dragMoveFlagIndexpath!.item)->\(toIndexPath.item)") // 标记位置 self._dragMoveFlagIndexpath = toIndexPath return true } else { Swift.debugPrint("drag... ") } } } else if (self._dragMoveFlagIndexpath != nil) { // 目标位置在拖放数组里且已有移动 if (self._dragMoveFlagIndexpath! != toIndexPath) { self.collectionView.animator().moveItem(at: self._dragMoveFlagIndexpath!, to: toIndexPath) Swift.debugPrint("move..., \(self._dragMoveFlagIndexpath!.item)->\(toIndexPath.item)") // 标记位置 self._dragMoveFlagIndexpath = toIndexPath return true } else { Swift.debugPrint("drag... ") } } return false } private func _moveItemsForDrag(at itemIndexPaths: Set, to toIndexPath: IndexPath) -> Bool { if (itemIndexPaths.isEmpty) { return false } var itemIndexs = IndexSet() for ip in itemIndexPaths { itemIndexs.insert(ip.item) } return self._moveItemsForDrag(at: itemIndexs, to: toIndexPath.item) } private func _moveItemsForDrag(at itemIndexs: IndexSet, to toIndex: Int) -> Bool { if (itemIndexs.isEmpty) { return false } var flagIndexs = IndexSet() if (itemIndexs.first! > toIndex) { // 往前拖放 // 3,4 -> 2 // 3->2,4->3 var cnt: Int = 0 for item in itemIndexs { let indexpath = IndexPath(item: item, section: 0) let _toIndexPath = IndexPath(item: toIndex+cnt, section: 0) self.collectionView.animator().moveItem(at: indexpath, to: _toIndexPath) // 标记位置 flagIndexs.insert(_toIndexPath.item) cnt += 1 } } else if (itemIndexs.last! < toIndex) { // 往后拖放 // 1,2 -> 3 // 2->3, 1->2 // 1,2 -> 4 // 2->4, 1->3 // 1,3 -> 4 // 3->4, 1->3 var cnt: Int = 0 for item in itemIndexs.reversed() { let indexpath = IndexPath(item: item, section: 0) let _toIndexPath = IndexPath(item: toIndex-cnt, section: 0) self.collectionView.animator().moveItem(at: indexpath, to: _toIndexPath) // 标记位置 flagIndexs.insert(_toIndexPath.item) cnt += 1 } } else { // 往中间拖放(选中不连续) // 1,3 -> 2 // 1->2, ... var cnt: Int = 0 for item in itemIndexs { let indexpath = IndexPath(item: item, section: 0) if (cnt == 0) { // 第一个 let _toIndexPath = IndexPath(item: toIndex, section: 0) self.collectionView.animator().moveItem(at: indexpath, to: _toIndexPath) flagIndexs.insert(toIndex) } else { // 第二个... if (indexpath.item == toIndex + cnt) { // 已在对应的位置 // no doings flagIndexs.insert(toIndex+cnt) } else { // 需要移动 let _toIndexPath = IndexPath(item: toIndex+cnt, section: 0) self.collectionView.animator().moveItem(at: indexpath, to: _toIndexPath) flagIndexs.insert(_toIndexPath.item) } } cnt += 1 } } // 标记位置 self._dragMoveFlagIndexs = flagIndexs return true } private func _doDragMoveEffect(to toIndexPath: IndexPath) -> Bool { if (self.dragedIndexPaths.isEmpty) { Swift.debugPrint("not find selected items.\(self.dragedIndexPaths)") return false } if (self.dragedIndexPaths.contains(toIndexPath) == false) { // 目标位置不在拖放数组里 if (self._dragMoveFlagIndexs == nil) { // 第一次移动 var itemIndexPaths = Set() for indexpath in self.dragedIndexPaths { itemIndexPaths.insert(indexpath) } return self._moveItemsForDrag(at: itemIndexPaths, to: toIndexPath) } else { // 已有移动 if (self._dragMoveFlagIndexs!.contains(toIndexPath.item) == false) { return self._moveItemsForDrag(at: self._dragMoveFlagIndexs!, to: toIndexPath.item) } else { Swift.debugPrint("drag... ") } } } else if (self._dragMoveFlagIndexs != nil) { // 目标位置在拖放数组里且已有移动 if (self._dragMoveFlagIndexs!.contains(toIndexPath.item) == false) { return self._moveItemsForDrag(at: self._dragMoveFlagIndexs!, to: toIndexPath.item) } else { Swift.debugPrint("drag... ") } } return false } } // MARK: - NSCollectionViewDataSource, NSCollectionViewDelegate extension KMThumbnailView: NSCollectionViewDataSource { public func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int { if let items = self.delegate?.thumbnailView?(thumbanView: self, numberOfItemsInSection: section) { return items } return self.numberOfSections } func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem { if let item = self.delegate?.thumbnailView?(thumbanView: self, itemForRepresentedObjectAt: indexPath) { return item } return NSCollectionViewItem() } } // MARK: - NSCollectionViewDelegate extension KMThumbnailView: NSCollectionViewDelegate { func collectionView(_ collectionView: NSCollectionView, shouldSelectItemsAt indexPaths: Set) -> Set { if let lastSelectedIndexPath = collectionView.selectionIndexPaths.first { if NSApp.currentEvent?.modifierFlags.contains(.shift) == true { // Shift 键按住,进行连续多选 let selectedIndexPaths = collectionView.selectionIndexPaths var allIndexPaths = Set(selectedIndexPaths) // 获取两个 IndexPath 之间的所有 IndexPath let startIndex = lastSelectedIndexPath.item let endIndex = indexPaths.first?.item ?? startIndex let range = startIndex < endIndex ? startIndex...endIndex : endIndex...startIndex for index in range { let indexPath = IndexPath(item: index, section: lastSelectedIndexPath.section) allIndexPaths.insert(indexPath) } return allIndexPaths } } return indexPaths } func collectionView(_ collectionView: NSCollectionView, didSelectItemsAt indexPaths: Set) {} func collectionView(_ collectionView: NSCollectionView, shouldDeselectItemsAt indexPaths: Set) -> Set { return indexPaths } func collectionView(_ collectionView: NSCollectionView, didDeselectItemsAt indexPaths: Set) {} func collectionView(_ collectionView: NSCollectionView, canDragItemsAt indexPaths: Set, with event: NSEvent) -> Bool { if let can = self.delegate?.thumbnailView?(thumbanView: self, canDragItemsAt: indexPaths, with: event) { return can } return true } func collectionView(_ collectionView: NSCollectionView, writeItemsAt indexPaths: Set, to pasteboard: NSPasteboard) -> Bool { let data: Data = try! NSKeyedArchiver.archivedData(withRootObject: indexPaths, requiringSecureCoding: true) pasteboard.declareTypes([self.localForDraggedTypes], owner: self) pasteboard.setData(data, forType: self.localForDraggedTypes) self.dragedIndexPaths.removeAll() for indexPath in indexPaths { self.dragedIndexPaths.append(indexPath) } return true } func collectionView(_ collectionView: NSCollectionView, draggingSession session: NSDraggingSession, willBeginAt screenPoint: NSPoint, forItemsAt indexes: IndexSet) { self.delegate?.thumbnailView?(thumbanView: self, draggingSession: session, willBeginAt: screenPoint, forItemsAt: indexes) } func collectionView(_ collectionView: NSCollectionView, draggingSession session: NSDraggingSession, endedAt screenPoint: NSPoint, dragOperation operation: NSDragOperation) { self._dragMoveFlagIndexpath = nil self._dragMoveFlagIndexs = nil self.delegate?.thumbnailView?(thumbanView: self, draggingSession: session, endedAt: screenPoint, dragOperation: operation) } func collectionView(_ collectionView: NSCollectionView, validateDrop draggingInfo: NSDraggingInfo, proposedIndexPath proposedDropIndexPath: AutoreleasingUnsafeMutablePointer, dropOperation proposedDropOperation: UnsafeMutablePointer) -> NSDragOperation { let pboard = draggingInfo.draggingPasteboard if (pboard.availableType(from: [self.localForDraggedTypes]) != nil) { return .move } else if (pboard.availableType(from: [.localDraggedTypes]) != nil) { if (proposedDropOperation.pointee == .on && self.dragMoveEffectAnimated) { _ = self._doDragMoveEffect(to: proposedDropIndexPath.pointee as IndexPath) } return .move } else if ((pboard.availableType(from: [.fileURL])) != nil) { guard let pbItems = pboard.pasteboardItems else { return NSDragOperation(rawValue: 0) } guard let _allowedFileTypes = self.kmAllowedFileTypes else { return .generic } var hasValidFile = false for item in pbItems { guard let data = item.string(forType: .fileURL), let _url = URL(string: data) else { continue } let type = _url.pathExtension.lowercased() if (_allowedFileTypes.contains(type)) { hasValidFile = true break } } if (!hasValidFile) { return NSDragOperation(rawValue: 0) } } return .generic } func collectionView(_ collectionView: NSCollectionView, acceptDrop draggingInfo: NSDraggingInfo, indexPath: IndexPath, dropOperation: NSCollectionView.DropOperation) -> Bool { if let should = self.delegate?.thumbnailView?(thumbanView: self, shouldAcceptDrop: draggingInfo, indexPath: indexPath, dropOperation: dropOperation), !should { return should } let pboard = draggingInfo.draggingPasteboard if (pboard.availableType(from: [self.localForDraggedTypes]) != nil) { let dragInfo = [ KMThumbnailViewDragInfoKey.draggingInfo.rawValue : draggingInfo, KMThumbnailViewDragInfoKey.dropOperation.rawValue : dropOperation ] as [Int : Any] self.delegate?.thumbnailView?(thumbanView: self, didDrag: self.dragedIndexPaths, indexpath: indexPath, dragInfo: dragInfo) self.dragedIndexPaths.removeAll() return true } else if (pboard.availableType(from: [.localDraggedTypes]) != nil) { var _dragIndexpaths = Set() draggingInfo.enumerateDraggingItems( options: NSDraggingItemEnumerationOptions.concurrent, for: collectionView, classes: [NSPasteboardItem.self], searchOptions: [:], using: {(draggingItem, idx, stop) in if let pasteboardItem = draggingItem.item as? NSPasteboardItem { do { if let indexPathData = pasteboardItem.data(forType: .localDraggedTypes), let _indexPath = try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(indexPathData) as? IndexPath { _dragIndexpaths.insert(_indexPath) } } catch { Swift.debugPrint("failed to unarchive indexPath for dropped photo item.") } } }) let dragInfo = [ KMThumbnailViewDragInfoKey.draggingInfo.rawValue : draggingInfo, KMThumbnailViewDragInfoKey.dropOperation.rawValue : dropOperation ] as [Int : Any] self.delegate?.thumbnailView?(thumbanView: self, didDrag: _dragIndexpaths.sorted(), indexpath: indexPath, dragInfo: dragInfo) self.dragedIndexPaths.removeAll() return true } else if ((pboard.availableType(from: [.fileURL])) != nil) { var array: [URL] = [] for item: NSPasteboardItem in pboard.pasteboardItems! { let string: String = item.string(forType: NSPasteboard.PasteboardType.fileURL)! let url = NSURL(string: string) array.append(url! as URL) } self.delegate?.thumbnailView?(thumbanView: self, didDragAddFiles: array, indexpath: indexPath) return true } return false } } // MARK: - NSCollectionViewDelegateFlowLayout extension KMThumbnailView: NSCollectionViewDelegateFlowLayout { func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> NSSize { if let size_ = self.delegate?.thumbnailView?(thumbanView: self, sizeForItemAt: indexPath) { return size_ } return self.itemSize } func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat { if let minimumLineSpacing_ = self.delegate?.thumbnailView?(thumbanView: self, minimumLineSpacingForSectionAt: section) { return minimumLineSpacing_ } return self.minimumLineSpacing } func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { if let minimumInteritemSpacing_ = self.delegate?.thumbnailView?(thumbanView: self, minimumInteritemSpacingForSectionAt: section) { return minimumInteritemSpacing_ } return self.minimumInteritemSpacing } func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, insetForSectionAt section: Int) -> NSEdgeInsets { if let inset = self.delegate?.thumbnailView?(thumbanView: self, insetForSectionAt: section) { return inset } return self.sectionInset } }