How to get a search bar into an NSToolbar in a Catalyst app?

2.5k Views Asked by At

I've seen some Catalyst apps add a search bar to the NSToolbar and was wondering how I could do the same. Would I have to import AppKit/Cocoa to get an NSSearchField and the actual NSToolbar? Or is there some way with UISearchBars that I am just not seeing? Any help would be great, thanks.

I have tried importing AppKit and Cocoa (Using a bundle loader), but I don't think I had done it right. If anyone has a solid guide on how to do this, please link it.

I also tried creating a UIBarButtonItem with a custom view of a UISearchBar, which doesn't end up rendering anything. (that can be found below)

This is found in a switch case on itemIdentifier.rawValue (in the toolbar itemForIdentifier function)

let search = UISearchBar()
search.sizeToFit()
let button = UIBarButtonItem(customView: search)
button.width = 100
let toolbarItem = NSToolbarItem(itemIdentifier: itemIdentifier, barButtonItem: button)
toolbarItem.isBordered = true
toolbarItem.isEnabled = true
return toolbarItem
3

There are 3 best solutions below

2
Magnetar Developer On BEST ANSWER

Thanks to mmackh, the search bar can be added in just as easily as any other toolbar item. Thank all of you who looked into this.

https://github.com/mmackh/Catalyst-Helpers


Swift Example

Once you add the files to your project (and in this case your bridging header), just return this as you would any other toolbar item.

let item = NSToolbarItem_Catalyst.searchItem(withItemIdentifier: "searchBar", textDidChangeHandler: { string in
    print(string)
})
7
duke4e On

I found a soulution that works for me, maybe it will be good enough for you too.

While I can't create a search bar in toolbar, I can create the button that looks pretty much as a search bar. That button then toggles the visibility of search bar in Catalyst application.

Here's the (obj-c) code for it:

    UIImageSymbolConfiguration *conf = [UIImageSymbolConfiguration configurationWithPointSize:32 weight:UIImageSymbolWeightRegular];
    UIImage *backImage = [UIImage systemImageNamed:@"magnifyingglass" withConfiguration:conf];
    UIBarButtonItem *buttonItem = [[UIBarButtonItem alloc] initWithImage:backImage style:UIBarButtonItemStylePlain target:self action:@selector(searchButtonAction)];
    NSToolbarItem *item = [NSToolbarItem itemWithItemIdentifier:itemIdentifier barButtonItem:buttonItem];
    item.title = @"Search";
    return item;

And here are the images of how this looks in app: iOS search bar off iOS search bar on

As a bonus, you could use some non trimmable space character (like this one) to add space after "Search" string, making the button look even more like a proper search bar.

Here's how that looks:

final searchbar

0
deniz On

You have 3 options. If your catalyst app is targeting minimum macOS 13 or later, you can simply embed UISearchBar in NSToolbar using this method NSToolbarItem.init(itemIdentifier:barButtonItem:). This would be your first option.

