画布拖动结束时,在目标的坐标空间中计算项目的最终几何形状。先检查是否完全包含,再检查是否存在正面积重叠。本教程为你自己的 Mac 应用中的矩形项目实现这一规则,并说明 AppKit 中的对应做法。
定义重叠和包含的判定标准
设想一个简单的桌面画布,其中有一个可移动的蓝色矩形和一个固定的灰色目标。读者需要判断释放的项目是完全位于目标内、与目标部分重叠,还是与目标没有共享区域。示例中的所有内容都是合成数据,并存储在内存中。
包含具有方向性:目标必须包含项目。Apple 文档指出,当矩形的并集等于目标矩形时,CGRectContainsRect(target, item) 会返回 true。这也包括与目标完全一致的项目。先检查包含,可以避免把完全包含的项目报告为部分重叠。CGRectContainsRect 文档
在此示例中,重叠要求共享区域的宽度和高度都大于零。仅接触边或角算作 No area overlap。计算交集并检查其尺寸,而不要让接触时的判定行为含糊不清。Apple 文档指出,CGRectIntersection 会返回共享矩形;若没有交集,则返回空矩形。CGRectIntersection 文档
| 结果 | 本教程使用的规则 | 示例 |
|---|---|---|
Contained |
目标包含完整项目,包括边界重合的情况 | 项目位于目标内,或与目标完全一致 |
Overlap |
不满足包含条件,但共享区域的宽度和高度都大于零 | 项目跨过目标的边缘 |
No area overlap |
不存在正面积交集 | 项目彼此分离,或仅接触边缘 |
Invalid geometry |
任一矩形的坐标不是有限值,或尺寸不大于零 | 宽度为零的项目 |
这些是根据几何运算制定的应用规则。如果界面要求项目在目标内侧留出边距,请单独定义该边距,并针对更小的接受区域进行检测。让拖动项目的人能够理解可见目标和接受规则。
使用同一坐标空间和最终拖动位移
你绘制的矩形和用于分类的矩形必须描述同一位置。在此示例中,项目和目标都使用画布坐标。画布使用一个命名坐标空间,项目的 DragGesture 也使用相同的名称。Apple 当前的 coordinateSpace(_:) 修饰符支持 macOS 14 及更高版本中的这种模式。命名坐标空间文档
将项目已提交的矩形与临时拖动位移分开存储。手势进行期间,根据位移偏移已提交的矩形来绘制项目。手势成功结束后,再根据回调中的值计算最终矩形,进行分类,并提交一次。
Apple 将 translation 定义为从拖动开始到当前事件的总位移。因此,如果对已更新的位置反复添加整个总位移,就会累积出过多的移动量。应将它应用于未改变的已提交位置。拖动位移文档
仅凭释放点无法判断整个项目是否适合放入目标。用户可能抓住矩形的一个角附近拖动,即使指针位于目标内,矩形的大部分仍可能在外面。应用位移还可以保留最初的抓取偏移,而不会把项目中心移到指针位置。
构建完整的 SwiftUI 矩形示例
在 Xcode 中创建一个部署目标为 macOS 14 或更高版本的 macOS SwiftUI 应用。用下面两个代码块替换生成的应用入口点,并将它们依次放在同一个 Swift 文件中。删除原有的应用入口点,确保目标中只有一个 @main 声明。两个代码块合在一起构成完整程序。
第一个代码块定义几何分类器。它会明确拒绝无效尺寸,而不是默默将其转换为另一种矩形。Swift 枚举可以区分四种可能的结果;其字符串原始值用于显示消息。Swift 枚举文档
import SwiftUI
import CoreGraphics
enum Placement: String {
case contained = "Contained"
case overlap = "Overlap"
case separate = "No area overlap"
case invalid = "Invalid geometry"
}
func isUsable(_ rect: CGRect) -> Bool {
let values = [rect.origin.x, rect.origin.y,
rect.width, rect.height,
rect.maxX, rect.maxY]
return values.allSatisfy { $0.isFinite }
&& rect.width > 0 && rect.height > 0
}
func classify(item: CGRect, target: CGRect) -> Placement {
guard isUsable(item), isUsable(target) else {
return .invalid
}
if CGRectContainsRect(target, item) {
return .contained
}
let shared = CGRectIntersection(item, target)
if !shared.isNull && shared.width > 0 && shared.height > 0 {
return .overlap
}
return .separate
}
第二个代码块提供应用和画布。按照 Apple 的 DragGesture 文档所述,将手势附加到可移动矩形上。固定的画布尺寸让此示例中的几何关系一目了然。DragGesture 文档
@main
struct RectanglePlacementApp: App {
var body: some Scene {
WindowGroup {
PlacementCanvas()
}
}
}
struct PlacementCanvas: View {
private let target = CGRect(x: 200, y: 100,
width: 200, height: 160)
@State private var item = CGRect(x: 40, y: 120,
width: 80, height: 60)
@State private var message = "Drag the blue item, then release."
@GestureState private var translation: CGSize = .zero
private var preview: CGRect {
item.offsetBy(dx: translation.width,
dy: translation.height)
}
private var move: some Gesture {
DragGesture(minimumDistance: 1,
coordinateSpace: .named("placementCanvas"))
.updating($translation) { value, state, _ in
state = value.translation
}
.onEnded { value in
let finalItem = item.offsetBy(
dx: value.translation.width,
dy: value.translation.height
)
let result = classify(item: finalItem, target: target)
message = result.rawValue
if result != .invalid {
item = finalItem
}
}
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(message)
ZStack(alignment: .topLeading) {
Color.white
Rectangle()
.fill(Color.gray.opacity(0.25))
.frame(width: target.width, height: target.height)
.position(x: target.midX, y: target.midY)
Rectangle()
.fill(Color.blue)
.frame(width: item.width, height: item.height)
.position(x: preview.midX, y: preview.midY)
.gesture(move)
}
.frame(width: 640, height: 380)
.coordinateSpace(.named("placementCanvas"))
Button("Reset") {
item = CGRect(x: 40, y: 120, width: 80, height: 60)
message = "Drag the blue item, then release."
}
}
.padding()
}
}
将蓝色项目拖到灰色目标上并释放。消息会记录最近一次完成放置的结果。部分重叠和分离的项目会留在释放的位置;包含不会自动改变父子关系,也不会将项目吸附到其他容器中。Reset 按钮会恢复初始矩形。
手势的更新回调中刻意没有持久化位置更新。@GestureState 只保存预览位移,并会在手势变为非活动状态时重置。Apple 指出,onEnded 仅在手势成功完成后运行,并建议对必须在手势无论如何结束时都重置的值使用手势状态。因此,中断的手势可以丢弃预览,同时保留已提交的矩形和上一次的结果。GestureState 文档,onEnded 文档
此实现将填充矩形本身视为几何形状。添加装饰性阴影或轮廓不会扩大接受区域。如果加入缩放、旋转或调整大小功能,请先更新几何模型和坐标转换,再使用同一分类器。
添加更多项目之前检查边界情况
使用下表中的合成案例测试示例目标:原点 (200, 100),宽度 200,高度 160。坐标描述同一画布空间中的项目矩形。
| 项目原点 | 项目尺寸 | 预期结果 | 原因 |
|---|---|---|---|
(220, 120) |
80 × 60 |
Contained |
每条边都位于目标边界内 |
(360, 120) |
80 × 60 |
Overlap |
项目超出目标的右边缘 |
(400, 120) |
80 × 60 |
No area overlap |
仅垂直边缘相接 |
(401, 120) |
80 × 60 |
No area overlap |
矩形之间有间隙 |
(200, 100) |
200 × 160 |
Contained |
相等的矩形满足包含条件 |
(180, 80) |
240 × 200 |
Overlap |
项目包围目标,包含关系反转 |
表中的计算已用独立的确定性计算进行了核对。研究环境未提供 Swift 编译器,因此只检查了 Swift 程序,并未编译。请在 macOS 应用目标中构建,并测试靠近各边界的释放位置。
对于多个存储在内存中的项目,应保留拖动开始时选中项目的身份。拖动结束时,将其最终几何形状与符合条件的目标比较,并排除项目自身。如果多个目标都符合条件,请定义稳定的选择规则,例如明确规定目标顺序。几何分类本身无法决定哪个目标应拥有该项目。
在 AppKit 画布中应用相同的几何规则
对于现有的自定义 AppKit 视图,应让分类器独立于事件传递。mouseUp(with:) 表示左键释放;只有在画布已经拥有该拖动时,才用它来完成拖动。无关的鼠标抬起事件不应触发放置。AppKit 鼠标抬起文档
实用的事件处理顺序如下:
- 在
mouseDown(with:)中,使用画布现有的命中测试规则选择一个合成项目。保存其身份、起始矩形和起始指针位置。 - 将每个相关鼠标事件的位置转换为画布坐标。附加到窗口的视图可使用转换表达式
convert(event.locationInWindow, from: nil)。 - 在
mouseDragged(with:)中,根据保存的矩形和指针位移计算预览位置。 - 在
mouseUp(with:)中,使用释放事件重新计算位移,对最终矩形进行分类,然后仅提交一次并清除当前拖动状态。
Apple 将 locationInWindow 记录为鼠标事件在窗口坐标中的位置。当 NSView.convert(_:from:) 的视图参数为 nil 时,其点转换重载会从窗口坐标进行转换。这样就不会将窗口坐标中的点与画布本地坐标中的矩形进行比较。事件位置文档,点转换文档
应由已经负责交互的组件处理事件。Apple 区分了直接处理事件的 NSView 子类,以及必须让父视图处理未消费事件的子类。遵循文档说明的响应者行为;添加放置逻辑并不意味着可以截获另一个视图的事件序列,也不应通过两个独立处理器处理同一次释放事件。NSView 事件处理指南
了解矩形何时无法回答形状问题
此示例能准确判断轴对齐的填充矩形。对于圆形、凹形轮廓、带孔形状或旋转形状,边界矩形只能用于初步几何测试。它们的共享面积可能包含任何一个可见形状都没有填充的空间。
点是否位于路径内的测试,也无法确定另一个形状是否完全包含在内。AppKit 的 NSBezierPath.contains(_:) 接受路径坐标系中的单个点;Apple 指出它不会考虑描边宽度。因此,测试项目中心只能回答该中心是否位于路径内。NSBezierPath 点包含文档
对于 macOS 14 及更高版本中的填充路径,SwiftUI 记录了受支持的布尔运算:Path.intersection(_:eoFill:) 会生成共同的填充区域,Path.subtracting(_:eoFill:) 会生成第一个填充区域中位于第二个区域之外的部分。这些运算提供了基于路径来研究重叠以及项目超出目标部分的方式。让两个路径处于同一坐标空间,并使用与渲染一致的填充规则。Path 交集文档,Path 差集文档
这些运算针对的是填充区域,并假定未闭合的子路径会自动闭合。描边线条或应用对边界接触的判定规则都需要单独定义几何形状。矩形项目应继续使用矩形分类器;当画布开始绘制其他形状时,再有意扩展几何层。
可靠的释放结果取决于三个明确的选择:保留拖动开始时的几何形状,在目标的坐标空间中计算项目的最终位置,并采用明确的包含与重叠策略。确定这些选择后,界面便能一致地报告结果,而不必将几何计算与事件路由或项目所有权耦合起来。
来源
- CGRectContainsRect(::) — Apple
- CGRectIntersection(::) — Apple
- coordinateSpace(_:) — Apple
- translation — Apple
- Enumerations — Swift.org
- DragGesture — Apple
- GestureState — Apple
- onEnded(_:) — Apple
- mouseUp(with:) — Apple
- locationInWindow — Apple
- convert(_:from:) — Apple
- NSView — Apple
- contains(_:) — Apple
- intersection(_:eoFill:) — Apple
- subtracting(_:eoFill:) — Apple