← Back to posts Cover image for Mastering Flutter Desktop: Building Robust Multi-Window Experiences and System Integrations

Mastering Flutter Desktop: Building Robust Multi-Window Experiences and System Integrations

· 5 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

Flutter’s journey to the desktop has been one of its most exciting chapters. We can now deploy a single codebase to mobile, web, and desktop, but creating a desktop application that feels native—with proper multi-window flows, system tray integration, and custom window controls—requires moving beyond the default MaterialApp scaffold. Let’s explore how to build these robust desktop experiences.

The Core Challenge: Platform Integration

The primary hurdle is that Flutter, by design, renders its own UI. For basic apps, this is fine. But for desktop, users expect behaviors like dragging from a custom title bar, minimizing to a system tray, or opening a detached settings window. These require direct communication with the host operating system’s windowing APIs (Win32 on Windows, AppKit on macOS, GTK/X11/Wayland on Linux).

Thankfully, the ecosystem has stepped up with powerful plugins that abstract this complexity. We’ll focus on patterns using a window management plugin to solve common desktop problems.

Taking Control of Your Windows

Let’s start by customizing the application window. A common requirement is removing the standard OS title bar to implement your own cohesive branding while still allowing window manipulation.

First, add the window_manager dependency to your pubspec.yaml. Always check for the latest stable version.

dependencies:
  window_manager: ^0.2.9

Now, you can initialize window controls early in your app’s lifecycle. A good place is in main() before runApp.

import 'package:window_manager/window_manager.dart';

Future<void> main() async {
  // Ensure Flutter bindings are initialized
  WidgetsFlutterBinding.ensureInitialized();

  // Wait for the window manager to be ready
  await windowManager.ensureInitialized();

  // Configure the window to have a frameless style
  WindowOptions windowOptions = const WindowOptions(
    size: Size(800, 600),
    center: true,
    titleBarStyle: TitleBarStyle.hidden, // Hides the standard OS title bar
  );

  await windowManager.waitUntilReadyToShow(windowOptions, null);
  await windowManager.show();

  runApp(const MyDesktopApp());
}

With the native title bar hidden, you must provide your own draggable region and window control buttons (minimize, maximize, close). Here’s a simple custom title bar widget:

import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';

class CustomTitleBar extends StatelessWidget {
  const CustomTitleBar({super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 50,
      color: Colors.blueGrey[900],
      child: Row(
        children: [
          // Draggable area for moving the window
          Expanded(
            child: GestureDetector(
              behavior: HitTestBehavior.translucent,
              onPanStart: (_) => windowManager.startDragging(),
              child: const Padding(
                padding: EdgeInsets.only(left: 16),
                child: Text('My Desktop App',
                    style: TextStyle(color: Colors.white)),
              ),
            ),
          ),
          // Window control buttons
          WindowControlButtons(),
        ],
      ),
    );
  }
}

class WindowControlButtons extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        IconButton(
          icon: const Icon(Icons.minimize, size: 18),
          color: Colors.white,
          onPressed: () => windowManager.minimize(),
        ),
        IconButton(
          icon: const Icon(Icons.crop_square, size: 18),
          color: Colors.white,
          onPressed: () => windowManager.maximizeOrRestore(),
        ),
        IconButton(
          icon: const Icon(Icons.close, size: 18),
          color: Colors.white,
          onPressed: () => windowManager.close(),
        ),
      ],
    );
  }
}

Integrate this CustomTitleBar at the top of your app’s scaffold. Remember to set extendBodyBehindAppBar: true and adjust your app bar’s height to avoid content being hidden underneath.

Building Multi-Window Experiences

Desktop apps often use secondary windows for tools, settings, or details. Creating a new window is essentially launching a new Flutter instance. The key is to pass context or data between them, which can be done via platform channels, SharedPreferences, or a state management solution that works across isolates.

Here’s a basic pattern for opening a settings window:

Future<void> openSettingsWindow() async {
  // This would typically be handled by your platform-specific
  // main entry point for the second window.
  // The plugin often provides a method to launch a new instance.
  await windowManager.createWindow(
    WindowOptions(
      title: 'Settings',
      size: Size(500, 400),
      alwaysOnTop: true,
    ),
  );
  // The new window would run a different `runApp` target,
  // e.g., `runApp(const SettingsWindowApp())`.
}

In practice, you’d configure multiple entry points in your main.dart and use platform-specific code (like windowId arguments) to decide which runApp to execute.

Integrating with the System Tray

For background apps or utilities, a system tray icon is essential. This requires another plugin, often used in tandem with the window manager. The typical flow is:

  1. Create a tray icon with a menu.
  2. Handle menu click events (e.g., to show/hide the main window).
  3. Manage window visibility states (minimize to tray vs. close).

Common Mistake: Not handling the platform-specific lifecycle correctly. On macOS, closing the last window often doesn’t terminate the app, while on Windows it might. Always use the window manager’s lifecycle events (like onWindowClose) to decide whether to hide the window to the tray or actually exit the app.

Going Beyond the Basics

These integrations form the foundation. From here, you can explore:

  • Global Shortcuts: Registering system-wide hotkeys to show your app.
  • Drag-and-Drop: Handling files dropped from the OS onto your Flutter window.
  • Native Menus: Building application menus that sit at the top of the screen on macOS or in the window frame on Windows/Linux.

Final Architecture Tip

Keep your platform-specific integration logic cleanly separated. Create a DesktopService class that wraps all calls to windowManager and other plugins. This abstracts the complexity and makes your core business logic easier to test and maintain.

class DesktopIntegrationService {
  final WindowManager _windowManager = windowManager;

  Future<void> initWindow() async { /* ... */ }
  Future<void> createToolWindow() async { /* ... */ }
  void minimizeToTray() { /* ... */ }
  // ... other desktop-specific operations
}

By embracing these patterns, you transform your Flutter app from a mobile interface running on desktop to a powerful, integrated desktop citizen. The tools are now mature enough to build professional, native-feeling applications—it’s just a matter of knowing how to wire them together.

This blog is produced with the assistance of AI by a human editor. Learn more

Related Posts

Cover image for Demystifying Dart's Reflection: When to Use Code Generation for Powerful Flutter Features

Demystifying Dart's Reflection: When to Use Code Generation for Powerful Flutter Features

Dart's lack of full runtime reflection in Flutter often frustrates developers used to languages like C#, limiting dynamic tool building. This post will clarify why Flutter restricts reflection (tree-shaking benefits), explain the `dart:mirrors` library's role, and most importantly, provide practical strategies for achieving similar powerful capabilities through compile-time code generation and annotations, with real-world examples.

Cover image for Fixing Flutter ANR Issues: Strategies for Unblocking the Main Thread

Fixing Flutter ANR Issues: Strategies for Unblocking the Main Thread

App Not Responding (ANR) errors plague many Flutter apps, often misdiagnosed as general slowness. This post will delve into identifying and resolving ANRs by focusing on common causes of main thread blocks, providing practical tools and techniques for ensuring a smooth, responsive user experience.

Cover image for Mastering CI/CD for Flutter: A Practical Guide to Fastlane and GitHub Actions

Mastering CI/CD for Flutter: A Practical Guide to Fastlane and GitHub Actions

Implementing robust Continuous Integration and Continuous Deployment (CI/CD) is essential for shipping Flutter apps efficiently, yet many developers struggle with setting up reliable pipelines for Android and iOS. This post will provide a practical guide to leveraging Fastlane and GitHub Actions to automate builds, testing, and deployments, addressing common challenges and sharing best practices for a streamlined release workflow.