If this is not feasible for your application or you don't like how UISearchBar is presented in your toolbar then you can embed a NSSearchBar from AppKit. However this 2nd option is a bit more involved so I will write the summary.

  1. Add a new macOS framework to your XCode project as a new target. Give a name to the framework - let's say macOSBridging. This name will be important later on. Make sure this new target is linked to your primary app/target correctly.

  2. In your macOS framework subclass NSSearchFieldCell as shown below:

    import AppKit
    
    class ToolbarSearchFieldCell: NSSearchFieldCell {
        //customize the NSSearchFieldCell according to your needs
    }
    
  3. In a separate file subclass NSSearchField in your macOS target.

     import AppKit
    
     class ToolbarSearchField: NSSearchField {
         //customize NSSearchField according to your needs
     }
    
  4. Again subclass NSToolbarItem in your macOS framework.

         import AppKit
    
         class SearchbarToolItem: NSToolbarItem, NSSearchFieldDelegate {
         private let searchField = ToolbarSearchField()
    
         override init(itemIdentifier: NSToolbarItem.Identifier) {
             super.init(itemIdentifier: itemIdentifier)
    
             searchField.delegate = self
             self.view = searchField
    
             //further customize searchField according to your needs
    
             visibilityPriority = .high
         }
    
         func controlTextDidChange(_ obj: Notification) {
             //according to Apple Docs, we can retrieve the text value from the userInfo dictionary in Notification object
             if let attachedTextView = obj.userInfo?["NSFieldEditor"] as? NSTextView {
                 //using notification center, we can relay this message back to the ViewController
                 NotificationCenter.default.post(name: "search-bar-text", object: attachedTextView.string)
             }
         }
     }
    
  5. Now head on over to your iOS target (your app) and in your toolbar delegate when it is time to insert a toolbar item, add this code.

     func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
       var toolbarItem: NSToolbarItem?
       switch itemIdentifier {
         case .customSearchBar: //assumes that you have NSToolbarItem.Identifier.customSearchBar defined earlier  
    
             if let frameworksPath = Bundle.main.privateFrameworksPath {
                 let bundlePath = "\(frameworksPath)/macOSBridging.framework" //or whatever the name you picked in Step 1.
                 do {
                     //we want to check whether we can load this dependent framework
                     //make sure the name of the framework is correct
                     try Bundle(path: bundlePath)?.loadAndReturnError()
                     _ = Bundle(path: bundlePath)! //at this point, we can load this!
    
                     //we have to use some Objective-C trickery to load the SearchbarToolItem from AppKit
                     if let searchbarToolItemClass = NSClassFromString("macOSBridging.SearchbarToolItem") as? NSToolbarItem.Type {
                         let newItemToAdd = searchbarToolItemClass.init(itemIdentifier: itemIdentifier)
                         toolbarItem = newItemToAdd
                         print("Successfully loaded NSSearchBar into our toolbar!!!")
                     } else {
                         print("There is no class with the name SearchbarToolItem in macOSBridging.framework - make sure you spelled the name correctly")
                     }
                 } catch {
                     print("error while loading the dependent framework: \(error.localizedDescription)")
                 }
             }
         ...
         ... //configure other items in your toolbar
         ...
         return toolbarItem
     }
    
  6. Now in your view controller listen for the notifications from NSSearchBar like so:

     override func viewDidLoad() {
         super.viewDidLoad()
         // Do any additional setup after loading the view.
         ...
         ...
         NotificationCenter.default.addObserver(self, selector: #selector(userPerformedSearch(_:)), name: NSNotification.Name(rawValue: "search-bar-text"), object: nil)
         //removing observers are optional now.
         //see https://developer.apple.com/documentation/foundation/notificationcenter/1415360-addobserver#discussion
     }
    
     func userPerformedSearch(_ notification: NSNotification) {
         //NSNotification.object contains the typed text in NSSearchBar
         if let searchedText = notification.object as? String {
             //do something with the search text
         }
     }
    

Result with NSSearchBar in the toolbar:

working example of NSSearchBar in a sample project

Your 3rd option may be significantly easier to implement if you are targeting macOS 11 or later. There is a new class named NSSearchToolbarItem introduced with macOS 11. It basically does the same thing as the 2nd option. The implementation of that would be a bit different as you would eliminate steps 2, 3 and 4 from option 2 and replace them with this instead:

    import AppKit

    class DefaultSearchToolbarItem: NSSearchToolbarItem, NSSearchFieldDelegate {
        private var defaultCancelButtonAction: Selector?
        
        override init(itemIdentifier: NSToolbarItem.Identifier) {
            super.init(itemIdentifier: itemIdentifier)
            //customize it according to your needs
        }
        
        func controlTextDidChange(_ obj: Notification) {
            if let attachedTextView = obj.userInfo?["NSFieldEditor"] as? NSTextView {
                NotificationCenter.default.post(name: NSNotification.Name(rawValue: "search-bar-text"), object: attachedTextView.string)
            }
        }
    }

Note:

If you want to see a working code sample I created an example project on Github. If you have trouble linking the framework to your app or if you need assistance in customizing classes in AppKit, additional guidance is available here. Lastly, you need a way to send messages from the classes in AppKit to your ViewControllers. This answer uses NSNotificationCenter to establish this communication. However you are free to choose any other method such as responder-chain that meets your requirements.