本文将详细讲解在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/