要让自定义 SwiftUI 拖动控件吸附到离散值,请将指针的局部位置转换为有界分数,将其四舍五入为整数索引,然后根据该索引绘制滑块。这个 macOS 示例可在五种圆形尺寸中进行选择,并在松开时应用所选尺寸。
编写手势前先确定交互方式
读者的任务很简单:为 Mac 应用添加一个自定义水平尺寸选择器。可选尺寸为 20、40、60、80 和 100 点。拖动时预览吸附后的选择;松开时将该尺寸应用到一个示例圆形上。所有状态都保留在内存中。
首先判断是否确实需要自定义绘制。Apple 的标准 Slider 已通过 step 参数支持离散增量,并会在滑块移动时更新绑定值。对于常规尺寸选择器,使用范围 20...100 和步长 20 就能直接表示这些选项。如果该控件的外观和交互符合需求,就使用这个受支持的控件。Apple 的 Slider 文档
当你需要控制轨道、刻度线、滑块和松开时的行为时,下面的自定义实现就很有用。它刻意使用整数选择索引。显示的尺寸取自数组,因此模型不会意外保存 57.3 这样的中间尺寸。
本示例由三个决定构成:
- 刻度位置等距排列,较大的尺寸位于右侧。
- 拖动时,滑块和控件的数值标签都会吸附到刻度。
- 父视图中的已选尺寸在松开时才会改变;预览期间,圆形仍保留此前已应用的尺寸。
将指针位置转换为离散索引
让输入几何和绘制几何保持一致。此处控件宽 336 点,滑块半径为 12 点。因此,滑块中心的移动范围是从 12 到 324,可用轨道长度为 312 点。如果输入计算使用完整宽度,指针位置就会与端点刻度不一致。
映射包含四个操作:减去左侧内缩量,除以可用轨道长度,将结果限制在 0...1 范围内,再乘以间隔数并四舍五入。五个选项之间有四个间隔。反向映射则会将选中的滑块放到对应刻度上。
下面的辅助函数会包含在下一节组装的完整应用中。由于归一化索引均为非负数,它的舍入规则会在恰好处于中点时选择较大的索引。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 应用经过了检查,但未在此处编译或运行。
构建 macOS 控件及其所属视图
在 Xcode 中创建一个使用 SwiftUI、最低部署目标为 macOS 13 或更高版本的 macOS App 项目。用下面的代码替换生成的 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 文档说明,translation 表示从手势开始时算起的位移,而不是与上一次回调相比的移动量。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 中,就由该轨道处理平移手势,并通过应用的模型连接报告选择变化。这样,事件坐标、预览和已提交选择之间的路径就能保持清晰。
检查端点、阈值和中断的拖动
在 Mac 应用中按以下简短、具体的步骤验证交互:
- 从中间刻度开始。拖过每个间隔,并确认选中标签只显示五种允许的尺寸。
- 移动到水平两端之外。滑块应停在第一个或最后一个刻度上。
- 在两个刻度之间松开。圆形应采用最接近的尺寸,并与最终滑块位置一致。
- 点击轨道上未被占用的位置。确认跳转到指针位置的行为是否符合需求。
- 中断正在进行的交互,并检查是否有临时预览残留。
- 使用两个步进按钮和 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