Writing
macOS 27 的 Toolbar auto grouping 讓 ToolbarSpacer 失效
如果你正在學 SwiftUI Toolbar,Phil Zakharchenko 的〈A Tale of Adopting Customizable SwiftUI Toolbars〉是一篇值得讀的文章。
文章把 SwiftUI 的普通 toolbar 與 customizable toolbar 分得很清楚。對 customizable toolbar 來說,每個可自訂控制項都應該有自己的 ToolbarItem(id:),不同的邏輯區域再用 ToolbarSpacer 分隔。
這套結構到了 macOS 27 出現問題。
macOS 27 重新決定了 Toolbar grouping
SwiftChat 的 toolbar 原本需要三個區域:
- Sidebar 與 New chat。
- Conversation source。
- Share 與 Open in New Window。
第一與第二區都位於 navigation placement,中間已經加入 fixed ToolbarSpacer。但在 macOS 27 實際執行時,系統仍把它們視為同一個 navigation island。
ToolbarSpacer 存在於 toolbar 結構中,卻無法阻止系統重新 grouping。Conversation source 因此被合併到 Sidebar 與 New chat 的 capsule 裡。
這不是 spacing 數值能修正的版面問題。Toolbar 的分組已由系統決定。
Workaround:macOS 改由 AppKit 接管 Toolbar
目前的 workaround 是保留 SwiftUI 管理的狀態與 actions,但不再讓 SwiftUI 生成 macOS toolbar 的結構。
macOS 分支使用 SwiftUI Introspect 取得 NSWindow,再把 window.toolbar 替換成由 AppKit 建立的 NSToolbar。其他平台則繼續使用原本的 SwiftUI ToolbarContent。
分開 macOS 與其他平台
extension View {
@ViewBuilder
func conversationToolbar(
sidebarVisibility: Binding<NavigationSplitViewVisibility>,
surface: Binding<ChatSurfaceKind>,
subtitle: String,
newConversation: @escaping () -> Void,
shareURL: URL?,
openConversationInNewWindow: @escaping () -> Void
) -> some View {
#if os(macOS)
configureConversationToolbar(
sidebarVisibility: sidebarVisibility,
surface: surface,
subtitle: subtitle,
newConversation: newConversation,
shareURL: shareURL,
openConversationInNewWindow: openConversationInNewWindow
)
#else
toolbar {
ConversationToolbar(
sidebarVisibility: sidebarVisibility,
surface: surface,
subtitle: subtitle,
newConversation: newConversation,
shareURL: shareURL,
openConversationInNewWindow: openConversationInNewWindow
)
}
#endif
}
}AppShellView 不再直接使用 .toolbar {},而是改用這個跨平台入口:
.conversationToolbar(
sidebarVisibility: $navigation.sidebarVisibility,
surface: $navigation.selectedSurface,
subtitle: conversationSubtitle,
newConversation: presentNewConversation,
shareURL: shareableChatGPTConversationURL,
openConversationInNewWindow: openConversationInNewWindow
)取得 NSWindow
configureConversationToolbar 透過 SwiftUI Introspect 取得目前的 NSWindow:
extension View {
func configureConversationToolbar(
sidebarVisibility: Binding<NavigationSplitViewVisibility>,
surface: Binding<ChatSurfaceKind>,
subtitle: String,
newConversation: @escaping () -> Void,
shareURL: URL?,
openConversationInNewWindow: @escaping () -> Void
) -> some View {
introspect(.window, on: .macOS(.v26, .v27)) { window in
MacConversationToolbar.install(
on: window,
configuration: MacConversationToolbarConfiguration(
sidebarIsVisible:
sidebarVisibility.wrappedValue != .detailOnly,
surface: surface.wrappedValue,
subtitle: subtitle,
shareURL: shareURL,
toggleSidebar: {
sidebarVisibility.wrappedValue =
sidebarVisibility.wrappedValue == .detailOnly
? .all
: .detailOnly
},
newConversation: newConversation,
selectSurface: {
surface.wrappedValue = $0
},
openConversationInNewWindow:
openConversationInNewWindow
)
)
}
}
}SwiftUI 仍然持有:
- Sidebar 顯示狀態。
- Conversation surface。
- Conversation subtitle。
- New conversation action。
- Share URL。
- Open in New Window action。
AppKit controller 收到的是這些狀態目前的值,以及需要回到 SwiftUI 執行的 closures。
每個 Window 保存一個 Toolbar Controller
每個 NSWindow 都有自己的 MacConversationToolbarController:
@MainActor
private enum MacConversationToolbar {
private static let controllers =
NSMapTable<NSWindow, MacConversationToolbarController>(
keyOptions: .weakMemory,
valueOptions: .strongMemory
)
static func install(
on window: NSWindow,
configuration: MacConversationToolbarConfiguration
) {
if let controller = controllers.object(forKey: window) {
controller.update(configuration)
controller.installIfNeeded(on: window)
return
}
let controller = MacConversationToolbarController(
configuration: configuration
)
controllers.setObject(controller, forKey: window)
controller.installIfNeeded(on: window)
}
}使用 weak window key,window 被釋放後,對應的 controller entry 也會消失。
使用真正的 NSToolbarItemGroup
AppKit 的 NSToolbarItemGroup 本身就是一個包含 subitems 的 toolbar item。系統會把整個 group 視為單一 toolbar 單位,而不是再從相鄰的 SwiftUI toolbar items 推測 grouping。Apple 的文件也明確區分了 group 與一般 toolbar item。
Navigation controls 使用一個真正的 NSToolbarItemGroup:
private lazy var navigationGroup: NSToolbarItemGroup = {
let group = NSToolbarItemGroup(
itemIdentifier: .conversationNavigation
)
group.label = "Navigation"
group.paletteLabel = "Navigation"
group.controlRepresentation = .expanded
group.subitems = [
sidebarItem,
newConversationItem,
]
return group
}()Conversation source 是獨立的 NSMenuToolbarItem:
private lazy var surfaceItem: NSMenuToolbarItem = {
let item = NSMenuToolbarItem(
itemIdentifier: .conversationSurface
)
item.paletteLabel = "Conversation source"
item.toolTip = "Choose conversation source"
item.showsIndicator = true
item.menu = NSMenu(title: "Conversation source")
return item
}()右側 actions 也是一個明確的 NSToolbarItemGroup:
private lazy var actionsGroup: NSToolbarItemGroup = {
let group = NSToolbarItemGroup(
itemIdentifier: .conversationActions
)
group.label = "Conversation actions"
group.paletteLabel = "Conversation actions"
group.controlRepresentation = .expanded
group.subitems = [
shareItem,
openConversationItem,
]
return group
}()兩個 group 都指定:
group.controlRepresentation = .expanded這使 group 直接顯示所有 subitems,不把它們收進另一個 collapsed control。AppKit 提供的 representation 包括 automatic、collapsed 與 expanded。Apple 文件
明確指定 Toolbar 順序
Toolbar controller 實作 NSToolbarDelegate,直接返回需要的 item 順序:
func toolbarDefaultItemIdentifiers(
_ toolbar: NSToolbar
) -> [NSToolbarItem.Identifier] {
[
.conversationNavigation,
.conversationSurface,
.flexibleSpace,
.conversationActions,
]
}實際結構是:
Navigation group
Conversation source
Flexible space
Actions groupNSToolbarDelegate 再根據 identifier 返回相應的 AppKit item:
func toolbar(
_ toolbar: NSToolbar,
itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,
willBeInsertedIntoToolbar flag: Bool
) -> NSToolbarItem? {
switch itemIdentifier {
case .conversationNavigation:
navigationGroup
case .conversationSurface:
surfaceItem
case .conversationActions:
actionsGroup
default:
nil
}
}這種寫法與 Apple 的 NSToolbar 模型一致:toolbar 由 identifiers、delegate 及對應的 NSToolbarItem 組成。
安裝到 Window
Controller 建立自己的 NSToolbar:
private lazy var toolbar: NSToolbar = {
let toolbar = NSToolbar(
identifier: .swiftChatConversation
)
toolbar.delegate = self
toolbar.displayMode = .iconOnly
toolbar.allowsUserCustomization = false
toolbar.autosavesConfiguration = false
return toolbar
}()最後直接安裝到目前的 window:
func installIfNeeded(on window: NSWindow) {
guard window.toolbar !== toolbar else {
return
}
window.toolbar = toolbar
}當 SwiftUI 狀態改變時,controller 更新受影響的 toolbar item,不重新建立整個 toolbar:
func update(
_ configuration: MacConversationToolbarConfiguration
) {
self.configuration = configuration
applyConfiguration()
}applyConfiguration() 使用 snapshot 避免重複更新:
private func applyConfiguration() {
let newSnapshot = Snapshot(
sidebarIsVisible: configuration.sidebarIsVisible,
surface: configuration.surface,
subtitle: configuration.subtitle,
shareURL: configuration.shareURL
)
guard snapshot != newSnapshot else {
return
}
snapshot = newSnapshot
let sidebarTitle = configuration.sidebarIsVisible
? "Hide Sidebar"
: "Show Sidebar"
sidebarItem.label = sidebarTitle
sidebarItem.paletteLabel = sidebarTitle
sidebarItem.toolTip = sidebarTitle
surfaceItem.title = configuration.surfaceTitle
surfaceItem.label = configuration.surfaceTitle
surfaceItem.menu = makeSurfaceMenu()
actionsGroup.isHidden =
configuration.surface != .chatGPT
|| configuration.shareURL == nil
toolbar.validateVisibleItems()
}完成後,Sidebar 與 New chat 保留在左側 navigation group;Conversation source 成為旁邊獨立的 capsule;Share 與 Open in New Window 留在右側。