Hello fellow devs!

As some of you may know, I’ve been working on an app called Shortcut Keeper – it’s all about saving and managing your app shortcuts:

But, I’ve just added a cool new update!

Now, the app has its own icon in the macOS menu bar. Clicking on it shows you shortcuts for the app you’re currently using:

Neat, right?

Adding this in a Flutter app wasn’t straightforward, but it was certainly a great learning experience mixing Flutter with native macOS APIs and SwiftUI.

In this post, I’m taking you along on this adventure.

We’ll go through what was tricky, how I figured things out, and some handy code snippets. If you’re a Flutter fan like me or just love learning new things, get ready for this exciting exploration.

Let’s dive in!

Why build this feature

My journey to enhance Shortcut Keeper started with a simple question: how could I make accessing saved shortcuts even easier and quicker for the users?

The answer was, of course, to put a clickable icon right where it’s most handy – the menu bar:

A popover from Shortcut Keeper's menu bar icon, that displays saved shortcuts for Photoshop

It was also a frequent request in user feedback for the app, as everyone likes to save a few clicks.

However, Flutter isn’t built to handle macOS features like a menu bar icon specifically, which made this seemingly simple feature a nice challenge.

In this pursuit, not only was I aiming to make Shortcut Keeper more efficient, but also to offer invaluable insights to other devs on integrating Flutter and SwiftUI for a better user experience.

Let’s dive in to see how this was turned into a reality.

Bird’s-eye view

So, we want to implement what Apple calls a Menu Bar Extra:

Example of a menu bar extra from Apple's HIG guidelines.

A menu bar extra is registered when the app is initialized, so we surely need to delve into Swift code.

Then, when the user clicks the menu bar icon, we want to:

  • Grab the currently active app’s name.
  • Pass that to Flutter/Dart using a platform channel.
  • Our Flutter app should then filter the saved shortcuts by the app name, and send them back to the Swift side via the same channel.
  • We then use SwiftUI to render the result (the shortcuts) into a popover, alongside some other useful buttons and toggles.

Please note that my knowledge of Swift/SwiftUI is fairly limited, so there may be better ways to implement this, but it eventually works for our purposes.

OK, so let’s begin!

The menu bar icon

I assume you already have a macOS app built with Flutter available. If not, you can try this with a brand-new project, using:

flutter create --platforms=macos menubar_sample

Now navigate to your Flutter’s app directory (cd menubar_sample) and use open macos/Runner/xcworkspace to view your macOS app in XCode.

From here, we need to create and add a menu bar icon to our app’s Assets.

Create three PNG versions (16×16, 32×32, 48×48) for the icon you want to display in the menu bar. Since app icons are mostly monochromatic, use a combination of a single color and transparency to create a crisp-looking design for your icon. You can also check a more detailed guide for these icons.

For Shortcut Keeper, I simply used a white/transparent version of its original app icon:

Shortcut Keeper's icon in the macOS menu bar.

Now, from the project sidebar, select Runner > Resources > Assets. Right-click inside Assets, select New Image Set, and name it as you want (e.g. “MenuBarIcon”). Drag and drop the PNG icons you designed, each to their corresponding slot for 1x, 2x, 3x versions. Finally, in the sidebar on the right, select Render As > Template image.

This way, XCode knows where to find our icon, so let’s move into the coding part.

Registering the menu bar icon

Menu Bar Extras are managed by NSStatusItem, a special class in Apple’s AppKit library. We create and add our menu bar extra during the application’s launch, so we need to open Runner/AppDelegate.swift in XCode and replace its content with this:

import Cocoa
import FlutterMacOS
@NSApplicationMain
class AppDelegate: FlutterAppDelegate {
    var statusBar: NSStatusItem?
    var popover = NSPopover()
    var flutterViewController: FlutterViewController?
    
    override func applicationDidFinishLaunching(_ aNotification: Notification) {
        statusBar = NSStatusBar.system.statusItem(withLength: CGFloat(NSStatusItem.variableLength))
        if let button = statusBar?.button {
            button.image = NSImage(named: "MenuBarIcon")
            button.action = #selector(togglePopover(_:))
        }
    }
    
    @objc func togglePopover(_ sender: AnyObject) {
        print("clicked!")
    }
}

We initially prepare some variables for the NSStatusItem, the NSPopover that will eventually be shown, and the controller of the Flutter view.

Next, for the configuration of the icon in the menu bar, we use the statusItem method on NSStatusBar.system to add a new item to the status bar.

