若要讓自訂 SwiftUI 拖曳控制項吸附至離散值,請將指標的局部位置轉換為受限分數,四捨五入成整數索引,再依該索引繪製滑塊。這個 macOS 範例可選擇五種圓形尺寸之一,並在放開時套用所選尺寸。

撰寫手勢前,先決定互動方式

讀者的需求很直接:為 Mac App 新增自訂水平尺寸選擇器。可用尺寸為 20、40、60、80 和 100 點。拖曳時會預覽吸附後的選取項目;放開時則將該尺寸套用至一個合成圓形。所有資料都留在記憶體中。

先判斷是否真的需要自訂繪圖。Apple 標準的 Slider 已可透過 step 參數支援離散遞增,並會在滑塊移動時更新繫結值。對一般尺寸選擇器而言,使用 20...100 範圍和 20 步進值,就能直接表示這些選項。若標準控制項的外觀和互動方式符合需求,請使用這個受支援的控制項。Apple 的 Slider 文件

當你需要控制軌道、刻度、滑塊和放開時的行為,自訂實作就很實用。以下實作刻意使用整數選取索引。顯示的尺寸取自陣列,因此模型不會意外存入像 57.3 這樣的中間尺寸。

這個範例由三項決策構成:

  • 刻度等距排列,較大的尺寸位於右側。
  • 拖曳時,滑塊和控制項的值標籤都會吸附至刻度。
  • 只有放開時,父視圖中的所選尺寸才會變更;預覽期間,圓形維持先前已提交的尺寸。

將指標位置轉換為離散索引

輸入幾何和繪圖幾何必須一致。此處控制項寬 336 點,滑塊半徑為 12 點。因此,滑塊中心會從 12 移動至 324,可用軌道長度為 312 點。若輸入計算使用整個寬度,指標位置就會與端點刻度不一致。

這個映射包含四個操作:減去左側內縮值、除以可用軌道長度、限制在 0...1 範圍內,再乘以間隔數後四捨五入。五個選項有四個間隔。反向映射則會把所選滑塊放在對應的刻度上。

以下輔助函式會納入下一節組成的完整 App。其捨入規則在正中點時選擇較大的索引,因為正規化索引皆為非負數。Swift 文件將 .toNearestOrAwayFromZero 說明為取最近值,遇到中點時遠離零捨入。Apple 的 rounded(_:) 文件

import SwiftUI

enum SnapGeometry {
    static func index(
        at x: CGFloat,
        width: CGFloat,
        inset: CGFloat,
        count: Int
    ) -> Int {
        guard count > 1, x.isFinite, width > 2 * inset else {
            return 0
        }
        let fraction = min(max((x - inset) / (width - 2 * inset), 0), 1)
        return Int(
            (fraction * CGFloat(count - 1))
                .rounded(.toNearestOrAwayFromZero)
        )
    }

    static func position(
        for index: Int,
        width: CGFloat,
        inset: CGFloat,
        count: Int
    ) -> CGFloat {
        guard count > 1 else { return width / 2 }
        return inset + CGFloat(index) / CGFloat(count - 1)
            * (width - 2 * inset)
    }
}

這些示意輸入展示了固定幾何下的邊界行為。限制範圍可確保超出任一端點的拖曳仍落在有效選項上,不會直接丟棄互動。

指標 x 座標 選取索引 尺寸(點)
-10 0 20
12 0 20
51 1 40
90 1 40
168 2 60
324 4 100
350 4 100

在 51,指標正好位於前兩個刻度的中點,因此規則會選擇索引 1。我用 Python 檢查了這些確定性計算。研究環境中沒有 Swift 編譯器,因此下方的 SwiftUI App 已經過檢視,但未在此處編譯或執行。

建立 macOS 控制項及其擁有者視圖

在 Xcode 中建立 macOS App 專案,使用 SwiftUI,並將部署目標設為 macOS 13 或更新版本。將產生的 App 宣告和內容視圖替換為下方程式碼,並把前一節的輔助函式加入同一個 Swift 檔案。輔助函式和接下來的兩個程式碼區塊合在一起就是完整程式;只保留一個 @main App 宣告。

父視圖以 @State 擁有已提交的索引。控制項接收 @Binding,連結至該資料來源,不會另行建立第二個持續存在的選取狀態。這符合 Apple 文件所述的繫結擁有權模式。Apple 的 Binding 文件

@main
struct SnapSizeApp: App {
    var body: some Scene {
        WindowGroup {
            SizeDemo()
        }
    }
}

struct SizeDemo: View {
    private let sizes = [20, 40, 60, 80, 100]
    @State private var selectedIndex = 2

