要让自定义 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 应用中按以下简短、具体的步骤验证交互:

  1. 从中间刻度开始。拖过每个间隔,并确认选中标签只显示五种允许的尺寸。
  2. 移动到水平两端之外。滑块应停在第一个或最后一个刻度上。
  3. 在两个刻度之间松开。圆形应采用最接近的尺寸,并与最终滑块位置一致。
  4. 点击轨道上未被占用的位置。确认跳转到指针位置的行为是否符合需求。
  5. 中断正在进行的交互,并检查是否有临时预览残留。
  6. 使用两个步进按钮和 VoiceOver 调节功能。确认它们都会在相同端点处停止。

对于 20、30、60 和 100 这类不等距选项,此索引设计仍会让各选项间距相同。这是一种有意采用的按序排列。如果间距应反映数值大小,就将每个数值映射到其各自的轨道位置,并选择最近的位置;对均匀索引进行舍入表达的是另一种交互方式。

可靠的吸附控件会对输入和绘制使用同一坐标系和同一映射。两者对齐后,整数索引能确保每个选中值都是离散值,而将预览状态与已提交状态分开,则能明确松开时的行为。

来源