Designing Platform-Specific UIs in Flutter: A Comprehensive Guide
In this article, we’ll explore how to create platform-specific UIs in Flutter. We’ll ensure our app follows the Material 3 design system on Android, Fluent UI on Windows, and Human Interface Guidelines (HIG) on iOS and macOS.
We’ll utilise a few plugins to achieve these customisations.
The fluent_ui plugin helps us to create UIs that conform with Microsoft’s fluent design system and the macos_ui can help us with HIG on MacOS.
Flutter natively supports the Material three design system and HIG on iOS.
NOTE: the macos_ui plugin is only supported on MacOS, so to test the HIG for MacOs you will need a Mac computer or laptop.
Lets get started
To create a flutter project you can run the flutter create command in the terminal or command prompt.
flutter createOpen the project in your favourite IDE. Install the fluent_ui and macos_ui plugins by running the commands below.
flutter pub add fluent_ui
flutter pub add macos_uiNOTE: At the time of writing this article the latest version of
macos_uiplugin is 2.0.7 but there are issues with this version. Version 2.0.2 worked for me and I am using version 2.0.2 for this demo.
The macos_ui plugin needs a higher deployment target for macos. Go to Project > macos > Podfile. Update the first line of the file to platform :osx, ‘10.15’.
Open the macos project (Project > macos > Runner.xcworkspace) in Xcode and update the deployment target to 10.5 as shown in the screenshot below.
For adding window buttons and to customise the title bar of the Windows application we need another plugin.
To install window_manager plugin run the command below in the project terminal or command prompt
flutter pub add window_managerRemove all the code from the main.dart file and paste in the code available below.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:macos_ui/macos_ui.dart';
import 'package:platform_ui/android_app_view/android_app_view.dart';
import 'package:platform_ui/ios_view/ios_view.dart';
import 'package:platform_ui/macos_view/macos_view.dart';
import 'package:platform_ui/win_view/win_view.dart';
import 'package:window_manager/window_manager.dart';
/// This method initializes macos_window_utils and styles the window.
Future<void> _configureMacosWindowUtils() async {
const config = MacosWindowUtilsConfig();
await config.apply();
}
Future<void> _configureWindowSize({Size minSize = const Size(1000, 550)}) async {
await WindowManager.instance.ensureInitialized();
WindowOptions windowOptions = WindowOptions(
size: minSize,
backgroundColor: Colors.transparent,
skipTaskbar: false,
titleBarStyle: TitleBarStyle.hidden,
minimumSize: minSize,
windowButtonVisibility: false,
);
windowManager.waitUntilReadyToShow(windowOptions).then((_) async {
await windowManager.show();
await windowManager.focus();
await windowManager.setPreventClose(true);
});
}
void main() {
WidgetsFlutterBinding.ensureInitialized();
switch (Platform.operatingSystem) {
case 'macos':
_configureMacosWindowUtils();
runApp(const MacOSView());
break;
case 'windows':
_configureWindowSize();
runApp(const WinView());
break;
case 'ios':
runApp(const IosView());
break;
default:
runApp(const AndroidAppView());
break;
}
}We now need to create the separate views for each platform.
For MacOS view use the below code
// MacOSView
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart' show Divider, ThemeMode;
import 'package:macos_ui/macos_ui.dart';
class MacOSView extends StatelessWidget {
const MacOSView({super.key});
@override
Widget build(BuildContext context) {
return const MacosApp(
title: 'TempBox',
themeMode: ThemeMode.system,
debugShowCheckedModeBanner: false,
home: MacOsHome(),
);
}
}
class MacOsHome extends StatelessWidget {
const MacOsHome({super.key});
@override
Widget build(BuildContext context) {
final typography = MacosTypography.of(context);
return MacosWindow(
sidebar: Sidebar(
top: Container(
margin: const EdgeInsets.only(left: 10, right: 10, bottom: 20),
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(borderRadius: BorderRadius.all(Radius.circular(5))),
child: CupertinoListTile(
title: Text('New Folder', style: typography.body),
trailing: MacosIcon(
CupertinoIcons.add_circled_solid,
size: 18,
color: CupertinoColors.systemGrey.resolveFrom(context),
),
backgroundColor: CupertinoColors.systemGrey2.resolveFrom(context).withAlpha(44),
backgroundColorActivated: CupertinoColors.systemGrey4.resolveFrom(context),
onTap: () {},
),
),
minWidth: 240,
maxWidth: 270,
builder: (context, scrollController) => SidebarItems(
currentIndex: 0,
onChanged: (i) {},
scrollController: scrollController,
items: const [
SidebarItem(leading: MacosIcon(CupertinoIcons.tray, size: 15), label: Text('Folder One')),
SidebarItem(leading: MacosIcon(CupertinoIcons.tray, size: 15), label: Text('Folder Two')),
],
),
),
child: const SelectedFolderView(),
);
}
}
class SelectedFolderView extends StatefulWidget {
const SelectedFolderView({super.key});
@override
State<SelectedFolderView> createState() => _SelectedFolderViewState();
}
class _SelectedFolderViewState extends State<SelectedFolderView> {
double ratingValue = 0;
double capacitorValue = 0;
double sliderValue = 0.3;
@override
Widget build(BuildContext context) {
return MacosScaffold(
toolBar: ToolBar(
title: Builder(builder: (context) => const Text("Folder One")),
actions: [
ToolBarIconButton(
icon: const MacosIcon(CupertinoIcons.share),
onPressed: () {},
label: 'Share',
showLabel: false,
tooltipMessage: 'Share note',
),
ToolBarIconButton(
icon: const MacosIcon(CupertinoIcons.trash),
onPressed: () {},
label: 'Delete',
showLabel: false,
tooltipMessage: 'Delete note',
),
],
),
children: [
ResizablePane(
minSize: 200,
startSize: 300,
windowBreakpoint: 700,
resizableSide: ResizableSide.right,
builder: (context, scrollcontroller) {
return ListView.separated(
controller: scrollcontroller,
itemCount: 20,
itemBuilder: (ctx, index) => MacosListTile(title: Text('Note ${index + 1}'), subtitle: const Text('note data')),
separatorBuilder: (context, index) => const Divider(indent: 15, endIndent: 15, thickness: 0, height: 0),
);
},
),
ContentArea(builder: (context, scrollController) => const Center(child: Text('Note Data'))),
],
);
}
}For Windows view use the below code
// Windows View
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:window_manager/window_manager.dart';
class WinView extends StatelessWidget {
const WinView({super.key});
@override
Widget build(BuildContext context) {
return Builder(builder: (context) {
return FluentApp(
title: 'TempBox',
debugShowCheckedModeBanner: false,
darkTheme: FluentThemeData(brightness: Brightness.dark),
home: const WinHome(),
);
});
}
}
class WinHome extends StatelessWidget {
const WinHome({super.key});
@override
Widget build(BuildContext context) {
return NavigationView(
appBar: NavigationAppBar(
leading: const FlutterLogo(),
title: const DragToMoveArea(child: Align(alignment: AlignmentDirectional.centerStart, child: Text('TempBox'))),
actions: Row(mainAxisAlignment: MainAxisAlignment.end, children: [
Tooltip(
message: 'Share message',
child: IconButton(icon: const Icon(FluentIcons.share, size: 20), onPressed: () {}),
),
const SizedBox(width: 10),
Tooltip(
message: 'Delete message',
child: IconButton(icon: const Icon(CupertinoIcons.trash, size: 20), onPressed: () {}),
),
const SizedBox(width: 10),
const WindowButtons(),
]),
),
pane: NavigationPane(
header: Card(
margin: const EdgeInsets.only(right: 15, left: 8, bottom: 15),
padding: EdgeInsets.zero,
child: ListTile(
title: Text('New Address', style: FluentTheme.of(context).typography.body),
trailing: const Icon(CupertinoIcons.add_circled_solid),
onPressed: () {},
),
),
onItemPressed: (index) {},
size: NavigationPaneSize(openWidth: MediaQuery.of(context).size.width / 5, openMinWidth: 250, openMaxWidth: 250),
items: [
PaneItem(icon: const Icon(CupertinoIcons.tray), title: const Text('Folder One'), body: const SizedBox.shrink()),
PaneItem(icon: const Icon(CupertinoIcons.tray), title: const Text('Folder Two'), body: const SizedBox.shrink()),
],
displayMode: PaneDisplayMode.open,
toggleable: true,
selected: 0,
),
paneBodyBuilder: (item, child) {
final name = item?.key is ValueKey ? (item!.key as ValueKey).value : null;
return FocusTraversalGroup(key: ValueKey('body$name'), child: const SelectedNoteView());
},
);
}
}
class SelectedNoteView extends StatelessWidget {
const SelectedNoteView({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(
width: 280,
child: ScaffoldPage(
padding: const EdgeInsets.symmetric(vertical: 2),
content: Builder(builder: (context) {
return ListView.separated(
itemCount: 20,
itemBuilder: (ctx, index) => ListTile(title: Text('Note ${index + 1}'), subtitle: const Text('note data')),
separatorBuilder: (context, index) => const Divider(),
);
}),
),
),
const Divider(direction: Axis.vertical),
const Expanded(child: ScaffoldPage(padding: EdgeInsets.symmetric(vertical: 3), content: Center(child: Text('Note data')))),
],
);
}
}
class WindowButtons extends StatelessWidget {
const WindowButtons({super.key});
@override
Widget build(BuildContext context) {
final FluentThemeData theme = FluentTheme.of(context);
return SizedBox(
width: 138,
height: 50,
child: WindowCaption(brightness: theme.brightness, backgroundColor: Colors.transparent),
);
}
}Lets work with Android view now
// Android View
import 'package:flutter/material.dart';
class AndroidAppView extends StatelessWidget {
const AndroidAppView({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const AndroidHome(),
);
}
}
class AndroidHome extends StatelessWidget {
const AndroidHome({super.key});
_navigateToNotesList(BuildContext context) {
Navigator.of(context).push(MaterialPageRoute(builder: (context) => const NotesList()));
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
const SliverAppBar.large(title: Text('Platform UI')),
SliverList.list(children: [
ListTile(title: const Text('Folder One'), onTap: () => _navigateToNotesList(context)),
ListTile(title: const Text('Folder Two'), onTap: () => _navigateToNotesList(context)),
])
],
),
);
}
}
class NotesList extends StatelessWidget {
const NotesList({super.key});
_navigateToNoteDetail(BuildContext context) {
Navigator.of(context).push(MaterialPageRoute(builder: (context) => const NoteDetail()));
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
const SliverAppBar.large(title: Text('Notes List')),
SliverList.list(
children: List.generate(30, (index) {
return ListTile(title: Text('Note ${index + 1}'), onTap: () => _navigateToNoteDetail(context));
}),
)
],
),
);
}
}
class NoteDetail extends StatelessWidget {
const NoteDetail({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: CustomScrollView(
slivers: [SliverAppBar.large(title: Text('Note Detail')), SliverToBoxAdapter(child: Center(child: Text('Note data')))],
),
);
}
}And at the end lets add the iOS view
// iOS view
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
class IosView extends StatelessWidget {
const IosView({super.key});
@override
Widget build(BuildContext context) {
return const CupertinoApp(
debugShowCheckedModeBanner: false,
title: 'Platform UI',
localizationsDelegates: <LocalizationsDelegate<dynamic>>[
DefaultMaterialLocalizations.delegate,
DefaultWidgetsLocalizations.delegate,
DefaultCupertinoLocalizations.delegate,
],
home: IosHome(),
);
}
}
class IosHome extends StatelessWidget {
const IosHome({super.key});
_navigateToNotesList(BuildContext context) {
Navigator.of(context).push(CupertinoPageRoute(builder: (context) => const NotesList()));
}
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
backgroundColor: CupertinoColors.systemGroupedBackground,
child: CustomScrollView(
slivers: [
const CupertinoSliverNavigationBar(largeTitle: Text('Platform UI')),
SliverList.list(children: [
CupertinoListSection.insetGrouped(
children: [
CupertinoListTile(title: const Text('Folder One'), onTap: () => _navigateToNotesList(context)),
CupertinoListTile(title: const Text('Folder Two'), onTap: () => _navigateToNotesList(context)),
],
)
])
],
),
);
}
}
class NotesList extends StatelessWidget {
const NotesList({super.key});
_navigateToNoteDetail(BuildContext context) {
Navigator.of(context).push(CupertinoPageRoute(builder: (context) => const NoteDetail()));
}
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
backgroundColor: CupertinoColors.systemGroupedBackground,
child: CustomScrollView(
slivers: [
const CupertinoSliverNavigationBar(largeTitle: Text('Notes List')),
SliverList.list(
children: [
CupertinoListSection.insetGrouped(
children: List.generate(30, (index) {
return CupertinoListTile(title: Text('Note ${index + 1}'), onTap: () => _navigateToNoteDetail(context));
}),
)
],
)
],
),
);
}
}
class NoteDetail extends StatelessWidget {
const NoteDetail({super.key});
@override
Widget build(BuildContext context) {
return const CupertinoPageScaffold(
backgroundColor: CupertinoColors.systemGroupedBackground,
child: CustomScrollView(
slivers: [CupertinoSliverNavigationBar(largeTitle: Text('Note Detail')), SliverToBoxAdapter(child: Center(child: Text('Note data')))],
),
);
}
}And that’s it, Congratulations on just creating a flutter app with UI specific to each platform and where users feel right at home when using the app.
You can check out the GitHub repository for the complete code.
Thanks for reading this article ❤️
If I got something wrong? Let me know in the comments. I would love to improve.
Clap 👏 If this article helps you.
