菜单与命令(Menus and Commands)
深入学习 SwiftUI 中的菜单与命令,包括菜单创建、上下文菜单、命令定义和菜单定制
概述
Menus and Commands 是 SwiftUI 提供的一套 API,用于为用户提供节省空间、上下文相关的命令和控件访问方式。通过菜单,可以将相关的操作组织在一起,在需要时才显示,从而保持界面的简洁性。
在不同平台上,菜单的表现形式有所不同:
- macOS 和 iPadOS:可以通过
commands(content:)场景修饰符向应用的菜单栏添加项目 - 所有平台:可以使用
contextMenu(menuItems:)视图修饰符创建上下文菜单,在用户当前任务附近显示 - iOS 和 iPadOS:菜单通常以弹出菜单或操作表的形式呈现
创建菜单
Menu 基础用法
Menu 是一个用于呈现操作菜单的控件。最基本的菜单包含一个标签和一组菜单项。
struct BasicMenuExample: View {
var body: some View {
Menu("操作") {
Button("复制", action: duplicate)
Button("重命名", action: rename)
Button("删除…", action: delete)
}
}
func duplicate() { }
func rename() { }
func delete() { }
}使用自定义标签
可以使用视图构建器创建包含图标和文本的自定义标签:
struct CustomLabelMenuExample: View {
var body: some View {
Menu {
Button("在预览中打开", action: openInPreview)
Button("存储为 PDF", action: saveAsPDF)
} label: {
Label("PDF", systemImage: "doc.fill")
}
}
func openInPreview() { }
func saveAsPDF() { }
}创建嵌套菜单
通过在菜单内嵌套 Menu,可以创建子菜单:
struct NestedMenuExample: View {
var body: some View {
Menu("文件操作") {
Button("新建", action: createNew)
Button("打开…", action: open)
Menu("最近打开") {
Button("文档 1.txt", action: { openRecent(1) })
Button("文档 2.txt", action: { openRecent(2) })
Button("文档 3.txt", action: { openRecent(3) })
}
Divider()
Button("保存", action: save)
}
}
func createNew() { }
func open() { }
func openRecent(_ index: Int) { }
func save() { }
}菜单项标题和副标题
通过在 Button 中使用多个 Text 视图,可以为菜单项添加副标题。第一个文本表示标题,第二个文本表示副标题:
struct MenuWithSubtitlesExample: View {
var body: some View {
Menu {
Button(action: openInPreview) {
Text("在预览中打开")
Text("在预览应用中查看文档")
}
Button(action: saveAsPDF) {
Text("存储为 PDF")
Text("将文档导出为 PDF 文件")
}
} label: {
Label("PDF 选项", systemImage: "doc.fill")
}
}
func openInPreview() { }
func saveAsPDF() { }
}带主要操作的菜单
菜单可以配置主要操作。当用户点击或轻触控件主体时执行主要操作,而菜单呈现则通过次要手势(如长按或点击菜单指示器)触发:
struct PrimaryActionMenuExample: View {
var body: some View {
Menu {
Button(action: addCurrentTabToReadingList) {
Label("添加到阅读列表", systemImage: "eyeglasses")
}
Button(action: bookmarkAll) {
Label("为所有标签页添加书签", systemImage: "book")
}
Button(action: showAllBookmarks) {
Label("显示所有书签", systemImage: "books.vertical")
}
} label: {
Label("添加书签", systemImage: "book")
} primaryAction: {
addBookmark()
}
}
func addBookmark() { }
func addCurrentTabToReadingList() { }
func bookmarkAll() { }
func showAllBookmarks() { }
}上下文菜单
contextMenu(menuItems:)
使用 contextMenu(menuItems:) 修饰符为视图添加上下文菜单。在 iOS 和 iPadOS 上,用户通过长按激活上下文菜单;在 macOS 上,通过右键点击激活。
struct ContextMenuExample: View {
var body: some View {
Text("海龟岩")
.padding()
.contextMenu {
Button {
// 添加到收藏列表
} label: {
Label("添加到收藏", systemImage: "heart")
}
Button {
// 在地图中打开并居中显示
} label: {
Label("在地图中显示", systemImage: "mappin")
}
}
}
}在菜单中使用多种控件
上下文菜单可以包含各种控件,如 Button、Toggle、Picker 等:
struct AdvancedContextMenuExample: View {
@State private var isFavorite = false
@State private var sortOrder = "name"
var body: some View {
Text("项目列表")
.padding()
.contextMenu {
Toggle(isOn: $isFavorite) {
Label("收藏", systemImage: "star")
}
Divider()
Menu("排序方式") {
Button("按名称") { sortOrder = "name" }
Button("按日期") { sortOrder = "date" }
Button("按大小") { sortOrder = "size" }
}
Divider()
Button(role: .destructive) {
// 删除操作
} label: {
Label("删除", systemImage: "trash")
}
}
}
}contextMenu(menuItems:preview:)
使用此变体可以为上下文菜单提供自定义预览视图:
struct ContextMenuWithPreviewExample: View {
var body: some View {
Image(systemName: "photo")
.font(.system(size: 60))
.contextMenu {
Button("分享", action: share)
Button("编辑", action: edit)
} preview: {
Image(systemName: "photo.fill")
.font(.system(size: 120))
.foregroundStyle(.blue)
.padding()
}
}
func share() { }
func edit() { }
}contextMenu(forSelectionType:menu:primaryAction:)
此变体用于支持选择的容器(如 List 或 Table),可以区分在选择项上激活菜单和在空白区域激活菜单:
struct SelectionContextMenuExample: View {
@State private var selection = Set<String>()
let items = ["项目 1", "项目 2", "项目 3", "项目 4"]
var body: some View {
List(items, id: \.self, selection: $selection) { item in
Text(item)
}
.contextMenu(forSelectionType: String.self) { selectedItems in
if selectedItems.isEmpty {
Button("新建项目", action: createNew)
} else if selectedItems.count == 1 {
Button("重命名", action: rename)
Button("删除", action: delete)
} else {
Button("删除 \(selectedItems.count) 个项目", action: deleteMultiple)
}
} primaryAction: { selectedItems in
// 双击时的主要操作
if let first = selectedItems.first {
openItem(first)
}
}
}
func createNew() { }
func rename() { }
func delete() { }
func deleteMultiple() { }
func openItem(_ item: String) { }
}定义命令
Commands 协议
Commands 协议表示一组相关命令,可以通过 macOS 上的主菜单和 iOS 上的键盘命令向用户公开。
struct AppCommands: Commands {
var body: some Commands {
CommandMenu("自定义") {
Button("执行操作") {
// 执行自定义操作
}
.keyboardShortcut("k", modifiers: [.command, .shift])
}
}
}commands(content:)
使用 commands(content:) 场景修饰符向应用添加命令:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.commands {
AppCommands()
}
}
}CommandMenu
CommandMenu 创建独立的顶级菜单,用于包含执行相关应用特定命令的控件。在 macOS 上,这些菜单会按声明顺序插入到内置的"视图"和"窗口"菜单之间。
struct CustomCommands: Commands {
var body: some Commands {
CommandMenu("工具") {
Button("工具 1") {
// 工具 1 操作
}
.keyboardShortcut("1", modifiers: .command)
Button("工具 2") {
// 工具 2 操作
}
.keyboardShortcut("2", modifiers: .command)
Divider()
Button("设置…") {
// 打开设置
}
.keyboardShortcut(",", modifiers: .command)
}
}
}CommandGroup
CommandGroup 用于向现有命令菜单添加、替换或修改控件组。
在现有组之后添加命令:
struct AddAfterCommands: Commands {
var body: some Commands {
CommandGroup(after: .newItem) {
Button("新建特殊项目") {
// 创建特殊项目
}
.keyboardShortcut("n", modifiers: [.command, .shift])
}
}
}在现有组之前添加命令:
struct AddBeforeCommands: Commands {
var body: some Commands {
CommandGroup(before: .help) {
Button("用户指南") {
// 打开用户指南
}
.keyboardShortcut("?", modifiers: .command)
}
}
}替换现有组:
struct ReplaceCommands: Commands {
var body: some Commands {
CommandGroup(replacing: .help) {
Button("自定义帮助") {
// 打开自定义帮助
}
}
}
}CommandGroupPlacement
常用的命令组位置包括:
.newItem- 新建项目组.saveItem- 保存项目组.printItem- 打印项目组.undoRedo- 撤销/重做组.pasteboard- 剪贴板操作组.textFormatting- 文本格式化组.toolbar- 工具栏组.sidebar- 侧边栏组.help- 帮助组
commandsRemoved() 和 commandsReplaced(content:)
使用 commandsRemoved() 移除默认命令,使用 commandsReplaced(content:) 替换所有默认命令:
@main
struct MinimalApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.commandsRemoved() // 移除所有默认命令
}
}菜单样式
MenuStyle 协议
MenuStyle 协议定义了菜单的外观和交互行为。使用 menuStyle(_:) 修饰符应用样式。
struct MenuStyleExample: View {
var body: some View {
VStack(spacing: 20) {
Menu("默认样式") {
Button("选项 1", action: {})
Button("选项 2", action: {})
}
.menuStyle(.automatic)
Menu("按钮样式") {
Button("选项 1", action: {})
Button("选项 2", action: {})
}
.menuStyle(.button)
}
.padding()
}
}内置菜单样式
DefaultMenuStyle / .automatic
默认菜单样式,根据平台和上下文自动选择合适的呈现方式。
ButtonMenuStyle / .button
将菜单呈现为按钮样式,适用于需要明确按钮外观的场景。
struct ButtonMenuStyleExample: View {
var body: some View {
Menu("选择颜色") {
Button("红色", action: {})
Button("绿色", action: {})
Button("蓝色", action: {})
}
.menuStyle(.button)
.buttonStyle(.bordered)
}
}菜单行为定制
menuIndicator(_:)
控制菜单指示器(通常是向下的箭头)的显示:
struct MenuIndicatorExample: View {
var body: some View {
VStack(spacing: 20) {
Menu("显示指示器") {
Button("选项 1", action: {})
Button("选项 2", action: {})
}
.menuIndicator(.visible)
Menu("隐藏指示器") {
Button("选项 1", action: {})
Button("选项 2", action: {})
}
.menuIndicator(.hidden)
}
}
}menuOrder(_:)
设置菜单项的显示顺序。在 iOS 和 iPadOS 上,默认情况下系统会重新排列菜单项,使第一个项目显示在最接近用户交互点的位置。使用 menuOrder(_:) 可以覆盖此行为。
struct MenuOrderExample: View {
var body: some View {
Menu("操作") {
Button("第一项", action: {})
Button("第二项", action: {})
Button("第三项", action: {})
}
.menuOrder(.fixed) // 保持定义的顺序
}
}MenuOrder 选项:
.automatic- 自动排序(默认).fixed- 固定顺序,按定义的顺序显示.priority- 按优先级排序
menuActionDismissBehavior(_:)
控制菜单项被点击后菜单是否关闭。默认情况下,点击菜单项后菜单会立即关闭。如果希望用户进行多次选择或重复操作,可以禁用此行为。
struct MenuDismissBehaviorExample: View {
@State private var fontSize: Double = 14
var body: some View {
VStack {
Text("示例文本")
.font(.system(size: fontSize))
Menu("字体大小") {
Button("增大") {
fontSize += 1
}
.menuActionDismissBehavior(.disabled)
Button("减小") {
fontSize -= 1
}
.menuActionDismissBehavior(.disabled)
Divider()
Button("重置为默认") {
fontSize = 14
}
// 此按钮点击后会关闭菜单(默认行为)
}
}
.padding()
}
}MenuActionDismissBehavior 选项:
.automatic- 自动行为(默认,点击后关闭).disabled- 禁用关闭,点击后菜单保持打开.enabled- 启用关闭,点击后菜单关闭
填充菜单的自适应控件
在菜单中使用多种控件
菜单可以包含多种 SwiftUI 控件,使其更加灵活和功能丰富:
struct AdaptiveMenuExample: View {
@State private var isEnabled = true
@State private var opacity: Double = 1.0
@State private var selectedColor = "蓝色"
var body: some View {
Menu("设置") {
Toggle("启用功能", isOn: $isEnabled)
Divider()
Menu("选择颜色") {
Button("红色") { selectedColor = "红色" }
Button("绿色") { selectedColor = "绿色" }
Button("蓝色") { selectedColor = "蓝色" }
}
Divider()
// 注意:Slider 在某些平台的菜单中可能不可用
Button("不透明度: \(Int(opacity * 100))%") {
// 可以打开一个单独的控制面板
}
}
}
}使用 Section 组织菜单项
使用 Section 可以在菜单中创建逻辑分组,提供清晰的分隔和可选的标题:
struct SectionMenuExample: View {
var body: some View {
Menu("编辑") {
Section("基本操作") {
Button("剪切", action: cut)
Button("复制", action: copy)
Button("粘贴", action: paste)
}
Section("高级操作") {
Button("查找", action: find)
Button("替换", action: replace)
}
Section {
Button("全选", action: selectAll)
}
}
}
func cut() { }
func copy() { }
func paste() { }
func find() { }
func replace() { }
func selectAll() { }
}使用 ControlGroup 创建紧凑布局
ControlGroup 可以在菜单中创建水平排列的控件组(最多四个项目),提供紧凑的布局:
struct ControlGroupMenuExample: View {
@State private var alignment = "left"
var body: some View {
Menu("格式") {
ControlGroup {
Button {
alignment = "left"
} label: {
Image(systemName: "text.alignleft")
}
Button {
alignment = "center"
} label: {
Image(systemName: "text.aligncenter")
}
Button {
alignment = "right"
} label: {
Image(systemName: "text.alignright")
}
}
}
}
}构建和定制菜单栏
使用 FocusedValue 创建上下文相关命令
在 macOS 和 iPadOS 上,菜单栏中的命令可以根据当前焦点动态启用或禁用。使用 FocusedValue 可以创建依赖于当前焦点的命令。
定义焦点值:
struct FocusedNoteValue: FocusedValueKey {
typealias Value = Binding<String>
}
extension FocusedValues {
var noteText: FocusedNoteValue.Value? {
get { self[FocusedNoteValue.self] }
set { self[FocusedNoteValue.self] = newValue }
}
}在视图中设置焦点值:
struct NoteEditorView: View {
@State private var text = ""
var body: some View {
TextEditor(text: $text)
.focusedValue(\.noteText, $text)
}
}在命令中使用焦点值:
struct NoteCommands: Commands {
@FocusedValue(\.noteText) private var noteText: Binding<String>?
var body: some Commands {
CommandMenu("笔记") {
Button("清空笔记") {
noteText?.wrappedValue = ""
}
.disabled(noteText == nil)
Button("添加时间戳") {
if var text = noteText?.wrappedValue {
text += "\n\(Date())"
noteText?.wrappedValue = text
}
}
.disabled(noteText == nil)
}
}
}使用 focusedSceneValue 设置场景级焦点值
对于需要在整个场景中共享的值,使用 focusedSceneValue(_:) 修饰符:
@Observable
class DocumentModel {
var title: String = "未命名文档"
var content: String = ""
}
struct DocumentView: View {
@State private var document = DocumentModel()
var body: some View {
VStack {
TextField("标题", text: $document.title)
TextEditor(text: $document.content)
}
.focusedSceneValue(\.document, document)
}
}
extension FocusedValues {
var document: DocumentModel? {
get { self[DocumentKey.self] }
set { self[DocumentKey.self] = newValue }
}
}
private struct DocumentKey: FocusedValueKey {
typealias Value = DocumentModel
}最佳实践
-
选择合适的菜单类型
- 使用
Menu提供常规操作选项 - 使用
contextMenu提供与特定内容相关的操作 - 使用
Commands在 macOS 和 iPadOS 上添加菜单栏命令
- 使用
-
保持菜单简洁
- 避免在单个菜单中放置过多项目(建议不超过 7-10 项)
- 使用子菜单和分隔符组织相关操作
- 将不常用的操作放入子菜单
-
提供清晰的标签
- 使用描述性的动词短语(如"添加到收藏"而不是"收藏")
- 为破坏性操作使用明确的警告(如"删除…")
- 使用
Label添加图标以提高可识别性
-
合理使用键盘快捷键
- 为常用命令提供键盘快捷键
- 遵循平台约定(如 Cmd+C 用于复制)
- 避免与系统快捷键冲突
-
考虑平台差异
- 在 macOS 上充分利用菜单栏
- 在 iOS 上使用上下文菜单和工具栏
- 测试不同平台上的菜单行为
-
使用语义化角色
- 为破坏性操作使用
.destructive角色 - 为取消操作使用
.cancel角色 - 帮助系统正确呈现和定位菜单项
- 为破坏性操作使用
-
优化用户体验
- 对于需要多次操作的场景,使用
menuActionDismissBehavior(.disabled) - 提供有意义的预览(在上下文菜单中)
- 根据上下文动态启用/禁用菜单项
- 对于需要多次操作的场景,使用
总结
SwiftUI 的 Menus and Commands API 提供了强大而灵活的菜单管理能力。通过 Menu 可以创建各种类型的菜单,从简单的操作列表到包含子菜单和多种控件的复杂菜单。contextMenu 修饰符使得在任何视图上添加上下文相关的操作变得简单。Commands 协议则允许在 macOS 和 iPadOS 上定制应用的菜单栏。
理解这些 API 的使用方式,以及如何根据不同平台和场景选择合适的菜单类型,是构建优秀 SwiftUI 应用的重要技能。通过合理组织菜单结构、提供清晰的标签和适当的键盘快捷键,可以为用户提供高效、直观的操作界面,提升应用的整体用户体验。
上次更新于