Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dark mode elevation overlay color is only applied to Material of type canvas, when surface and background colors are equal #90353

Open
rydmike opened this issue Sep 19, 2021 · 4 comments
Labels
c: new feature Nothing broken; request for a new capability c: proposal A detailed proposal for a change to Flutter f: material design flutter/packages/flutter/material repository. found in release: 2.5 Found to occur in 2.5 found in release: 2.6 Found to occur in 2.6 framework flutter/packages/flutter repository. See also f: labels. has reproducible steps The issue has been confirmed reproducible and is ready to work on team-design Owned by Design Languages team triaged-design Triaged by Design Languages team

Comments

@rydmike
Copy link
Contributor

rydmike commented Sep 19, 2021

Description

In dark mode, when using themes that specify applyElevationOverlayColor: true with themes that specify slightly different background and surface colors, the Material dark mode overlay color only gets applied correctly to Material of type card, it is not applied to Material of type canvas.

If we are using the same themed color for background and surface in a dark theme, then elevation overlay color is applied to both Material of type canvas and of type card. This behavior is inconsistent and not expected.

Expected behavior would be to get the elevation overlay color also on Material of of type canvas, even if its themed color differs slighlt from surface color.

This issue applies to

All Flutter channels and all platforms.

Steps to Reproduce

In dark mode, when using theme's that specify applyElevationOverlayColor: true themes with default background and surface colors get elevation overlay color applied correctly, as shown here:

OK - Default or Equal background & surface colors => Elevation overlay is OK

OK - Default background and surface - Example code
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

// Light theme mode colors.
const Color kLightPrimary = Color(0xFF0A496A);
const Color kLightPrimaryVariant = Color(0xFF2A9D8F);
const Color kLightSecondary = Color(0xFFF86541);
const Color kLightSecondaryVariant = Color(0xFFF07E24);

// Dark theme mode colors.
const Color kDarkPrimary = Color(0xFF62AEDC);
const Color kDarkPrimaryVariant = Color(0xFF50A399);
const Color kDarkSecondary = Color(0xFFF06E4B);
const Color kDarkSecondaryVariant = Color(0xFFD57534);

class MyApp extends StatefulWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  ThemeMode themeMode = ThemeMode.dark;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter Demo',
      theme: ThemeData.from(
        colorScheme: const ColorScheme.light(
          primary: kLightPrimary,
          primaryVariant: kLightPrimaryVariant,
          secondary: kLightSecondary,
          secondaryVariant: kLightSecondaryVariant,
        ),
      ).copyWith(
        scaffoldBackgroundColor:
            Color.alphaBlend(kLightPrimary.withAlpha(0x05), Colors.white),
      ),
      darkTheme: ThemeData.from(
          colorScheme: const ColorScheme.dark(
        primary: kDarkPrimary,
        primaryVariant: kDarkPrimaryVariant,
        secondary: kDarkSecondary,
        secondaryVariant: kDarkSecondaryVariant,
      )).copyWith(
        scaffoldBackgroundColor: Color.alphaBlend(
            kDarkPrimary.withAlpha(0x05), const Color(0xff121212)),
      ),
      themeMode: themeMode,
      home: MyHomePage(
        title: 'Flutter Demo Home Page',
        themeMode: themeMode,
        onThemeModeChanged: (ThemeMode mode) {
          setState(() {
            themeMode = mode;
          });
        },
      ),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({
    Key? key,
    required this.title,
    required this.themeMode,
    required this.onThemeModeChanged,
  }) : super(key: key);

  final String title;
  final ThemeMode themeMode;
  final ValueChanged<ThemeMode> onThemeModeChanged;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: ListView(
        padding: const EdgeInsets.all(32),
        children: <Widget>[
          const Text('Toggle theme'),
          ThemeModeSwitch(
            themeMode: themeMode,
            onChanged: onThemeModeChanged,
          ),
          const Divider(),
          const ShowThemeColors(),
          const Divider(),
          const Material(
            type: MaterialType.canvas,
            elevation: 0,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Material type canvas, elevation 0'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Material(
            type: MaterialType.canvas,
            elevation: 1,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Material type canvas, elevation 1'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Material(
            type: MaterialType.canvas,
            elevation: 4,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Material type canvas, elevation 4'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Material(
            type: MaterialType.canvas,
            elevation: 8,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Material type canvas, elevation 8'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Divider(),
          const Material(
            elevation: 0,
            type: MaterialType.card,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Material type card, elevation 0'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Material(
            elevation: 1,
            type: MaterialType.card,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Material type card, elevation 1'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Material(
            elevation: 4,
            type: MaterialType.card,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Material type card, elevation 4'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Material(
            elevation: 8,
            type: MaterialType.card,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Material type card, elevation 8'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Divider(),
          const Card(
            elevation: 0,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Card widget, elevation 0'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Card(
            elevation: 1,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Card widget, elevation 1'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Card(
            elevation: 4,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Card widget, elevation 4'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Card(
            elevation: 8,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Card widget, elevation 8'),
              ),
            ),
          ),
          const Divider(),
        ],
      ),
    );
  }
}

/// Widget used to toggle the theme style of the application.
@immutable
class ThemeModeSwitch extends StatelessWidget {
  const ThemeModeSwitch({
    Key? key,
    required this.themeMode,
    required this.onChanged,
  }) : super(key: key);
  final ThemeMode themeMode;
  final ValueChanged<ThemeMode> onChanged;

  @override
  Widget build(BuildContext context) {
    final List<bool> isSelected = <bool>[
      themeMode == ThemeMode.light,
      themeMode == ThemeMode.system,
      themeMode == ThemeMode.dark,
    ];
    return ToggleButtons(
      isSelected: isSelected,
      onPressed: (int newIndex) {
        if (newIndex == 0) {
          onChanged(ThemeMode.light);
        } else if (newIndex == 1) {
          onChanged(ThemeMode.system);
        } else {
          onChanged(ThemeMode.dark);
        }
      },
      children: const <Widget>[
        Icon(Icons.wb_sunny),
        Icon(Icons.phone_iphone),
        Icon(Icons.bedtime),
      ],
    );
  }
}

