27

I am trying to add a toolbar inside the title bar to a macOS app using SwiftUI, something similar to what is shown below.

Toolbar in the titlebar in macOS app

I am unable to figure out a way to achieve this using SwiftUI. Currently, I have my toolbar (which just has a text field) inside my view, but I want to move it into the title bar.

My current code:

struct TestView: View {
    var body: some View {
        VStack {
            TextField("Placeholder", text: .constant("")).padding()
            Spacer()
        }
    }
}

So, in my case, I need to have the textfield inside the toolbar.

Bijoy Thangaraj
  • 5,434
  • 4
  • 43
  • 70
  • 1
    I’m talking bout SwiftUI in macOS target. – Bijoy Thangaraj Feb 29 '20 at 14:56
  • 1
    No, navigationBarTitle modifier is not available in macOS SwiftUI. – Bijoy Thangaraj Feb 29 '20 at 15:02
  • 1
    @Asperi I was able to do this - please see the answer below. Toolbars (or titlebar accessories) are still widely used in macOS apps, isn't it? – Bijoy Thangaraj Feb 29 '20 at 18:11
  • 1
    @Asperi To use the one from AppKit, did you mean using NSViewRepresentable for NSToolbar? If so, I tried that method but wasn't successful. If you have a solution that way, I would love to check it out. – Bijoy Thangaraj Feb 29 '20 at 18:35
  • 1
    Think it's easier to keep the toolbar managed by AppKit cause SwiftUI does not provide good support to UI outside window's "viewContent". I've posted sample code of a programmatic AppKit implementation here: https://github.com/billibala/SUIToolbarPlay – billibala Apr 25 '20 at 10:17

5 Answers5

28

As of macOS 11 you’ll likely want to use the new API as documented in WWDC Session 10104 as the new standard. Explicit code examples were provided in WWDC Session 10041 at the 12min mark.

NSWindowToolbarStyle.unified

or

NSWindowToolbarStyle.unifiedCompact

And in SwiftUI you can use the new .toolbar { } builder.

struct ContentView: View {


  var body: some View {
        List {
            Text("Book List")
        }
        .toolbar {
            Button(action: recordProgress) {
                Label("Record Progress", systemImage: "book.circle")
            }
        }
    }

    private func recordProgress() {}
}
mcritz
  • 709
  • 7
  • 14
8

Approach 1:

This is done by adding a titlebar accessory. I was able to get this done by modifying the AppDelegate.swift file. I had to apply some weird padding to make it look right.

AppDelegate.swift

func applicationDidFinishLaunching(_ aNotification: Notification) {
        // Create the SwiftUI view that provides the window contents.
        let contentView = ContentView()

        // Create the titlebar accessory
        let titlebarAccessoryView = TitlebarAccessory().padding([.top, .leading, .trailing], 16.0).padding(.bottom,-8.0).edgesIgnoringSafeArea(.top)

        let accessoryHostingView = NSHostingView(rootView:titlebarAccessoryView)
        accessoryHostingView.frame.size = accessoryHostingView.fittingSize

        let titlebarAccessory = NSTitlebarAccessoryViewController()
        titlebarAccessory.view = accessoryHostingView       

        // Create the window and set the content view. 
        window = NSWindow(
            contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
            styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
            backing: .buffered, defer: false)
        window.center()
        window.setFrameAutosaveName("Main Window")

        // Add the titlebar accessory
        window.addTitlebarAccessoryViewController(titlebarAccessory)

        window.contentView = NSHostingView(rootView: contentView)
        window.makeKeyAndOrderFront(nil)
    }

TitlebarAccessory.swift

import SwiftUI

struct TitlebarAccessory: View {
    var body: some View {

        TextField("Placeholder", text: .constant(""))

    }
}

Result:

Content inside macOS toolbar

Approach 2 (Alternative method):

The idea here is to do the toolbar part using storyboard and the rest of the app using SwiftUI. This is done by creating a new app with storyboard as the user interface. Then go to the storyboard and delete the default View Controller and add a new NSHostingController. Connect the newly added Hosting Controller to the main window by setting its relationship. Add your toolbar to the window using interface builder.

Storyboard configuration

Attach a custom class to your NSHostingController and load your SwiftUI view into it.

Example code below:

import Cocoa
import SwiftUI

class HostingController: NSHostingController<SwiftUIView> {

    @objc required dynamic init?(coder: NSCoder) {
        super.init(coder: coder, rootView: SwiftUIView())       

    }

}

Using this approach also gives you the ability to customize the toolbar.

Bijoy Thangaraj
  • 5,434
  • 4
  • 43
  • 70
  • Is there a way to make it editable by right clicking and selecting "Customise Toolbar"? (Similar to the Finder, Pages etc.) – sqwk Mar 08 '20 at 17:46
  • 2
    With the first solution, no. But, look at the alternative approach I added to my response above. That way, you get the option to customize the toolbar. – Bijoy Thangaraj Mar 09 '20 at 11:45
  • @BijoyThangaraj how to change the `State` variables of the `SwiftUIView` using a button in the toolbar? I tried but the state doesn't change. –  May 16 '20 at 20:57
  • @Alan, one way to do that would be by posting a notification from your WindowController which you can observe in your HostingController. In your HostingController, you can hold an ObservableObject and modify its properties based on the notification received. Finally, you can pass this observable object to your SwiftUIView like this: `super.init(coder: coder, rootView: AnyView(SwiftUIView().environmentObject(myObservableObject)))` – Bijoy Thangaraj May 17 '20 at 13:32
  • For the Approach 1, the text is somewhat missaligned/missplaced. Is there any way to fix that to make it better visually? This is somehow also happens for Buttons. – Aryo Oct 13 '20 at 15:51
0

https://developer.apple.com/documentation/uikit/uititlebar

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

        guard let windowScene = (scene as? UIWindowScene) else { return }
        let window = UIWindow(windowScene: windowScene)

        if let titlebar = windowScene.titlebar {

            //toolbar
            let identifier = NSToolbar.Identifier(toolbarIdentifier)
            let toolbar = NSToolbar(identifier: identifier)
            toolbar.allowsUserCustomization = true
            toolbar.centeredItemIdentifier = NSToolbarItem.Identifier(rawValue: centerToolbarIdentifier)
            titlebar.toolbar = toolbar
            titlebar.toolbar?.delegate = self

            titlebar.titleVisibility = .hidden
            titlebar.autoHidesToolbarInFullScreen = true
        }

        window.makeKeyAndVisible()

    }
#if targetEnvironment(macCatalyst)
let toolbarIdentifier = "com.example.apple-samplecode.toolbar"
let centerToolbarIdentifier = "com.example.apple-samplecode.centerToolbar"
let addToolbarIdentifier = "com.example.apple-samplecode.add"

extension SceneDelegate: NSToolbarDelegate {

    func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
        if itemIdentifier == NSToolbarItem.Identifier(rawValue: toolbarIdentifier) {
            let group = NSToolbarItemGroup(itemIdentifier: NSToolbarItem.Identifier(rawValue: toolbarIdentifier), titles: ["Solver", "Resistance", "Settings"], selectionMode: .selectOne, labels: ["section1", "section2", "section3"], target: self, action: #selector(toolbarGroupSelectionChanged))

            group.setSelected(true, at: 0)

            return group
        }

        if itemIdentifier == NSToolbarItem.Identifier(rawValue: centerToolbarIdentifier) {
            let group = NSToolbarItemGroup(itemIdentifier: NSToolbarItem.Identifier(rawValue: centerToolbarIdentifier), titles: ["Solver1", "Resistance1", "Settings1"], selectionMode: .selectOne, labels: ["section1", "section2", "section3"], target: self, action: #selector(toolbarGroupSelectionChanged))

            group.setSelected(true, at: 0)

            return group
        }

        if itemIdentifier == NSToolbarItem.Identifier(rawValue: addToolbarIdentifier) {
            let barButtonItem = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.add, target: self, action: #selector(self.add(sender:)))
            let button = NSToolbarItem(itemIdentifier: itemIdentifier, barButtonItem: barButtonItem)
            return button
        }

        return nil
    }

    @objc func toolbarGroupSelectionChanged(sender: NSToolbarItemGroup) {
        print("selection changed to index: \(sender.selectedIndex)")
    }

    @objc func add(sender: UIBarButtonItem) {
        print("add clicked")
    }

    func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
        [NSToolbarItem.Identifier(rawValue: toolbarIdentifier), NSToolbarItem.Identifier(rawValue: centerToolbarIdentifier), NSToolbarItem.Identifier.flexibleSpace,
            NSToolbarItem.Identifier(rawValue: addToolbarIdentifier),
            NSToolbarItem.Identifier(rawValue: addToolbarIdentifier)]
    }

