macOS自定义NSWindow和NSView的截图工具案例
macOS自定义NSWindow和NSView的截图工具案例

macOS自定义NSWindow和NSView的截图工具案例

本文将详细讲解在SwiftUI中,NSWindow和NSView的初始化以及重写生命周期函数的过程,便于深入理解NSView和NSWindow。

本案例用于开发mac截图工具:创建NSWindow和NSView设置截图的遮罩层,通过鼠标绘制截图区域。

NSWindow和NSView需要在窗口中显示,所以本案例步骤如下:

1、使用SwiftUI显示NSView,便于调试NSView的显示;

2、初始化NSView并重写mouseDown/Dragged等方法逻辑;

3、创建NSWindow并放上NSView,检查窗口的显示布局;

4、实现截图等代码逻辑。

实际案例

1、使用SwiftUI预览NSView

首先创建一个NSView视图:

// NSView视图
class ScreenshotOverlayView: NSView {
}

使用NSViewRepresentable协议,将NSView包装到SwiftUI View中:

// 桥接视图,将 NSView 显示在 SwiftUI
struct NSScreenshotOverlayView: NSViewRepresentable {
    func makeNSView(context: Context) -> ScreenshotOverlayView {
        return ScreenshotOverlayView()
    }
    func updateNSView(_ nsView: ScreenshotOverlayView, context: Context) {
    }
}

在SwiftUI中,显示包装NSView的视图:

struct ScreenshotOverlaySwiftUIView: View {
    var body: some View {
        NSScreenshotOverlayView()   // 桥接视图
    }
}

在SwiftUI中显示NSView。

2、初始化NSView并重写mouseDown/Dragged等方法

1、初始化NSView
class ScreenshotOverlayView: NSView {
    override init(frame frameRect: NSRect) {
        super.init(frame: frameRect)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

这里涉及继承、required等知识,请见《Swift重写父类的override》、《Swift引用父类的super》和《Swift完整初始化覆盖链》。

2、绘制遮罩视图

绘制一个半透明的黑色遮罩层,启用wantsLayer属性,开启CALayer实现图像的绘制。

class ScreenshotOverlayView: NSView {
    override init(frame frameRect: NSRect) {
        super.init(frame: frameRect)
        wantsLayer = true
        layer?.backgroundColor = NSColor.black.withAlphaComponent(0.2).cgColor
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

在SwiftUI中使用ZStack,添加一个实际的图片,和这个0.2透明度的NSView,查看遮罩效果:

struct ScreenshotOverlaySwiftUIView: View {
    var body: some View {
        ZStack {
            Image("testBackground")	// 新添加的桌面截图照片
            NSScreenshotOverlayView()
        }
    }
}
3、监听鼠标事件

在视图的遮罩层上,当鼠标点击并拖动时,绘制截图区域,当鼠标松开时,完成截图区域的绘制。

需要在NSView重写mouse的三个方法:

1)mouseDown(with:):鼠标按下;

2)mouseUp(with:):鼠标抬起;

3)mouseDragged(with:):鼠标拖动;

并保存鼠标的位置:

1)截图起始位置:鼠标按下时,保存鼠标的起始位置;

2)截图结束为止:鼠标拖动时,保存鼠标的拖动为止,当鼠标抬起时,鼠标位置更新为截图的结束位置;

这样,根据截图的起始位置和结束位置,就可以绘制出截图的区域。

var startPoint: NSPoint?
var currentPoint: NSPoint?
override func mouseDown(with event: NSEvent) {
    print("按下鼠标")
    startPoint = event.locationInWindow
    print("startPoint:\(startPoint ?? NSPoint())")
}
override func mouseDragged(with event: NSEvent) {
    print("移动鼠标")
    currentPoint = event.locationInWindow
    print("currentPoint:\(currentPoint ?? NSPoint())")
}
override func mouseUp(with event: NSEvent) {
    print("抬起鼠标")
    currentPoint = event.locationInWindow
    print("currentPoint:\(currentPoint ?? NSPoint())")
}

在视图中,移动鼠标时,就会输出鼠标的内容。

注意:NSView的坐标系是左下角为0,0 原点。

按下鼠标
startPoint:(83.27734375, 143.41015625)
移动鼠标
currentPoint:(83.27734375, 143.6171875)
移动鼠标
currentPoint:(83.68359375, 143.6171875)
...
抬起鼠标
currentPoint:(179.17578125, 155.37890625)
4、绘制截图区域

