From c362d8da07b0bb8a953a26b4a2aa4b1c7f3490eb Mon Sep 17 00:00:00 2001 From: Will Larche Date: Thu, 24 Jan 2019 08:01:21 -0800 Subject: [PATCH] [Material] Theme data type for cards (#26796) This change adds a CardTheme and tests for it. Golden is here: https://github.com/flutter/goldens/blob/ec26eeebb44068599c100d3f0c2db7102b047737/packages/flutter/test/material/card_theme.custom_shape.png From commit: https://github.com/flutter/goldens/commit/ec26eeebb44068599c100d3f0c2db7102b047737#diff-7564b206413654283ddc5cb59ecd64d4 --- bin/internal/goldens.version | 2 +- packages/flutter/lib/material.dart | 1 + packages/flutter/lib/src/material/card.dart | 43 +++-- .../flutter/lib/src/material/card_theme.dart | 142 ++++++++++++++ .../flutter/lib/src/material/theme_data.dart | 17 ++ .../test/material/card_theme_test.dart | 182 ++++++++++++++++++ 6 files changed, 370 insertions(+), 17 deletions(-) create mode 100644 packages/flutter/lib/src/material/card_theme.dart create mode 100644 packages/flutter/test/material/card_theme_test.dart diff --git a/bin/internal/goldens.version b/bin/internal/goldens.version index 7646d271f91..2c224cf8db5 100644 --- a/bin/internal/goldens.version +++ b/bin/internal/goldens.version @@ -1 +1 @@ -24e9ab38cea4fd85f11ee5a3a491576037294bb9 +6fc7ec65d51116c3f83acb5251e57e779af2ebbb diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index 2277e971746..4968e194d31 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -29,6 +29,7 @@ export 'src/material/button.dart'; export 'src/material/button_bar.dart'; export 'src/material/button_theme.dart'; export 'src/material/card.dart'; +export 'src/material/card_theme.dart'; export 'src/material/checkbox.dart'; export 'src/material/checkbox_list_tile.dart'; export 'src/material/chip.dart'; diff --git a/packages/flutter/lib/src/material/card.dart b/packages/flutter/lib/src/material/card.dart index f20a78075f3..4f1495f8b94 100644 --- a/packages/flutter/lib/src/material/card.dart +++ b/packages/flutter/lib/src/material/card.dart @@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart'; +import 'card_theme.dart'; import 'material.dart'; import 'theme.dart'; @@ -65,25 +66,25 @@ import 'theme.dart'; class Card extends StatelessWidget { /// Creates a material design card. /// - /// The [clipBehavior] and [elevation] arguments must not be null. - /// Additionally, the [elevation] must be non-negative. + /// The [elevation] must be null or non-negative. const Card({ Key key, this.color, - this.elevation = 1.0, + this.elevation, this.shape, - this.margin = const EdgeInsets.all(4.0), - this.clipBehavior = Clip.none, + this.margin, + this.clipBehavior, this.child, this.semanticContainer = true, - }) : assert(elevation != null && elevation >= 0.0), + }) : assert(elevation == null || elevation >= 0.0), super(key: key); /// The card's background color. /// /// Defines the card's [Material.color]. /// - /// The default color is defined by the ambient [Theme]: [ThemeData.cardColor]. + /// If this property is null then [ThemeData.cardTheme.color] is used, + /// if that's null then [ThemeData.cardColor] is used. final Color color; /// The z-coordinate at which to place this card. This controls the size of @@ -91,25 +92,30 @@ class Card extends StatelessWidget { /// /// Defines the card's [Material.elevation]. /// - /// The default elevation is 1.0. The value is always non-negative. + /// If this property is null then [ThemeData.cardTheme.elevation] is used, + /// if that's null, the default value is 1.0. final double elevation; /// The shape of the card's [Material]. /// /// Defines the card's [Material.shape]. /// - /// The default shape is a [RoundedRectangleBorder] with a circular corner - /// radius of 4.0. + /// If this property is null then [ThemeData.cardTheme.shape] is used. + /// If that's null then the shape will be a [RoundedRectangleBorder] with a + /// circular corner radius of 4.0. final ShapeBorder shape; /// {@macro flutter.widgets.Clip} + /// If this property is null then [ThemeData.cardTheme.clipBehavior] is used. + /// If that's null then the behavior will be [Clip.none]. final Clip clipBehavior; /// The empty space that surrounds the card. /// /// Defines the card's outer [Container.margin]. /// - /// The default margin is 4.0 logical pixels on all sides: + /// If this property is null then [ThemeData.cardTheme.margin] is used, + /// if that's null, the default margin is 4.0 logical pixels on all sides: /// `EdgeInsets.all(4.0)`. final EdgeInsetsGeometry margin; @@ -131,20 +137,25 @@ class Card extends StatelessWidget { /// {@macro flutter.widgets.child} final Widget child; + static const double _defaultElevation = 1.0; + static const Clip _defaultClipBehavior = Clip.none; + @override Widget build(BuildContext context) { + final CardTheme cardTheme = CardTheme.of(context); + return Semantics( container: semanticContainer, child: Container( - margin: margin ?? const EdgeInsets.all(4.0), + margin: margin ?? cardTheme.margin ?? const EdgeInsets.all(4.0), child: Material( type: MaterialType.card, - color: color ?? Theme.of(context).cardColor, - elevation: elevation, - shape: shape ?? const RoundedRectangleBorder( + color: color ?? cardTheme.color ?? Theme.of(context).cardColor, + elevation: elevation ?? cardTheme.elevation ?? _defaultElevation, + shape: shape ?? cardTheme.shape ?? const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(4.0)), ), - clipBehavior: clipBehavior, + clipBehavior: clipBehavior ?? cardTheme.clipBehavior ?? _defaultClipBehavior, child: Semantics( explicitChildNodes: !semanticContainer, child: child, diff --git a/packages/flutter/lib/src/material/card_theme.dart b/packages/flutter/lib/src/material/card_theme.dart new file mode 100644 index 00000000000..266017c534b --- /dev/null +++ b/packages/flutter/lib/src/material/card_theme.dart @@ -0,0 +1,142 @@ +// Copyright 2019 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:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +/// Defines default property values for descendant [Card] widgets. +/// +/// Descendant widgets obtain the current [CardTheme] object using +/// `CardTheme.of(context)`. Instances of [CardTheme] can be +/// customized with [CardTheme.copyWith]. +/// +/// Typically a [CardTheme] is specified as part of the overall [Theme] +/// with [ThemeData.cardTheme]. +/// +/// All [CardTheme] properties are `null` by default. When null, the [Card] +/// will use the values from [ThemeData] if they exist, otherwise it will +/// provide its own defaults. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +class CardTheme extends Diagnosticable { + + /// Creates a theme that can be used for [ThemeData.cardTheme]. + /// + /// The [elevation] must be null or non-negative. + const CardTheme({ + this.clipBehavior, + this.color, + this.elevation, + this.margin, + this.shape, + }) : assert(elevation == null || elevation >= 0.0); + + /// Default value for [Card.clipBehavior]. + /// + /// If null, [Card] uses [Clip.none]. + final Clip clipBehavior; + + /// Default value for [Card.color]. + /// + /// If null, [Card] uses [ThemeData.cardColor]. + final Color color; + + /// Default value for [Card.elevation]. + /// + /// If null, [Card] uses a default of 1.0. + final double elevation; + + /// Default value for [Card.margin]. + /// + /// If null, [Card] uses a default margin of 4.0 logical pixels on all sides: + /// `EdgeInsets.all(4.0)`. + final EdgeInsetsGeometry margin; + + /// Default value for [Card.shape]. + /// + /// If null, [Card] then uses a [RoundedRectangleBorder] with a circular + /// corner radius of 4.0. + final ShapeBorder shape; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + CardTheme copyWith({ + Clip clipBehavior, + Color color, + double elevation, + EdgeInsetsGeometry margin, + ShapeBorder shape, + }) { + return CardTheme( + clipBehavior: clipBehavior ?? this.clipBehavior, + color: color ?? this.color, + elevation: elevation ?? this.elevation, + margin: margin ?? this.margin, + shape: shape ?? this.shape, + ); + } + + /// The [ThemeData.cardTheme] property of the ambient [Theme]. + static CardTheme of(BuildContext context) { + return Theme.of(context).cardTheme; + } + + /// Linearly interpolate between two Card themes. + /// + /// The argument `t` must not be null. + /// + /// {@macro dart.ui.shadow.lerp} + static CardTheme lerp(CardTheme a, CardTheme b, double t) { + assert(t != null); + return CardTheme( + clipBehavior: t < 0.5 ? a?.clipBehavior : b?.clipBehavior, + color: Color.lerp(a?.color, b?.color, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + margin: EdgeInsetsGeometry.lerp(a?.margin, b?.margin, t), + shape: ShapeBorder.lerp(a?.shape, b?.shape, t), + ); + } + + @override + int get hashCode { + return hashValues( + clipBehavior, + color, + elevation, + margin, + shape, + ); + } + + @override + bool operator ==(dynamic other) { + if (identical(this, other)) + return true; + if (other.runtimeType != runtimeType) + return false; + final CardTheme typedOther = other; + return typedOther.clipBehavior == clipBehavior + && typedOther.color == color + && typedOther.elevation == elevation + && typedOther.margin == margin + && typedOther.shape == shape; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('clipBehavior', clipBehavior, defaultValue: null)); + properties.add(DiagnosticsProperty('color', color, defaultValue: null)); + properties.add(DiagnosticsProperty('elevation', elevation, defaultValue: null)); + properties.add(DiagnosticsProperty('margin', margin, defaultValue: null)); + properties.add(DiagnosticsProperty('shape', shape, defaultValue: null)); + } +} diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart index 7704a45cc41..0a09e24d1fb 100644 --- a/packages/flutter/lib/src/material/theme_data.dart +++ b/packages/flutter/lib/src/material/theme_data.dart @@ -12,6 +12,7 @@ import 'package:flutter/widgets.dart'; import 'app_bar_theme.dart'; import 'bottom_app_bar_theme.dart'; import 'button_theme.dart'; +import 'card_theme.dart'; import 'chip_theme.dart'; import 'color_scheme.dart'; import 'colors.dart'; @@ -149,6 +150,7 @@ class ThemeData extends Diagnosticable { IconThemeData accentIconTheme, SliderThemeData sliderTheme, TabBarTheme tabBarTheme, + CardTheme cardTheme, ChipThemeData chipTheme, TargetPlatform platform, MaterialTapTargetSize materialTapTargetSize, @@ -248,6 +250,7 @@ class ThemeData extends Diagnosticable { tabBarTheme ??= const TabBarTheme(); appBarTheme ??= const AppBarTheme(); bottomAppBarTheme ??= const BottomAppBarTheme(); + cardTheme ??= const CardTheme(); chipTheme ??= ChipThemeData.fromDefaults( secondaryColor: primaryColor, brightness: brightness, @@ -296,6 +299,7 @@ class ThemeData extends Diagnosticable { accentIconTheme: accentIconTheme, sliderTheme: sliderTheme, tabBarTheme: tabBarTheme, + cardTheme: cardTheme, chipTheme: chipTheme, platform: platform, materialTapTargetSize: materialTapTargetSize, @@ -359,6 +363,7 @@ class ThemeData extends Diagnosticable { @required this.accentIconTheme, @required this.sliderTheme, @required this.tabBarTheme, + @required this.cardTheme, @required this.chipTheme, @required this.platform, @required this.materialTapTargetSize, @@ -407,6 +412,7 @@ class ThemeData extends Diagnosticable { assert(accentIconTheme != null), assert(sliderTheme != null), assert(tabBarTheme != null), + assert(cardTheme != null), assert(chipTheme != null), assert(platform != null), assert(materialTapTargetSize != null), @@ -604,6 +610,11 @@ class ThemeData extends Diagnosticable { /// A theme for customizing the size, shape, and color of the tab bar indicator. final TabBarTheme tabBarTheme; + /// The colors and styles used to render [Card]. + /// + /// This is the value returned from [CardTheme.of]. + final CardTheme cardTheme; + /// The colors and styles used to render [Chip], [ /// /// This is the value returned from [ChipTheme.of]. @@ -708,6 +719,7 @@ class ThemeData extends Diagnosticable { IconThemeData accentIconTheme, SliderThemeData sliderTheme, TabBarTheme tabBarTheme, + CardTheme cardTheme, ChipThemeData chipTheme, TargetPlatform platform, MaterialTapTargetSize materialTapTargetSize, @@ -760,6 +772,7 @@ class ThemeData extends Diagnosticable { accentIconTheme: accentIconTheme ?? this.accentIconTheme, sliderTheme: sliderTheme ?? this.sliderTheme, tabBarTheme: tabBarTheme ?? this.tabBarTheme, + cardTheme: cardTheme ?? this.cardTheme, chipTheme: chipTheme ?? this.chipTheme, platform: platform ?? this.platform, materialTapTargetSize: materialTapTargetSize ?? this.materialTapTargetSize, @@ -890,6 +903,7 @@ class ThemeData extends Diagnosticable { accentIconTheme: IconThemeData.lerp(a.accentIconTheme, b.accentIconTheme, t), sliderTheme: SliderThemeData.lerp(a.sliderTheme, b.sliderTheme, t), tabBarTheme: TabBarTheme.lerp(a.tabBarTheme, b.tabBarTheme, t), + cardTheme: CardTheme.lerp(a.cardTheme, b.cardTheme, t), chipTheme: ChipThemeData.lerp(a.chipTheme, b.chipTheme, t), platform: t < 0.5 ? a.platform : b.platform, materialTapTargetSize: t < 0.5 ? a.materialTapTargetSize : b.materialTapTargetSize, @@ -950,6 +964,7 @@ class ThemeData extends Diagnosticable { (otherData.accentIconTheme == accentIconTheme) && (otherData.sliderTheme == sliderTheme) && (otherData.tabBarTheme == tabBarTheme) && + (otherData.cardTheme == cardTheme) && (otherData.chipTheme == chipTheme) && (otherData.platform == platform) && (otherData.materialTapTargetSize == materialTapTargetSize) && @@ -1010,6 +1025,7 @@ class ThemeData extends Diagnosticable { sliderTheme, hashValues( tabBarTheme, + cardTheme, chipTheme, platform, materialTapTargetSize, @@ -1066,6 +1082,7 @@ class ThemeData extends Diagnosticable { properties.add(DiagnosticsProperty('accentIconTheme', accentIconTheme)); properties.add(DiagnosticsProperty('sliderTheme', sliderTheme)); properties.add(DiagnosticsProperty('tabBarTheme', tabBarTheme)); + properties.add(DiagnosticsProperty('cardTheme', cardTheme)); properties.add(DiagnosticsProperty('chipTheme', chipTheme)); properties.add(DiagnosticsProperty('materialTapTargetSize', materialTapTargetSize)); properties.add(DiagnosticsProperty('pageTransitionsTheme', pageTransitionsTheme)); diff --git a/packages/flutter/test/material/card_theme_test.dart b/packages/flutter/test/material/card_theme_test.dart new file mode 100644 index 00000000000..fa943a9b351 --- /dev/null +++ b/packages/flutter/test/material/card_theme_test.dart @@ -0,0 +1,182 @@ +// Copyright 2019 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:io' show Platform; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('CardTheme copyWith, ==, hashCode basics', () { + expect(const CardTheme(), const CardTheme().copyWith()); + expect(const CardTheme().hashCode, const CardTheme().copyWith().hashCode); + }); + + testWidgets('Passing no CardTheme returns defaults', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + body: Card() + ), + )); + + final Container container = _getCardContainer(tester); + final Material material = _getCardMaterial(tester); + + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.white); + expect(material.elevation, 1.0); + expect(container.margin, const EdgeInsets.all(4.0)); + expect(material.shape, const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4.0)), + )); + }); + + testWidgets('Card uses values from CardTheme', (WidgetTester tester) async { + final CardTheme cardTheme = _cardTheme(); + + await tester.pumpWidget(MaterialApp( + theme: ThemeData(cardTheme: cardTheme), + home: const Scaffold( + body: Card() + ), + )); + + final Container container = _getCardContainer(tester); + final Material material = _getCardMaterial(tester); + + expect(material.clipBehavior, cardTheme.clipBehavior); + expect(material.color, cardTheme.color); + expect(material.elevation, cardTheme.elevation); + expect(container.margin, cardTheme.margin); + expect(material.shape, cardTheme.shape); + }); + + testWidgets('Card widget properties take priority over theme', (WidgetTester tester) async { + const Clip clip = Clip.hardEdge; + const Color color = Colors.orange; + const double elevation = 7.0; + const EdgeInsets margin = EdgeInsets.all(3.0); + const ShapeBorder shape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(9.0)), + ); + + await tester.pumpWidget(MaterialApp( + theme: _themeData().copyWith(cardTheme: _cardTheme()), + home: const Scaffold( + body: Card( + clipBehavior: clip, + color: color, + elevation: elevation, + margin: margin, + shape: shape, + ) + ), + )); + + final Container container = _getCardContainer(tester); + final Material material = _getCardMaterial(tester); + + expect(material.clipBehavior, clip); + expect(material.color, color); + expect(material.elevation, elevation); + expect(container.margin, margin); + expect(material.shape, shape); + }); + + testWidgets('CardTheme properties take priority over ThemeData properties', (WidgetTester tester) async { + final CardTheme cardTheme = _cardTheme(); + final ThemeData themeData = _themeData().copyWith(cardTheme: cardTheme); + + await tester.pumpWidget(MaterialApp( + theme: themeData, + home: const Scaffold( + body: Card() + ), + )); + + final Material material = _getCardMaterial(tester); + expect(material.color, cardTheme.color); + }); + + testWidgets('ThemeData properties are used when no CardTheme is set', (WidgetTester tester) async { + final ThemeData themeData = _themeData(); + + await tester.pumpWidget(MaterialApp( + theme: themeData, + home: const Scaffold( + body: Card() + ), + )); + + final Material material = _getCardMaterial(tester); + expect(material.color, themeData.cardColor); + }); + + testWidgets('CardTheme customizes shape', (WidgetTester tester) async { + const CardTheme cardTheme = CardTheme( + color: Colors.white, + shape: BeveledRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(7))), + elevation: 1.0, + ); + + final Key painterKey = UniqueKey(); + + await tester.pumpWidget(MaterialApp( + theme: ThemeData(cardTheme: cardTheme), + home: Scaffold( + body: RepaintBoundary( + key: painterKey, + child: Center( + child: Card( + child: SizedBox.fromSize(size: const Size(200, 300),), + ) + ) + ) + ), + )); + + await expectLater( + find.byKey(painterKey), + matchesGoldenFile('card_theme.custom_shape.png'), + skip: !Platform.isLinux, + ); + }); +} + +CardTheme _cardTheme() { + return const CardTheme( + clipBehavior: Clip.antiAlias, + color: Colors.green, + elevation: 6.0, + margin: EdgeInsets.all(7.0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(5.0)), + ) + ); +} + +ThemeData _themeData() { + return ThemeData( + cardColor: Colors.pink, + ); +} + +Material _getCardMaterial(WidgetTester tester) { + return tester.widget( + find.descendant( + of: find.byType(Card), + matching: find.byType(Material), + ), + ); +} + +Container _getCardContainer(WidgetTester tester) { + return tester.widget( + find.descendant( + of: find.byType(Card), + matching: find.byType(Container), + ), + ); +}