    func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
        self.toolbarDefaultItemIdentifiers(toolbar)
    }
    
}
#endif
hstdt
  • 5,652
  • 2
  • 34
  • 34
  • 1
    This answer could be improved by explaining how it solved OP's problem in the question. Code-only answers are generally considered low-quality on Stack Overflow, and a link alone does not suffice as an explanation (links may become invalid over time). – codewario Jun 02 '22 at 16:37
  • This solution is for Mac Catalyst. The question is about a macOS app. – soundflix Aug 23 '23 at 17:38
0

Inspired by your first approach I managed to get a toolbar too. As I'm using Divider()s in it, your Paddings didn't work great for me.

Picture showing wrong alignment

This one seems to work a bit smoother with different Layout-Sizes:

 let titlebarAccessoryView = TitlebarAccessory().padding([.leading, .trailing], 10).edgesIgnoringSafeArea(.top)

    let accessoryHostingView = NSHostingView(rootView:titlebarAccessoryView)
    accessoryHostingView.frame.size.height = accessoryHostingView.fittingSize.height+16
    accessoryHostingView.frame.size.width = accessoryHostingView.fittingSize.width

Result with the right paddings

Maybe there is an even smoother way to get rid of this +16 and the padding trailing and leading (there are several other options instead of fittingSize), but I couldn't find any that looks great without adding numerical values.

Alexander
  • 59,041
  • 12
  • 98
  • 151
0

I've finally managed to do this without any fiddly padding and in a way which looks great in full screen as well. Also, The previous solutions do not allow horizontal resizing.

Aligned title bar

Wrap your title view in an HStack() and add in an invisible text view which is allowed to expand to infinity height. This seems to be what keeps everything centered. Ignore the safe area at the top to now center it in the full height of the titlebar.

       struct TitleView : View {
   
            var body: some View {
                HStack {
                    Text("").font(.system(size: 0, weight: .light, design: .default)).frame(maxHeight: .infinity)
                    Text("This is my Title")
                }.edgesIgnoringSafeArea(.top)
            }
        }

In your app delegate, when you add in the NSTitlebarAccessoryViewController() set the layoutAttribute to top. This will allow it to resize horizontally as your window size changes (leading and left fix the width to minimums and has caused me a lot of pain looking for the answer to this.

    let titlebarAccessoryView = TitleView()

    let accessoryHostingView = NSHostingView(rootView: titlebarAccessoryView)
    accessoryHostingView.frame.size = accessoryHostingView.fittingSize
    
    let titlebarAccessory = NSTitlebarAccessoryViewController()
    titlebarAccessory.view = accessoryHostingView
    titlebarAccessory.layoutAttribute = .top

In my case I also want some buttons on the very right which position independently of the rest of the title, so I chose to add them separately, making use of the ability to add multiple view controllers

    let titlebarAccessoryRight = NSTitlebarAccessoryViewController()
    titlebarAccessoryRight.view = accessoryHostingRightView
    titlebarAccessoryRight.layoutAttribute = .trailing
    
    window.toolbar = NSToolbar()
    window.toolbar?.displayMode = .iconOnly
    window.addTitlebarAccessoryViewController(titlebarAccessory)
    window.addTitlebarAccessoryViewController(titlebarAccessoryRight)
Ben Toner
  • 19
  • 2