当鼠标拖动时,需要根据startPoint和currentPoint,绘制一个截图区域,在这里可以使用CAShapeLayer绘制图形。

定义一个CAShapeLayer变量用于绘制截图区域的图形。

private var selectionLayer = CAShapeLayer()

前面设置了一个半透明黑色遮罩视图,截图区域应该设置为透明的视图,在CALayer中需要设置mask遮罩属性。

先设置selectionLayer为测试的矩形:

override init(frame frameRect: NSRect) {
    super.init(frame: frameRect)
    wantsLayer = true
    layer?.backgroundColor = NSColor.black.withAlphaComponent(0.2).cgColor
    selectionLayer.path = CGPath(rect: CGRect(origin: CGPoint(x: 50, y: 50), size: CGSize(width: 100, height: 100)), transform: nil)    // 矩形
    layer?.mask = selectionLayer    // 设置遮罩图层
}

当使用mask遮罩时,实际上会根据CAShapeLayer图形的大小,显示内容。

实际上需要显示不在CAShapeLayer图像区域的黑色遮罩视图,而CAShapeLayer则是用户使用鼠标拖动选区的区域,需要设置相反的mask遮罩图层。

这里需要使用CGMutablePath绘制两个路径,一个是全屏路径,一个是用户绘制的路径,使用CAShapeLayer剔除两个路径重合的区域:

override init(frame frameRect: NSRect) {
    super.init(frame: frameRect)
    wantsLayer = true
    layer?.backgroundColor = NSColor.black.withAlphaComponent(0.2).cgColor
    let mutablePath = CGMutablePath()
    mutablePath.addRect(bounds) // 全屏路径
    mutablePath.addRect(CGRect(x: 100, y: 100, width: 100, height: 100))    //用于测试的矩形路径
selectionLayer.path = mutablePath   // 将路径添加到 CAShapeLayer
    selectionLayer.fillRule = .evenOdd  // 设置填充规则为 .evenOdd(剔除重合的路径部分)
    layer?.mask = selectionLayer    // 设置 CALayer 图层的蒙版为用户选区的图形区域
}

Xcode预览仍然是只显示矩形区域,而没有显示全屏的区域,后面排查为init()构造方法中bounds为默认值(0,0,0,0),需要将路径代码迁移至layout代码中:

override func layout() {
    wantsLayer = true
    layer?.backgroundColor = NSColor.black.withAlphaComponent(0.2).cgColor
    let path = CGMutablePath()
    path.addRect(bounds)
    path.addRect(CGRect(x: 100, y: 100, width: 100, height: 100))
}

这样就实现了根据用户绘制的路径,显示截图区域的部分。

5、计算用户鼠标拖动的区域

在layout代码中,使用CGRect作为测试矩形,现在需要计算用户鼠标拖动时选中的矩形区域。

假设我从左上角向右下角拖动一个矩形区域,鼠标坐标(AppKit坐标系原点为左下角)为:

按下鼠标
startPoint:(226.5703125, 118.89453125)
...
抬起鼠标
currentPoint:(294.03515625, 87.15234375)

这个矩形的宽度为 | currentPoint.x – startPoint.x | ≈ 68,高度 | currentPoint.y – startPoint.y | ≈ 32。

在AppKit坐标系中,当绘制图形时,都是从左下角开始绘制,向右上角绘制这个矩形的宽度和高度。

所以,左下角原点为min(currentPoint.x, startPoint.x) ≈ 226,min(currentPoint.y, startPoint.y) ≈ 87。

同理,当我从右下角向左上角拖动鼠标时,鼠标坐标为:

按下鼠标
startPoint:(307.63671875, 84.90625)
...
抬起鼠标
currentPoint:(203.90625, 159.53515625)

这个矩形的宽度为 | currentPoint.x – startPoint.x | ≈ 104,高度 | currentPoint.y – startPoint.y | ≈ 75。

左下角原点为min(currentPoint.x, startPoint.x) ≈ 203,min(currentPoint.y, startPoint.y) ≈ 84。

因此,计算矩形区域的方法为:

func selectedRect() -> CGRect? {
    guard let start = startPoint, let end = currentPoint else { return nil }
    return CGRect(
        x: min(start.x, end.x),
        y: min(start.y, end.y),
        width: abs(start.x - end.x),
        height: abs(start.y - end.y)
    )
}

当startPoint和currentPoint有数值时,返回一个CGRect矩形。

6、渲染鼠标拖动区域

在鼠标拖动时,调用方法并渲染鼠标拖动的区域。

