PAYATHON 2026

如何在 SwiftUI macOS 侧栏切换按钮中集成下拉菜单?

数据小陈

结论

可以用带 primaryAction 的 Menu 实现:点击主体切换侧栏,点击菜单指示器打开菜单。将它放入 ToolbarItem,再移除默认的 .sidebarToggle,就能避免出现两个侧栏按钮。

替换按钮后,SwiftUI 会重新决定工具栏布局。这个方案使用系统菜单控件,但尺寸、间距和位置未必与默认侧栏按钮完全一致。如果必须使用 macOS 原生的分段菜单工具栏项,可以考虑 AppKit 的 NSMenuToolbarItem。

为什么 HStack 无法自动获得一体式样式?

HStack(spacing: 0) 只消除两个视图之间的布局间距。内部的 Button 和 Menu 仍是两个控件,各有自己的样式、内边距和点击区域。ControlGroup 也不会把它们变成默认侧栏按钮。

带 primaryAction 的 Menu 则可以让主体执行操作、指示器展开菜单。Apple 文档说明,点击主体会执行主操作,菜单通过次级交互打开,例如点击菜单指示器。Menu 文档

SwiftUI 实现:用一个 Menu 替换默认按钮

以下示例以 macOS 14 及以上为部署目标,使用双列 NavigationSplitView:

import SwiftUI

@available(macOS 14.0, *)
struct SidebarMenuExample: View {
    @State private var columnVisibility: NavigationSplitViewVisibility = .all

    var body: some View {
        NavigationSplitView(columnVisibility: $columnVisibility) {
            List {
                Text("Sidebar")
            }
            .toolbar(removing: .sidebarToggle)
        } detail: {
            Text("Detail")
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .toolbar {
                    ToolbarItem(placement: .navigation) {
                        Menu {
                            Button("Show Sidebar") {
                                columnVisibility = .all
                            }

                            Button("Hide Sidebar") {
                                columnVisibility = .detailOnly
                            }
                        } label: {
                            Label("Toggle Sidebar", systemImage: "sidebar.left")
                        } primaryAction: {
                            toggleSidebar()
                        }
                        .labelStyle(.iconOnly)
                        .menuIndicator(.visible)
                        .help("Toggle sidebar or open sidebar options")
                    }
                }
        }
    }

    private func toggleSidebar() {
        withAnimation {
            columnVisibility = columnVisibility == .detailOnly
                ? .all
                : .detailOnly
        }
    }
}

代码中有几处需要注意:

  • .toolbar(removing: .sidebarToggle) 放在侧栏内容上,移除系统自动添加的按钮,与 Apple 文档中的用法一致。toolbar(removing:) 文档
  • 自定义工具栏项放在 detail 一侧,侧栏隐藏后仍可访问。
  • label 中只放侧栏图标,由系统绘制菜单箭头,不再手动添加 Image(systemName: "chevron.down")。

菜单中的显示和隐藏操作只是示例,可以替换成自己的菜单内容。columnVisibility 控制列的可见性,上述切换逻辑适用于双列布局。三列布局需要根据是否保留中间列另行处理。NavigationSplitView 文档

为什么位置仍可能与默认按钮不同?

.navigation 表示这是一个导航操作。在 macOS 上,系统将这类项目放在工具栏前端,但没有承诺使用默认侧栏按钮的内部槽位,也没有提供像素级坐标控制。ToolbarItemPlacement.navigation 文档

这是一个替代控件,仍需在目标应用中检查窗口工具栏样式、其他工具栏内容和 macOS 版本带来的影响。仅凭原问题提供的代码,无法确认某个具体外观已经完全复现。

需要原生分段工具栏项时:NSMenuToolbarItem

如果要求主体和箭头呈现为 macOS 工具栏中的原生分段控件,可以使用 NSMenuToolbarItem。设置 action 并将 showsIndicator 设为 true 后,系统会将菜单指示器显示为单独的可点击分段。NSMenuToolbarItem 文档

以下核心代码用于创建工具栏项,可集成到已有的 AppKit 工具栏中:

import AppKit

@MainActor
func makeSidebarMenuItem(
    target: AnyObject,
    action: Selector,
    menu: NSMenu
) -> NSMenuToolbarItem {
    let item = NSMenuToolbarItem(
        itemIdentifier: NSToolbarItem.Identifier("SidebarMenu")
    )

    item.label = "Sidebar"
    item.paletteLabel = "Sidebar"
    item.toolTip = "Toggle sidebar or open sidebar options"
    item.image = NSImage(
        systemSymbolName: "sidebar.left",
        accessibilityDescription: "Toggle Sidebar"
    )
    item.target = target
    item.action = action
    item.menu = menu
    item.showsIndicator = true

    return item
}

调用方需要提供可通过 Objective-C selector 调用的侧栏切换方法,以及包含实际菜单操作的 NSMenu,再通过自己管理的 NSToolbarDelegate 返回这个工具栏项。持有该操作方法的对象也需要由应用保留。

这段代码只负责创建工具栏项。NSMenuToolbarItem 本身是工具栏项,不是 NSView,不能直接作为 SwiftUI ToolbarItem 的内容使用。采用这个方案时,还需要管理窗口的 NSToolbar、项目顺序,以及与 SwiftUI columnVisibility 的状态同步。

如何选择?

如果重点是在一个工具栏项内完成两种点击操作,可以先用 Menu 的 primaryAction,减少手动拼接控件带来的样式问题。

如果原生分段外观和工具栏排列控制是硬性要求,可以采用 AppKit 工具栏集成。即使使用 NSMenuToolbarItem,也仍需配置项目位置,才能接近默认侧栏按钮的布局。

备注:内容仅供参考。