    var body: some View {
        VStack(spacing: 24) {
            Text("Circle size")
                .font(.headline)

            Circle()
                .fill(Color.accentColor)
                .frame(
                    width: CGFloat(sizes[selectedIndex]),
                    height: CGFloat(sizes[selectedIndex])
                )
                .frame(width: 120, height: 120)

            DiscreteSizeControl(
                sizes: sizes,
                selection: $selectedIndex
            )

            Text("Applied size: \(sizes[selectedIndex]) pt")
        }
        .padding(32)
        .frame(minWidth: 420, minHeight: 340)
    }
}

控制項使用 @GestureState 暫存預覽狀態。Apple 文件指出,手勢變為非作用中時,手勢狀態會重設為初始值。在此處,回到 nil 時,控制項會再次顯示已提交的繫結值,因此暫時選取項目不需要透過另一個持續狀態變數手動清除。Apple 的 GestureState 文件

struct DiscreteSizeControl: View {
    let sizes: [Int]
    @Binding var selection: Int
    @GestureState private var previewIndex: Int? = nil

    private let trackWidth: CGFloat = 336
    private let radius: CGFloat = 12

    private var shownIndex: Int {
        previewIndex ?? selection
    }

    private func snappedIndex(at x: CGFloat) -> Int {
        SnapGeometry.index(
            at: x, width: trackWidth,
            inset: radius, count: sizes.count
        )
    }

    private func xPosition(for index: Int) -> CGFloat {
        SnapGeometry.position(
            for: index, width: trackWidth,
            inset: radius, count: sizes.count
        )
    }

    private func moveSelection(by amount: Int) {
        guard previewIndex == nil else { return }
        selection = min(max(selection + amount, 0), sizes.count - 1)
    }

    var body: some View {
        VStack(spacing: 10) {
            Text("Selected size: \(sizes[shownIndex]) pt")
                .monospacedDigit()

            ZStack(alignment: .topLeading) {
                Capsule()
                    .fill(Color.secondary.opacity(0.25))
                    .frame(width: trackWidth - 2 * radius, height: 4)
                    .position(x: trackWidth / 2, y: 20)

                ForEach(sizes.indices, id: \.self) { index in
                    Circle()
                        .fill(Color.secondary)
                        .frame(width: 5, height: 5)
                        .position(x: xPosition(for: index), y: 20)
                }

                Circle()
                    .fill(Color.accentColor)
                    .frame(width: 2 * radius, height: 2 * radius)
                    .position(x: xPosition(for: shownIndex), y: 20)
            }
            .frame(width: trackWidth, height: 40)
            .contentShape(Rectangle())
            .gesture(
                DragGesture(minimumDistance: 0, coordinateSpace: .local)
                    .updating($previewIndex) { value, state, _ in
                        state = snappedIndex(at: value.location.x)
                    }
                    .onEnded { value in
                        selection = snappedIndex(at: value.location.x)
                    }
            )
            .accessibilityElement(children: .ignore)
            .accessibilityLabel("Circle size")
            .accessibilityValue("\(sizes[shownIndex]) points")
            .accessibilityAdjustableAction { direction in
                switch direction {
                case .increment:
                    moveSelection(by: 1)
                case .decrement:
                    moveSelection(by: -1)
                @unknown default:
                    break
                }
            }

            HStack {
                Button("Smaller") { moveSelection(by: -1) }
                    .disabled(selection == 0 || previewIndex != nil)
                Button("Larger") { moveSelection(by: 1) }
                    .disabled(
                        selection == sizes.count - 1 || previewIndex != nil
                    )
            }
        }
    }
}

此範例有明確的前提:尺寸陣列不可為空,已提交的索引必須有效,而且互動期間陣列保持不變。提供的父視圖符合這三項條件。幾何輔助函式的防護回傳值並不代表空陣列也適用於此控制項,因為標籤仍會索引該陣列。如果選項來自會變動的模型,請在繪製前驗證集合和選取項目,並定義拖曳期間選項變更時的處理方式。

將手勢附加至穩定的控制項幾何

拖曳手勢附加在固定不動、寬 336 點的軌道容器上。滑塊在容器內移動。Apple 將 location 定義為目前事件的位置,而 DragGesture 可指定位置所使用的座標空間。將 .local 用於穩定容器,回報的 x 座標就會符合繪圖計算。Apple 的 DragGesture 文件、Apple 的 location 文件