// Draw a number of boxes showing the colors of key theme color properties
// in the ColorScheme of the inherited ThemeData and some of its key color
// properties.
// This widget is just used so we can visually see the active theme colors
// in the examples and their used FlexColorScheme based themes.
class ShowThemeColors extends StatelessWidget {
  const ShowThemeColors({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final ThemeData theme = Theme.of(context);
    final ColorScheme colorScheme = theme.colorScheme;
    final Color appBarColor =
        theme.appBarTheme.backgroundColor ?? theme.primaryColor;

    // A Wrap widget is just the right handy widget for this type of
    // widget to make it responsive.
    return Wrap(
      spacing: 4,
      runSpacing: 4,
      crossAxisAlignment: WrapCrossAlignment.center,
      children: <Widget>[
        ThemeCard(
          label: 'Primary',
          color: colorScheme.primary,
          textColor: colorScheme.onPrimary,
        ),
        ThemeCard(
          label: 'Primary\nColor',
          color: theme.primaryColor,
          textColor: theme.primaryTextTheme.subtitle1!.color ?? Colors.white,
        ),
        ThemeCard(
          label: 'Primary\nColorDark',
          color: theme.primaryColorDark,
          textColor:
              ThemeData.estimateBrightnessForColor(theme.primaryColorDark) ==
                      Brightness.dark
                  ? Colors.white
                  : Colors.black,
        ),
        ThemeCard(
          label: 'Primary\nColorLight',
          color: theme.primaryColorLight,
          textColor:
              ThemeData.estimateBrightnessForColor(theme.primaryColorLight) ==
                      Brightness.dark
                  ? Colors.white
                  : Colors.black,
        ),
        ThemeCard(
          label: 'Secondary\nHeader',
          color: theme.secondaryHeaderColor,
          textColor: ThemeData.estimateBrightnessForColor(
                      theme.secondaryHeaderColor) ==
                  Brightness.dark
              ? Colors.white
              : Colors.black,
        ),
        ThemeCard(
          label: 'Primary\nVariant',
          color: colorScheme.primaryVariant,
          textColor: ThemeData.estimateBrightnessForColor(
                      colorScheme.primaryVariant) ==
                  Brightness.dark
              ? Colors.white
              : Colors.black,
        ),
        ThemeCard(
          label: 'Secondary',
          color: colorScheme.secondary,
          textColor: colorScheme.onSecondary,
        ),
        ThemeCard(
          label: 'Toggleable\nActive',
          color: theme.toggleableActiveColor,
          textColor: ThemeData.estimateBrightnessForColor(
                      theme.toggleableActiveColor) ==
                  Brightness.dark
              ? Colors.white
              : Colors.black,
        ),
        ThemeCard(
          label: 'Secondary\nVariant',
          color: colorScheme.secondaryVariant,
          textColor: ThemeData.estimateBrightnessForColor(
                      colorScheme.secondaryVariant) ==
                  Brightness.dark
              ? Colors.white
              : Colors.black,
        ),
        ThemeCard(
          label: 'AppBar',
          color: appBarColor,
          textColor: ThemeData.estimateBrightnessForColor(appBarColor) ==
                  Brightness.dark
              ? Colors.white
              : Colors.black,
        ),
        ThemeCard(
          label: 'Bottom\nAppBar',
          color: theme.bottomAppBarColor,
          textColor:
              ThemeData.estimateBrightnessForColor(theme.bottomAppBarColor) ==
                      Brightness.dark
                  ? Colors.white
                  : Colors.black,
        ),
        ThemeCard(
          label: 'Divider',
          color: theme.dividerColor,
          textColor: colorScheme.onBackground,
        ),
        ThemeCard(
          label: 'Background',
          color: colorScheme.background,
          textColor: colorScheme.onBackground,
        ),
        ThemeCard(
          label: 'Canvas',
          color: theme.canvasColor,
          textColor: colorScheme.onBackground,
        ),
        ThemeCard(
          label: 'Surface',
          color: colorScheme.surface,
          textColor: colorScheme.onSurface,
        ),
        ThemeCard(
          label: 'Card',
          color: theme.cardColor,
          textColor: colorScheme.onBackground,
        ),
        ThemeCard(
          label: 'Dialog',
          color: theme.dialogBackgroundColor,
          textColor: colorScheme.onBackground,
        ),
        ThemeCard(
          label: 'Scaffold\nbackground',
          color: theme.scaffoldBackgroundColor,
          textColor: colorScheme.onBackground,
        ),
        ThemeCard(
          label: 'Error',
          color: colorScheme.error,
          textColor: colorScheme.onError,
        ),
      ],
    );
  }
}

// This is just simple SizedBox with a Card with a passed in label, background
// and text label color. Used to show the colors of a theme color property.
class ThemeCard extends StatelessWidget {
  const ThemeCard({
    Key? key,
    required this.label,
    required this.color,
    required this.textColor,
  }) : super(key: key);

  final String label;
  final Color color;
  final Color textColor;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 50,
      width: 85,
      child: Card(
        margin: EdgeInsets.zero,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(4),
          side: BorderSide(
            color: Theme.of(context).dividerColor,
          ),
        ),
        elevation: 0,
        color: color,
        child: Center(
          child: Text(
            label,
            style: TextStyle(color: textColor, fontSize: 12),
            textAlign: TextAlign.center,
          ),
        ),
      ),
    );
  }
}

Issue: Using surface and background colors that are not equal

If we define colors for background and surface in dark theme mode that are different from each other, we no longer get any elevation color overlay on Material using the background color, that Material of type canvas uses for its color. We still do get it on Material using the surface colored material, which Material of type card uses.

This is inconsistent with the default built in background colors behavior and also not consistent with expected behavior when using Material type canvas with elevation > 0.

Typically one gets into to this situation when using background and surface colors that for example blend in a bit of primary color into the surfaces, but do so at different strength for background and surface, for more a nuanced primary color branded designed.

Fail example

In this example we create such a theme to demonstrate the issue. With this example we can see that we no longer get any elevation color applied to Material of type canvas.

FAIL - No background overlay elevation color - Example code
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

// Light theme mode colors.
const Color kLightPrimary = Color(0xFF0A496A);
const Color kLightPrimaryVariant = Color(0xFF2A9D8F);
const Color kLightSecondary = Color(0xFFF86541);
const Color kLightSecondaryVariant = Color(0xFFF07E24);

// Dark theme mode colors.
const Color kDarkPrimary = Color(0xFF62AEDC);
const Color kDarkPrimaryVariant = Color(0xFF50A399);
const Color kDarkSecondary = Color(0xFFF06E4B);
const Color kDarkSecondaryVariant = Color(0xFFD57534);

