diff --git a/packages/flutter/lib/src/material/material.dart b/packages/flutter/lib/src/material/material.dart index 2b867465d8..293d1fc52b 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 @@ class Material extends StatefulWidget { /// {@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 @@ class Material extends StatefulWidget { /// 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 @@ class Material extends StatefulWidget { 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 @@ class Material extends StatefulWidget { 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 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 @@ class _MaterialState extends State with TickerProviderStateMixin { 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 @@ class _MaterialInteriorState extends AnimatedWidgetBaseState<_MaterialInterior> @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 @@ class _MaterialInteriorState extends AnimatedWidgetBaseState<_MaterialInterior> 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 53dc55883c..e0c1dd6c86 100644 --- a/packages/flutter/lib/src/material/theme_data.dart +++ b/packages/flutter/lib/src/material/theme_data.dart @@ -159,6 +159,7 @@ class ThemeData extends Diagnosticable { ChipThemeData chipTheme, TargetPlatform platform, MaterialTapTargetSize materialTapTargetSize, + bool applyElevationOverlayColor, PageTransitionsTheme pageTransitionsTheme, AppBarTheme appBarTheme, BottomAppBarTheme bottomAppBarTheme, @@ -228,6 +229,7 @@ class ThemeData extends Diagnosticable { 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 @@ class ThemeData extends Diagnosticable { chipTheme: chipTheme, platform: platform, materialTapTargetSize: materialTapTargetSize, + applyElevationOverlayColor: applyElevationOverlayColor, pageTransitionsTheme: pageTransitionsTheme, appBarTheme: appBarTheme, bottomAppBarTheme: bottomAppBarTheme, @@ -384,6 +387,7 @@ class ThemeData extends Diagnosticable { @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 @@ class ThemeData extends Diagnosticable { /// 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]. + /// * , 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 @@ class ThemeData extends Diagnosticable { ChipThemeData chipTheme, TargetPlatform platform, MaterialTapTargetSize materialTapTargetSize, + bool applyElevationOverlayColor, PageTransitionsTheme pageTransitionsTheme, AppBarTheme appBarTheme, BottomAppBarTheme bottomAppBarTheme, @@ -837,6 +874,7 @@ class ThemeData extends Diagnosticable { 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 @@ class ThemeData extends Diagnosticable { 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 @@ class ThemeData extends Diagnosticable { (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 @@ class ThemeData extends Diagnosticable { chipTheme, platform, materialTapTargetSize, + applyElevationOverlayColor, pageTransitionsTheme, appBarTheme, bottomAppBarTheme, @@ -1160,6 +1201,7 @@ class ThemeData extends Diagnosticable { properties.add(DiagnosticsProperty('cardTheme', cardTheme)); properties.add(DiagnosticsProperty('chipTheme', chipTheme)); properties.add(DiagnosticsProperty('materialTapTargetSize', materialTapTargetSize)); + properties.add(DiagnosticsProperty('applyElevationOverlayColor', applyElevationOverlayColor)); properties.add(DiagnosticsProperty('pageTransitionsTheme', pageTransitionsTheme)); properties.add(DiagnosticsProperty('appBarTheme', appBarTheme, defaultValue: defaultData.appBarTheme)); properties.add(DiagnosticsProperty('bottomAppBarTheme', bottomAppBarTheme, defaultValue: defaultData.bottomAppBarTheme)); diff --git a/packages/flutter/test/material/material_test.dart b/packages/flutter/test/material/material_test.dart index cb5b103c14..4a7cb82640 100644 --- a/packages/flutter/test/material/material_test.dart +++ b/packages/flutter/test/material/material_test.dart @@ -21,12 +21,14 @@ class NotifyMaterial extends StatelessWidget { 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 @@ Widget buildMaterial({ ); } -RenderPhysicalShape getShadow(WidgetTester tester) { +RenderPhysicalShape getModel(WidgetTester tester) { return tester.renderObject(find.byType(PhysicalShape)); } @@ -55,6 +57,12 @@ class PaintRecorder extends CustomPainter { 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 @@ void main() { // 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 @@ void main() { // 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(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(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 elevationColors = [ + 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();