We then assign its image (the icon) via NSImage(named: "MenuBarIcon") statement (make sure you use the same name as the one you have in Assets).

The #selector(togglePopover(_:) line indicates that when the button is clicked, it’ll call a function named togglePopover, which for now just prints to the console.

This provides us with a clickable icon in the macOS menu bar when our application gets launched:

Adding the icon to our menu bar.

Getting the current app name and sending it to Flutter

Now, let’s start bridging our Flutter app with native macOS functionalities.

Add this override in AppDelegate.swift, so we can grab an instance of Flutter’s content controller (FlutterViewController, as documented here):

override func applicationWillFinishLaunching(_ notification: Notification) {
    let flutterViewController = NSApplication.shared.windows.first?.contentViewController as? FlutterViewController
    self.flutterViewController = flutterViewController
}

Next, when the icon is clicked, we should grab the current app’s name and send it to Flutter, before displaying the popover. Let’s update the togglePopover method:

@objc func togglePopover(_ sender: AnyObject) {
    if let button = statusBar?.button {
        if popover.isShown {
            popover.performClose(sender)
        } else {
            let frontmostAppName = NSWorkspace.shared.frontmostApplication?.localizedName // The currently active app name, e.g. "XCode".
      
            if let flutterViewController = flutterViewController {
                let statusBarChannel = FlutterMethodChannel(name: "com.sk.statusbar", binaryMessenger: flutterViewController.engine.binaryMessenger)
                statusBarChannel.invokeMethod("getShortcutsForApp", arguments: frontmostAppName) { (result: Any?) in
                    if let error = result as? FlutterError {
                        print("Error Occurred: \(error.message ?? "")")
                    } else if let shortcutsJSON = result as? [[String: Any]] {
                        print(shortcutsJSON)
                    }
                }
            }
        }
    }
}

If the popover is open, we close it. Else, we use a macOS API call to get the name of the currently active app (e.g. “Photoshop” or “XCode”), and set up a platform channel to communicate with Flutter.

We name the platform channel com.sk.statusbar, which should be the exact name we’ll use in our Dart code later. This channel will handle retrieving the shortcuts for the provided app by calling the getShortcutsForApp function, and sending it back to us as JSON (we use Swift’s [[String: Any]] format).

The Dart side of the moon

Moving over to our Flutter’s app Dart code, we want to:

  • Register the same platform channel for communication.
  • Filter the shortcuts by the incoming app name.
  • Send the result back to Swift in a valid format.

In our main.dart file:

void main() {
  // We need this before implementing the platform call.
  WidgetsFlutterBinding.ensureInitialized(); 
  const platform = MethodChannel('com.sk.statusbar');
  platform.setMethodCallHandler((MethodCall call) async {
    if (call.method == "getShortcutsForApp") {
      try {
        String appName = call.arguments.toString();
        // Implemented elsewhere, returns a List<String, dynamic> with shortcuts.
        final shortcutsList = getShortcutsForApp(appName); 
        final shortcutsJson = shortcutsList.map((e) => e.toJson()).toList();
        return shortcutsJson;
      } catch (e, stackTrace) {
        debugPrint("Error Occurred: $e\nStackTrace: $stackTrace");
        return null;
      }
    }
  });
  runApp(const MyApp());
}

The getShortcutsForApp function (not shown here) will return a list of the filtered shortcuts, like:

[
  {
    "combination": "Shift-Command-I",
    "description": "Open Developer Tools",
    "app": "Microsoft Visual Studio Code",
    "createdAt": 1686733115884
  },
  {
    "combination": "Control-Command-T",
    "description": "Tile tabs vertically",
    "app": "Microsoft Visual Studio Code",
    "createdAt": 1686733115883
  },
  {
    "combination": "Control-Shift-Command-T",
    "description": "Tile tabs horizontally",
    "app": "Microsoft Visual Studio Code",
    "createdAt": 1686733115882
  }
]

This is then converted to JSON and sent back to Swift.

Receiving the data and showing the popover

We can now start designing the popover that will display our data.

While one option could have been to use the traditional AppKit components approach, I opted for SwiftUI, which offers a more contemporary and intriguing way to tackle the task.

So, if we have succeeded in our platform call, we want to show a popover with the shortcuts. In our togglePopover method, we add:

