要让 Mac 上 SwiftUI 行内的空白区域可点击,请将行内容扩展到可用宽度,并应用矩形内容形状。对于执行操作的行,请将这两个修饰符都放在 Button 标签内。

本教程将构建一个小型 macOS 应用,其中的行包含合成图形和文本。点击图形、其标签或旁边的空白区域,都会执行同一个行操作。行外的区域不属于该操作的目标范围。

为什么文本会响应,而空白区域不会?

布局和命中测试回答的是不同的问题。框架决定视图如何占用为其提议的空间;内容形状定义用于命中测试的区域。Apple 在灵活框架修饰符和内容形状修饰符的文档中分别介绍了这两者。

例如,一行可能因为其容器填满了窗口而显得很宽,但交互只关联到一个短文本视图。同样,添加空白布局空间并不会明确指定整行都应参与命中测试。实际的解决方法是先确定预期的行边界,再为该区域指定交互形状。

使用 .frame(maxWidth: .infinity, alignment: .leading),让标签在父视图提议更多宽度时扩展。然后将 .contentShape(.interaction, Rectangle()) 应用于扩展后的标签。Apple 专门将 interaction 内容形状类型定义为用于命中测试和辅助功能。

这不会让行无限变宽。该框架仍会受父视图的布局提议影响。父视图较窄,行就会较窄;内容形状无法补足缺失的布局空间。这种组合解决了两个要求:标签宽度足够,以及命中测试区域明确。

构建完整的行按钮示例

在 Xcode 中使用 SwiftUI 和 Swift 创建 macOS 应用项目。将部署目标设为 macOS 12 或更高版本,以使用显式的 .interaction 内容形状重载;Apple 列出的起始支持版本为 macOS 12。用下面的单文件程序替换生成的应用声明和内容视图,并确保只保留一个 @main 应用声明。可参阅 Apple 的内容形状重载文档了解可用性。

这个示例使用 Button,因为每一行都会触发一个操作。Apple 建议对执行操作的界面元素使用按钮,并明确支持将任意视图用作按钮标签。Apple 的点击手势文档还建议,当基于手势的控件在功能上等同于按钮时,应使用经过自定义的按钮。

import SwiftUI

@main
struct ClickableRowsApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

struct SampleItem: Identifiable {
    let id: Int
    let title: String
    let color: Color
}

struct ContentView: View {
    private let items = [
        SampleItem(id: 1, title: "Blue square", color: .blue),
        SampleItem(id: 2, title: "Orange square", color: .orange),
        SampleItem(id: 3, title: "Purple square", color: .purple)
    ]

    @State private var lastActivated = "None"
    @State private var activationCount = 0

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Choose a shape")
                .font(.headline)

            ScrollView {
                VStack(spacing: 8) {
                    ForEach(items) { item in
                        Button {
                            lastActivated = item.title
                            activationCount += 1
                        } label: {
                            HStack(spacing: 12) {
                                Rectangle()
                                    .fill(item.color)
                                    .frame(width: 24, height: 24)
                                Text(item.title)
                            }
                            .padding(.horizontal, 12)
                            .padding(.vertical, 10)
                            .frame(maxWidth: .infinity,
                                   alignment: .leading)
                            .contentShape(.interaction, Rectangle())
                        }
                        .buttonStyle(.plain)
                        .background(Color.gray.opacity(0.12))
                    }
                }
            }

            Text("Last activated: \(lastActivated)")
            Text("Activations: \(activationCount)")
        }
        .padding(16)
        .frame(minWidth: 360, minHeight: 280)
    }
}

所有项目和交互状态都保存在内存中。每个项目都有固定的整数标识,与表示稳定身份的 Swift Identifiable 协议一致。激活状态由父视图拥有,这遵循了 Apple 的指导:将局部 State 保持为私有,并由需要拥有它的视图管理。

运行应用并点击任意标题右侧的空白区域。激活计数应增加一次,状态文本应显示该行的名称。灰色背景便于在检查时看清预期的行区域;显式内容形状则定义了交互区域。

已根据检索到的 API 文档检查此程序,但由于此处没有 Swift 工具链,因此未编译或运行。集成之前,请在你的 macOS 目标环境中验证指针和键盘行为。

框架、内边距和内容形状应放在哪里?

在此示例中,修饰符顺序是有意安排的:构建标签内容,为其添加内部内边距,扩展最终标签,再为该区域定义交互形状。所有这些操作都在按钮的标签闭包内完成。Apple 的文档指出,修饰符顺序很重要。

将内边距放在这个带形状的标签内部,会把内边距区域纳入预期的目标范围。若将内边距放在按钮外部,它会创建外围布局空间;不要假设这些空间属于按钮的交互标签。同样,仅扩展外部容器,并不能像扩展按钮内部的内容那样明确标签的几何范围。

.buttonStyle(.plain) 修饰符让示例在视觉上保持简洁。Apple 将纯样式按钮描述为闲置时不带装饰,但按下、聚焦或启用时可能出现效果。这是一种样式选择,不能替代对标签宽度和内容形状的定义。

