// MIT License
//
// Copyright (c) 2023 Mike Rydstrom
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import 'package:flutter/material.dart';
// A seed color for the M3 ColorScheme.
const Color seedColor = Color(0xFF6750A4);
// Make M3 ColorSchemes from a seed color.
final ColorScheme schemeLight = ColorScheme.fromSeed(
brightness: Brightness.light,
seedColor: seedColor,
);
final ColorScheme schemeDark = ColorScheme.fromSeed(
brightness: Brightness.dark,
seedColor: seedColor,
);
// Example theme
ThemeData demoTheme(Brightness mode, bool useMaterial3) {
return ThemeData(
colorScheme: mode == Brightness.light ? schemeLight : schemeDark,
useMaterial3: useMaterial3,
visualDensity: VisualDensity.standard,
);
}
void main() {
runApp(const IssueDemoApp());
}
class IssueDemoApp extends StatefulWidget {
const IssueDemoApp({super.key});
@override
State<IssueDemoApp> createState() => _IssueDemoAppState();
}
class _IssueDemoAppState extends State<IssueDemoApp> {
bool useMaterial3 = true;
ThemeMode themeMode = ThemeMode.light;
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
themeMode: themeMode,
theme: demoTheme(Brightness.light, useMaterial3),
darkTheme: demoTheme(Brightness.dark, useMaterial3),
home: Scaffold(
drawer: const NavigationDrawerShowcase(),
appBar: AppBar(
title: const Text('NavigationDrawer Width'),
actions: [
IconButton(
icon: useMaterial3
? const Icon(Icons.filter_3)
: const Icon(Icons.filter_2),
onPressed: () {
setState(() {
useMaterial3 = !useMaterial3;
});
},
tooltip: "Switch to Material ${useMaterial3 ? 2 : 3}",
),
IconButton(
icon: themeMode == ThemeMode.dark
? const Icon(Icons.wb_sunny_outlined)
: const Icon(Icons.wb_sunny),
onPressed: () {
setState(() {
if (themeMode == ThemeMode.light) {
themeMode = ThemeMode.dark;
} else {
themeMode = ThemeMode.light;
}
});
},
tooltip: "Toggle brightness",
),
],
),
body: const HomePage(),
),
);
}
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
const SizedBox(height: 8),
Text(
'NavigationDrawer has wrong width in M3',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 16),
const Text(
'ISSUE: NavigationDrawer has wrong width in M3 mode.\n'
'\n'
'EXPECT: NavigationDrawer width to match M3 spec in M3 mode '
'and be 360dp, it is 304dp and using the M2 spec.',
),
const SizedBox(height: 16),
const Padding(
padding: EdgeInsets.all(32.0),
child: DrawerDesktopWrapper(),
),
const SizedBox(height: 16),
const ShowColorSchemeColors(),
],
);
}
}
class DrawerDesktopWrapper extends StatelessWidget {
const DrawerDesktopWrapper({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
MediaQuery.removePadding(
context: context,
removeBottom: true,
removeTop: true,
removeLeft: true,
removeRight: true,
child: const SizedBox(
height: 320,
child: NavigationDrawerShowcase(),
),
),
],
);
}
}
class NavigationDrawerShowcase extends StatefulWidget {
const NavigationDrawerShowcase({
super.key,
});
@override
State<NavigationDrawerShowcase> createState() =>
_NavigationDrawerShowcaseState();
}
class _NavigationDrawerShowcaseState extends State<NavigationDrawerShowcase> {
int selectedIndex = 0;
late final GlobalKey _key = GlobalKey();
RenderBox? renderBox;
_afterLayout(_) {
setState(() {
if (_key.currentContext?.findRenderObject() != null) {
renderBox = _key.currentContext!.findRenderObject() as RenderBox;
}
});
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback(_afterLayout);
}
@override
Widget build(BuildContext context) {
final double width = renderBox?.size.width ?? 0;
return NavigationDrawer(
key: _key,
selectedIndex: selectedIndex,
onDestinationSelected: (int value) {
setState(() {
selectedIndex = value;
});
},
children: <Widget>[
const SizedBox(height: 16),
const NavigationDrawerDestination(
icon: Badge(
label: Text('26'),
child: Icon(Icons.chat_bubble),
),
label: Text('Chat'),
),
const NavigationDrawerDestination(
icon: Icon(Icons.beenhere),
label: Text('Tasks'),
),
const Divider(),
const NavigationDrawerDestination(
icon: Icon(Icons.create_new_folder),
label: Text('Folder'),
),
const NavigationDrawerDestination(
icon: Icon(Icons.logout),
label: Text('Logout'),
),
const SizedBox(height: 16),
Text('Drawer is $width dp wide', textAlign: TextAlign.center),
],
);
}
}
/// Draw a number of boxes showing the colors of key theme color properties
/// in the ColorScheme of the inherited ThemeData and its color properties.
class ShowColorSchemeColors extends StatelessWidget {
const ShowColorSchemeColors({super.key, this.onBackgroundColor});
/// The color of the background the color widget are being drawn on.
///
/// Some of the theme colors may have semi transparent fill color. To compute
/// a legible text color for the sum when it shown on a background color, we
/// need to alpha merge it with background and we need the exact background
/// color it is drawn on for that. If not passed in from parent, it is
/// assumed to be drawn on card color, which usually is close enough.
final Color? onBackgroundColor;
// Return true if the color is light, meaning it needs dark text for contrast.
static bool _isLight(final Color color) =>
ThemeData.estimateBrightnessForColor(color) == Brightness.light;
// On color used when a theme color property does not have a theme onColor.
static Color _onColor(final Color color, final Color bg) =>
_isLight(Color.alphaBlend(color, bg)) ? Colors.black : Colors.white;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme colorScheme = theme.colorScheme;
final bool useMaterial3 = theme.useMaterial3;
const double spacing = 4;
// Grab the card border from the theme card shape
ShapeBorder? border = theme.cardTheme.shape;
// If we had one, copy in a border side to it.
if (border is RoundedRectangleBorder) {
border = border.copyWith(
side: BorderSide(
color: colorScheme.outlineVariant,
width: 1,
),
);
// If
} else {
// If border was null, make one matching Card default, but with border
// side, if it was not null, we leave it as it was.
border ??= RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(useMaterial3 ? 12 : 4)),
side: BorderSide(
color: colorScheme.outlineVariant,
width: 1,
),
);
}
// Get effective background color.
final Color background =
onBackgroundColor ?? theme.cardTheme.color ?? theme.cardColor;
// Wrap this widget branch in a custom theme where card has a border outline
// if it did not have one, but retains its ambient themed border radius.
return Theme(
data: Theme.of(context).copyWith(
cardTheme: CardTheme.of(context).copyWith(
elevation: 0,
surfaceTintColor: Colors.transparent,
shape: border,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'ColorScheme Colors',
style: theme.textTheme.titleMedium,
),
),
Wrap(
alignment: WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: spacing,
runSpacing: spacing,
children: <Widget>[
ColorCard(
label: 'Primary',
color: colorScheme.primary,
textColor: colorScheme.onPrimary,
),
ColorCard(
label: 'on\nPrimary',
color: colorScheme.onPrimary,
textColor: colorScheme.primary,
),
ColorCard(
label: 'Primary\nContainer',
color: colorScheme.primaryContainer,
textColor: colorScheme.onPrimaryContainer,
),
ColorCard(
label: 'onPrimary\nContainer',
color: colorScheme.onPrimaryContainer,
textColor: colorScheme.primaryContainer,
),
ColorCard(
label: 'Secondary',
color: colorScheme.secondary,
textColor: colorScheme.onSecondary,
),
ColorCard(
label: 'on\nSecondary',
color: colorScheme.onSecondary,
textColor: colorScheme.secondary,
),
ColorCard(
label: 'Secondary\nContainer',
color: colorScheme.secondaryContainer,
textColor: colorScheme.onSecondaryContainer,
),
ColorCard(
label: 'on\nSecondary\nContainer',
color: colorScheme.onSecondaryContainer,
textColor: colorScheme.secondaryContainer,
),
ColorCard(
label: 'Tertiary',
color: colorScheme.tertiary,
textColor: colorScheme.onTertiary,
),
ColorCard(
label: 'on\nTertiary',
color: colorScheme.onTertiary,
textColor: colorScheme.tertiary,
),
ColorCard(
label: 'Tertiary\nContainer',
color: colorScheme.tertiaryContainer,
textColor: colorScheme.onTertiaryContainer,
),
ColorCard(
label: 'on\nTertiary\nContainer',
color: colorScheme.onTertiaryContainer,
textColor: colorScheme.tertiaryContainer,
),
ColorCard(
label: 'Error',
color: colorScheme.error,
textColor: colorScheme.onError,
),
ColorCard(
label: 'on\nError',
color: colorScheme.onError,
textColor: colorScheme.error,
),
ColorCard(
label: 'Error\nContainer',
color: colorScheme.errorContainer,
textColor: colorScheme.onErrorContainer,
),
ColorCard(
label: 'onError\nContainer',
color: colorScheme.onErrorContainer,
textColor: colorScheme.errorContainer,
),
ColorCard(
label: 'Background',
color: colorScheme.background,
textColor: colorScheme.onBackground,
),
ColorCard(
label: 'on\nBackground',
color: colorScheme.onBackground,
textColor: colorScheme.background,
),
ColorCard(
label: 'Surface',
color: colorScheme.surface,
textColor: colorScheme.onSurface,
),
ColorCard(
label: 'on\nSurface',
color: colorScheme.onSurface,
textColor: colorScheme.surface,
),
ColorCard(
label: 'Surface\nVariant',
color: colorScheme.surfaceVariant,
textColor: colorScheme.onSurfaceVariant,
),
ColorCard(
label: 'onSurface\nVariant',
color: colorScheme.onSurfaceVariant,
textColor: colorScheme.surfaceVariant,
),
ColorCard(
label: 'Outline',
color: colorScheme.outline,
textColor: colorScheme.background,
),
ColorCard(
label: 'Outline\nVariant',
color: colorScheme.outlineVariant,
textColor: colorScheme.onBackground,
),
ColorCard(
label: 'Shadow',
color: colorScheme.shadow,
textColor: _onColor(colorScheme.shadow, background),
),
ColorCard(
label: 'Scrim',
color: colorScheme.scrim,
textColor: _onColor(colorScheme.scrim, background),
),
ColorCard(
label: 'Inverse\nSurface',
color: colorScheme.inverseSurface,
textColor: colorScheme.onInverseSurface,
),
ColorCard(
label: 'onInverse\nSurface',
color: colorScheme.onInverseSurface,
textColor: colorScheme.inverseSurface,
),
ColorCard(
label: 'Inverse\nPrimary',
color: colorScheme.inversePrimary,
textColor: colorScheme.inverseSurface,
),
ColorCard(
label: 'Surface\nTint',
color: colorScheme.surfaceTint,
textColor: colorScheme.onPrimary,
),
],
),
],
),
);
}
}
/// A [SizedBox] with a [Card] and string text in it. Used in this demo to
/// display theme color boxes.
///
/// Can specify label text color and background color.
class ColorCard extends StatelessWidget {
const ColorCard({
super.key,
required this.label,
required this.color,
required this.textColor,
this.size,
});
final String label;
final Color color;
final Color textColor;
final Size? size;
@override
Widget build(BuildContext context) {
const double fontSize = 11;
const Size effectiveSize = Size(86, 58);
return SizedBox(
width: effectiveSize.width,
height: effectiveSize.height,
child: Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
color: color,
child: Center(
child: Text(
label,
style: TextStyle(color: textColor, fontSize: fontSize),
textAlign: TextAlign.center,
),
),
),
);
}
}
NavigationDrawer has wrong width
The
NavigationDrawerdrawer width does not follow Material-3 specification.Reference: Material-3 Navigation Drawer specification https://m3.material.io/components/navigation-drawer/specs
Expected Results
Expected default
NavigationDrawerto follow the Material-3 specification in Material 3 mode, and be 360 dp wide.Actual Results
Cause
In Flutter the
NavigationDrawergets its width fromDrawer, one of its building blocks, that if not specified uses a const value from file drawer.dart that has value 304:Modifying the NavigationDrawer Width
Users can currently only modify the width of the
NavigationDrawerby specifying aDrawerThemeDatawidth different width, that then gets used byDrawerthatNavigationDraweris using.There is no widget property in
NavigationDrawer, that would be passed toDrawerit uses, or any property in themeNavigationDrawerThemeDatato control the width. There is also no token used from the Material-3 token database that would generate any default of width of 360 dp for theNavigationDrawer.Maybe consider adding a
drawerWidthtoNavigationDrawerandNavigationDrawerThemeDatato make it more obvious how to customize the width of theNavigationDrawer.Issue Sample Code
Code sample
Used Flutter Version
Channel master, 3.9.0-16.0.pre.42
Flutter doctor
I could stop here and say it is in the Material-3 specification, it should be used, but that would not be very me 😄.
I would like to take this opportunity to discuss the Material-3 specification default value for the
NavigationDrawerand how it looks when used on different sized phones.Ping @rodydavis and anybody else on the Material design team. Below my thoughts, input and a discussion of some design considerations, when it comes to the Drawer's width.
Drawer Width Design Considerations
The
NavigationDraweris primarily intended to be used on phone sized devices and should look good and work well on phones, typically when used in vertical orientation.Flutter has up until now defaulted to using above fixed width of 304 dp on its
Drawerimplementation.Was this the Material-2 design specification? No it actually was not, it is only a default that Flutter implementation has used that fit well on most phones. The Flutter framework never actually implemented the correct Material-2 drawer width specification.
We can still find in the available Material-2 specification, that the width should be, width of the phone in vertical-mode minus 56 dp.
Reference: Material-2 Drawer specification https://m2.material.io/components/navigation-drawer#specs
Additionally, in Flutter code comments we can find this extra information:
Mobile:
Desktop/Tablet:
Why bring up this? Because the device variable
Drawerwidth as specified in Material-2 design makes a lot of sense. It creates a design that is both visually much more appealing and also more usable than the current Material-3 specification using fixed value of 360 dp. It varies with slight phone width variations, leaves a minimum constant gap at the end side, making it easier to fling the drawer back with your thumb or tap background to dismiss it.The best way to demonstrate these design differences, and why the actual past Material-2 design specification, results in a better design than both Flutter's past/current 304 dp fixed size, and the new Material-3 360 dp fixed size, is to show them visually on different sized phones.
A "Brief" Drawer Width Design Study
Below a comparison of what a Drawer looks like on phones of different sizes when you open it, and then toggle the theme between Material-2 and Material-3.
For demonstration convenience, this uses Themes Playground v7 final beta (7.0.0-dev.3) a companion app to FlexColorScheme, using a custom Material-3 theme that in this beta defaults the Drawer to the Material-3 360dp specification in Material-3 mode, and to current Flutter default 304 dp in Material-2 mode.
We can use the Themes Playground mock device simulator to show the Drawer on devices of commonly used sizes. We can also use the Themes Playground, to quickly visually compare what a Drawer using the new Material-3 spec value of 360dp looks like, compared to what it would like, if it would use actual Material-2 spec width, on a device of that size, while otherwise using a Material-3 designed Drawer with elevation tint and rounded edges.
Small Device Width (iPhone SE)
On a small device we can see that using the M3 width 360 dp, almost entirely covers the width of the small phone. The Flutter fixed width of 304 dp actually works better on a small phone.
Screen.Recording.2023-03-24.at.2.04.13.mov
Comparing 360dp fixed width to 56dp edge space on iPhone SE
Using the variable width with 56 dp space on the edge, looks and works best of the options on a small phone, shown below.
Very Small Device Width - Width 360 dp or Lower (Galaxy S20)
There are still many phones around that have only a device pixel width of 360 dp, rarely less though. On such small devices we can see that using the M3 width 360 dp, covers the width of the small phone entirely. This does not look very good, and also UX suffers. The only default way to close the Drawer is now to fling or swipe it back, there is no visible background area that can be tapped to dismiss it.
The past Flutter fixed width of 304 dp actually works better on a narrow phone. This is demonstrated by using a Samsung Galaxy S20 device, that here represents a small width, 360 dp wide phone.
Screen.Recording.2023-03-24.at.2.08.40.mov
Comparing 360dp fixed width to 56dp edge space on Galaxy S20
Below we can again we can show that using the variable width with 56dp space on the edge, looks and works best of the options on narrow phones. There is, of course a risk that the width of the Drawer gets too narrow to fit needed content on very narrow phones. However, the 360 dp width pretty much represents the smallest width one might still run into today on commonly used phones.
Medium Device Width (iPhone 13)
On a medium width device we can see that using the M3 width 360 dp, while not so close to the edge anymore, it is still a bit uncomfortably close. The Flutter fixed width of 304 dp is however now looking too narrow.
Video-iPhone13.mov
Comparing 360dp fixed width to 56dp edge space on iPhone13
Using the variable width with 56dp space on the edge, looks and works best of the options on medium sized phone, this is shown below.
Medium+ Device Width and Very Tall (Sony Experia 1 II)
On a medium+ width device, that is a bit wider device pixel wise than an iPhone 13, but a lot taller, we can see that using the M3 width 360 dp is looking mostly OK, maybe the shown background is a bit narrow, but not so bad. The Flutter fixed width of 304 dp is however now looking much too narrow.
Video-Android-Sony-Experia.mov
Comparing 360dp fixed width to 56dp edge space on Sony Experia 1 II
Using the variable width with 56 dp space on the edge, looks and works best of the options here too. In this case the difference is only 5 dp to the M3 spec default, so the M3 spec default is also a reasonably good fit.
Large Device Width (iPhone 13 Pro Max)
On a large width device we can see that using the M3 width 360 dp looks and works fine. The Flutter fixed width of 304 dp is obviously now looking way too narrow and not well sized to such a wide device.
Video-iPhone13ProMax.mov
Comparing 360dp fixed width to 56dp edge space on Phone 13 Pro Max
Using the variable width with 56 dp space on the edge, works well too, but so does the M3 spec default of 360 dp.
Specification Considerations
The comment in the Flutter source code about how Material-2 Drawer spec should use an edge gap of 56 dp, matches the existing web published Material-2 spec. I could however not find any mention of the in the Flutter source comment, that it should also be constrained to max width 320 dp.
A maximum constraint on the Drawer width does however make sense. The Material 3 design width 360 dp and now generally wider phones, makes the 360 dp value a good fit for that constraint. However, without leaving what still seems like an appropriate minimum edge gap of 56 dp, a 360 dp fixed width Drawer is not a design that fits well on many phones.
Only very few phone devices have a viewport that is so wide that they would get a maximum width constrained 360 dp wide Drawer, and still have an edge gap that is 56 dp or larger. Some such devices are e.g. iPhone 13/14 Pro Max, with their massive 428 dp wide viewport. Above this would be represented by the "Using M3 360dp Width" case. This looks totally fine too, instead of the right side 56 dp space.
All other phones in the above examples, would get the 56dp edge result and the Drawer would be more narrow than its max constraint of 360 dp.
Conclusion: A 360 dp Fixed Width Drawer Is Too Wide
No other phones in the used examples are as wide as the iPhone 13/14 Pro Max. I searched and did not find many on the market that have a width viewport that is as wide. For example, Pixel 6/7 Pro, and Samsung Ultra S20...S23 have a 412 dp wide viewport. Their Drawer would still only become 356 dp wide. A 360 dp wide Drawer could even be considered too wide for these flagship Android phones.
The only phones that a 360 dp fixed width wide Drawer fits comfortable on are, iPhone 14 Plus, 12/13/14 Pro Max. While certainly a popular range of phones, they are not a good representation of the global market.
The Material 3 design specification does however not mention anywhere that 360 dp should be used as the max width constraint on phones, and that at least a 56 dp edge gap should be left. It does in fact specify 360 dp as the default fixed width on a navigation Drawer
Recommendation
My suggestion is that the fixed M3 spec drawer width of 360 dp should be reconsidered, maybe using some of the findings and suggestions above.
I recommend using two properties to control the width of the
NavigationDrawerthat would be available as widget and theme properties:maxWidth, a constraint describing how wide theNavigationDraweris allowed to become at its maximum width. This could default to e.g. 360 dp as show above.edgeSpace, the minimum space always left uncovered on the open side. This could default to 56 dp, which seems to be a suitable value from M2 experience already.Advice to devs that want to use M3
Don't use the current M3 specified 360 dp width on navigation drawers. You still have complete control control over the width of the
NavigationDrawerin Flutter, albeit at the moment only a bit awkwardly via theDrawerTheme. Use a width that fits with your content and design.