// ...
} else if let shortcutsJSON = result as? [[String: Any]] {
    var shortcuts: [Shortcut] = []
    
    shortcutsJSON.forEach { item in
        if let combination = item["combination"] as? String,
            let createdAt = item["createdAt"] as? Int,
            let description = item["description"] as? String {
            shortcuts.append(Shortcut(combination: combination, description: description, createdAt: createdAt))
        }
    }
    
    if #available(macOS 10.15, *) {
        let popover = NSPopover()
        popover.contentSize = NSSize(width: 300, height: 400)
        popover.contentViewController = NSHostingController(rootView: contentView)
        popover.behavior = .transient
        popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
        self.popover = popover
    }
}
// ...

We also use a Struct to represent a shortcut:

struct Shortcut {
    var combination: String
    var description: String
    var createdAt: Int
}

After extracting the shortcuts’ data from the JSON, we add it to a [Shortcut] list. Then, we instantiate a PopoverContentView component (more on this later), which will handle displaying the shortcuts in a SwiftUI view.

Please note that, to use SwiftUI here, we must target macOS > 10.15. That’s why we use the flag check if #available(macOS 10.15, *), but we also need to go to Runner > General tab, and set the Minimum Deployment to macOS 10.15, to make sure Swift compiles the project.

We finally set the dimensions of the popover, its placement right below the menu bar icon, and make it transient (so that it is closed when the user clicks outside of it).

Rendering with SwiftUI

We are finally ready to draw our popover’s content, using SwiftUI. We first need to import it:

import SwiftUI

And then, create our PopoverContentView component (the equivalent of a Flutter widget):

struct PopoverContentView: View {
    var appName: String
    var shortcuts: [Shortcut]
    
    init(appName: String, shortcuts: [Shortcut]) {
        self.appName = appName
        self.shortcuts = shortcuts
    }
    
    var body: some View {
        ZStack {
            VStack{
                Text("\(appName) shortcuts:")
                    .font(.headline)
                    .padding()
                
                if shortcuts.isEmpty {
                    VStack {
                        Text("No shortcuts saved for \(appName).").font(.headline)
                        Text("Go to Shortcut Keeper to \n add shortcuts for this app.")
                            .multilineTextAlignment(.center).padding()
                    }.padding()
                } else {
                    shortcutsList
                }
            }
        }
    }
    
    var shortcutsList: some View {
        List(shortcuts, id: \.createdAt) { item in
            HStack {
                Text(item.combination)
                    .font(.subheadline)
                    .bold()
                    .frame(maxWidth: .infinity, alignment: .leading)
                Text(item.description)
                    .font(.subheadline)
                    .frame(maxWidth: .infinity, alignment: .leading)
            }
        }
    }
}

If you are proficient in SwiftUI, this should be easy to understand. If you are coming from Flutter, you will find it’s a pretty similar declarative model, with some subtle differences:

  • We use VStack and HStack for Column and Row respectively.
  • Padding is not a separate widget, but an extension method called on each component.
  • The same extension method paradigm is used for other attributes, like making the text bold or setting its alignment.

And after all our work, this is the final result:

The final popover, displaying VS Code shortcuts when clicking the menu bar icon of the app.

Clicking on the menu bar icon gets the current app name, makes a platform call to our Flutter app, and finally displays the result in a simple list view in the popover using a SwiftUI component. Neat!

Lastly, I’ve compiled a gist that showcases the completed state of the app’s files for your reference.

Additional development

In its finished form, this feature in Shortcut Keeper also offers the following functionalities:

  1. Theme-switching capabilities (light/dark) courtesy of preferredColorScheme.
  2. Allowing shortcuts to be sorted by description, combination, or creation date using a state variable in our SwiftUI component.
  3. A display option that shows the symbols of the modifier keys.
  4. A button that brings the Shortcut Keeper app to the forefront of the user’s screen.

Here is what it looks like:

The final design of the Shortcut Keeper menu bar popover.

If you’re currently a user, or plan to become one, I welcome and appreciate all feedback! Feel free to share your thoughts and suggestions.

Closing Thoughts

This journey was quite an adventure! We navigated Flutter, SwiftUI and native macOS APIs, and came out with a cool new feature for the app.

It shows that Flutter isn’t limited to just cross-platform mobile apps – with a bit of tinkering, you can really customize it for specific platforms like macOS.

Although the process was a bit tricky, Shortcut Keeper now has a feature that makes it a lot quicker and simpler to use:

I hope you got a kick out of joining me on this ride. Remember, in coding, sometimes the fun is in the challenge.

So keep exploring, keep coding, and let’s see what else we can build with Flutter. Obligatory cat pic to end this:

The cat in kung-fu stance.

Till next time!