若要讓自訂 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 中透過一組簡短明確的步驟驗證互動:
- 從中間刻度開始。拖過每個間隔,確認所選標籤只會顯示五種允許的尺寸。
- 將指標移至左右端點之外。滑塊應停留在第一個或最後一個刻度。
- 在兩個刻度之間放開。圓形應套用最近的尺寸,並與滑塊的最終位置一致。
- 按一下軌道上沒有其他內容的位置。確認跳至指標位置的行為符合需求。
- 中斷進行中的互動,檢查是否還留有未清除的暫時預覽。
- 使用兩個步進按鈕和 VoiceOver 調整功能。確認它們都會在相同端點停止。
對於像 20、30、60 和 100 這類不等距選項,此索引設計仍會將各選項等距排列。這是刻意採用的順序排列。如果間距應反映數值差距,就應將每個數值映射至各自的軌道位置,並選取最近的位置;將均勻索引四捨五入會代表不同的互動方式。
可靠的吸附控制項會使用同一個座標系統和映射來處理輸入與繪圖。兩者一致時,整數索引可確保每個選取值都是離散的,而將預覽狀態與已提交狀態分開,則能明確定義放開時的行為。
來源
- Slider — Apple
- rounded(_:) — Apple
- Binding — Apple
- GestureState — Apple
- DragGesture — Apple
- location — Apple
- contentShape(_:eoFill:) — Apple
- translation — Apple
- gesture(_:including:) — Apple
- accessibilityAdjustableAction(_:) — Apple
- addGestureRecognizer(_:) — Apple
- translation(in:) — Apple