Support for elevation based dark theme overlay color in the Material widget (#35560)
Added support for a semi-transparent white overlay color for `Material` widgets to indicate their elevation in a dart theme. A new `ThemeData.applyElevationOverlayColor` flag was added to control this behavior, which is off by default for backwards compatibility reasons.
diff --git a/packages/flutter/lib/src/material/material.dart b/packages/flutter/lib/src/material/material.dart
index 2b86746..293d1fc 100644
--- a/packages/flutter/lib/src/material/material.dart
+++ b/packages/flutter/lib/src/material/material.dart
@@ -1,11 +1,13 @@
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
+import 'colors.dart';
import 'constants.dart';
import 'theme.dart';
@@ -200,15 +202,23 @@
/// {@template flutter.material.material.elevation}
/// The z-coordinate at which to place this material relative to its parent.
///
- /// This controls the size of the shadow below the material.
+ /// This controls the size of the shadow below the material and the opacity
+ /// of the elevation overlay color if it is applied.
///
/// If this is non-zero, the contents of the material are clipped, because the
/// widget conceptually defines an independent printed piece of material.
///
- /// Defaults to 0. Changing this value will cause the shadow to animate over
- /// [animationDuration].
+ /// Defaults to 0. Changing this value will cause the shadow and the elevation
+ /// overlay to animate over [animationDuration].
///
/// The value is non-negative.
+ ///
+ /// See also:
+ ///
+ /// * [ThemeData.applyElevationOverlayColor] which controls the whether
+ /// an overlay color will be applied to indicate elevation.
+ /// * [color] which may have an elevation overlay applied.
+ ///
/// {@endtemplate}
final double elevation;
@@ -217,6 +227,11 @@
/// Must be opaque. To create a transparent piece of material, use
/// [MaterialType.transparency].
///
+ /// To support dark themes, if the surrounding
+ /// [ThemeData.applyElevationOverlayColor] is [true] and
+ /// this color is [ThemeData.colorScheme.surface] then a semi-transparent
+ /// white will be composited on top this color to indicate the elevation.
+ ///
/// By default, the color is derived from the [type] of material.
final Color color;
@@ -252,7 +267,7 @@
final Clip clipBehavior;
/// Defines the duration of animated changes for [shape], [elevation],
- /// and [shadowColor].
+ /// [shadowColor] and the elevation overlay if it is applied.
///
/// The default value is [kThemeChangeDuration].
final Duration animationDuration;
@@ -301,20 +316,43 @@
static const double defaultSplashRadius = 35.0;
}
+// Apply a semi-transparent white on surface colors to
+// indicate the level of elevation.
+Color _elevationOverlayColor(BuildContext context, Color background, double elevation) {
+ final ThemeData theme = Theme.of(context);
+ if (elevation > 0.0 &&
+ theme.applyElevationOverlayColor &&
+ background == theme.colorScheme.surface) {
+
+ // Compute the opacity for the given elevation
+ // This formula matches the values in the spec:
+ // https://material.io/design/color/dark-theme.html#properties
+ final double opacity = (4.5 * math.log(elevation + 1) + 2) / 100.0;
+ final Color overlay = Colors.white.withOpacity(opacity);
+ return Color.alphaBlend(overlay, background);
+ }
+ return background;
+}
+
class _MaterialState extends State<Material> with TickerProviderStateMixin {
final GlobalKey _inkFeatureRenderer = GlobalKey(debugLabel: 'ink renderer');
Color _getBackgroundColor(BuildContext context) {
- if (widget.color != null)
- return widget.color;
- switch (widget.type) {
- case MaterialType.canvas:
- return Theme.of(context).canvasColor;
- case MaterialType.card:
- return Theme.of(context).cardColor;
- default:
- return null;
+ final ThemeData theme = Theme.of(context);
+ Color color = widget.color;
+ if (color == null) {
+ switch (widget.type) {
+ case MaterialType.canvas:
+ color = theme.canvasColor;
+ break;
+ case MaterialType.card:
+ color = theme.cardColor;
+ break;
+ default:
+ break;
+ }
}
+ return color;
}
@override
@@ -366,7 +404,7 @@
clipBehavior: widget.clipBehavior,
borderRadius: BorderRadius.zero,
elevation: widget.elevation,
- color: backgroundColor,
+ color: _elevationOverlayColor(context, backgroundColor, widget.elevation),
shadowColor: widget.shadowColor,
animateColor: false,
child: contents,
@@ -711,6 +749,7 @@
@override
Widget build(BuildContext context) {
final ShapeBorder shape = _border.evaluate(animation);
+ final double elevation = _elevation.evaluate(animation);
return PhysicalShape(
child: _ShapeBorderPaint(
child: widget.child,
@@ -722,8 +761,8 @@
textDirection: Directionality.of(context),
),
clipBehavior: widget.clipBehavior,
- elevation: _elevation.evaluate(animation),
- color: widget.color,
+ elevation: elevation,
+ color: _elevationOverlayColor(context, widget.color, elevation),
shadowColor: _shadowColor.evaluate(animation),
);
}
diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart
index 53dc558..e0c1dd6 100644
--- a/packages/flutter/lib/src/material/theme_data.dart
+++ b/packages/flutter/lib/src/material/theme_data.dart
@@ -159,6 +159,7 @@
ChipThemeData chipTheme,
TargetPlatform platform,
MaterialTapTargetSize materialTapTargetSize,
+ bool applyElevationOverlayColor,
PageTransitionsTheme pageTransitionsTheme,
AppBarTheme appBarTheme,
BottomAppBarTheme bottomAppBarTheme,
@@ -228,6 +229,7 @@
final TextTheme defaultAccentTextTheme = accentIsDark ? typography.white : typography.black;
accentTextTheme = defaultAccentTextTheme.merge(accentTextTheme);
materialTapTargetSize ??= MaterialTapTargetSize.padded;
+ applyElevationOverlayColor ??= false;
if (fontFamily != null) {
textTheme = textTheme.apply(fontFamily: fontFamily);
primaryTextTheme = primaryTextTheme.apply(fontFamily: fontFamily);
@@ -315,6 +317,7 @@
chipTheme: chipTheme,
platform: platform,
materialTapTargetSize: materialTapTargetSize,
+ applyElevationOverlayColor: applyElevationOverlayColor,
pageTransitionsTheme: pageTransitionsTheme,
appBarTheme: appBarTheme,
bottomAppBarTheme: bottomAppBarTheme,
@@ -384,6 +387,7 @@
@required this.chipTheme,
@required this.platform,
@required this.materialTapTargetSize,
+ @required this.applyElevationOverlayColor,
@required this.pageTransitionsTheme,
@required this.appBarTheme,
@required this.bottomAppBarTheme,
@@ -679,6 +683,38 @@
/// Configures the hit test size of certain Material widgets.
final MaterialTapTargetSize materialTapTargetSize;
+ /// Apply a semi-transparent white overlay on Material surfaces to indicate
+ /// elevation for dark themes.
+ ///
+ /// Material drop shadows can be difficult to see in a dark theme, so the
+ /// elevation of a surface should be portrayed with an "overlay" in addition
+ /// to the shadow. As the elevation of the component increases, the white
+ /// overlay increases in opacity. [applyElevationOverlayColor] turns the
+ /// application of this overlay on or off.
+ ///
+ /// If [true] a semi-transparent white overlay will be applied to the color
+ /// of [Material] widgets when their [Material.color] is [colorScheme.surface].
+ /// The level of transparency is based on [Material.elevation] as per the
+ /// Material Dark theme specification.
+ ///
+ /// If [false] the surface color will be used unmodified.
+ ///
+ /// Defaults to [false].
+ ///
+ /// Note: this setting is here to maintain backwards compatibility with
+ /// apps that were built before the Material Dark theme specification
+ /// was published. New apps should set this to [true] for any themes
+ /// where [brightness] is [Brightness.dark].
+ ///
+ /// See also:
+ ///
+ /// * [Material.elevation], which effects how transparent the white overlay is.
+ /// * [Material.color], the white color overlay will only be applied of the
+ /// material's color is [colorScheme.surface].
+ /// * <https://material.io/design/color/dark-theme.html>, which specifies how
+ /// the overlay should be applied.
+ final bool applyElevationOverlayColor;
+
/// Default [MaterialPageRoute] transitions per [TargetPlatform].
///
/// [MaterialPageRoute.buildTransitions] delegates to a [PageTransitionsBuilder]
@@ -779,6 +815,7 @@
ChipThemeData chipTheme,
TargetPlatform platform,
MaterialTapTargetSize materialTapTargetSize,
+ bool applyElevationOverlayColor,
PageTransitionsTheme pageTransitionsTheme,
AppBarTheme appBarTheme,
BottomAppBarTheme bottomAppBarTheme,
@@ -837,6 +874,7 @@
chipTheme: chipTheme ?? this.chipTheme,
platform: platform ?? this.platform,
materialTapTargetSize: materialTapTargetSize ?? this.materialTapTargetSize,
+ applyElevationOverlayColor: applyElevationOverlayColor ?? this.applyElevationOverlayColor,
pageTransitionsTheme: pageTransitionsTheme ?? this.pageTransitionsTheme,
appBarTheme: appBarTheme ?? this.appBarTheme,
bottomAppBarTheme: bottomAppBarTheme ?? this.bottomAppBarTheme,
@@ -973,6 +1011,7 @@
chipTheme: ChipThemeData.lerp(a.chipTheme, b.chipTheme, t),
platform: t < 0.5 ? a.platform : b.platform,
materialTapTargetSize: t < 0.5 ? a.materialTapTargetSize : b.materialTapTargetSize,
+ applyElevationOverlayColor: t < 0.5 ? a.applyElevationOverlayColor : b.applyElevationOverlayColor,
pageTransitionsTheme: t < 0.5 ? a.pageTransitionsTheme : b.pageTransitionsTheme,
appBarTheme: AppBarTheme.lerp(a.appBarTheme, b.appBarTheme, t),
bottomAppBarTheme: BottomAppBarTheme.lerp(a.bottomAppBarTheme, b.bottomAppBarTheme, t),
@@ -1037,6 +1076,7 @@
(otherData.chipTheme == chipTheme) &&
(otherData.platform == platform) &&
(otherData.materialTapTargetSize == materialTapTargetSize) &&
+ (otherData.applyElevationOverlayColor == applyElevationOverlayColor) &&
(otherData.pageTransitionsTheme == pageTransitionsTheme) &&
(otherData.appBarTheme == appBarTheme) &&
(otherData.bottomAppBarTheme == bottomAppBarTheme) &&
@@ -1100,6 +1140,7 @@
chipTheme,
platform,
materialTapTargetSize,
+ applyElevationOverlayColor,
pageTransitionsTheme,
appBarTheme,
bottomAppBarTheme,
@@ -1160,6 +1201,7 @@
properties.add(DiagnosticsProperty<CardTheme>('cardTheme', cardTheme));
properties.add(DiagnosticsProperty<ChipThemeData>('chipTheme', chipTheme));
properties.add(DiagnosticsProperty<MaterialTapTargetSize>('materialTapTargetSize', materialTapTargetSize));
+ properties.add(DiagnosticsProperty<bool>('applyElevationOverlayColor', applyElevationOverlayColor));
properties.add(DiagnosticsProperty<PageTransitionsTheme>('pageTransitionsTheme', pageTransitionsTheme));
properties.add(DiagnosticsProperty<AppBarTheme>('appBarTheme', appBarTheme, defaultValue: defaultData.appBarTheme));
properties.add(DiagnosticsProperty<BottomAppBarTheme>('bottomAppBarTheme', bottomAppBarTheme, defaultValue: defaultData.bottomAppBarTheme));
diff --git a/packages/flutter/test/material/material_test.dart b/packages/flutter/test/material/material_test.dart
index cb5b103..4a7cb82 100644
--- a/packages/flutter/test/material/material_test.dart
+++ b/packages/flutter/test/material/material_test.dart
@@ -21,12 +21,14 @@
Widget buildMaterial({
double elevation = 0.0,
Color shadowColor = const Color(0xFF00FF00),
+ Color color = const Color(0xFF0000FF),
}) {
return Center(
child: SizedBox(
height: 100.0,
width: 100.0,
child: Material(
+ color: color,
shadowColor: shadowColor,
elevation: elevation,
shape: const CircleBorder(),
@@ -35,7 +37,7 @@
);
}
-RenderPhysicalShape getShadow(WidgetTester tester) {
+RenderPhysicalShape getModel(WidgetTester tester) {
return tester.renderObject(find.byType(PhysicalShape));
}
@@ -55,6 +57,12 @@
bool shouldRepaint(PaintRecorder oldDelegate) => false;
}
+class ElevationColor {
+ const ElevationColor(this.elevation, this.color);
+ final double elevation;
+ final Color color;
+}
+
void main() {
testWidgets('default Material debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
@@ -163,23 +171,23 @@
// a kThemeChangeDuration time interval.
await tester.pumpWidget(buildMaterial(elevation: 0.0));
- final RenderPhysicalShape modelA = getShadow(tester);
+ final RenderPhysicalShape modelA = getModel(tester);
expect(modelA.elevation, equals(0.0));
await tester.pumpWidget(buildMaterial(elevation: 9.0));
- final RenderPhysicalShape modelB = getShadow(tester);
+ final RenderPhysicalShape modelB = getModel(tester);
expect(modelB.elevation, equals(0.0));
await tester.pump(const Duration(milliseconds: 1));
- final RenderPhysicalShape modelC = getShadow(tester);
+ final RenderPhysicalShape modelC = getModel(tester);
expect(modelC.elevation, closeTo(0.0, 0.001));
await tester.pump(kThemeChangeDuration ~/ 2);
- final RenderPhysicalShape modelD = getShadow(tester);
+ final RenderPhysicalShape modelD = getModel(tester);
expect(modelD.elevation, isNot(closeTo(0.0, 0.001)));
await tester.pump(kThemeChangeDuration);
- final RenderPhysicalShape modelE = getShadow(tester);
+ final RenderPhysicalShape modelE = getModel(tester);
expect(modelE.elevation, equals(9.0));
});
@@ -188,26 +196,96 @@
// a kThemeChangeDuration time interval.
await tester.pumpWidget(buildMaterial(shadowColor: const Color(0xFF00FF00)));
- final RenderPhysicalShape modelA = getShadow(tester);
+ final RenderPhysicalShape modelA = getModel(tester);
expect(modelA.shadowColor, equals(const Color(0xFF00FF00)));
await tester.pumpWidget(buildMaterial(shadowColor: const Color(0xFFFF0000)));
- final RenderPhysicalShape modelB = getShadow(tester);
+ final RenderPhysicalShape modelB = getModel(tester);
expect(modelB.shadowColor, equals(const Color(0xFF00FF00)));
await tester.pump(const Duration(milliseconds: 1));
- final RenderPhysicalShape modelC = getShadow(tester);
+ final RenderPhysicalShape modelC = getModel(tester);
expect(modelC.shadowColor, within<Color>(distance: 1, from: const Color(0xFF00FF00)));
await tester.pump(kThemeChangeDuration ~/ 2);
- final RenderPhysicalShape modelD = getShadow(tester);
+ final RenderPhysicalShape modelD = getModel(tester);
expect(modelD.shadowColor, isNot(within<Color>(distance: 1, from: const Color(0xFF00FF00))));
await tester.pump(kThemeChangeDuration);
- final RenderPhysicalShape modelE = getShadow(tester);
+ final RenderPhysicalShape modelE = getModel(tester);
expect(modelE.shadowColor, equals(const Color(0xFFFF0000)));
});
+ group('Elevation Overlay', () {
+
+ testWidgets('applyElevationOverlayColor set to false does not change surface color', (WidgetTester tester) async {
+ const Color surfaceColor = Color(0xFF121212);
+ await tester.pumpWidget(Theme(
+ data: ThemeData(
+ applyElevationOverlayColor: false,
+ colorScheme: const ColorScheme.dark().copyWith(surface: surfaceColor),
+ ),
+ child: buildMaterial(color: surfaceColor, elevation: 8.0))
+ );
+ final RenderPhysicalShape model = getModel(tester);
+ expect(model.color, equals(surfaceColor));
+ });
+
+ testWidgets('applyElevationOverlayColor set to true overlays a transparent white on surface color', (WidgetTester tester) async {
+ // The colors we should get with a base surface color of 0xFF121212 for
+ // a given elevation
+ const List<ElevationColor> elevationColors = <ElevationColor>[
+ ElevationColor(0.0, Color(0xFF121212)),
+ ElevationColor(1.0, Color(0xFF1E1E1E)),
+ ElevationColor(2.0, Color(0xFF222222)),
+ ElevationColor(3.0, Color(0xFF252525)),
+ ElevationColor(4.0, Color(0xFF282828)),
+ ElevationColor(6.0, Color(0xFF2B2B2B)),
+ ElevationColor(8.0, Color(0xFF2D2D2D)),
+ ElevationColor(12.0, Color(0xFF323232)),
+ ElevationColor(16.0, Color(0xFF353535)),
+ ElevationColor(24.0, Color(0xFF393939)),
+ ];
+ const Color surfaceColor = Color(0xFF121212);
+
+ for (ElevationColor test in elevationColors) {
+ await tester.pumpWidget(
+ Theme(
+ data: ThemeData(
+ applyElevationOverlayColor: true,
+ colorScheme: const ColorScheme.dark().copyWith(surface: surfaceColor),
+ ),
+ child: buildMaterial(
+ color: surfaceColor,
+ elevation: test.elevation,
+ ),
+ )
+ );
+ await tester.pumpAndSettle(); // wait for the elevation animation to finish
+ final RenderPhysicalShape model = getModel(tester);
+ expect(model.color, equals(test.color));
+ }
+ });
+
+ testWidgets('overlay will only apply to materials using colorScheme.surface', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ Theme(
+ data: ThemeData(
+ applyElevationOverlayColor: true,
+ colorScheme: const ColorScheme.dark().copyWith(surface: const Color(0xFF121212)),
+ ),
+ child: buildMaterial(
+ color: Colors.cyan,
+ elevation: 8.0
+ ),
+ )
+ );
+ final RenderPhysicalShape model = getModel(tester);
+ expect(model.color, equals(Colors.cyan));
+ });
+
+ });
+
group('Transparency clipping', () {
testWidgets('No clip by default', (WidgetTester tester) async {
final GlobalKey materialKey = GlobalKey();