class MyApp extends StatefulWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  ThemeMode themeMode = ThemeMode.dark;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter Demo',
      theme: ThemeData.from(
        colorScheme: ColorScheme.light(
          primary: kLightPrimary,
          primaryVariant: kLightPrimaryVariant,
          secondary: kLightSecondary,
          secondaryVariant: kLightSecondaryVariant,
          background:
              Color.alphaBlend(kLightPrimary.withAlpha(0x10), Colors.white),
          surface:
              Color.alphaBlend(kLightPrimary.withAlpha(0x20), Colors.white),
        ),
      ).copyWith(
        scaffoldBackgroundColor:
            Color.alphaBlend(kLightPrimary.withAlpha(0x05), Colors.white),
      ),
      darkTheme: ThemeData.from(
          colorScheme: ColorScheme.dark(
        primary: kDarkPrimary,
        primaryVariant: kDarkPrimaryVariant,
        secondary: kDarkSecondary,
        secondaryVariant: kDarkSecondaryVariant,
        background: Color.alphaBlend(
            kDarkPrimary.withAlpha(0x25), const Color(0xff121212)),
        surface: Color.alphaBlend(
            kDarkPrimary.withAlpha(0x40), const Color(0xff121212)),
      )).copyWith(
        scaffoldBackgroundColor: Color.alphaBlend(
            kDarkPrimary.withAlpha(0x05), const Color(0xff121212)),
      ),
      themeMode: themeMode,
      home: MyHomePage(
        title: 'Flutter Demo Home Page',
        themeMode: themeMode,
        onThemeModeChanged: (ThemeMode mode) {
          setState(() {
            themeMode = mode;
          });
        },
      ),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({
    Key? key,
    required this.title,
    required this.themeMode,
    required this.onThemeModeChanged,
  }) : super(key: key);

  final String title;
  final ThemeMode themeMode;
  final ValueChanged<ThemeMode> onThemeModeChanged;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: ListView(
        padding: const EdgeInsets.all(32),
        children: <Widget>[
          const Text('Toggle theme'),
          ThemeModeSwitch(
            themeMode: themeMode,
            onChanged: onThemeModeChanged,
          ),
          const Divider(),
          const ShowThemeColors(),
          const Divider(),
          const Material(
            type: MaterialType.canvas,
            elevation: 0,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Material type canvas, elevation 0'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Material(
            type: MaterialType.canvas,
            elevation: 1,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Material type canvas, elevation 1'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Material(
            type: MaterialType.canvas,
            elevation: 4,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Material type canvas, elevation 4'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Material(
            type: MaterialType.canvas,
            elevation: 8,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Material type canvas, elevation 8'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Divider(),
          const Material(
            elevation: 0,
            type: MaterialType.card,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Material type card, elevation 0'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Material(
            elevation: 1,
            type: MaterialType.card,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Material type card, elevation 1'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Material(
            elevation: 4,
            type: MaterialType.card,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Material type card, elevation 4'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Material(
            elevation: 8,
            type: MaterialType.card,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Material type card, elevation 8'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Divider(),
          const Card(
            elevation: 0,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Card widget, elevation 0'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Card(
            elevation: 1,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Card widget, elevation 1'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Card(
            elevation: 4,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Card widget, elevation 4'),
              ),
            ),
          ),
          const SizedBox(height: 10),
          const Card(
            elevation: 8,
            child: SizedBox(
              height: 50,
              child: Center(
                child: Text('Card widget, elevation 8'),
              ),
            ),
          ),
          const Divider(),
        ],
      ),
    );
  }
}

/// Widget used to toggle the theme style of the application.
@immutable
class ThemeModeSwitch extends StatelessWidget {
  const ThemeModeSwitch({
    Key? key,
    required this.themeMode,
    required this.onChanged,
  }) : super(key: key);
  final ThemeMode themeMode;
  final ValueChanged<ThemeMode> onChanged;

  @override
  Widget build(BuildContext context) {
    final List<bool> isSelected = <bool>[
      themeMode == ThemeMode.light,
      themeMode == ThemeMode.system,
      themeMode == ThemeMode.dark,
    ];
    return ToggleButtons(
      isSelected: isSelected,
      onPressed: (int newIndex) {
        if (newIndex == 0) {
          onChanged(ThemeMode.light);
        } else if (newIndex == 1) {
          onChanged(ThemeMode.system);
        } else {
          onChanged(ThemeMode.dark);
        }
      },
      children: const <Widget>[
        Icon(Icons.wb_sunny),
        Icon(Icons.phone_iphone),
        Icon(Icons.bedtime),
      ],
    );
  }
}

