diff --git a/packages/flutter/lib/src/material/chip.dart b/packages/flutter/lib/src/material/chip.dart index 73a0be1845..043cc0af97 100644 --- a/packages/flutter/lib/src/material/chip.dart +++ b/packages/flutter/lib/src/material/chip.dart @@ -94,10 +94,39 @@ abstract class ChipAttributes { /// * [MaterialState.pressed]. TextStyle? get labelStyle; - /// The [ShapeBorder] to draw around the chip. + /// The color and weight of the chip's outline. /// - /// Defaults to the shape in the ambient [ChipThemeData]. - ShapeBorder? get shape; + /// Defaults to the border side in the ambient [ChipThemeData]. If the theme + /// border side resolves to null, the default is the border side of [shape]. + /// + /// This value is combined with [shape] to create a shape decorated with an + /// outline. If it is a [MaterialStateBorderSide], + /// [MaterialStateProperty.resolve] is used for the following + /// [MaterialState]s: + /// + /// * [MaterialState.disabled]. + /// * [MaterialState.selected]. + /// * [MaterialState.hovered]. + /// * [MaterialState.focused]. + /// * [MaterialState.pressed]. + BorderSide? get side; + + /// The [OutlinedBorder] to draw around the chip. + /// + /// Defaults to the shape in the ambient [ChipThemeData]. If the theme + /// shape resolves to null, the default is [StadiumBorder]. + /// + /// This shape is combined with [side] to create a shape decorated with an + /// outline. If it is a [MaterialStateOutlinedBorder], + /// [MaterialStateProperty.resolve] is used for the following + /// [MaterialState]s: + /// + /// * [MaterialState.disabled]. + /// * [MaterialState.selected]. + /// * [MaterialState.hovered]. + /// * [MaterialState.focused]. + /// * [MaterialState.pressed]. + OutlinedBorder? get shape; /// {@macro flutter.widgets.Clip} /// @@ -576,6 +605,7 @@ class Chip extends StatelessWidget implements ChipAttributes, DeletableChipAttri this.deleteIconColor, this.useDeleteButtonTooltip = true, this.deleteButtonTooltipMessage, + this.side, this.shape, this.clipBehavior = Clip.none, this.focusNode, @@ -602,7 +632,9 @@ class Chip extends StatelessWidget implements ChipAttributes, DeletableChipAttri @override final EdgeInsetsGeometry? labelPadding; @override - final ShapeBorder? shape; + final BorderSide? side; + @override + final OutlinedBorder? shape; @override final Clip clipBehavior; @override @@ -646,6 +678,7 @@ class Chip extends StatelessWidget implements ChipAttributes, DeletableChipAttri useDeleteButtonTooltip: useDeleteButtonTooltip, deleteButtonTooltipMessage: deleteButtonTooltipMessage, tapEnabled: false, + side: side, shape: shape, clipBehavior: clipBehavior, focusNode: focusNode, @@ -742,6 +775,7 @@ class InputChip extends StatelessWidget this.disabledColor, this.selectedColor, this.tooltip, + this.side, this.shape, this.clipBehavior = Clip.none, this.focusNode, @@ -801,7 +835,9 @@ class InputChip extends StatelessWidget @override final String? tooltip; @override - final ShapeBorder? shape; + final BorderSide? side; + @override + final OutlinedBorder? shape; @override final Clip clipBehavior; @override @@ -850,6 +886,7 @@ class InputChip extends StatelessWidget disabledColor: disabledColor, selectedColor: selectedColor, tooltip: tooltip, + side: side, shape: shape, clipBehavior: clipBehavior, focusNode: focusNode, @@ -945,6 +982,7 @@ class ChoiceChip extends StatelessWidget this.selectedColor, this.disabledColor, this.tooltip, + this.side, this.shape, this.clipBehavior = Clip.none, this.focusNode, @@ -986,7 +1024,9 @@ class ChoiceChip extends StatelessWidget @override final String? tooltip; @override - final ShapeBorder? shape; + final BorderSide? side; + @override + final OutlinedBorder? shape; @override final Clip clipBehavior; @override @@ -1028,6 +1068,7 @@ class ChoiceChip extends StatelessWidget showCheckmark: false, onDeleted: null, tooltip: tooltip, + side: side, shape: shape, clipBehavior: clipBehavior, focusNode: focusNode, @@ -1156,6 +1197,7 @@ class FilterChip extends StatelessWidget this.disabledColor, this.selectedColor, this.tooltip, + this.side, this.shape, this.clipBehavior = Clip.none, this.focusNode, @@ -1199,7 +1241,9 @@ class FilterChip extends StatelessWidget @override final String? tooltip; @override - final ShapeBorder? shape; + final BorderSide? side; + @override + final OutlinedBorder? shape; @override final Clip clipBehavior; @override @@ -1242,6 +1286,7 @@ class FilterChip extends StatelessWidget pressElevation: pressElevation, selected: selected, tooltip: tooltip, + side: side, shape: shape, clipBehavior: clipBehavior, focusNode: focusNode, @@ -1325,6 +1370,7 @@ class ActionChip extends StatelessWidget implements ChipAttributes, TappableChip required this.onPressed, this.pressElevation, this.tooltip, + this.side, this.shape, this.clipBehavior = Clip.none, this.focusNode, @@ -1362,7 +1408,9 @@ class ActionChip extends StatelessWidget implements ChipAttributes, TappableChip @override final String? tooltip; @override - final ShapeBorder? shape; + final BorderSide? side; + @override + final OutlinedBorder? shape; @override final Clip clipBehavior; @override @@ -1393,6 +1441,7 @@ class ActionChip extends StatelessWidget implements ChipAttributes, TappableChip tooltip: tooltip, labelStyle: labelStyle, backgroundColor: backgroundColor, + side: side, shape: shape, clipBehavior: clipBehavior, focusNode: focusNode, @@ -1477,6 +1526,7 @@ class RawChip extends StatefulWidget this.disabledColor, this.selectedColor, this.tooltip, + this.side, this.shape, this.clipBehavior = Clip.none, this.focusNode, @@ -1535,7 +1585,9 @@ class RawChip extends StatefulWidget @override final String? tooltip; @override - final ShapeBorder? shape; + final BorderSide? side; + @override + final OutlinedBorder? shape; @override final Clip clipBehavior; @override @@ -1731,6 +1783,15 @@ class _RawChipState extends State with TickerProviderStateMixin(widget.side, _states) + ?? MaterialStateProperty.resolveAs(theme.side, _states); + final OutlinedBorder resolvedShape = MaterialStateProperty.resolveAs(widget.shape, _states) + ?? MaterialStateProperty.resolveAs(theme.shape, _states) + ?? const StadiumBorder(); + return resolvedShape.copyWith(side: resolvedSide); + } + /// Picks between three different colors, depending upon the state of two /// different animations. Color? getBackgroundColor(ChipThemeData theme) { @@ -1860,7 +1921,7 @@ class _RawChipState extends State with TickerProviderStateMixin with TickerProviderStateMixin(effectiveLabelStyle.color, _states); + final Color? resolvedLabelColor = MaterialStateProperty.resolveAs(effectiveLabelStyle.color, _states); final TextStyle resolvedLabelStyle = effectiveLabelStyle.copyWith(color: resolvedLabelColor); final EdgeInsetsGeometry labelPadding = widget.labelPadding ?? chipTheme.labelPadding ?? _defaultLabelPadding; @@ -1877,7 +1938,7 @@ class _RawChipState extends State with TickerProviderStateMixin with TickerProviderStateMixin[selectController, enableController]), builder: (BuildContext context, Widget? child) { return Container( decoration: ShapeDecoration( - shape: shape, + shape: resolvedShape, color: getBackgroundColor(chipTheme), ), child: child, diff --git a/packages/flutter/lib/src/material/chip_theme.dart b/packages/flutter/lib/src/material/chip_theme.dart index a2b407a4cb..7b05942f78 100644 --- a/packages/flutter/lib/src/material/chip_theme.dart +++ b/packages/flutter/lib/src/material/chip_theme.dart @@ -187,7 +187,8 @@ class ChipThemeData with Diagnosticable { this.checkmarkColor, this.labelPadding, required this.padding, - required this.shape, + this.side, + this.shape, required this.labelStyle, required this.secondaryLabelStyle, required this.brightness, @@ -198,7 +199,6 @@ class ChipThemeData with Diagnosticable { assert(selectedColor != null), assert(secondarySelectedColor != null), assert(padding != null), - assert(shape != null), assert(labelStyle != null), assert(secondaryLabelStyle != null), assert(brightness != null); @@ -244,7 +244,6 @@ class ChipThemeData with Diagnosticable { const int disabledAlpha = 0x0c; // 38% * 12% = 5% const int selectAlpha = 0x3d; // 12% + 12% = 24% const int textLabelAlpha = 0xde; // 87% - const ShapeBorder shape = StadiumBorder(); const EdgeInsetsGeometry padding = EdgeInsets.all(4.0); primaryColor = primaryColor ?? (brightness == Brightness.light ? Colors.black : Colors.white); @@ -265,7 +264,6 @@ class ChipThemeData with Diagnosticable { selectedColor: selectedColor, secondarySelectedColor: secondarySelectedColor, padding: padding, - shape: shape, labelStyle: labelStyle, secondaryLabelStyle: secondaryLabelStyle, brightness: brightness!, @@ -350,10 +348,37 @@ class ChipThemeData with Diagnosticable { /// Defaults to 4 logical pixels on all sides. final EdgeInsetsGeometry padding; + /// The color and weight of the chip's outline. + /// + /// If null, the chip defaults to the border side of [shape]. + /// + /// This value is combined with [shape] to create a shape decorated with an + /// outline. If it is a [MaterialStateBorderSide], + /// [MaterialStateProperty.resolve] is used for the following + /// [MaterialState]s: + /// + /// * [MaterialState.disabled]. + /// * [MaterialState.selected]. + /// * [MaterialState.hovered]. + /// * [MaterialState.focused]. + /// * [MaterialState.pressed]. + final BorderSide? side; + /// The border to draw around the chip. /// - /// Defaults to a [StadiumBorder]. Must not be null. - final ShapeBorder shape; + /// If null, the chip defaults to a [StadiumBorder]. + /// + /// This shape is combined with [side] to create a shape decorated with an + /// outline. If it is a [MaterialStateOutlinedBorder], + /// [MaterialStateProperty.resolve] is used for the following + /// [MaterialState]s: + /// + /// * [MaterialState.disabled]. + /// * [MaterialState.selected]. + /// * [MaterialState.hovered]. + /// * [MaterialState.focused]. + /// * [MaterialState.pressed]. + final OutlinedBorder? shape; /// The style to be applied to the chip's label. /// @@ -396,7 +421,8 @@ class ChipThemeData with Diagnosticable { Color? checkmarkColor, EdgeInsetsGeometry? labelPadding, EdgeInsetsGeometry? padding, - ShapeBorder? shape, + BorderSide? side, + OutlinedBorder? shape, TextStyle? labelStyle, TextStyle? secondaryLabelStyle, Brightness? brightness, @@ -414,6 +440,7 @@ class ChipThemeData with Diagnosticable { checkmarkColor: checkmarkColor ?? this.checkmarkColor, labelPadding: labelPadding ?? this.labelPadding, padding: padding ?? this.padding, + side: side ?? this.side, shape: shape ?? this.shape, labelStyle: labelStyle ?? this.labelStyle, secondaryLabelStyle: secondaryLabelStyle ?? this.secondaryLabelStyle, @@ -443,7 +470,8 @@ class ChipThemeData with Diagnosticable { checkmarkColor: Color.lerp(a?.checkmarkColor, b?.checkmarkColor, t), labelPadding: EdgeInsetsGeometry.lerp(a?.labelPadding, b?.labelPadding, t), padding: EdgeInsetsGeometry.lerp(a?.padding, b?.padding, t)!, - shape: ShapeBorder.lerp(a?.shape, b?.shape, t)!, + side: _lerpSides(a?.side, b?.side, t), + shape: _lerpShapes(a?.shape, b?.shape, t), labelStyle: TextStyle.lerp(a?.labelStyle, b?.labelStyle, t)!, secondaryLabelStyle: TextStyle.lerp(a?.secondaryLabelStyle, b?.secondaryLabelStyle, t)!, brightness: t < 0.5 ? a?.brightness ?? Brightness.light : b?.brightness ?? Brightness.light, @@ -452,6 +480,24 @@ class ChipThemeData with Diagnosticable { ); } + // Special case because BorderSide.lerp() doesn't support null arguments. + static BorderSide? _lerpSides(BorderSide? a, BorderSide? b, double t) { + if (a == null && b == null) + return null; + if (a == null) + return BorderSide.lerp(BorderSide(width: 0, color: b!.color.withAlpha(0)), b, t); + if (b == null) + return BorderSide.lerp(BorderSide(width: 0, color: a.color.withAlpha(0)), a, t); + return BorderSide.lerp(a, b, t); + } + + // TODO(perclasson): OutlinedBorder needs a lerp method - https://github.com/flutter/flutter/issues/60555. + static OutlinedBorder? _lerpShapes(OutlinedBorder? a, OutlinedBorder? b, double t) { + if (a == null && b == null) + return null; + return ShapeBorder.lerp(a, b, t) as OutlinedBorder?; + } + @override int get hashCode { return hashValues( @@ -465,6 +511,7 @@ class ChipThemeData with Diagnosticable { checkmarkColor, labelPadding, padding, + side, shape, labelStyle, secondaryLabelStyle, @@ -493,6 +540,7 @@ class ChipThemeData with Diagnosticable { && other.checkmarkColor == checkmarkColor && other.labelPadding == labelPadding && other.padding == padding + && other.side == side && other.shape == shape && other.labelStyle == labelStyle && other.secondaryLabelStyle == secondaryLabelStyle @@ -520,6 +568,7 @@ class ChipThemeData with Diagnosticable { properties.add(ColorProperty('checkMarkColor', checkmarkColor, defaultValue: defaultData.checkmarkColor)); properties.add(DiagnosticsProperty('labelPadding', labelPadding, defaultValue: defaultData.labelPadding)); properties.add(DiagnosticsProperty('padding', padding, defaultValue: defaultData.padding)); + properties.add(DiagnosticsProperty('side', side, defaultValue: defaultData.side)); properties.add(DiagnosticsProperty('shape', shape, defaultValue: defaultData.shape)); properties.add(DiagnosticsProperty('labelStyle', labelStyle, defaultValue: defaultData.labelStyle)); properties.add(DiagnosticsProperty('secondaryLabelStyle', secondaryLabelStyle, defaultValue: defaultData.secondaryLabelStyle)); diff --git a/packages/flutter/lib/src/material/material_state.dart b/packages/flutter/lib/src/material/material_state.dart index 59f7eda8b4..f016b5ac0e 100644 --- a/packages/flutter/lib/src/material/material_state.dart +++ b/packages/flutter/lib/src/material/material_state.dart @@ -21,9 +21,15 @@ import 'package:flutter/rendering.dart'; /// * [MaterialStateColor], a [Color] that implements `MaterialStateProperty` /// which is used in APIs that need to accept either a [Color] or a /// `MaterialStateProperty`. -/// * [MaterialStateMouseCursor], a [MouseCursor] that implements `MaterialStateProperty` -/// which is used in APIs that need to accept either a [MouseCursor] or a -/// [MaterialStateProperty]. +/// * [MaterialStateMouseCursor], a [MouseCursor] that implements +/// `MaterialStateProperty` which is used in APIs that need to accept either +/// a [MouseCursor] or a [MaterialStateProperty]. +/// * [MaterialStateOutlinedBorder], an [OutlinedBorder] that implements +/// `MaterialStateProperty` which is used in APIs that need to accept either +/// an [OutlinedBorder] or a [MaterialStateProperty]. +/// * [MaterialStateBorderSide], a [BorderSide] that implements +/// `MaterialStateProperty` which is used in APIs that need to accept either +/// a [BorderSide] or a [MaterialStateProperty]. enum MaterialState { /// The state when the user drags their mouse cursor over the given widget. @@ -282,6 +288,123 @@ class _EnabledAndDisabledMouseCursor extends MaterialStateMouseCursor { String get debugDescription => 'MaterialStateMouseCursor($name)'; } +/// Defines a [BorderSide] whose value depends on a set of [MaterialState]s +/// which represent the interactive state of a component. +/// +/// To use a [MaterialStateBorderSide], you should create a subclass of a +/// [MaterialStateBorderSide] and override the abstract `resolve` method. +/// +/// {@tool dartpad --template=stateful_widget_material} +/// +/// This example defines a subclass of [MaterialStateBorderSide], that resolves +/// to a red border side when its widget is selected. +/// +/// ```dart preamble +/// class RedSelectedBorderSide extends MaterialStateBorderSide { +/// @override +/// BorderSide resolve(Set states) { +/// if (states.contains(MaterialState.selected)) { +/// return BorderSide( +/// width: 1, +/// color: Colors.red, +/// ); +/// } +/// return null; // Defer to default value on the theme or widget. +/// } +/// } +/// ``` +/// +/// ```dart +/// bool isSelected = true; +/// +/// Widget build(BuildContext context) { +/// return FilterChip( +/// label: Text('Select chip'), +/// selected: isSelected, +/// onSelected: (bool value) { +/// setState(() { +/// isSelected = value; +/// }); +/// }, +/// side: RedSelectedBorderSide(), +/// ); +/// } +/// ``` +/// {@end-tool} +/// +/// This class should only be used for parameters which are documented to take +/// [MaterialStateBorderSide], otherwise only the default state will be used. +abstract class MaterialStateBorderSide extends BorderSide implements MaterialStateProperty { + /// Creates a [MaterialStateBorderSide]. + const MaterialStateBorderSide(); + + /// Returns a [BorderSide] that's to be used when a Material component is + /// in the specified state. Return null to defer to the default value of the + /// widget or theme. + @override + BorderSide? resolve(Set states); +} + +/// Defines an [OutlinedBorder] whose value depends on a set of [MaterialState]s +/// which represent the interactive state of a component. +/// +/// To use a [MaterialStateOutlinedBorder], you should create a subclass of an +/// [OutlinedBorder] and implement [MaterialStateOutlinedBorder]'s abstract +/// `resolve` method. +/// +/// {@tool dartpad --template=stateful_widget_material} +/// +/// This example defines a subclass of [RoundedRectangleBorder] and an +/// implementation of [MaterialStateOutlinedBorder], that resolves to +/// [RoundedRectangleBorder] when its widget is selected. +/// +/// ```dart preamble +/// class SelectedBorder extends RoundedRectangleBorder implements MaterialStateOutlinedBorder { +/// @override +/// OutlinedBorder resolve(Set states) { +/// if (states.contains(MaterialState.selected)) { +/// return RoundedRectangleBorder(); +/// } +/// return null; // Defer to default value on the theme or widget. +/// } +/// } +/// ``` +/// +/// ```dart +/// bool isSelected = true; +/// +/// Widget build(BuildContext context) { +/// return FilterChip( +/// label: Text('Select chip'), +/// selected: isSelected, +/// onSelected: (bool value) { +/// setState(() { +/// isSelected = value; +/// }); +/// }, +/// shape: SelectedBorder(), +/// ); +/// } +/// ``` +/// {@end-tool} +/// +/// This class should only be used for parameters which are documented to take +/// [MaterialStateOutlinedBorder], otherwise only the default state will be used. +/// +/// See also: +/// +/// * [ShapeBorder] the base class for shape outlines. +abstract class MaterialStateOutlinedBorder extends OutlinedBorder implements MaterialStateProperty { + /// Creates a [MaterialStateOutlinedBorder]. + const MaterialStateOutlinedBorder(); + + /// Returns an [OutlinedBorder] that's to be used when a Material component is + /// in the specified state. Return null to defer to the default value of the + /// widget or theme. + @override + OutlinedBorder? resolve(Set states); +} + /// Interface for classes that [resolve] to a value of type `T` based /// on a widget's interactive "state", which is defined as a set /// of [MaterialState]s. diff --git a/packages/flutter/test/material/chip_test.dart b/packages/flutter/test/material/chip_test.dart index 1e0a8f8b6b..9f7cfa340c 100644 --- a/packages/flutter/test/material/chip_test.dart +++ b/packages/flutter/test/material/chip_test.dart @@ -2382,6 +2382,216 @@ void main() { await gesture.removePointer(); }); + testWidgets('Chip uses stateful border side color in different states', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + + const Color pressedColor = Color(0x00000001); + const Color hoverColor = Color(0x00000002); + const Color focusedColor = Color(0x00000003); + const Color defaultColor = Color(0x00000004); + const Color selectedColor = Color(0x00000005); + const Color disabledColor = Color(0x00000006); + + BorderSide getBorderSide(Set states) { + Color sideColor = defaultColor; + + if (states.contains(MaterialState.disabled)) + sideColor = disabledColor; + + else if (states.contains(MaterialState.pressed)) + sideColor = pressedColor; + + else if (states.contains(MaterialState.hovered)) + sideColor = hoverColor; + + else if (states.contains(MaterialState.focused)) + sideColor = focusedColor; + + else if (states.contains(MaterialState.selected)) + sideColor = selectedColor; + + return BorderSide(color: sideColor, width: 1); + } + + Widget chipWidget({ bool enabled = true, bool selected = false }) { + return MaterialApp( + home: Scaffold( + body: Focus( + focusNode: focusNode, + child: ChoiceChip( + label: const Text('Chip'), + selected: selected, + onSelected: enabled ? (_) {} : null, + side: _MaterialStateBorderSide(getBorderSide), + ), + ), + ), + ); + } + + // Default, not disabled. + await tester.pumpWidget(chipWidget()); + expect(find.byType(RawChip), paints..rrect(color: defaultColor)); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect(find.byType(RawChip), paints..rrect(color: selectedColor)); + + // Focused. + final FocusNode chipFocusNode = focusNode.children.first; + chipFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(find.byType(RawChip), paints..rrect(color: focusedColor)); + + // Hovered. + final Offset center = tester.getCenter(find.byType(ChoiceChip)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(find.byType(RawChip), paints..rrect(color: hoverColor)); + + // Pressed. + await gesture.down(center); + await tester.pumpAndSettle(); + expect(find.byType(RawChip), paints..rrect(color: pressedColor)); + + // Disabled. + await tester.pumpWidget(chipWidget(enabled: false)); + await tester.pumpAndSettle(); + expect(find.byType(RawChip), paints..rrect(color: disabledColor)); + + // Teardown. + await gesture.removePointer(); + }); + + testWidgets('Chip uses stateful shape in different states', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + OutlinedBorder? getShape(Set states) { + + if (states.contains(MaterialState.disabled)) + return const BeveledRectangleBorder(); + + else if (states.contains(MaterialState.pressed)) + return const CircleBorder(); + + else if (states.contains(MaterialState.hovered)) + return const ContinuousRectangleBorder(); + + else if (states.contains(MaterialState.focused)) + return const RoundedRectangleBorder(); + + else if (states.contains(MaterialState.selected)) + return const BeveledRectangleBorder(); + + return null; + } + + Widget chipWidget({ bool enabled = true, bool selected = false }) { + return MaterialApp( + home: Scaffold( + body: Focus( + focusNode: focusNode, + child: ChoiceChip( + selected: selected, + label: const Text('Chip'), + shape: _MaterialStateOutlinedBorder(getShape), + onSelected: enabled ? (_) {} : null, + ), + ), + ), + ); + } + + // Default, not disabled. Defers to default shape. + await tester.pumpWidget(chipWidget()); + expect(getMaterial(tester).shape, isA()); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect(getMaterial(tester).shape, isA()); + + // Focused. + final FocusNode chipFocusNode = focusNode.children.first; + chipFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(getMaterial(tester).shape, isA()); + + // Hovered. + final Offset center = tester.getCenter(find.byType(ChoiceChip)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(getMaterial(tester).shape, isA()); + + // Pressed. + await gesture.down(center); + await tester.pumpAndSettle(); + expect(getMaterial(tester).shape, isA()); + + // Disabled. + await tester.pumpWidget(chipWidget(enabled: false)); + await tester.pumpAndSettle(); + expect(getMaterial(tester).shape, isA()); + + // Teardown. + await gesture.removePointer(); + }); + + testWidgets('Chip defers to theme, if shape and side resolves to null', (WidgetTester tester) async { + const OutlinedBorder themeShape = StadiumBorder(); + const OutlinedBorder selectedShape = RoundedRectangleBorder(); + const BorderSide themeBorderSide = BorderSide(color: Color(0x00000001), width: 1); + const BorderSide selectedBorderSide = BorderSide(color: Color(0x00000002), width: 1); + + OutlinedBorder? getShape(Set states) { + if (states.contains(MaterialState.selected)) + return selectedShape; + return null; + } + + BorderSide? getBorderSide(Set states) { + if (states.contains(MaterialState.selected)) + return selectedBorderSide; + return null; + } + + Widget chipWidget({ bool enabled = true, bool selected = false }) { + return MaterialApp( + theme: ThemeData( + chipTheme: ThemeData.light().chipTheme.copyWith( + shape: themeShape, + side: themeBorderSide, + ), + ), + home: Scaffold( + body: ChoiceChip( + selected: selected, + label: const Text('Chip'), + shape: _MaterialStateOutlinedBorder(getShape), + side: _MaterialStateBorderSide(getBorderSide), + onSelected: enabled ? (_) {} : null, + ), + ), + ); + } + + // Default, not disabled. Defer to theme. + await tester.pumpWidget(chipWidget()); + expect(getMaterial(tester).shape, isA()); + expect(find.byType(RawChip), paints..rrect(color: themeBorderSide.color)); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect(getMaterial(tester).shape, isA()); + expect(find.byType(RawChip), paints..drrect(color: selectedBorderSide.color)); + }); + testWidgets('loses focus when disabled', (WidgetTester tester) async { final FocusNode focusNode = FocusNode(debugLabel: 'InputChip'); await tester.pumpWidget( @@ -2707,3 +2917,21 @@ void main() { await tapGesture.up(); }); } + +class _MaterialStateOutlinedBorder extends StadiumBorder implements MaterialStateOutlinedBorder { + const _MaterialStateOutlinedBorder(this.resolver); + + final MaterialPropertyResolver resolver; + + @override + OutlinedBorder? resolve(Set states) => resolver(states); +} + +class _MaterialStateBorderSide extends MaterialStateBorderSide { + const _MaterialStateBorderSide(this.resolver); + + final MaterialPropertyResolver resolver; + + @override + BorderSide? resolve(Set states) => resolver(states); +} diff --git a/packages/flutter/test/material/chip_theme_test.dart b/packages/flutter/test/material/chip_theme_test.dart index 183fac8368..068c1a5d79 100644 --- a/packages/flutter/test/material/chip_theme_test.dart +++ b/packages/flutter/test/material/chip_theme_test.dart @@ -184,7 +184,8 @@ void main() { expect(lightTheme.secondarySelectedColor, equals(customColor1.withAlpha(0x3d))); expect(lightTheme.labelPadding, isNull); expect(lightTheme.padding, equals(const EdgeInsets.all(4.0))); - expect(lightTheme.shape, isA()); + expect(lightTheme.side, isNull); + expect(lightTheme.shape, isNull); expect(lightTheme.labelStyle.color, equals(Colors.black.withAlpha(0xde))); expect(lightTheme.secondaryLabelStyle.color, equals(customColor1.withAlpha(0xde))); expect(lightTheme.brightness, equals(Brightness.light)); @@ -202,7 +203,8 @@ void main() { expect(darkTheme.secondarySelectedColor, equals(customColor1.withAlpha(0x3d))); expect(darkTheme.labelPadding, isNull); expect(darkTheme.padding, equals(const EdgeInsets.all(4.0))); - expect(darkTheme.shape, isA()); + expect(darkTheme.side, isNull); + expect(darkTheme.shape, isNull); expect(darkTheme.labelStyle.color, equals(Colors.white.withAlpha(0xde))); expect(darkTheme.secondaryLabelStyle.color, equals(customColor1.withAlpha(0xde))); expect(darkTheme.brightness, equals(Brightness.dark)); @@ -220,7 +222,8 @@ void main() { expect(customTheme.secondarySelectedColor, equals(customColor2.withAlpha(0x3d))); expect(customTheme.labelPadding, isNull); expect(customTheme.padding, equals(const EdgeInsets.all(4.0))); - expect(customTheme.shape, isA()); + expect(customTheme.side, isNull); + expect(customTheme.shape, isNull); expect(customTheme.labelStyle.color, equals(customColor1.withAlpha(0xde))); expect(customTheme.secondaryLabelStyle.color, equals(customColor2.withAlpha(0xde))); expect(customTheme.brightness, equals(Brightness.light)); @@ -234,6 +237,8 @@ void main() { ).copyWith( elevation: 1.0, labelPadding: const EdgeInsets.symmetric(horizontal: 8.0), + shape: const StadiumBorder(), + side: const BorderSide(color: Colors.black), pressElevation: 4.0, shadowColor: Colors.black, selectedShadowColor: Colors.black, @@ -246,6 +251,8 @@ void main() { ).copyWith( padding: const EdgeInsets.all(2.0), labelPadding: const EdgeInsets.only(top: 8.0, bottom: 8.0), + shape: const BeveledRectangleBorder(), + side: const BorderSide(color: Colors.white), elevation: 5.0, pressElevation: 10.0, shadowColor: Colors.white, @@ -264,7 +271,8 @@ void main() { expect(lerp.selectedShadowColor, equals(middleGrey)); expect(lerp.labelPadding, equals(const EdgeInsets.all(4.0))); expect(lerp.padding, equals(const EdgeInsets.all(3.0))); - expect(lerp.shape, isA()); + expect(lerp.side!.color, equals(middleGrey)); + expect(lerp.shape, isA()); expect(lerp.labelStyle.color, equals(middleGrey.withAlpha(0xde))); expect(lerp.secondaryLabelStyle.color, equals(middleGrey.withAlpha(0xde))); expect(lerp.brightness, equals(Brightness.light)); @@ -284,7 +292,8 @@ void main() { expect(lerpANull25.selectedShadowColor, equals(Colors.white.withAlpha(0x40))); expect(lerpANull25.labelPadding, equals(const EdgeInsets.only(left: 0.0, top: 2.0, right: 0.0, bottom: 2.0))); expect(lerpANull25.padding, equals(const EdgeInsets.all(0.5))); - expect(lerpANull25.shape, isA()); + expect(lerpANull25.side!.color, equals(Colors.white.withAlpha(0x3f))); + expect(lerpANull25.shape, isA()); expect(lerpANull25.labelStyle.color, equals(Colors.black.withAlpha(0x38))); expect(lerpANull25.secondaryLabelStyle.color, equals(Colors.white.withAlpha(0x38))); expect(lerpANull25.brightness, equals(Brightness.light)); @@ -302,7 +311,8 @@ void main() { expect(lerpANull75.selectedShadowColor, equals(Colors.white.withAlpha(0xbf))); expect(lerpANull75.labelPadding, equals(const EdgeInsets.only(left: 0.0, top: 6.0, right: 0.0, bottom: 6.0))); expect(lerpANull75.padding, equals(const EdgeInsets.all(1.5))); - expect(lerpANull75.shape, isA()); + expect(lerpANull75.side!.color, equals(Colors.white.withAlpha(0xbf))); + expect(lerpANull75.shape, isA()); expect(lerpANull75.labelStyle.color, equals(Colors.black.withAlpha(0xa7))); expect(lerpANull75.secondaryLabelStyle.color, equals(Colors.white.withAlpha(0xa7))); expect(lerpANull75.brightness, equals(Brightness.light)); @@ -320,6 +330,7 @@ void main() { expect(lerpBNull25.selectedShadowColor, equals(Colors.black.withAlpha(0xbf))); expect(lerpBNull25.labelPadding, equals(const EdgeInsets.only(left: 6.0, top: 0.0, right: 6.0, bottom: 0.0))); expect(lerpBNull25.padding, equals(const EdgeInsets.all(3.0))); + expect(lerpBNull25.side!.color, equals(Colors.black.withAlpha(0x3f))); expect(lerpBNull25.shape, isA()); expect(lerpBNull25.labelStyle.color, equals(Colors.white.withAlpha(0xa7))); expect(lerpBNull25.secondaryLabelStyle.color, equals(Colors.black.withAlpha(0xa7))); @@ -338,6 +349,7 @@ void main() { expect(lerpBNull75.selectedShadowColor, equals(Colors.black.withAlpha(0x40))); expect(lerpBNull75.labelPadding, equals(const EdgeInsets.only(left: 2.0, top: 0.0, right: 2.0, bottom: 0.0))); expect(lerpBNull75.padding, equals(const EdgeInsets.all(1.0))); + expect(lerpBNull75.side!.color, equals(Colors.black.withAlpha(0xbf))); expect(lerpBNull75.shape, isA()); expect(lerpBNull75.labelStyle.color, equals(Colors.white.withAlpha(0x38))); expect(lerpBNull75.secondaryLabelStyle.color, equals(Colors.black.withAlpha(0x38))); @@ -440,4 +452,95 @@ void main() { // Teardown. await gesture.removePointer(); }); + + testWidgets('Chip uses stateful border side from chip theme', (WidgetTester tester) async { + const Color selectedColor = Color(0x00000001); + const Color defaultColor = Color(0x00000002); + + BorderSide getBorderSide(Set states) { + Color color = defaultColor; + + if (states.contains(MaterialState.selected)) + color = selectedColor; + + return BorderSide(color: color, width: 1); + } + + Widget chipWidget({ bool selected = false }) { + return MaterialApp( + theme: ThemeData( + chipTheme: ThemeData.light().chipTheme.copyWith( + side: _MaterialStateBorderSide(getBorderSide), + ), + ), + home: Scaffold( + body: ChoiceChip( + label: const Text('Chip'), + selected: selected, + onSelected: (_) {}, + ), + ), + ); + } + + // Default. + await tester.pumpWidget(chipWidget()); + expect(find.byType(RawChip), paints..rrect(color: defaultColor)); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect(find.byType(RawChip), paints..rrect(color: selectedColor)); + }); + + testWidgets('Chip uses stateful shape from chip theme', (WidgetTester tester) async { + OutlinedBorder? getShape(Set states) { + if (states.contains(MaterialState.selected)) + return const RoundedRectangleBorder(); + + return null; + } + + Widget chipWidget({ bool selected = false }) { + return MaterialApp( + theme: ThemeData( + chipTheme: ThemeData.light().chipTheme.copyWith( + shape: _MaterialStateOutlinedBorder(getShape), + ), + ), + home: Scaffold( + body: ChoiceChip( + label: const Text('Chip'), + selected: selected, + onSelected: (_) {}, + ), + ), + ); + } + + // Default. + await tester.pumpWidget(chipWidget()); + expect(getMaterial(tester).shape, isA()); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect(getMaterial(tester).shape, isA()); + }); +} + +class _MaterialStateOutlinedBorder extends StadiumBorder implements MaterialStateOutlinedBorder { + const _MaterialStateOutlinedBorder(this.resolver); + + final MaterialPropertyResolver resolver; + + @override + OutlinedBorder? resolve(Set states) => resolver(states); +} + +class _MaterialStateBorderSide extends MaterialStateBorderSide { + const _MaterialStateBorderSide(this.resolver); + + final MaterialPropertyResolver resolver; + + @override + BorderSide? resolve(Set states) => resolver(states); }