Flutter desktop: building for macOS and Windows
Flutter's desktop support left beta in Flutter 3.0. macOS and Windows apps built with Flutter share virtually all business logic and UI with your mobile app — but desktop introduces constraints and interactions that mobile doesn't have: window resizing, keyboard navigation, right-click context menus, and file system access.
Enabling desktop targets
flutter config --enable-macos-desktop
flutter config --enable-windows-desktop
# Create or add desktop support to an existing project
flutter create --platforms=macos,windows .
This adds macos/ and windows/ directories alongside android/ and ios/.
Responsive layouts for desktop
Mobile layouts assume a narrow portrait screen. Desktop windows can be 1440px wide and resized at any time. Use LayoutBuilder to adapt:
class AdaptiveLayout extends StatelessWidget {
const AdaptiveLayout({super.key, required this.body});
final Widget body;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 1200) {
return _WideLayout(body: body);
} else if (constraints.maxWidth >= 600) {
return _MediumLayout(body: body);
} else {
return _NarrowLayout(body: body);
}
},
);
}
}
class _WideLayout extends StatelessWidget {
const _WideLayout({required this.body});
final Widget body;
@override
Widget build(BuildContext context) {
return Row(
children: [
const SizedBox(width: 260, child: AppSidebar()),
Expanded(child: body),
],
);
}
}
For desktop, a sidebar navigation (NavigationRail or custom) replaces the BottomNavigationBar.
Keyboard shortcuts
Desktop users expect keyboard shortcuts. Wire them with Shortcuts and Actions:
class AppShortcuts extends StatelessWidget {
const AppShortcuts({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyN):
const NewDocumentIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS):
const SaveIntent(),
LogicalKeySet(LogicalKeyboardKey.escape):
const DismissIntent(),
},
child: Actions(
actions: {
NewDocumentIntent: CallbackAction<NewDocumentIntent>(
onInvoke: (_) => context.read<DocumentBloc>().add(const NewDocument()),
),
SaveIntent: CallbackAction<SaveIntent>(
onInvoke: (_) => context.read<DocumentBloc>().add(const SaveDocument()),
),
},
child: Focus(autofocus: true, child: child),
),
);
}
}
Right-click context menus
class ContextMenuWrapper extends StatelessWidget {
const ContextMenuWrapper({super.key, required this.child, required this.items});
final Widget child;
final List<ContextMenuEntry> items;
@override
Widget build(BuildContext context) {
return GestureDetector(
onSecondaryTapDown: (details) {
showMenu(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
details.globalPosition.dx + 1,
details.globalPosition.dy + 1,
),
items: items.map((e) => PopupMenuItem(
onTap: e.onTap,
child: Row(
children: [
Icon(e.icon, size: 16),
const SizedBox(width: 8),
Text(e.label),
],
),
)).toList(),
);
},
child: child,
);
}
}
File system access
For file open/save dialogs, use file_picker:
dependencies:
file_picker: ^8.0.0
Future<void> openFile() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['json', 'yaml'],
);
if (result == null) return;
final path = result.files.single.path!;
final content = await File(path).readAsString();
// process content
}
Future<void> saveFile(String content) async {
final path = await FilePicker.platform.saveFile(
dialogTitle: 'Save file',
fileName: 'export.json',
);
if (path == null) return;
await File(path).writeAsString(content);
}
Window management
Control the window size and title with window_manager:
dependencies:
window_manager: ^0.3.0
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await windowManager.ensureInitialized();
const options = WindowOptions(
size: Size(1200, 800),
minimumSize: Size(800, 600),
center: true,
title: 'My Desktop App',
titleBarStyle: TitleBarStyle.hidden, // Custom title bar
);
await windowManager.waitUntilReadyToShow(options, () async {
await windowManager.show();
await windowManager.focus();
});
runApp(const MyApp());
}
Platform-specific code
Detect the platform and adapt:
import 'dart:io';
Widget buildNavigation(Widget body) {
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
return Row(
children: [const Sidebar(), Expanded(child: body)],
);
}
return Scaffold(
body: body,
bottomNavigationBar: const AppBottomNav(),
);
}
Common pitfalls
Touch-only interactions. Drag-to-dismiss drawers, swipe-to-delete, and long-press context menus don't work well with a mouse. Add hover states, right-click menus, and keyboard alternatives.
Small tap targets. Mobile buttons sized for fingers (48dp) look oversized on desktop. Adapt sizes: isDesktop ? 32.0 : 48.0.
Missing scrollbar. Desktop users expect visible scrollbars. Wrap ListView with Scrollbar(thumbVisibility: true, ...).
No window restore. Save window size and position to shared_preferences and restore on next launch — desktop users expect this.
macOS entitlements. macOS apps run in a sandbox. File access, network requests, and camera use require explicit entitlements in macos/Runner/DebugProfile.entitlements and ReleaseProfile.entitlements.
Sign in to like, dislike, or report.