// Draw a number of boxes showing the colors of key theme color properties
// in the ColorScheme of the inherited ThemeData and some of its key color
// properties.
// This widget is just used so we can visually see the active theme colors
// in the examples and their used FlexColorScheme based themes.
class ShowThemeColors extends StatelessWidget {
  const ShowThemeColors({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final ThemeData theme = Theme.of(context);
    final ColorScheme colorScheme = theme.colorScheme;
    final Color appBarColor =
        theme.appBarTheme.backgroundColor ?? theme.primaryColor;

    // A Wrap widget is just the right handy widget for this type of
    // widget to make it responsive.
    return Wrap(
      spacing: 4,
      runSpacing: 4,
      crossAxisAlignment: WrapCrossAlignment.center,
      children: <Widget>[
        ThemeCard(
          label: 'Primary',
          color: colorScheme.primary,
          textColor: colorScheme.onPrimary,
        ),
        ThemeCard(
          label: 'Primary\nColor',
          color: theme.primaryColor,
          textColor: theme.primaryTextTheme.subtitle1!.color ?? Colors.white,
        ),
        ThemeCard(
          label: 'Primary\nColorDark',
          color: theme.primaryColorDark,
          textColor:
              ThemeData.estimateBrightnessForColor(theme.primaryColorDark) ==
                      Brightness.dark
                  ? Colors.white
                  : Colors.black,
        ),
        ThemeCard(
          label: 'Primary\nColorLight',
          color: theme.primaryColorLight,
          textColor:
              ThemeData.estimateBrightnessForColor(theme.primaryColorLight) ==
                      Brightness.dark
                  ? Colors.white
                  : Colors.black,
        ),
        ThemeCard(
          label: 'Secondary\nHeader',
          color: theme.secondaryHeaderColor,
          textColor: ThemeData.estimateBrightnessForColor(
                      theme.secondaryHeaderColor) ==
                  Brightness.dark
              ? Colors.white
              : Colors.black,
        ),
        ThemeCard(
          label: 'Primary\nVariant',
          color: colorScheme.primaryVariant,
          textColor: ThemeData.estimateBrightnessForColor(
                      colorScheme.primaryVariant) ==
                  Brightness.dark
              ? Colors.white
              : Colors.black,
        ),
        ThemeCard(
          label: 'Secondary',
          color: colorScheme.secondary,
          textColor: colorScheme.onSecondary,
        ),
        ThemeCard(
          label: 'Toggleable\nActive',
          color: theme.toggleableActiveColor,
          textColor: ThemeData.estimateBrightnessForColor(
                      theme.toggleableActiveColor) ==
                  Brightness.dark
              ? Colors.white
              : Colors.black,
        ),
        ThemeCard(
          label: 'Secondary\nVariant',
          color: colorScheme.secondaryVariant,
          textColor: ThemeData.estimateBrightnessForColor(
                      colorScheme.secondaryVariant) ==
                  Brightness.dark
              ? Colors.white
              : Colors.black,
        ),
        ThemeCard(
          label: 'AppBar',
          color: appBarColor,
          textColor: ThemeData.estimateBrightnessForColor(appBarColor) ==
                  Brightness.dark
              ? Colors.white
              : Colors.black,
        ),
        ThemeCard(
          label: 'Bottom\nAppBar',
          color: theme.bottomAppBarColor,
          textColor:
              ThemeData.estimateBrightnessForColor(theme.bottomAppBarColor) ==
                      Brightness.dark
                  ? Colors.white
                  : Colors.black,
        ),
        ThemeCard(
          label: 'Divider',
          color: theme.dividerColor,
          textColor: colorScheme.onBackground,
        ),
        ThemeCard(
          label: 'Background',
          color: colorScheme.background,
          textColor: colorScheme.onBackground,
        ),
        ThemeCard(
          label: 'Canvas',
          color: theme.canvasColor,
          textColor: colorScheme.onBackground,
        ),
        ThemeCard(
          label: 'Surface',
          color: colorScheme.surface,
          textColor: colorScheme.onSurface,
        ),
        ThemeCard(
          label: 'Card',
          color: theme.cardColor,
          textColor: colorScheme.onBackground,
        ),
        ThemeCard(
          label: 'Dialog',
          color: theme.dialogBackgroundColor,
          textColor: colorScheme.onBackground,
        ),
        ThemeCard(
          label: 'Scaffold\nbackground',
          color: theme.scaffoldBackgroundColor,
          textColor: colorScheme.onBackground,
        ),
        ThemeCard(
          label: 'Error',
          color: colorScheme.error,
          textColor: colorScheme.onError,
        ),
      ],
    );
  }
}

// This is just simple SizedBox with a Card with a passed in label, background
// and text label color. Used to show the colors of a theme color property.
class ThemeCard extends StatelessWidget {
  const ThemeCard({
    Key? key,
    required this.label,
    required this.color,
    required this.textColor,
  }) : super(key: key);

  final String label;
  final Color color;
  final Color textColor;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 50,
      width: 85,
      child: Card(
        margin: EdgeInsets.zero,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(4),
          side: BorderSide(
            color: Theme.of(context).dividerColor,
          ),
        ),
        elevation: 0,
        color: color,
        child: Center(
          child: Text(
            label,
            style: TextStyle(color: textColor, fontSize: 12),
            textAlign: TextAlign.center,
          ),
        ),
      ),
    );
  }
}

Here we see that we get a flat uniform color, despite different elevation, with Material of type canvas:

The example is using slightly different color for surface and background in its dark theme:

        background: Color.alphaBlend(
            kDarkPrimary.withAlpha(0x25), const Color(0xff121212)),
        surface: Color.alphaBlend(
            kDarkPrimary.withAlpha(0x40), const Color(0xff121212)),

If we change the background color to use the same color definition as the surface color:

        background: Color.alphaBlend(
            kDarkPrimary.withAlpha(0x40), const Color(0xff121212)),
        surface: Color.alphaBlend(
            kDarkPrimary.withAlpha(0x40), const Color(0xff121212)),

then we get overlay color on them too, as seen here:

We expected overlay color also when background was using the slightly darker color branded background color
Color.alphaBlend(kDarkPrimary.withAlpha(0x25), const Color(0xff121212)), it should actually have looked like this:

Expected result:

NOTE: This example exaggerates the differences one might use in a real design, between the background and surface colors, just to show the issue visually in a more pronounced way.

Cause of issue

The lack of overlay color on the Material of type canvas in the issue case, is caused by the applyOverlay color function.

The applyOverlay function is used by the framework to apply the overlay color if theme specified it should be used, and if we are in dark mode. It also checks if the used color without opacity, is the surface color, and only then applies it.

  static Color applyOverlay(BuildContext context, Color color, double elevation) {
    final ThemeData theme = Theme.of(context);
    if (elevation > 0.0 &&
        theme.applyElevationOverlayColor &&
        theme.brightness == Brightness.dark &&
        color.withOpacity(1.0) == theme.colorScheme.surface.withOpacity(1.0)
    ) {
      return colorWithOverlay(color, theme.colorScheme.onSurface, elevation);
    }
    return color;
  }

However, since Material, when it is of type canvas, is actually using the colorScheme.background as its themed color, it will not get any elevation overlay color if the theme specifies background and surface colors that differs slightly from each other.

This situation typically occurs when making color branded surfaces and backgrounds, and one uses slightly different color branded strengths for these colors. This type of color branding is more common on desktop and web designs, but might find more uses on devices with Material V3 as well.

The current behavior in Flutter prevents us from using more nuanced color branded surfaces, where surface and background colors use slightly different dark color branded strengths, while retaining the nice elevation overlay on Material of type canvas.

The current behavior also creates a situation where sometimes themed Material of type canvas, gets overlay color applied when so defined and as expected, but sometimes it does not. Either it should never get it, or always, when Material of type card gets it as well. Not as now, only "sometimes", ie when the background color happens to be using a themed color, that happens to be equal to themed surface color.

It is worth noticing that if for some reason Material design spec would call for that Material of type canvas should never get overlay color applied in dark mode for elevations, then widgets like Drawer and BottomNavigationBar would not get it either. I don't think that is the intent.

Solution proposal

Consider adding a check to see if we are using the surface or background color to the applyOverlay function:

  static Color applyOverlay(BuildContext context, Color color, double elevation) {
    final ThemeData theme = Theme.of(context);
    if (elevation > 0.0 &&
        theme.applyElevationOverlayColor &&
        theme.brightness == Brightness.dark &&
        (color.withOpacity(1.0) == theme.colorScheme.surface.withOpacity(1.0) ||
         color.withOpacity(1.0) == theme.colorScheme.background.withOpacity(1.0)
        )
    ) {
      return colorWithOverlay(color, theme.colorScheme.onSurface, elevation);
    }
    return color;
  }

The above modification makes the applyOverlay color function work correctly with Material, also when using color branded themes where the themed colors colorScheme.surface and colorScheme.background differ slightly from each other.

