BlueSky Mastodon Homepage RSS Feed

Creating a macOS Tray App in Flutter

Creating a macOS Tray App in Flutter

tl;dr: you can use this template I created to get started quickly. It contains all necessary configurations and packages

this tutorial was written by a human.

Diving Into It

To create the tray application, will make use of Swift code and other native features. So make sure, you have Xcode installed.

Get started by creating a new Flutter application. To do this, run flutter run tray_app and open the resulting project in an IDE of your choice.

Since the tray will only work on macOS, consider deleting the other platform directories such as web, windows, ios.

Next, we can already get started to render our app in a tray-Popover. Open the macos folder in Xcode and create the new file Runner/Runner/StatusBarController.swift. Here we will define the look and behaviour of our Popover. The following code is based on this file of a similar project

import AppKit class StatusBarController { private var statusBar: NSStatusBar private var statusItem: NSStatusItem private var popover: NSPopover init(_ popover: NSPopover) { self.popover = popover statusBar = NSStatusBar.system statusItem = statusBar.statusItem(withLength: 18.0) if let statusBarButton = statusItem.button { statusBarButton.image = // add your image here statusBarButton.image?.size = NSSize(width: 18.0, height: 18.0) statusBarButton.image?.isTemplate = true statusBarButton.action = #selector(togglePopover(sender:)) statusBarButton.target = self } } func showPopover(_ sender: AnyObject) { if let statusBarButton = statusItem.button { popover.show(relativeTo: statusBarButton.bounds, of: statusBarButton, preferredEdge: NSRectEdge.maxY) } } func hidePopover(_ sender: AnyObject) { popover.performClose(sender) } @objc func togglePopover(sender: AnyObject) { if(popover.isShown) {hidePopover(sender)} else {showPopover(sender)} } }

Now, we need to reference this controller from the app's entry point. To do this, change the contents of your AppDelegate.swift to the following:

import Cocoa import FlutterMacOS //import ServiceManagement @main class AppDelegate: FlutterAppDelegate { var statusBar: StatusBarController? var popover = NSPopover.init() override init() { popover.behavior = NSPopover.Behavior.transient } override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return false } override func applicationDidFinishLaunching(_ aNotification: Notification) { // uncomment the line below and the import statement // at the top to enable auto-launching on login //do {try SMAppService.mainApp.register()} catch {} let ctrl: FlutterViewController = mainFlutterWindow?.contentViewController as! FlutterViewController popover.contentViewController = ctrl popover.contentSize = NSSize(width: 360, height: 250) statusBar = StatusBarController.init(popover) mainFlutterWindow?.close() super.applicationDidFinishLaunching(aNotification) } }

Optional: To make your app automatically start when the user logs in, un-comment the lines above. Don't forget to also un-comment the import statement.

Some of the features we're using require a higher minimum deployment version to work. Increase the minimum deployment via Xcode in the Runner General tab to at least 11.5. Also change the version in the first line of macos/Podfile to at least 10.14.6.

Finally, we need to add an icon for our menu entry. For this demo, I created a carrot icon. You can see it in the header image of this article. To set the icon, open the Runner/Runner/Ressources/Assets.xcassets in Xcode and drag an image file here. Then open the StatusBarController.swift file we created earlier and reference your icon at statusBarIcon.image = ...

Great, now you're good to go. Run flutter run, and your app should now look like this:

the unstyled app

Fixing the App's Behaviour

next, we will make our app behave in a way, that is consistent to other tray-applications on macOS. For this, we first need to hide the app's Dock icon. This can easily be done by adding LSUIElement = true to the Info.plist file:

<dict> ... <key>LSUIElement</key> <true/> </dict> </plist>

Finally, we want to prevent our app from running multiple times at once. We do this by checking the number of running instances that have the same bundleId as our app. We add a new function to the AppDelegate and reference it from the init function:

... class AppDelegate: FlutterAppDelegate { ... override init() { ... // quit if another instance is already running. AppDelegate.alreadyRunningGuard() } ... // if the app is already running, show an alert and terminate the new instance. // this is to prevent multiple instances of a tray app running at the same time. private static func alreadyRunningGuard() { let bundleID = Bundle.main.bundleIdentifier! let runCount = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID).count if runCount <= 1 { return } let a = NSAlert() a.messageText = "App already running" a.informativeText = "Another instance of this app is already running. Please close it first." a.addButton(withTitle: "OK") a.runModal() NSApp.terminate(nil) } }

That's it. Now we're ready to work on theming.

Add macOS Theme

to make the app feel more native, let's utilize some great packages to mimic Apple's Design Language. First import them in your pubspec.yaml. You may also need to upgrade your Flutter version.

environment: sdk: ">=3.5.3 <4.0.0" flutter: ^3.27.2 # macos_ui relies on a newer verson of Color dependencies: cupertino_icons: ^1.0.8 # this is the icon set we're going to use macos_ui: ^2.1.7 # a great collection of macOS widgets. flutter_acrylic: ^1.1.4 # allows us to make the app translucent

Now, we can add a transparency effect to our app. To do this, we will add 3 lines into the main function in your lib/main.dart. We will also tell macos_ui to treat the popover as the main window.

import 'package:flutter_acrylic/window.dart'; import 'package:flutter_acrylic/window_effect.dart'; import 'package:macos_ui/macos_ui.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); // this will make sure, the popover is treated as the view in focus WindowMainStateListener.instance.overrideIsMainWindow(true); // we want the popover to be translucent await Window.initialize(); await Window.setEffect(effect: WindowEffect.transparent); runApp(...); }

Below you can see the entire code for the demo app visible in the header image of this article:

import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_acrylic/window.dart'; import 'package:flutter_acrylic/window_effect.dart'; import 'package:macos_ui/macos_ui.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); // this will make sure, the popover is treated as the view in focus WindowMainStateListener.instance.overrideIsMainWindow(true); // we want the popover to be translucent await Window.initialize(); await Window.setEffect(effect: WindowEffect.transparent); runApp(const MyMacosApp()); } // ========== YOU CAN DEFINE YOUR OWN APP BELOW ============ class MyMacosApp extends StatelessWidget { const MyMacosApp({super.key}); @override Widget build(BuildContext context) => MacosApp( title: 'Tray App', debugShowCheckedModeBanner: false, home: _Demo()); } class _Demo extends StatefulWidget { @override createState() => __DemoState(); } class __DemoState extends State<_Demo> { final animals = [ CupertinoIcons.tortoise, CupertinoIcons.ant, CupertinoIcons.hare ]; int i = 0; @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.all(16), child: Column( children: [ Expanded(child: Center(child: Icon(animals[i], size: 100))), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ PushButton( secondary: true, controlSize: ControlSize.large, child: const Text('quit'), onPressed: () => exit(0), ), PushButton( secondary: true, controlSize: ControlSize.large, child: const Text('next animal'), onPressed: () => setState(() => i = (i + 1) % animals.length), ), ], ) ], )); }

Your app should now look like this:

the styled app

Great, now you're ready to start working on your app. All the code of this article is available here:

you can also clone that repository and use it as a jumping off point.

Happy coding,
Yours, Robin

share on BlueSky share on Mastodon