override func mouseDragged(with event: NSEvent) {
    print("移动鼠标")
    currentPoint = event.locationInWindow
    updateSelectionPath()
}

func updateSelectionPath() {
    guard let rect = selectedRect() else { return } // 当选择区域为矩形时
    
    let path = CGMutablePath()  // 设置 CGMutablePath
    path.addRect(bounds)
    path.addRect(rect)
    selectionLayer.path = path  // 将全屏路径和矩形路径,传入用户选择的图形
}

这样,每次拖动鼠标时,都会看到对应的矩形截图区域。

7、拖动结束显示工具栏

在拖动完成后,显示一个工具栏,需要在mouseUp方法中实现:

override func mouseUp(with event: NSEvent) {
    currentPoint = event.locationInWindow
    showCommonToolbar() // 显示常用工具栏
}

显示工具栏的方法:

// 显示常用工具栏
func showCommonToolbar() {
    guard let rect = selectedRect(),let window = window else { return }
    
    // 获取工具栏尺寸
    let toolbarSize = NSSize(width: 520, height: 50)
    // 将视图尺寸转换为窗口尺寸
    let origin = window.convertToScreen(rect).origin
    // 默认放在区域的底部
    let toolbarOrigin = NSPoint(
        x: origin.x + rect.width / 2 - toolbarSize.width / 2,
        y: origin.y - 50
    )
    // 创建工具栏窗口
    let toolbar = CommonToolbarWindow(rectOrigin: toolbarOrigin, rectSize: toolbarSize)
    window.addChildWindow(toolbar, ordered: .above)
}

1、设置一个toolbarWindow变量,存储工具栏的窗口。

2、只有当前用户鼠标拖动的点,可以被计算为矩形,并且可以获取当前的window,才能进行下一步的计算。其中的window在于视图中的NSRect转换为屏幕的NSRect,否则可能会出差错。

3、使用NSWindow的convertToScreen方法,转换视图到窗口的坐标。

// 将视图尺寸转换为窗口尺寸
let origin = window.convertToScreen(rect).origin

例如,当前的NSView视图中的NSRect为:

(80.765625, 145.30859375, 270.61328125, 87.25)

如果工具栏显示按照这个(80,145)的坐标显示,肯定会显示在屏幕的左下角。实际上NSView在屏幕上的坐标可能是:

(815, 384, 270.61328125, 87.25)

使用toolbarOrigin计算变量,计算工具栏的位置。

4、创建NSWindow,并将NSWindow添加到当前Window的子窗口中:

// 创建工具栏窗口
let toolbar = CommonToolbarWindow(rectOrigin: toolbarOrigin, rectSize: toolbarSize)
window.addChildWindow(toolbar, ordered: .above)

实现效果:

3、创建NSWindow并放入NSView

创建NSWindowController管理NSWindow窗口的显示/关闭。

import AppKit
import SwiftUI

class ScreenshotWindowController: NSWindowController {
    
    init() {
        super.init(window: ScreenshotWindow())
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func showScreenshotOverlay() {
        self.showWindow(nil)
        self.window?.makeKeyAndOrderFront(nil)
    }

    func closeScreenshotOverlay() {
        self.close()
    }
}

创建NSWindow管理NSView视图,实现键盘的事件。

import AppKit
import SwiftUI

class ScreenshotWindow: NSWindow {
    init() {
        let hostingView = ScreenshotOverlayView()   // 创建视图
        hostingView.frame = NSScreen.main?.frame ?? .zero
        super.init(
            contentRect: NSScreen.main?.frame ?? .zero,
            styleMask: [.borderless],
            backing: .buffered,
            defer: false)
        self.isOpaque = false
        self.backgroundColor = .clear
        self.level = .screenSaver // 保证在最上层
        self.hasShadow = false
        self.ignoresMouseEvents = false
        self.makeFirstResponder(nil)    // 设置为第一响应者
        self.contentView = hostingView
    }
    
    override var canBecomeKey: Bool {
        return true
    }
    
    override func keyDown(with event: NSEvent) {
        print("keyDown:\(event.keyCode)")
        if event.keyCode == 53 { // ESC
            print("检测到 ESC 键,窗口被关闭")
            if let screenshotWC = self.windowController as? ScreenshotWindowController {
                screenshotWC.closeScreenshotOverlay()
            }
        } else {
            super.keyDown(with: event)
        }
    }
    