矩形內容形狀會讓控制項的軌道區域可供命中測試,包括已繪製形狀之間的空隙。將最小距離設為零,即可不必先移動一段距離就辨識手勢。在此設計中,按下軌道上的任意位置會立刻預覽最近的刻度。這種跳至指標位置的行為是刻意設計的。Apple 的 contentShape(_:eoFill:) 文件、Apple 的 DragGesture 文件

若要只透過滑塊操作並保留初始抓取偏移,請改用另一種映射:只擷取一次滑塊的起始中心位置,再加上手勢的水平總位移,最後吸附至刻度。不要把總位移一再加到已更新的中心位置上。SwiftUI 文件指出,位移是相對於手勢開始時的位置,而非自前一次回呼以來的移動量。Apple 的 translation 文件

請勿將手勢附加至包含標籤和按鈕的外層堆疊。手勢附加位置會影響該視圖及其子視圖的手勢辨識關係;擴大範圍可能會與其他控制項爭用。如果要將此範例嵌入可捲動視圖,請明確測試其中的手勢辨識。在此處,零距離拖曳代表刻意設計的即時互動,因此獨立面板是最明確的起點。Apple 的 gesture(_:including:) 文件

決定預覽何時成為已提交狀態

拖曳期間,只有控制項的滑塊和所選值標籤會變更。較大的圓形和已套用值標籤則依照父視圖中已提交的索引。放開時,處理常式會依據最後的位置計算最終索引,而不依賴最近一次預覽回呼。

如果手勢在尚未提交放開結果時就變為非作用中,暫時預覽會重設,先前已提交的選取項目則會保留。這是因為預覽狀態放在 @GestureState 中,而持續狀態的寫入則放在結束處理常式中。Apple 的 GestureState 文件

若要改為即時套用,請在拖曳變更期間將吸附後的索引寫入繫結。這會改變互動契約:中間選項會成為已提交的模型更新。將選擇器連接至更大型的繪圖工作前,請先決定這是否符合需求。無論採用哪種設計,都要用同一個量化索引來繪製滑塊和呈現所選值。

Smaller 和 Larger 按鈕會立即套用單步變更,並在端點停止。軌道也提供可調整的輔助使用動作,其遞增和遞減處理常式會使用相同的步進函式。Apple 將這個修飾子說明為輔助技術(例如 VoiceOver)呼叫可調整動作的機制。Apple 的 accessibilityAdjustableAction(_:) 文件

當 AppKit 視圖負責處理互動

純 SwiftUI 視圖不需要為此工作橋接 AppKit。如果軌道已經位於你自己的 NSView 中,就在該視圖內處理手勢辨識。Apple 的 addGestureRecognizer(_:) 會將辨識器附加至視圖,將其代表的手勢範圍限制在該視圖及其子視圖,並對辨識器建立強參照。Apple 的 addGestureRecognizer(_:) 文件

若使用 NSPanGestureRecognizer 實作 AppKit 控制項,請在固定軌道視圖的座標系統中測量位移。擷取滑塊的起始位置,加上回報的總位移,再將結果 x 座標傳入相同的吸附計算。Apple 明確指出,translation(in:) 會回傳手勢開始以來的總距離,並套用所選視圖的轉換。Apple 的 translation(in:) 文件

每個互動表面都使用一種手勢實作。如果 AppKit 軌道嵌入 SwiftUI,就讓該軌道負責處理平移手勢,並透過 App 的模型連結回報選取變更。如此一來,從事件座標到預覽、再到已提交選取項目的流程就會清楚明確。

檢查端點、臨界值和中斷的拖曳

在 Mac App 中透過一組簡短明確的步驟驗證互動:

  1. 從中間刻度開始。拖過每個間隔,確認所選標籤只會顯示五種允許的尺寸。
  2. 將指標移至左右端點之外。滑塊應停留在第一個或最後一個刻度。
  3. 在兩個刻度之間放開。圓形應套用最近的尺寸,並與滑塊的最終位置一致。
  4. 按一下軌道上沒有其他內容的位置。確認跳至指標位置的行為符合需求。
  5. 中斷進行中的互動,檢查是否還留有未清除的暫時預覽。
  6. 使用兩個步進按鈕和 VoiceOver 調整功能。確認它們都會在相同端點停止。

對於像 20、30、60 和 100 這類不等距選項,此索引設計仍會將各選項等距排列。這是刻意採用的順序排列。如果間距應反映數值差距,就應將每個數值映射至各自的軌道位置,並選取最近的位置;將均勻索引四捨五入會代表不同的互動方式。

可靠的吸附控制項會使用同一個座標系統和映射來處理輸入與繪圖。兩者一致時,整數索引可確保每個選取值都是離散的,而將預覽狀態與已提交狀態分開,則能明確定義放開時的行為。

來源