The above code change was used to produce the earlier visual presentation of the expected outcome above.

Pull request

I'm willing to submit a pull request with this fix, if this solution is considered acceptable.

Flutter doctor
[√] Flutter (Channel master, 2.6.0-6.0.pre.147, on Microsoft Windows [Version 10.0.19041.1237], locale en-US)
    • Flutter version 2.6.0-6.0.pre.147 at C:\Users\mryds\fvm\versions\master
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 83dfb2237a (2 days ago), 2021-09-17 23:48:04 -0400
    • Engine revision 31792e0340
    • Dart version 2.15.0 (build 2.15.0-120.0.dev)

[√] Android toolchain - develop for Android devices (Android SDK version 30.0.3)
    • Android SDK at C:\Users\mryds\AppData\Local\Android\sdk
    • Platform android-30, build-tools 30.0.3
    • Java binary at: C:\Program Files\Android\Android Studio\jre\bin\java
    • Java version OpenJDK Runtime Environment (build 11.0.10+0-b96-7249189)
    • All Android licenses accepted.

[√] Chrome - develop for the web
    • Chrome at C:\Program Files (x86)\Google\Chrome\Application\chrome.exe

[√] Visual Studio - develop for Windows (Visual Studio Community 2019 16.10.4)
    • Visual Studio at C:\Program Files (x86)\Microsoft Visual Studio\2019\Community
    • Visual Studio Community 2019 version 16.10.31515.178
    • Windows 10 SDK version 10.0.19041.0

[√] Android Studio (version 2020.3)
    • Android Studio at C:\Program Files\Android\Android Studio
    • Flutter plugin can be installed from:
       https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
       https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 11.0.10+0-b96-7249189)

[√] IntelliJ IDEA Community Edition (version 2021.2)
    • IntelliJ at C:\Program Files\JetBrains\IntelliJ IDEA Community Edition 2018.3.1
    • Flutter plugin version 60.1.4
    • Dart plugin version 212.5284.31

[√] VS Code (version 1.60.0)
    • VS Code at C:\Users\mryds\AppData\Local\Programs\Microsoft VS Code
    • Flutter extension version 3.25.0

[√] Connected device (5 available)
    • SM T510 (mobile)                 • R52N906MLAV   • android-arm    • Android 11 (API 30)
    • sdk gphone x86 64 arm64 (mobile) • emulator-5554 • android-x64    • Android 11 (API 30) (emulator)
    • Windows (desktop)                • windows       • windows-x64    • Microsoft Windows [Version 10.0.19041.1237]
    • Chrome (web)                     • chrome        • web-javascript • Google Chrome 93.0.4577.82
    • Edge (web)                       • edge          • web-javascript • Microsoft Edge 93.0.961.52

• No issues found!

@danagbemava-nc danagbemava-nc added the in triage Presently being triaged by the triage team label Sep 20, 2021
@danagbemava-nc
Copy link
Member

Author's observations can be reproduced with samples provided on stable and master.

screenshots

ok sample

ok sample

fail sample

fail sample

flutter doctor -v
[✓] Flutter (Channel master, 2.6.0-6.0.pre.147, on macOS 11.5.1 20G80 darwin-arm, locale en-GB)
    • Flutter version 2.6.0-6.0.pre.147 at /Users/nexus/dev/sdks/flutters
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 83dfb2237a (2 days ago), 2021-09-17 23:48:04 -0400
    • Engine revision 31792e0340
    • Dart version 2.15.0 (build 2.15.0-120.0.dev)

[✓] Android toolchain - develop for Android devices (Android SDK version 31.0.0)
    • Android SDK at /Users/nexus/Library/Android/sdk
    • Platform android-31, build-tools 31.0.0
    • Java binary at: /Applications/Android Studio.app/Contents/jre/Contents/Home/bin/java
    • Java version OpenJDK Runtime Environment (build 11.0.10+0-b96-7249189)
    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS (Xcode 12.5.1)
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • CocoaPods version 1.11.0

[✓] Chrome - develop for the web
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Android Studio (version 2020.3)
    • Android Studio at /Applications/Android Studio.app/Contents
    • Flutter plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 11.0.10+0-b96-7249189)

[✓] VS Code (version 1.60.1)
    • VS Code at /Applications/Visual Studio Code.app/Contents
    • Flutter extension version 3.26.0

[✓] Connected device (3 available)
    • M2007J20CG (mobile) • 5dd3be00 • android-arm64  • Android 11 (API 30)
    • macOS (desktop)     • macos    • darwin-arm64   • macOS 11.5.1 20G80 darwin-arm
    • Chrome (web)        • chrome   • web-javascript • Google Chrome 93.0.4577.82

• No issues found!
[✓] Flutter (Channel stable, 2.5.1, on macOS 11.5.1 20G80 darwin-arm, locale en-GB)
    • Flutter version 2.5.1 at /Users/nexus/dev/sdks/flutter
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision ffb2ecea52 (3 days ago), 2021-09-17 15:26:33 -0400
    • Engine revision b3af521a05
    • Dart version 2.14.2

[✓] Android toolchain - develop for Android devices (Android SDK version 31.0.0)
    • Android SDK at /Users/nexus/Library/Android/sdk
    • Platform android-31, build-tools 31.0.0
    • Java binary at: /Applications/Android Studio.app/Contents/jre/Contents/Home/bin/java
    • Java version OpenJDK Runtime Environment (build 11.0.10+0-b96-7249189)
    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Xcode 12.5.1, Build version 12E507
    • CocoaPods version 1.11.0

[✓] Chrome - develop for the web
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Android Studio (version 2020.3)
    • Android Studio at /Applications/Android Studio.app/Contents
    • Flutter plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 11.0.10+0-b96-7249189)

[✓] VS Code (version 1.60.1)
    • VS Code at /Applications/Visual Studio Code.app/Contents
    • Flutter extension version 3.26.0

[✓] Connected device (3 available)
    • M2007J20CG (mobile) • 5dd3be00 • android-arm64  • Android 11 (API 30)
    • macOS (desktop)     • macos    • darwin-arm64   • macOS 11.5.1 20G80 darwin-arm
    • Chrome (web)        • chrome   • web-javascript • Google Chrome 93.0.4577.82

• No issues found!

@danagbemava-nc danagbemava-nc added f: material design flutter/packages/flutter/material repository. found in release: 2.5 Found to occur in 2.5 found in release: 2.6 Found to occur in 2.6 framework flutter/packages/flutter repository. See also f: labels. has reproducible steps The issue has been confirmed reproducible and is ready to work on and removed in triage Presently being triaged by the triage team labels Sep 20, 2021
@rydmike
Copy link
Contributor Author

