带内置箭头菜单的自定义侧边栏切换
如何在SwiftUI的 macOS NavigationSplitView中创建这个完全相同的工具栏按钮?

我想在右侧加入一个箭头图标,点击它时就会打开一个简单的下拉菜单。
- 侧边栏图标区域 → 切换侧边栏的展开与收起
- 箭头区域 → 打开下拉菜单
- 两者紧密排成一个视觉单元,位置与默认按钮相同
- 不添加额外的工具栏项
我已查阅NavigationSplitView和 ToolbarItem的文档,但没有原生修饰符可以将菜单附加到默认的侧边栏切换按钮上。
我尝试使用ControlGroup或带Menu的 HStack来重现这种外观,但无法匹配系统侧边栏按钮的原生样式和精确定位:
.toolbar {
ToolbarItem(placement: .navigation) {
HStack(spacing: 0) {
Button(action: toggleSidebar) {
Image(systemName: "sidebar.left")
}
Menu {
Button("Option 1") {}
Button("Option 2") {}
} label: {
// This creates a separate button rather than a split-unit
Image(systemName: "chevron.down")
}
.menuStyle(.button)
}
}
}
解决方案
实现这类组件有几个不同的方面:
- 位置定位
- 样式
- 动画
依次来看这些方面:
位置定位
你在问题中提到希望自定义组件出现在与默认侧边栏切换按钮相同的位置。要实现这一点:
- 当侧边栏显示时,工具栏需要绑定到侧边栏内容。在这种情况下,可以使用
.primaryAction进行放置。它通常显示在中间,但可以使用ToolbarSpacer将位置推到右侧。 - 当侧边栏隐藏时,工具栏需要绑定到详情内容。在这种情况下,可以使用
.navigation。
样式
样式主要有两个方面:
分组
为了把按钮和菜单进行分组,一个选项是使用 ToolbarItemGroup,而不是使用 HStack。不过,我发现最好将 HStack 作为项的容器,然后把它们显示为一个单一的 ToolbarItem。这会让你对项之间的间距有更好的控制。特别是,可以通过使用负间距来减小项之间的间隙。
在macOS 26运行时,原生的侧边栏切换按钮在侧边栏显示时确实会改变外观,与隐藏时不同。
- 当侧边栏显示时,图标是简单的,没有周围的边框。
- 当侧边栏隐藏时,图标显示为圆形的玻璃按钮,并带有阴影效果。
当组件以常规的 ToolbarItem 显示时,你也会得到同样的效果。不过,如果你更愿意在侧边栏隐藏时隐藏背景,以获得与显示时相同的外观,那么可以将 .sharedBackgroundVisibility(.hidden) 应用于 ToolbarItem。
箭头图标的尺寸
默认情况下,箭头图标的尺寸通常都相当大,即使应用了 .imageScale(.small)(该修饰符无效)。
- 让图标变小的一种方法是应用诸如
.plain的按钮样式,或使用自定义样式。但这需要额外工作来模仿工具栏项默认获得的其他样式,例如悬停高亮。 - 另一种变通做法是把
chevron.down.circle作为图标,因为这一版本的箭头尺寸较小。然后应用.symbolRenderingMode(.palette),并将Color.clear作为环的颜色。
要隐藏菜单按钮通常自带的默认菜单指示器,请将 .menuIndicator(.hidden) 应用于 Menu。
动画
默认切换在隐藏或显示侧边栏时提供了相当平滑的动画。
只需使用 withAnimation 来执行列可见性的变化,就能实现相当不错的动画效果。几点说明:
- 无法改变动画的样式——如果你指定了动画的样式,它会被忽略。
- 隐藏侧边栏时,动画相当平滑。
- 显示侧边栏时,动画会有一些怪癖。特别是,标题在初始时会有一次跳动,在动画进行时可以看到右上角的一个“溢出”选项。
我找不到一个简单的方法来解决最后一点的问题。如果你需要让动画效果更好,建议提出一个专注于这一方面的新问题。也可以参考 ToolbarItem in .leading placement NavigationSplitView jumps on visibility toggle 以获得一些想法。
下面是基于你提供的片段、应用了所有改动的独立示例:
struct ContentView: View {
@State private var columnVisibility = NavigationSplitViewVisibility.automatic
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
Text("Sidebar")
.toolbar(removing: .sidebarToggle)
.toolbar {
ToolbarSpacer(placement: .primaryAction)
if columnVisibility != .detailOnly {
ToolbarItem(placement: .primaryAction) {
HStack(spacing: -4) {
toolbarItems
}
}
}
}
} detail: {
Text("Detail")
.toolbar(removing: .sidebarToggle)
.toolbar {
if columnVisibility == .detailOnly {
ToolbarItemGroup(placement: .navigation) {
HStack(spacing: -8) {
toolbarItems
}
}
// .sharedBackgroundVisibility(.hidden) // if preferred
}
}
}
}
@ViewBuilder
private var toolbarItems: some View {
Button(action: toggleSidebar) {
Image(systemName: "sidebar.left")
}
Menu {
Button("Option 1") {}
Button("Option 2") {}
} label: {
Image(systemName: "chevron.down.circle")
.symbolRenderingMode(.palette)
.foregroundStyle(.primary, .clear)
}
.menuStyle(.button)
.menuIndicator(.hidden)
}
private func toggleSidebar() {
withAnimation {
if columnVisibility == .detailOnly {
columnVisibility = .doubleColumn
} else {
columnVisibility = .detailOnly
}
}
}
}
