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 原本需要三個區域:

  1. Sidebar 與 New chat。
  2. Conversation source。
  3. Share 與 Open in New Window。

第一與第二區都位於 navigation placement,中間已經加入 fixed ToolbarSpacer。但在 macOS 27 實際執行時,系統仍把它們視為同一個 navigation island。

ToolbarSpacer 存在於 toolbar 結構中,卻無法阻止系統重新 grouping。Conversation source 因此被合併到 Sidebar 與 New chat 的 capsule 裡。

macos-27-toolbar-auto-grouping-example.png

這不是 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 group

NSToolbarDelegate 再根據 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 留在右側。

SwiftChat 2026-09-22 清晨7.03.55.png