rydmike commented Sep 20, 2021

Thanks @danagbemava-nc for the triage.

Now let's see what the Flutter Material design experts thinks.

Personally I think it is just an oversight that the line with the or comparison with the background color:

color.withOpacity(1.0) == theme.colorScheme.background.withOpacity(1.0)

is missing from the applyOverlayColor function here.

It is not a common use cases on mobile devices, maybe a bit with tablets. However with the arrival of Material V3 (You) I think this kind of usage and need will increase on mobile devices too.

This type of usage, and the demonstrated issue, only becomes visible when using color branded surface and background colors that differs from each other slightly. This is currently more commonly used for designs on Web and Desktop. When using it, it is important that the overlay color in dark mode also works when surface and background colors differ from each other slightly, for the right effect on all Widgets using Material of type canvas in Flutter.

I have no idea how the Flutter Material team intends to support the beautiful color branded themes we have seen in Material V3 (You) examples, but this fix will enable more nuanced support for it, using already existing features in Flutter.

@rydmike
Copy link
Contributor Author

rydmike commented Sep 21, 2021

Thanks @danagbemava-nc for the typo corrections in the issue description.

I was looking for some tests for ElevationOverlay.applyOverlay but failed to locate any.

I wrote the passing and failing tests for the ElevationOverlay.applyOverlay function, with respect to how it it would be expected to behave, with light and dark theme mode that uses ThemeData.applyElevationOverlayColor: true by using default ThemeData.from(colorScheme: ColorScheme.light()) for light theme and ThemeData.from(colorScheme: ColorScheme.dark()) for dark theme.

The tests show how ElevationOverlay.applyOverlay correctly never applies elevation in light theme mode, but does so in dark theme mode when there is elevation, and that the elevation overlay color is applied for both colorScheme.background and colorScheme.surface color, as long as they are both using the same color.

If a dark theme is created this way, it means that elevated Material widgets of both type MaterialType.canvas and MaterialType.card will get elevationOverlay applied correctly.

This is because with this theme creation method:

  • MaterialType.canvas uses theme.canvasColor, that is set to is theme.colorScheme.background color as its default color.
  • MaterialType.card uses theme.cardColor, that is set to is theme.colorScheme.surfaceColor as its default color.

and in a default theme, both these ColorScheme colors happens to be equal.

The result is that in dark mode Widgets that use Material of type canvas like Dialogs, Drawer, BottomNavigationBar will get elevationOverlay applied in dark mode, as they should.

The tests that fail in this test suite, are the last Expect in F) and last Expect in G).

In failing part of test F) it is tested what happens when we use ThemeData.from() but have different colors on colorScheme.background and colorScheme.surface.

Currently elevationOverlay will then not be applied to widgets using Material of type canvas. This results in that in dark mode
e.g. Dialogs, Drawer, BottomNavigationBar will now suddenly not get any elevationOverlay color applied in dark mode, even if so requested, but they should. This looks very poor as demonstrated visually above in original issue post.

In failing part of test G) the same situation is demonstrated by using the older default ThemeData() factory and creating the dark theme with:

ThemeData dark = ThemeData(
  brightness: Brightness.dark,
  applyElevationOverlayColor: true,
);

We request applyElevationOverlayColor but still widgets like Dialogs, Drawer, BottomNavigationBar will not get elevationOverlay applied in dark mode, even if requested and even if they should. This is because the ThemeData() factory with Brightness.dark actually creates ThemeData where:

theme.colorScheme.background = Colors.grey[700]
theme.colorScheme.surface = Colors.grey[800]
theme.canvasColor = Colors.grey[850]
theme.cardColor = Colors.grey[800];
theme.backgroundColor = Colors.grey[700];
theme.dialogBackgroundColor = Colors.grey[800];

Creating a situation where very few surfaces that are elevated, will actually get any elevationOverlay color applied even if it was so requested.

It is correct that the Material Design Guide states that the semi-transparent color should use the onSurface color for the overlay color on surfaces. This is fine and will work correctly in dark mode despite difference in actual used dark surface colors. However, nowhere is it stated in the guide that elevated Material surfaces are only represented by the in Flutter called ColorScheme.surface color. Themed Material surfaces in Flutter can have many different colors, most notably:

  • Material of type canvas, that uses theme.canvasColor (that in a ThemeData.from is set to colorScheme.background, but may also use other themed colors in ThemeData() factory)
  • Material of type card that uses theme.cardColor (that in a ThemeData.from is set to colorScheme.surface, but may also use other themed colors in ThemeData() factory)
  • Dialogs uses theme.dialogBackgroundColor (that in a ThemeData.from is set to colorScheme.background, but may also use other themed colors in ThemeData() factory)

These are still all Material surfaces that can be elevated all needs to get elevationOverlay color applied in dark mode, when so requested.

That these elevated Material surfaces in Flutter, refer to their themed surface background colors with some other name than "surface", is an implementation detail in Flutter.

For correct elevationOverlay behavior in dark mode, these surface colors, when used on Material, should all get elevationOverlay color applied when so defined.

  • theme.colorScheme.background
  • theme.colorScheme.surface
  • theme.canvasColor
  • theme.cardColor
  • theme.backgroundColor
  • theme.dialogBackgroundColor

This would ensure correct dark mode elevation behavior when using ThemeData.applyElevationOverlayColor: true and using themes created with ThemeData.from() that may also use slightly different background and surface colors, as well as when creating ThemeData with the factory ThemeData() that may have even more nuanced differences in themed dark surface colors.

Tests for ThemeData and ElevationOverlay.applyOverlay

The tests below includes tests that pass and are OK, as well as two failing tests that should pass.

Considering all the above colors that should also be surface colors on Material, there are more failing test cases that could be written. Modifying the tests to actually use Material and test weather it gets an overlay applied or not, when using different theming methods, would be even better than these tests. These are still a bit simplified widget plus unit tests. I can certainly improve them for a PR with a fix.

These test on purpose do not verify the actual resulting elevationOverlay color, only if we got an overlay color when we should get one.

