畫布拖曳結束時,請在目標的座標空間中計算項目的最終幾何位置。先測試是否完全包含,再測試是否有正面積重疊。本教學會在你自己的 Mac App 中為矩形項目實作這項規則,並說明對應的 AppKit 做法。

定義重疊與包含的判定方式

想像一個簡單的桌面畫布,其中有一個可移動的藍色矩形和一個固定的灰色目標。讀者要判斷放開時的項目是否完全落在目標內、與目標部分重疊,或與目標沒有共用區域。範例中的所有內容都是合成資料,並儲存在記憶體中。

包含具有方向性:目標必須包含項目。Apple 文件指出,CGRectContainsRect(target, item) 在矩形聯集等於目標時會回傳 true。這也包括與目標完全相同的項目。先檢查包含,可避免將完全包含的項目誤判為部分重疊。CGRectContainsRect 文件

在此範例中,重疊必須有寬度和高度皆大於零的共用區域。僅接觸邊或角會判定為 No area overlap。請計算交集並檢查其尺寸,明確定義接觸時的行為。Apple 文件指出,CGRectIntersection 會回傳共用矩形;若沒有交集,則回傳 null 矩形。CGRectIntersection 文件

結果 本教學採用的規則 範例
Contained 目標包含完整項目,包括重合的邊界 項目位於目標內,或與目標完全相同
Overlap 未達包含條件,但共用區域的寬度和高度皆大於零 項目跨過目標邊緣
No area overlap 沒有正面積交集 項目彼此分開,或僅接觸邊緣
Invalid geometry 任一矩形的座標不是有限值,或尺寸不是正值 零寬度項目

這些是根據幾何運算制定的應用程式規則。如果介面要求項目與目標邊緣之間保留間距,請另外定義該間距,並以較小的接受矩形進行測試。讓拖曳項目的人能理解可見目標與接受規則。

使用單一座標空間與最終拖曳位移

你繪製的矩形和分類的矩形必須描述同一個位置。在此範例中,項目和目標都使用畫布座標。畫布會指定一個具名座標空間,項目的 DragGesture 也使用相同名稱。Apple 目前的 coordinateSpace(_:) 修飾器支援 macOS 14 及更新版本的這種模式。具名座標空間文件

請將項目已確認的矩形與暫時的拖曳位移分開儲存。手勢進行時,使用位移偏移已確認的矩形。手勢成功完成時,根據回呼提供的值重新計算最終矩形、進行分類,然後只提交一次。

Apple 將 translation 定義為從拖曳開始到目前事件的總位移。因此,若反覆將完整位移加到已更新的位置,移動量就會累積過多。請將位移套用到未變動的已確認位置。拖曳位移文件

僅憑放開時的指標位置,無法判斷整個項目是否完全放入目標。使用者可能抓住矩形的一個角,導致大部分矩形仍在外面,即使指標已位於目標內也是如此。套用位移也能保留原本的抓取偏移,不會將項目中心移到指標位置。

建立完整的 SwiftUI 矩形範例

在 Xcode 中建立 macOS SwiftUI App,部署目標設定為 macOS 14 或更新版本。以下兩個程式碼區塊請依序放在同一個 Swift 檔案中,並取代自動產生的 App 進入點。移除原本的 App 進入點,確保目標中只有一個 @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
}

第二個區塊提供 App 和畫布。請將手勢附加到可移動的矩形上,這與 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 App 目標中建置程式,並測試靠近各邊界的放開位置。

若有多個儲存在記憶體中的項目,請保留拖曳開始時所選項目的識別資訊。拖曳完成時,將該項目的最終幾何位置與符合條件的目標比較,並排除項目本身。如果有多個目標符合條件,請定義固定的選取規則,例如明確的目標順序。單靠幾何分類無法決定哪個目標應該收納該項目。

在 AppKit 畫布中套用相同的幾何規則

對於既有的自訂 AppKit 檢視,請讓分類器獨立於事件傳遞機制。mouseUp(with:) 會在使用者放開滑鼠左鍵時回報;請用它來完成畫布原本管理的拖曳。無關的滑鼠放開事件不應觸發放置。AppKit 滑鼠放開文件

實用的事件流程如下:

  1. 在 mouseDown(with:) 中,依照畫布現有的命中測試規則選取一個合成項目。儲存其識別資訊、起始矩形和起始指標位置。
  2. 將每個相關滑鼠事件的位置轉換為畫布座標。檢視附加至其視窗時,轉換運算式為 convert(event.locationInWindow, from: nil)。
  3. 在 mouseDragged(with:) 中,根據已儲存的矩形和指標位移計算預覽位置。
  4. 在 mouseUp(with:) 中,使用放開事件重新計算位移、分類最終矩形,然後只提交一次並清除作用中的拖曳狀態。

Apple 說明 locationInWindow 是滑鼠事件在視窗座標中的位置。當檢視引數為 nil 時,NSView.convert(_:from:) 的點座標轉換多載會從視窗座標進行轉換。這樣就能避免將視窗座標的點與使用畫布本機座標表示的矩形進行比較。事件位置文件、點座標轉換文件

讓負責互動的元件處理事件。Apple 區分了直接處理事件的 NSView 子類別,以及必須讓父檢視處理未消耗事件的子類別。請遵循文件中所述的 responder 行為;加入放置邏輯並不代表可以攔截其他檢視的事件流程,或透過兩個彼此獨立的處理常式重複處理同一個放開事件。NSView 事件處理說明

了解矩形何時無法回答形狀問題

此範例能對軸對齊的填滿矩形給出精確答案。若要處理圓形、凹形外框、孔洞或旋轉形狀,邊界矩形只能作為初步的幾何測試。矩形的共用區域可能包含兩個可見形狀都沒有填滿的空間。

指標位於路徑內的測試也無法證明另一個形狀完全包含在內。AppKit 的 NSBezierPath.contains(_:) 會以路徑座標空間中的單一點作為參數,Apple 並指出此方法不會考量描邊寬度。因此,測試項目的中心點只能回答該中心點是否位於路徑內。NSBezierPath 點包含測試文件

對於 macOS 14 及更新版本中的填滿路徑,SwiftUI 提供布林運算:Path.intersection(_:eoFill:) 會產生共用的填滿區域,Path.subtracting(_:eoFill:) 則會產生第一個填滿區域位於第二個區域外的部分。這些運算提供以路徑為基礎的方法,可用來檢查重疊,以及項目位於目標外的部分。請將兩個路徑對齊到相同座標空間,並使用與實際繪製方式相符的填滿規則。Path 交集文件、Path 相減文件

這些運算針對填滿區域,並假設未封閉的子路徑已封閉。描邊線條或應用程式對邊界接觸的規則,都需要自行定義幾何判定方式。矩形項目請繼續使用矩形分類器;畫布開始繪製其他形狀時,再有意識地擴充幾何層。

可靠的放開結果來自三項具體做法:保留拖曳開始時的幾何位置、在目標的座標空間中計算項目的最終位置,以及採用明確的包含與重疊規則。確定這些做法後,介面就能一致地回報結果,且無須將幾何判定與事件傳遞或項目歸屬綁在一起。

來源