    override func rightMouseDown(with event: NSEvent) {
        print("keyDown:\(event.keyCode)")
        if event.buttonNumber == 1 {    // 右键
            print("检测到 右 键,窗口被关闭")
            
            if let screenshotWC = self.windowController as? ScreenshotWindowController {
                screenshotWC.closeScreenshotOverlay()
            }
        } else {
            super.keyDown(with: event)
        }
    }
}

设置窗口的视图为对应的NSView视图,配置透明背景、最高的窗口层级等参数。

4、实现截图效果

总结

本文篇幅较长,很多代码并没有纳入到该文中。

主要是带大家通过这个案例,来理解自定义NSWindow和NSView的基本流程。对于事件处理(按键)则在NSWindow或NSView中重写。

使用NSWindowController管理NSWindow的生命周期和显示/隐藏。

在初始化NSView中,其实可以使用NSScreen.main.frame获取屏幕的宽度,而不用在layout中获取bounds。

相关文章

1、macOS视图NSView:https://fangjunyu.com/2025/07/01/macos%e8%a7%86%e5%9b%bensview/

2、macOS管理视图的NSViewController:https://fangjunyu.com/2025/07/01/macos%e7%ae%a1%e7%90%86%e8%a7%86%e5%9b%be%e7%9a%84nsviewcontroller/

3、macOS窗口NSWindow:https://fangjunyu.com/2025/07/01/macos%e7%aa%97%e5%8f%a3nswindow/

4、macOS利用NSWindowController创建窗口实现打开应用的实际案例:https://fangjunyu.com/2025/07/01/macos%e5%88%a9%e7%94%a8nswindowcontroller%e5%88%9b%e5%bb%ba%e7%aa%97%e5%8f%a3%e5%ae%9e%e7%8e%b0%e6%89%93%e5%bc%80%e5%ba%94%e7%94%a8%e7%9a%84%e5%ae%9e%e9%99%85%e6%a1%88%e4%be%8b/

5、macOS管理窗口的控制器类NSWindowController:https://fangjunyu.com/2025/06/30/macos%e7%ae%a1%e7%90%86%e7%aa%97%e5%8f%a3%e7%9a%84%e6%8e%a7%e5%88%b6%e5%99%a8%e7%b1%bbnswindowcontroller/

6、SwiftUI显示AppKit视图的NSViewRepresentable协议:https://fangjunyu.com/2025/07/02/swiftui%e6%98%be%e7%a4%baappkit%e8%a7%86%e5%9b%be%e7%9a%84nsviewrepresentable%e5%8d%8f%e8%ae%ae/

7、Swift完整初始化覆盖链:https://fangjunyu.com/2025/07/31/swift-%e5%ae%8c%e6%95%b4%e5%88%9d%e5%a7%8b%e5%8c%96%e8%a6%86%e7%9b%96%e9%93%be/

8、Swift重写父类的override:https://fangjunyu.com/2025/05/18/swift%e9%87%8d%e5%86%99%e7%88%b6%e7%b1%bb%e7%9a%84override/

9、Swift引用父类的super:https://fangjunyu.com/2025/05/19/swift%e5%bc%95%e7%94%a8%e7%88%b6%e7%b1%bb%e7%9a%84super/

10、Apple渲染图像、动画的CALayer:https://fangjunyu.com/2025/07/02/apple%e6%b8%b2%e6%9f%93%e5%9b%be%e5%83%8f%e3%80%81%e5%8a%a8%e7%94%bb%e7%9a%84calayer/

11、macOS用户输入事件NSEvent:https://fangjunyu.com/2025/07/04/macos%e7%94%a8%e6%88%b7%e8%be%93%e5%85%a5%e4%ba%8b%e4%bb%b6nsevent/

12、Core Animation绘制图像路径CAShapeLayer:https://fangjunyu.com/2025/07/31/core-animation%e7%bb%98%e5%88%b6%e5%9b%be%e5%83%8f%e8%b7%af%e5%be%84cashapelayer/

13、NSView初始化时bounds属性失效的问题:https://fangjunyu.com/2025/08/02/nsview%e5%88%9d%e5%a7%8b%e5%8c%96%e6%97%b6bounds%e5%b1%9e%e6%80%a7%e5%a4%b1%e6%95%88%e7%9a%84%e9%97%ae%e9%a2%98/

   

如果您认为这篇文章给您带来了帮助,您可以在此通过支付宝或者微信打赏网站开发者。

欢迎加入我们的 微信交流群QQ交流群,交流更多精彩内容!
微信交流群二维码 QQ交流群二维码

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注