TESTS: ThemeData and ElevationOverlay.applyOverlay
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  group('Test ElevationOverlay.applyOverlay', () {
    late BuildContext savedContext;
    Widget testApp(
      ThemeMode themeMode,
      ThemeData lightTheme,
      ThemeData darkTheme,
    ) {
      return MaterialApp(
        theme: lightTheme,
        darkTheme: darkTheme,
        themeMode: themeMode,
        home: Builder(
          builder: (BuildContext context) {
            savedContext = context;
            return Container();
          },
        ),
      );
    }

    ThemeData lightTheme(int surfaceBlend, int backgroundBlend) =>
        ThemeData.from(
          colorScheme: ColorScheme.light(
            surface: Color.alphaBlend(
                const ColorScheme.light().primary.withAlpha(surfaceBlend),
                const ColorScheme.light().surface),
            background: Color.alphaBlend(
                const ColorScheme.light().primary.withAlpha(backgroundBlend),
                const ColorScheme.light().background),
          ),
        );

    ThemeData darkTheme(int surfaceBlend, int backgroundBlend) =>
        ThemeData.from(
          colorScheme: ColorScheme.dark(
            surface: Color.alphaBlend(
                const ColorScheme.dark().primary.withAlpha(surfaceBlend),
                const ColorScheme.dark().surface),
            background: Color.alphaBlend(
                const ColorScheme.dark().primary.withAlpha(backgroundBlend),
                const ColorScheme.dark().background),
          ),
        );

    testWidgets(
        'A) Expect no overlay in light ThemeData.from with default background '
        'and surface colors.', (WidgetTester tester) async {
      // Default and equal surface and background colors.
      final ThemeData light = lightTheme(0x00, 0x00);
      final ThemeData dark = darkTheme(0x00, 0x00);

      await tester.pumpWidget(testApp(ThemeMode.light, light, dark));

      final Color surface = Theme.of(savedContext).colorScheme.surface;
      final Color background = Theme.of(savedContext).colorScheme.background;

      // EXPECT: no elevation overlay color for none elevated surface.
      expect(surface,
          equals(ElevationOverlay.applyOverlay(savedContext, surface, 0)));
      // EXPECT: no elevation overlay color for elevated surface.
      expect(surface,
          equals(ElevationOverlay.applyOverlay(savedContext, surface, 1)));

      // EXPECT: no elevation overlay color for none elevated background.
      expect(background,
          equals(ElevationOverlay.applyOverlay(savedContext, background, 0)));
      // EXPECT: no elevation overlay color for elevated background.
      expect(background,
          equals(ElevationOverlay.applyOverlay(savedContext, background, 1)));
    });

    testWidgets(
        'B) Expect no overlay in light ThemeData.from with background '
        'and surface colors using same none default color.',
        (WidgetTester tester) async {
      // None default surface and background colors, but equal.
      final ThemeData light = lightTheme(0x10, 0x10);
      final ThemeData dark = darkTheme(0x10, 0x10);

      await tester.pumpWidget(testApp(ThemeMode.light, light, dark));

      final Color surface = Theme.of(savedContext).colorScheme.surface;
      final Color background = Theme.of(savedContext).colorScheme.background;

      // EXPECT: no elevation overlay color for none elevated surface.
      expect(surface,
          equals(ElevationOverlay.applyOverlay(savedContext, surface, 0)));
      // EXPECT: no elevation overlay color for elevated surface.
      expect(surface,
          equals(ElevationOverlay.applyOverlay(savedContext, surface, 1)));

      // EXPECT: no elevation overlay color for none elevated background.
      expect(background,
          equals(ElevationOverlay.applyOverlay(savedContext, background, 0)));
      // EXPECT: no elevation overlay color for elevated background.
      expect(background,
          equals(ElevationOverlay.applyOverlay(savedContext, background, 1)));
    });

    testWidgets(
        'C) Expect no overlay in light ThemeData.from with background '
        'and surface colors using different none default color.',
        (WidgetTester tester) async {
      // None default and not equal surface and background colors.
      final ThemeData light = lightTheme(0x10, 0x20);
      final ThemeData dark = darkTheme(0x10, 0x20);

      await tester.pumpWidget(testApp(ThemeMode.light, light, dark));

      final Color surface = Theme.of(savedContext).colorScheme.surface;
      final Color background = Theme.of(savedContext).colorScheme.background;

      // EXPECT: no elevation overlay color for none elevated surface.
      expect(surface,
          equals(ElevationOverlay.applyOverlay(savedContext, surface, 0)));
      // EXPECT: no elevation overlay color for elevated surface.
      expect(surface,
          equals(ElevationOverlay.applyOverlay(savedContext, surface, 1)));

      // EXPECT: no elevation overlay color for none elevated background.
      expect(background,
          equals(ElevationOverlay.applyOverlay(savedContext, background, 0)));
      // EXPECT: no elevation overlay color for elevated background.
      expect(background,
          equals(ElevationOverlay.applyOverlay(savedContext, background, 1)));
    });

    testWidgets(
        'D) Expect overlay in dark ThemeData.from with default background '
        'and surface colors, when elevation > 0.', (WidgetTester tester) async {
      // Default and equal surface and background colors.
      final ThemeData light = lightTheme(0x00, 0x00);
      final ThemeData dark = darkTheme(0x00, 0x00);

      await tester.pumpWidget(testApp(ThemeMode.dark, light, dark));

      final Color surface = Theme.of(savedContext).colorScheme.surface;
      final Color background = Theme.of(savedContext).colorScheme.background;

      // EXPECT: no elevation overlay color for none elevated surface.
      expect(surface,
          equals(ElevationOverlay.applyOverlay(savedContext, surface, 0)));
      // EXPECT: elevation overlay color for elevated surface.
      expect(surface,
          isNot(ElevationOverlay.applyOverlay(savedContext, surface, 1)));

      // EXPECT: no elevation overlay color for none elevated background.
      expect(background,
          equals(ElevationOverlay.applyOverlay(savedContext, background, 0)));
      // EXPECT: elevation overlay color for elevated background.
      expect(background,
          isNot(ElevationOverlay.applyOverlay(savedContext, background, 1)));
    });

    testWidgets(
        'E) Expect overlay in dark ThemeData.from with background and surface '
        'colors using same none default color, when elevation > 0.',
        (WidgetTester tester) async {
      // None default surface and background colors, but equal.
      final ThemeData light = lightTheme(0x10, 0x10);
      final ThemeData dark = darkTheme(0x10, 0x10);

      await tester.pumpWidget(testApp(ThemeMode.dark, light, dark));

      final Color surface = Theme.of(savedContext).colorScheme.surface;
      final Color background = Theme.of(savedContext).colorScheme.background;

      // EXPECT: no elevation overlay color for none elevated surface.
      expect(surface,
          equals(ElevationOverlay.applyOverlay(savedContext, surface, 0)));
      // EXPECT: elevation overlay color for elevated surface.
      expect(surface,
          isNot(ElevationOverlay.applyOverlay(savedContext, surface, 1)));

      // EXPECT: no elevation overlay color for none elevated background.
      expect(background,
          equals(ElevationOverlay.applyOverlay(savedContext, background, 0)));
      // EXPECT: elevation overlay color for elevated background.
      expect(background,
          isNot(ElevationOverlay.applyOverlay(savedContext, background, 1)));
    });

    testWidgets(
        'F) Expect overlay in dark ThemeData.from with background and surface '
        'colors using separate and none default color, when elevation > 0.',
        (WidgetTester tester) async {
      // None default and not equal surface and background colors.
      final ThemeData light = lightTheme(0x10, 0x20);
      final ThemeData dark = darkTheme(0x10, 0x20);

      await tester.pumpWidget(testApp(ThemeMode.dark, light, dark));

      final Color surface = Theme.of(savedContext).colorScheme.surface;
      final Color background = Theme.of(savedContext).colorScheme.background;

      // EXPECT: no elevation overlay color for none elevated surface.
      expect(surface,
          equals(ElevationOverlay.applyOverlay(savedContext, surface, 0)));
      // EXPECT: elevation overlay color for elevated surface.
      expect(surface,
          isNot(ElevationOverlay.applyOverlay(savedContext, surface, 1)));

      // EXPECT: no elevation overlay color for none elevated background.
      expect(background,
          equals(ElevationOverlay.applyOverlay(savedContext, background, 0)));
      // EXPECT: elevation overlay color for elevated background.
      expect(background,
          isNot(ElevationOverlay.applyOverlay(savedContext, background, 1)));
    });

    testWidgets(
        'G) Expect overlay in dark ThemeData() on background and surface '
        'colors when using default ThemeData() factory with '
        'applyElevationOverlayColor = true.', (WidgetTester tester) async {
      // Light and dark theme data made using
      // ThemeData(applyElevationOverlayColor: true) factory.
      final ThemeData light = ThemeData(applyElevationOverlayColor: true);
      final ThemeData dark = ThemeData(
        brightness: Brightness.dark,
        applyElevationOverlayColor: true,
      );

      await tester.pumpWidget(testApp(ThemeMode.dark, light, dark));

      final Color surface = Theme.of(savedContext).colorScheme.surface;
      final Color background = Theme.of(savedContext).colorScheme.background;

      // EXPECT: no elevation overlay color for none elevated surface.
      expect(surface,
          equals(ElevationOverlay.applyOverlay(savedContext, surface, 0)));
      // EXPECT: elevation overlay color for elevated surface.
      expect(surface,
          isNot(ElevationOverlay.applyOverlay(savedContext, surface, 1)));

      // EXPECT: no elevation overlay color for none elevated background.
      expect(background,
          equals(ElevationOverlay.applyOverlay(savedContext, background, 0)));
      // EXPECT: elevation overlay color for elevated background.
      expect(background,
          isNot(ElevationOverlay.applyOverlay(savedContext, background, 1)));
    });
  });
}

