macOS 拖拽操作Drag和Drop (一)

4,399 阅读4分钟

英文原文地址

继续学习macOS开发相关内容,在Mac上的操作拖拽是比较常见的,是人机交互的重要部分,最常用的便是Finder文件操作

  • 粘贴板和拖拽session

拖拽和投放包含一个 源(source) 和一个目的地(destination)

从一个source拖拽出一个项目,它需要实现 NSDraggingSource 协议。然后投放它到一个destination中,它则必须实现 NSDraggingDestination 协议,为了确定是接受还是拒绝收到的项目。 NSPasteboard 是用来帮助交换数据的类。

整个的过程被称作 dragging session :

当拖拽一个文件的时候,就会发生下列的事:

1.当开始拖拽的时候,一个 拖拽session 就开始了
2.选择一些图片数据显示在拖拽粘贴板上
3.将图片投放到一个destination上,它会选择拒绝还是接受它,并采取一些动作 - 例如,移动文件到另一个目录下
4.拖拽session 结束

  • 创建一个拖拽的destination是一个view或window,它接受来自拖拽粘贴板的数据类型,需要遵守(adopt) NSDraggingDestination 协议来创建拖拽的目的地。

这个图表从拖拽destination的角度,展示了对于拖拽session的剖析。

创建destination包含以下几个步骤:

1.当构建view的时候,必须声明从任何的拖拽session中可以接受的类型。
2.当一个拖拽的图片进入这个view的时候你需要去实现决定这个view是否接受这种数据类型的逻辑,并让拖拽session知道这个决定。
3.当拖拽的图片“着陆”(lands on)在这个view上时,你会使用从拖拽粘贴板而来的数据,去展示它在你的view上。

创建一个DestinationView.swift,作为拖拽的目的地

注册view接受的数据类型,定义了支持类型的集合,仅支持 URLs,然后调用 register(forDraggedTypes:) 来接受包含这些类型的拖拽。

override func awakeFromNib() {
    setup()
}

var acceptableTypes: Set<String> { return [NSURLPboardType] }

func setup() {
    //接收URL的拖拽数据
    register(forDraggedTypes: Array(acceptableTypes))
  }

接下来分析拖拽session中的数据

//1.创建一个字典来定义期望的URL类型(图片)
let filteringOptions = [NSPasteboardURLReadingContentsConformToTypesKey:NSImage.imageTypes()]
 
func shouldAllowDrag(_ draggingInfo: NSDraggingInfo) -> Bool {
 
  var canAccept = false
 
  //2.从拖拽session信息中获取对拖拽粘贴板的引用
  let pasteBoard = draggingInfo.draggingPasteboard()
 
  //3.询问粘贴板它是否包含任何的URL,以及这些URL是指向图片的。如果有图片的话,就接受这个拖拽。否则,拒绝它
  if pasteBoard.canReadObject(forClasses: [NSURL.self], options: filteringOptions) {
    canAccept = true
  }
  return canAccept
 
}

NSView 遵守 NSDraggingDestination 协议,因此需要在 DestinationView.swift override draggingEntered(:) 方法: 在这个配置中,如果拖拽粘贴板带有一个图片,它就返回 .copy 来向用户展示将要复制的图片。否则,如果它不接受拖拽的项目,它就返回 NSDragOperation()

//创建 isReceivingDrag,追踪当前有拖拽session在这个view中,是否含有需要的数据,每次设置时,都会触发这个view的重绘。
var isReceivingDrag = false {
  didSet {
    needsDisplay = true
  }
}
 
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
  let allow = shouldAllowDrag(sender)
  isReceivingDrag = allow
  return allow ? .copy : NSDragOperation()
}

进入DestinationView的同时也有可能退出,所以app需要处理当一个拖拽session没有投放,就退出了DestinationView时的情况,添加下列的代码:

override func draggingExited(_ sender: NSDraggingInfo?) {
  isReceivingDrag = false
}

当有文件拖入到输入框时,将输入框的边框颜色改变,有一个提示的作用,DestinationView.swift 中,在override func draw(_ dirtyRect: NSRect) 方法中修改

override func draw(_ dirtyRect: NSRect) {
 
  if isReceivingDrag {
    NSColor.selectedControlColor.set()
 
    let path = NSBezierPath(rect:bounds)
    path.lineWidth = Appearance.lineWidth
    path.stroke()
  }
}

结束拖拽 到了这个部分的最后一步,必须接受拖拽,处理数据,并告知拖拽session。 DestinationView中实现下面的协议方法

  //接受拖拽,处理数据,这里是最后一次接受或拒绝拖拽的机会
  override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool {
    let allow = shouldAllowDrag(sender)
    return allow
  }

返回 true 意味着DestinationView接受了这个image。当接受时,系统就会移除拖拽的图片并调用协议序列中的下一个方法: performDragOperation(_:)

override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
    isReceivingDrag = false
    let pasteBoard = sender.draggingPasteboard()
    let point = convert(sender.draggingLocation(), from: nil)
    
    if let urls = pasteBoard.readObjects(forClasses: [NSURL.self], options: filteringOptions) as? [URL], urls.count > 0 {
      //在DestinationView中自定的协议DestinationViewDelegate传送数据
      delegate?.processImageURLs(urls, center: point)
      return true
    }
    
    return false
  }

DestinationView中声明的协议

protocol DestinationViewDelegate {
  func processImageURLs(_ urls: [URL], center: NSPoint) // 图片url
  func processImage(_ image: NSImage, center: NSPoint)  // 图片
}

StickerBoardViewController负责是DestinationView的控制器,将StickerBoardViewController指定为 DestinationView 的delegate。

@IBOutlet var topLayer: DestinationView!
@IBOutlet var targetLayer: NSView! //显示图片的view

override func viewDidLoad() {
    super.viewDidLoad()
    topLayer.delegate = self
  }

需要实现 DestinationViewDelegate协议 的方法,将图片放到DestinationView,实现协议方法 processImage(:center:) 和 processImageURLs(:center:)

extension StickerBoardViewController: DestinationViewDelegate {
    func processImageURLs(_ urls: [URL], center: NSPoint) {
    for (index, url) in urls.enumerated() {
      if let image = NSImage(contentsOf: url) {
        var newCenter = center
        
        if index > 0 {
          newCenter = center.addRandomNoise(Appearance.randomNoise)
        }
        
        processImage(image, center: newCenter)
      }
    }
  }
  
  func processImage(_ image: NSImage, center: NSPoint) {
    
    //为投放的图片,算出其保持长宽比的情况下,最大的尺寸。
    let constrainedSize = image.aspectFitSizeForMaxDimension(Appearance.maxStickerDimension)
    //中心定位在鼠标投放点上
    let subview = NSImageView(frame: NSRect(x: center.x - constrainedSize.width/2, y: center.y - constrainedSize.height/2, width: constrainedSize.width, height: constrainedSize.height))
    
    subview.image = image
    targetLayer.addSubview(subview)
    //随机地旋转这个图片的一点角度
    let maxrotation = CGFloat(arc4random_uniform(Appearance.maxRotation)) - Appearance.rotationOffset
    subview.frameCenterRotation = maxrotation
  }
}

现在就可以拖拽图片到目的view上了,效果如下图所示:

还有另外的两个拖拽的知识点,后面继续总结,示例的demo地址GitHub