diff --git a/packages/flutter/lib/src/material/button.dart b/packages/flutter/lib/src/material/button.dart index aea49ce2b8..b9a15b25bf 100644 --- a/packages/flutter/lib/src/material/button.dart +++ b/packages/flutter/lib/src/material/button.dart @@ -87,7 +87,7 @@ class RawMaterialButton extends StatefulWidget { /// Defines the default text style, with [Material.textStyle], for the /// button's [child]. /// - /// If [textStyle.color] is a [MaterialStateColor], [MaterialStateColor.resolveColor] + /// If [textStyle.color] is a [MaterialStateProperty], [MaterialStateProperty.resolve] /// is used for the following [MaterialState]s: /// /// * [MaterialState.pressed]. @@ -199,6 +199,14 @@ class RawMaterialButton extends StatefulWidget { /// /// The button's highlight and splash are clipped to this shape. If the /// button has an elevation, then its drop shadow is defined by this shape. + /// + /// If [shape] is a [MaterialStateProperty], [MaterialStateProperty.resolve] + /// is used for the following [MaterialState]s: + /// + /// * [MaterialState.pressed]. + /// * [MaterialState.hovered]. + /// * [MaterialState.focused]. + /// * [MaterialState.disabled]. final ShapeBorder shape; /// Defines the duration of animated changes for [shape] and [elevation]. @@ -317,7 +325,8 @@ class _RawMaterialButtonState extends State { @override Widget build(BuildContext context) { - final Color effectiveTextColor = MaterialStateColor.resolveColor(widget.textStyle?.color, _states); + final Color effectiveTextColor = MaterialStateProperty.resolveAs(widget.textStyle?.color, _states); + final ShapeBorder effectiveShape = MaterialStateProperty.resolveAs(widget.shape, _states); final Widget result = Focus( focusNode: widget.focusNode, @@ -327,7 +336,7 @@ class _RawMaterialButtonState extends State { child: Material( elevation: _effectiveElevation, textStyle: widget.textStyle?.copyWith(color: effectiveTextColor), - shape: widget.shape, + shape: effectiveShape, color: widget.fillColor, type: widget.fillColor == null ? MaterialType.transparency : MaterialType.button, animationDuration: widget.animationDuration, @@ -340,7 +349,7 @@ class _RawMaterialButtonState extends State { hoverColor: widget.hoverColor, onHover: _handleHoveredChanged, onTap: widget.onPressed, - customBorder: widget.shape, + customBorder: effectiveShape, child: IconTheme.merge( data: IconThemeData(color: effectiveTextColor), child: Container( diff --git a/packages/flutter/lib/src/material/button_theme.dart b/packages/flutter/lib/src/material/button_theme.dart index 9aa59d74e0..742f9e2b9c 100644 --- a/packages/flutter/lib/src/material/button_theme.dart +++ b/packages/flutter/lib/src/material/button_theme.dart @@ -481,11 +481,10 @@ class ButtonThemeData extends Diagnosticable { /// Otherwise the color scheme's [ColorScheme.onSurface] color is returned /// with its opacity set to 0.30 if [getBrightness] is dark, 0.38 otherwise. /// - /// If [MaterialButton.textColor] is a [MaterialStateColor], it will be used - /// as the `disabledTextColor`. It will be resolved in the - /// [MaterialState.disabled] state. + /// If [MaterialButton.textColor] is a [MaterialStateProperty], it will be + /// used as the `disabledTextColor`. It will be resolved in the [MaterialState.disabled] state. Color getDisabledTextColor(MaterialButton button) { - if (button.textColor is MaterialStateColor) + if (button.textColor is MaterialStateProperty) return button.textColor; if (button.disabledTextColor != null) return button.disabledTextColor; diff --git a/packages/flutter/lib/src/material/material_button.dart b/packages/flutter/lib/src/material/material_button.dart index 7612f63eb0..d17e9ddc5f 100644 --- a/packages/flutter/lib/src/material/material_button.dart +++ b/packages/flutter/lib/src/material/material_button.dart @@ -100,8 +100,8 @@ class MaterialButton extends StatelessWidget { /// The default text color depends on the button theme's text theme, /// [ButtonThemeData.textTheme]. /// - /// If [textColor] is a [MaterialStateColor], [disabledTextColor] will be - /// ignored. + /// If [textColor] is a [MaterialStateProperty], [disabledTextColor] + /// will be ignored. /// /// See also: /// @@ -117,8 +117,8 @@ class MaterialButton extends StatelessWidget { /// The default value is the theme's disabled color, /// [ThemeData.disabledColor]. /// - /// If [textColor] is a [MaterialStateColor], [disabledTextColor] will be - /// ignored. + /// If [textColor] is a [MaterialStateProperty], [disabledTextColor] + /// will be ignored. /// /// See also: /// diff --git a/packages/flutter/lib/src/material/material_state.dart b/packages/flutter/lib/src/material/material_state.dart index 0cadb2fa9a..0a6dd438d6 100644 --- a/packages/flutter/lib/src/material/material_state.dart +++ b/packages/flutter/lib/src/material/material_state.dart @@ -62,8 +62,9 @@ enum MaterialState { error, } -/// Signature for the function that returns a color based on a given set of states. -typedef MaterialStateColorResolver = Color Function(Set states); +/// Signature for the function that returns a value of type `T` based on a given +/// set of states. +typedef MaterialPropertyResolver = T Function(Set states); /// Defines a [Color] whose value depends on a set of [MaterialState]s which /// represent the interactive state of a component. @@ -109,7 +110,7 @@ typedef MaterialStateColorResolver = Color Function(Set states); /// ), /// ``` /// {@end-tool} -abstract class MaterialStateColor extends Color { +abstract class MaterialStateColor extends Color implements MaterialStateProperty { /// Creates a [MaterialStateColor]. /// /// If you want a `const` [MaterialStateColor], you'll need to extend @@ -141,33 +142,24 @@ abstract class MaterialStateColor extends Color { /// {@end-tool} const MaterialStateColor(int defaultValue) : super(defaultValue); - /// Creates a [MaterialStateColor] from a [MaterialStateColorResolver] callback function. + /// Creates a [MaterialStateColor] from a [MaterialPropertyResolver] + /// callback function. /// /// If used as a regular color, the color resolved in the default state (the /// empty set of states) will be used. /// /// The given callback parameter must return a non-null color in the default /// state. - factory MaterialStateColor.resolveWith(MaterialStateColorResolver callback) => _MaterialStateColor(callback); + static MaterialStateColor resolveWith(MaterialPropertyResolver callback) => _MaterialStateColor(callback); /// Returns a [Color] that's to be used when a Material component is in the /// specified state. + @override Color resolve(Set states); - - /// Returns the color for the given set of states if `color` is a - /// [MaterialStateColor], otherwise returns the color itself. - /// - /// This is useful for widgets that have parameters which can be [Color] or - /// [MaterialStateColor] values. - static Color resolveColor(Color color, Set states) { - if (color is MaterialStateColor) { - return color.resolve(states); - } - return color; - } } -/// A [MaterialStateColor] created from a [MaterialStateColorResolver] callback alone. +/// A [MaterialStateColor] created from a [MaterialPropertyResolver] +/// callback alone. /// /// If used as a regular color, the color resolved in the default state will /// be used. @@ -176,7 +168,7 @@ abstract class MaterialStateColor extends Color { class _MaterialStateColor extends MaterialStateColor { _MaterialStateColor(this._resolve) : super(_resolve(_defaultStates).value); - final MaterialStateColorResolver _resolve; + final MaterialPropertyResolver _resolve; /// The default state for a Material component, the empty set of interaction states. static const Set _defaultStates = {}; @@ -184,3 +176,46 @@ class _MaterialStateColor extends MaterialStateColor { @override Color resolve(Set states) => _resolve(states); } + +/// Interface for classes that can return a value of type `T` based on a set of +/// [MaterialState]s. +/// +/// For example, [MaterialStateColor] implements `MaterialStateProperty` +/// because it has a `resolve` method that returns a different [Color] depending +/// on the given set of [MaterialState]s. +abstract class MaterialStateProperty { + + /// Returns a different value of type `T` depending on the given `states`. + /// + /// Some widgets (such as [RawMaterialButton]) keep track of their set of + /// [MaterialState]s, and will call `resolve` with the current states at build + /// time for specified properties (such as [RawMaterialButton.textStyle]'s color). + T resolve(Set states); + + /// Returns the value resolved in the given set of states if `value` is a + /// [MaterialStateProperty], otherwise returns the value itself. + /// + /// This is useful for widgets that have parameters which can optionally be a + /// [MaterialStateProperty]. For example, [RaisedButton.textColor] can be a + /// [Color] or a [MaterialStateProperty]. + static T resolveAs(T value, Set states) { + if (value is MaterialStateProperty) { + final MaterialStateProperty property = value; + return property.resolve(states); + } + return value; + } + + /// Convenience method for creating a [MaterialStateProperty] from a + /// [MaterialPropertyResolver] function alone. + static MaterialStateProperty resolveWith(MaterialPropertyResolver callback) => _MaterialStateProperty(callback); +} + +class _MaterialStateProperty implements MaterialStateProperty { + _MaterialStateProperty(this._resolve); + + final MaterialPropertyResolver _resolve; + + @override + T resolve(Set states) => _resolve(states); +} diff --git a/packages/flutter/lib/src/material/outline_button.dart b/packages/flutter/lib/src/material/outline_button.dart index 58ace4fc77..decbed5599 100644 --- a/packages/flutter/lib/src/material/outline_button.dart +++ b/packages/flutter/lib/src/material/outline_button.dart @@ -8,6 +8,7 @@ import 'package:flutter/widgets.dart'; import 'button_theme.dart'; import 'colors.dart'; import 'material_button.dart'; +import 'material_state.dart'; import 'raised_button.dart'; import 'theme.dart'; @@ -132,12 +133,16 @@ class OutlineButton extends MaterialButton { /// /// By default the border's color does not change when the button /// is pressed. + /// + /// This field is ignored if [borderSide.color] is a [MaterialStateProperty]. final Color highlightedBorderColor; /// The outline border's color when the button is not [enabled]. /// /// By default the outline border's color does not change when the /// button is disabled. + /// + /// This field is ignored if [borderSide.color] is a [MaterialStateProperty]. final Color disabledBorderColor; /// Defines the color of the border when the button is enabled but not @@ -148,6 +153,10 @@ class OutlineButton extends MaterialButton { /// /// If null the default border's style is [BorderStyle.solid], its /// [BorderSide.width] is 1.0, and its color is a light shade of grey. + /// + /// If [borderSide.color] is a [MaterialStateProperty], [MaterialStateProperty.resolve] + /// is used in all states and both [highlightedBorderColor] and [disabledBorderColor] + /// are ignored. final BorderSide borderSide; @override @@ -370,18 +379,26 @@ class _OutlineButtonState extends State<_OutlineButton> with SingleTickerProvide return colorTween.evaluate(_fillAnimation); } + Color get _outlineColor { + // If outline color is a `MaterialStateProperty`, it will be used in all + // states, otherwise we determine the outline color in the current state. + if (widget.borderSide?.color is MaterialStateProperty) + return widget.borderSide.color; + if (!widget.enabled) + return widget.disabledBorderColor; + if (_pressed) + return widget.highlightedBorderColor; + return widget.borderSide?.color; + } + BorderSide _getOutline() { if (widget.borderSide?.style == BorderStyle.none) return widget.borderSide; - final Color specifiedColor = widget.enabled - ? (_pressed ? widget.highlightedBorderColor : null) ?? widget.borderSide?.color - : widget.disabledBorderColor; - final Color themeColor = Theme.of(context).colorScheme.onSurface.withOpacity(0.12); return BorderSide( - color: specifiedColor ?? themeColor, + color: _outlineColor ?? themeColor, width: widget.borderSide?.width ?? 1.0, ); } @@ -433,7 +450,7 @@ class _OutlineButtonState extends State<_OutlineButton> with SingleTickerProvide // Render the button's outline border using using the OutlineButton's // border parameters and the button or buttonTheme's shape. -class _OutlineBorder extends ShapeBorder { +class _OutlineBorder extends ShapeBorder implements MaterialStateProperty{ const _OutlineBorder({ @required this.shape, @required this.side, @@ -512,4 +529,12 @@ class _OutlineBorder extends ShapeBorder { @override int get hashCode => hashValues(side, shape); + + @override + ShapeBorder resolve(Set states) { + return _OutlineBorder( + shape: shape, + side: side.copyWith(color: MaterialStateProperty.resolveAs(side.color, states), + )); + } } diff --git a/packages/flutter/test/material/outline_button_test.dart b/packages/flutter/test/material/outline_button_test.dart index e26a860955..33e01dc908 100644 --- a/packages/flutter/test/material/outline_button_test.dart +++ b/packages/flutter/test/material/outline_button_test.dart @@ -318,6 +318,138 @@ void main() { expect(textColor(), isNot(unusedDisabledTextColor)); }); + testWidgets('OutlineButton uses stateful color for border color in different states', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + + const Color pressedColor = Color(1); + const Color hoverColor = Color(2); + const Color focusedColor = Color(3); + const Color defaultColor = Color(4); + + Color getBorderColor(Set states) { + if (states.contains(MaterialState.pressed)) { + return pressedColor; + } + if (states.contains(MaterialState.hovered)) { + return hoverColor; + } + if (states.contains(MaterialState.focused)) { + return focusedColor; + } + return defaultColor; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: OutlineButton( + child: const Text('OutlineButton'), + onPressed: () {}, + focusNode: focusNode, + borderSide: BorderSide(color: MaterialStateColor.resolveWith(getBorderColor)), + ), + ), + ), + ), + ); + + final Finder outlineButton = find.byType(OutlineButton); + + // Default, not disabled. + expect(outlineButton, paints..path(color: defaultColor)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(outlineButton, paints..path(color: focusedColor)); + + // Hovered. + final Offset center = tester.getCenter(find.byType(OutlineButton)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(outlineButton, paints..path(color: hoverColor)); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect(outlineButton, paints..path(color: pressedColor)); + await gesture.removePointer(); + }); + + testWidgets('OutlineButton ignores highlightBorderColor if border color is stateful', (WidgetTester tester) async { + const Color pressedColor = Color(1); + const Color defaultColor = Color(2); + const Color ignoredPressedColor = Color(3); + + Color getBorderColor(Set states) { + if (states.contains(MaterialState.pressed)) { + return pressedColor; + } + return defaultColor; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: OutlineButton( + child: const Text('OutlineButton'), + onPressed: () {}, + borderSide: BorderSide(color: MaterialStateColor.resolveWith(getBorderColor)), + highlightedBorderColor: ignoredPressedColor, + ), + ), + ), + ), + ); + + final Finder outlineButton = find.byType(OutlineButton); + + // Default, not disabled. + expect(outlineButton, paints..path(color: defaultColor)); + + // Highlighted (pressed). + await tester.press(outlineButton); + await tester.pumpAndSettle(); + expect(outlineButton, paints..path(color: pressedColor)); + }); + + testWidgets('OutlineButton ignores disabledBorderColor if border color is stateful', (WidgetTester tester) async { + const Color disabledColor = Color(1); + const Color defaultColor = Color(2); + const Color ignoredDisabledColor = Color(3); + + Color getBorderColor(Set states) { + if (states.contains(MaterialState.disabled)) { + return disabledColor; + } + return defaultColor; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: OutlineButton( + child: const Text('OutlineButton'), + onPressed: null, + borderSide: BorderSide(color: MaterialStateColor.resolveWith(getBorderColor)), + highlightedBorderColor: ignoredDisabledColor, + ), + ), + ), + ), + ); + + // Disabled. + expect(find.byType(OutlineButton), paints..path(color: disabledColor)); + }); + testWidgets('Outline button responds to tap when enabled', (WidgetTester tester) async { int pressedCount = 0;