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.
This commit is contained in:
parent
5501a1c1e7
commit
e17f8d367d
@ -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,21 +316,44 @@ 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;
|
||||
final ThemeData theme = Theme.of(context);
|
||||
Color color = widget.color;
|
||||
if (color == null) {
|
||||
switch (widget.type) {
|
||||
case MaterialType.canvas:
|
||||
return Theme.of(context).canvasColor;
|
||||
color = theme.canvasColor;
|
||||
break;
|
||||
case MaterialType.card:
|
||||
return Theme.of(context).cardColor;
|
||||
color = theme.cardColor;
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -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();
|
||||
|
Loading…
x
Reference in New Issue
Block a user