如果部署目标早于 macOS 12,请将 .contentShape(.interaction, Rectangle()) 替换为 .contentShape(Rectangle())。Apple 的文档指出,较早的重载用于定义命中测试形状,并列出的起始支持版本为 macOS 10.15。此替换会改变内容形状 API 的版本要求;请另行检查应用中的其他 API。

行应使用按钮、列表选择还是手势?

根据点击行的含义选择交互方式。让空白区域响应点击时,应保留该含义,并明确行内控件的归属。

面向用户的行为 合适的起点 需要保留的边界
整行执行一个操作 使用扩展且带形状标签的 Button 整行只由一个操作负责
行代表当前选中项 使用带选择绑定的 List 由列表管理选择行为
自定义视图需要独立的点击手势 使用带形状的视图和 onTapGesture 将手势关联到预期区域
行内包含多个独立控件 使用彼此并列的控件 每个控件都有自己的目标区域

对于列表选择,应先使用容器的选择模型,再添加整行点击处理程序。Apple 的 List 文档介绍了单选时绑定到可选项目标识符,以及多选时绑定到标识符集合的方式。执行操作的按钮和会被选中的列表行,属于不同的交互需求。

如果你确实需要在自定义视图上识别手势,那么手势仍然是合适的选择。附加 onTapGesture 之前,先按相同的几何顺序设置内边距和框架,然后设置内容形状。Apple 的文档说明,其 count 参数表示所需的点击或轻点次数。内容形状提供目标区域;它本身不提供操作。参见onTapGesture(count:perform:)。

如果行内还包含独立的按钮或切换开关,请将它放在主按钮标签之外。一种实用结构是使用水平堆栈,其中包含一个可扩展的主按钮和一个单独的尾随控件。主标签中的空白区域属于主按钮操作;尾随控件则拥有自己的区域。避免在包住整行的按钮内部嵌套交互控件。

不要仅仅为了修复空白区域的点击目标而添加手势优先级修饰符。Apple 将highPriorityGesture描述为用于改变优先级,而simultaneousGesture允许手势同时处理。这些操作会改变手势之间的关系,并可能影响子视图交互;它们不会补上缺失的行几何范围。

诊断仍无法响应点击的行

请在最精简的行实现上按以下步骤检查。这样可以区分尺寸问题、目标形状问题和视图重叠问题。

  1. 检查标签的实际边界。 在标签的框架修饰符之后,临时添加 .border(Color.orange)。如果边框止于文本旁边,请检查父视图的宽度提议和固定宽度约束。
  2. 检查修饰符的位置。 确认内容形状覆盖扩展后的标签,并包含你希望设为可点击区域的内边距。
  3. 检查交互的归属。 对按钮行使用按钮操作。排查问题时,移除多余的行点击手势。
  4. 检查是否有装饰视图重叠。 检查行上方的覆盖视图和同级视图。对于不应接收输入的装饰视图,可考虑只在该装饰视图上使用 .allowsHitTesting(false)。
  5. 逐个恢复其他控件。 恢复每个控件后,分别测试其区域,再测试剩余的空白区域。

Apple 将allowsHitTesting(_:)描述为用于控制视图是否参与命中测试操作。将其应用于整行会使整行不再参与,因此如果这是预期行为,请只应用于特定的装饰视图。

在几个不同的点击位置确认结果。下表描述的是所提供程序的预期行为,并非实测结果。

点击位置 预期结果
彩色方块或行标题 激活该行一次
灰色行内右侧的空白区域 激活该行一次
标题上方或下方的内边距 激活该行一次
行间距 不激活任何行
外部内边距或行下方的空白区域 不激活任何行

调整窗口大小后,再次检查空白区域。还要使用 Mac 的键盘导航设置验证键盘焦点和激活行为。这些检查有助于确认最终组件是否如预期那样表现为控件,而不只是能够响应指针操作。

何时需要考虑 AppKit 命中测试?

对于普通的 SwiftUI 行,应从 SwiftUI 的布局、内容形状和控件 API 入手。当应用包含自定义原生视图或会改变事件目标的嵌入式视图时,AppKit 才会变得相关。

Apple 将NSView.hitTest(_:)描述为查找包含某个点的最远后代视图。窗口主要通过它确定哪个视图接收鼠标按下事件。其输入点以视图的父视图坐标系表示;Apple 还指出,直接调用这种方法的情况很少。

如果你维护自定义 AppKit 视图,请在该边界检查其边界和命中测试重写方法。SwiftUI 内容形状并不是要求绕过另一个原生视图的事件归属规则。应在负责接收点击的视图层级内进行修正。

对于本教程中的行,推荐的方法很直接:让按钮标签占据预期的行宽,定义矩形交互形状,并让其他控件保有自己的区域。这样一来,有用的空白区域可以点击,同时窗口其余部分的交互也能正常进行。

来源