@danagbemava-nc danagbemava-nc added c: proposal A detailed proposal for a change to Flutter c: new feature Nothing broken; request for a new capability labels Sep 22, 2021
@rydmike
Copy link
Contributor Author

rydmike commented Sep 26, 2021

Summary

Why is the current implementation problematic?

It prevents creation of more nuanced themed dark modes, where Flutter Material using built in Widgets, that use different ThemeData background color properties for their used elevated Material surface, would actually get an elevation overlay color applied when setting ThemeData.applyElevationOverlayColor is true.

For a more correct Material Design based dark mode, when using such nuanced dark themes, the dark surfaces with slightly differing primary color blended surface colors, should also get elevation overlay color applied.

If current built-in Flutter Widgets use a default themed color, that is not equal to colorScheme.surface the elevation overlay color is never applied. Many built in widgets that use Material, actually use some other color property than colorScheme.surface as their themed default background color.

This creates challenges and limitations when using more nuanced dark themes, that use slight variations for different Material background colors in such dark themes. One can still create such themes, but they do not get any elevation overlay applied in dark mode. The only way to get it applied, is if the same color branded blend strength is used on all the different background related ThemeData color properties, that Material using Widgets that support elevation use as their themed background color property.

They must all be set to the same color value, as the one used on colorScheme.surface, creating severe limitations in the nuances that dark mode color branded surfaces themes can use.

A more complete applyOverlay function

A version of the applyOverlay function that correctly considers all background colors, that built in Widgets that use elevated Material may have, when created either with the ThemeData() or ThemeData.from() factory, could look like this:

  static Color applyOverlay(BuildContext context, Color color, double elevation) {
    final ThemeData theme = Theme.of(context);
    final Color opaqueColor = color.withAlpha(255);
    final bool isSurfaceColor =
        opaqueColor == theme.colorScheme.surface.withAlpha(255) ||
        opaqueColor == theme.colorScheme.background.withAlpha(255) ||
        opaqueColor == theme.backgroundColor.withAlpha(255) ||
        opaqueColor == theme.canvasColor.withAlpha(255) ||
        opaqueColor == theme.cardColor.withAlpha(255) ||
        opaqueColor == theme.dialogBackgroundColor.withAlpha(255);

    if (elevation > 0.0 &&
        theme.applyElevationOverlayColor &&
        theme.brightness == Brightness.dark &&
        isSurfaceColor) {
      return colorWithOverlay(color, theme.colorScheme.onSurface, elevation);
    }
    return color;
  }

Using the above function, built-in Widgets that depend on Material will all receive overlay elevation color in dark mode when so requested, even if each property would have a slightly different primary color branded color, using e.g. minor variations in alpha blend strengths into their background surface color.

Next steps

  • I will make better and more complete tests, for passing and failing cases.
  • I will investigate what current tests, if any, using the above function would break.
  • I will wait to see what changes Material 3 (Material You) brings to the table.
  • If breaks are very few or none, and Material 3 does not bring any solution to this, I will submit a PR for review.

Based on visuals and examples seen from Material 3 in Android 12. Material 3 is shown using surfaces and background with alpha blends of primary and variant colors. Thus it might be that Material 3 will bring some changes to this behavior as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
c: new feature Nothing broken; request for a new capability c: proposal A detailed proposal for a change to Flutter f: material design flutter/packages/flutter/material repository. found in release: 2.5 Found to occur in 2.5 found in release: 2.6 Found to occur in 2.6 framework flutter/packages/flutter repository. See also f: labels. has reproducible steps The issue has been confirmed reproducible and is ready to work on team-design Owned by Design Languages team triaged-design Triaged by Design Languages team
Projects
None yet
Development

No branches or pull requests

3 participants