Unverified Commit e17f8d36 authored by Darren Austin's avatar Darren Austin Committed by GitHub

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.
parent 5501a1c1
// 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<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 @@ class _MaterialState extends State<Material> 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),
);
}
......
......@@ -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].
/// * <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 @@ 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', 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));
......
......@@ -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<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();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment