SwiftUIViews

菜单与命令(Menus and Commands)

深入学习 SwiftUI 中的菜单与命令,包括菜单创建、上下文菜单、命令定义和菜单定制

概述

Menus and Commands 是 SwiftUI 提供的一套 API,用于为用户提供节省空间、上下文相关的命令和控件访问方式。通过菜单,可以将相关的操作组织在一起,在需要时才显示,从而保持界面的简洁性。

在不同平台上,菜单的表现形式有所不同:

  • macOS 和 iPadOS:可以通过 commands(content:) 场景修饰符向应用的菜单栏添加项目
  • 所有平台:可以使用 contextMenu(menuItems:) 视图修饰符创建上下文菜单,在用户当前任务附近显示
  • iOS 和 iPadOS:菜单通常以弹出菜单或操作表的形式呈现

创建菜单

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")
                }
            }
    }
}

在菜单中使用多种控件

上下文菜单可以包含各种控件,如 ButtonTogglePicker 等:

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:)

此变体用于支持选择的容器(如 ListTable),可以区分在选择项上激活菜单和在空白区域激活菜单:

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(_:) 修饰符应用样式。

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)
    }
}

菜单行为定制

控制菜单指示器(通常是向下的箭头)的显示:

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)
        }
    }
}

设置菜单项的显示顺序。在 iOS 和 iPadOS 上,默认情况下系统会重新排列菜单项,使第一个项目显示在最接近用户交互点的位置。使用 menuOrder(_:) 可以覆盖此行为。

struct MenuOrderExample: View {
    var body: some View {
        Menu("操作") {
            Button("第一项", action: {})
            Button("第二项", action: {})
            Button("第三项", action: {})
        }
        .menuOrder(.fixed)  // 保持定义的顺序
    }
}

MenuOrder 选项:

  • .automatic - 自动排序(默认)
  • .fixed - 固定顺序,按定义的顺序显示
  • .priority - 按优先级排序

控制菜单项被点击后菜单是否关闭。默认情况下,点击菜单项后菜单会立即关闭。如果希望用户进行多次选择或重复操作,可以禁用此行为。

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
}

最佳实践

  1. 选择合适的菜单类型

    • 使用 Menu 提供常规操作选项
    • 使用 contextMenu 提供与特定内容相关的操作
    • 使用 Commands 在 macOS 和 iPadOS 上添加菜单栏命令
  2. 保持菜单简洁

    • 避免在单个菜单中放置过多项目(建议不超过 7-10 项)
    • 使用子菜单和分隔符组织相关操作
    • 将不常用的操作放入子菜单
  3. 提供清晰的标签

    • 使用描述性的动词短语(如"添加到收藏"而不是"收藏")
    • 为破坏性操作使用明确的警告(如"删除…")
    • 使用 Label 添加图标以提高可识别性
  4. 合理使用键盘快捷键

    • 为常用命令提供键盘快捷键
    • 遵循平台约定(如 Cmd+C 用于复制)
    • 避免与系统快捷键冲突
  5. 考虑平台差异

    • 在 macOS 上充分利用菜单栏
    • 在 iOS 上使用上下文菜单和工具栏
    • 测试不同平台上的菜单行为
  6. 使用语义化角色

    • 为破坏性操作使用 .destructive 角色
    • 为取消操作使用 .cancel 角色
    • 帮助系统正确呈现和定位菜单项
  7. 优化用户体验

    • 对于需要多次操作的场景,使用 menuActionDismissBehavior(.disabled)
    • 提供有意义的预览(在上下文菜单中)
    • 根据上下文动态启用/禁用菜单项

总结

SwiftUI 的 Menus and Commands API 提供了强大而灵活的菜单管理能力。通过 Menu 可以创建各种类型的菜单,从简单的操作列表到包含子菜单和多种控件的复杂菜单。contextMenu 修饰符使得在任何视图上添加上下文相关的操作变得简单。Commands 协议则允许在 macOS 和 iPadOS 上定制应用的菜单栏。

理解这些 API 的使用方式,以及如何根据不同平台和场景选择合适的菜单类型,是构建优秀 SwiftUI 应用的重要技能。通过合理组织菜单结构、提供清晰的标签和适当的键盘快捷键,可以为用户提供高效、直观的操作界面,提升应用的整体用户体验。

在 GitHub 上编辑

上次更新于