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:
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:
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