Unverified Commit 3f941599 authored by MH Johnson's avatar MH Johnson Committed by GitHub

[Material] Support for hovered, focused, and pressed border color on `OutlineButton`s (#34872)

* outline border implements material state property
parent d780c2cf
...@@ -87,7 +87,7 @@ class RawMaterialButton extends StatefulWidget { ...@@ -87,7 +87,7 @@ class RawMaterialButton extends StatefulWidget {
/// Defines the default text style, with [Material.textStyle], for the /// Defines the default text style, with [Material.textStyle], for the
/// button's [child]. /// button's [child].
/// ///
/// If [textStyle.color] is a [MaterialStateColor], [MaterialStateColor.resolveColor] /// If [textStyle.color] is a [MaterialStateProperty<Color>], [MaterialStateProperty.resolve]
/// is used for the following [MaterialState]s: /// is used for the following [MaterialState]s:
/// ///
/// * [MaterialState.pressed]. /// * [MaterialState.pressed].
...@@ -199,6 +199,14 @@ class RawMaterialButton extends StatefulWidget { ...@@ -199,6 +199,14 @@ class RawMaterialButton extends StatefulWidget {
/// ///
/// The button's highlight and splash are clipped to this shape. If the /// 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. /// button has an elevation, then its drop shadow is defined by this shape.
///
/// If [shape] is a [MaterialStateProperty<ShapeBorder>], [MaterialStateProperty.resolve]
/// is used for the following [MaterialState]s:
///
/// * [MaterialState.pressed].
/// * [MaterialState.hovered].
/// * [MaterialState.focused].
/// * [MaterialState.disabled].
final ShapeBorder shape; final ShapeBorder shape;
/// Defines the duration of animated changes for [shape] and [elevation]. /// Defines the duration of animated changes for [shape] and [elevation].
...@@ -317,7 +325,8 @@ class _RawMaterialButtonState extends State<RawMaterialButton> { ...@@ -317,7 +325,8 @@ class _RawMaterialButtonState extends State<RawMaterialButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color effectiveTextColor = MaterialStateColor.resolveColor(widget.textStyle?.color, _states); final Color effectiveTextColor = MaterialStateProperty.resolveAs<Color>(widget.textStyle?.color, _states);
final ShapeBorder effectiveShape = MaterialStateProperty.resolveAs<ShapeBorder>(widget.shape, _states);
final Widget result = Focus( final Widget result = Focus(
focusNode: widget.focusNode, focusNode: widget.focusNode,
...@@ -327,7 +336,7 @@ class _RawMaterialButtonState extends State<RawMaterialButton> { ...@@ -327,7 +336,7 @@ class _RawMaterialButtonState extends State<RawMaterialButton> {
child: Material( child: Material(
elevation: _effectiveElevation, elevation: _effectiveElevation,
textStyle: widget.textStyle?.copyWith(color: effectiveTextColor), textStyle: widget.textStyle?.copyWith(color: effectiveTextColor),
shape: widget.shape, shape: effectiveShape,
color: widget.fillColor, color: widget.fillColor,
type: widget.fillColor == null ? MaterialType.transparency : MaterialType.button, type: widget.fillColor == null ? MaterialType.transparency : MaterialType.button,
animationDuration: widget.animationDuration, animationDuration: widget.animationDuration,
...@@ -340,7 +349,7 @@ class _RawMaterialButtonState extends State<RawMaterialButton> { ...@@ -340,7 +349,7 @@ class _RawMaterialButtonState extends State<RawMaterialButton> {
hoverColor: widget.hoverColor, hoverColor: widget.hoverColor,
onHover: _handleHoveredChanged, onHover: _handleHoveredChanged,
onTap: widget.onPressed, onTap: widget.onPressed,
customBorder: widget.shape, customBorder: effectiveShape,
child: IconTheme.merge( child: IconTheme.merge(
data: IconThemeData(color: effectiveTextColor), data: IconThemeData(color: effectiveTextColor),
child: Container( child: Container(
......
...@@ -481,11 +481,10 @@ class ButtonThemeData extends Diagnosticable { ...@@ -481,11 +481,10 @@ class ButtonThemeData extends Diagnosticable {
/// Otherwise the color scheme's [ColorScheme.onSurface] color is returned /// Otherwise the color scheme's [ColorScheme.onSurface] color is returned
/// with its opacity set to 0.30 if [getBrightness] is dark, 0.38 otherwise. /// with its opacity set to 0.30 if [getBrightness] is dark, 0.38 otherwise.
/// ///
/// If [MaterialButton.textColor] is a [MaterialStateColor], it will be used /// If [MaterialButton.textColor] is a [MaterialStateProperty<Color>], it will be
/// as the `disabledTextColor`. It will be resolved in the /// used as the `disabledTextColor`. It will be resolved in the [MaterialState.disabled] state.
/// [MaterialState.disabled] state.
Color getDisabledTextColor(MaterialButton button) { Color getDisabledTextColor(MaterialButton button) {
if (button.textColor is MaterialStateColor) if (button.textColor is MaterialStateProperty<Color>)
return button.textColor; return button.textColor;
if (button.disabledTextColor != null) if (button.disabledTextColor != null)
return button.disabledTextColor; return button.disabledTextColor;
......
...@@ -100,8 +100,8 @@ class MaterialButton extends StatelessWidget { ...@@ -100,8 +100,8 @@ class MaterialButton extends StatelessWidget {
/// The default text color depends on the button theme's text theme, /// The default text color depends on the button theme's text theme,
/// [ButtonThemeData.textTheme]. /// [ButtonThemeData.textTheme].
/// ///
/// If [textColor] is a [MaterialStateColor], [disabledTextColor] will be /// If [textColor] is a [MaterialStateProperty<Color>], [disabledTextColor]
/// ignored. /// will be ignored.
/// ///
/// See also: /// See also:
/// ///
...@@ -117,8 +117,8 @@ class MaterialButton extends StatelessWidget { ...@@ -117,8 +117,8 @@ class MaterialButton extends StatelessWidget {
/// The default value is the theme's disabled color, /// The default value is the theme's disabled color,
/// [ThemeData.disabledColor]. /// [ThemeData.disabledColor].
/// ///
/// If [textColor] is a [MaterialStateColor], [disabledTextColor] will be /// If [textColor] is a [MaterialStateProperty<Color>], [disabledTextColor]
/// ignored. /// will be ignored.
/// ///
/// See also: /// See also:
/// ///
......
...@@ -62,8 +62,9 @@ enum MaterialState { ...@@ -62,8 +62,9 @@ enum MaterialState {
error, error,
} }
/// Signature for the function that returns a color based on a given set of states. /// Signature for the function that returns a value of type `T` based on a given
typedef MaterialStateColorResolver = Color Function(Set<MaterialState> states); /// set of states.
typedef MaterialPropertyResolver<T> = T Function(Set<MaterialState> states);
/// Defines a [Color] whose value depends on a set of [MaterialState]s which /// Defines a [Color] whose value depends on a set of [MaterialState]s which
/// represent the interactive state of a component. /// represent the interactive state of a component.
...@@ -109,7 +110,7 @@ typedef MaterialStateColorResolver = Color Function(Set<MaterialState> states); ...@@ -109,7 +110,7 @@ typedef MaterialStateColorResolver = Color Function(Set<MaterialState> states);
/// ), /// ),
/// ``` /// ```
/// {@end-tool} /// {@end-tool}
abstract class MaterialStateColor extends Color { abstract class MaterialStateColor extends Color implements MaterialStateProperty<Color> {
/// Creates a [MaterialStateColor]. /// Creates a [MaterialStateColor].
/// ///
/// If you want a `const` [MaterialStateColor], you'll need to extend /// If you want a `const` [MaterialStateColor], you'll need to extend
...@@ -141,33 +142,24 @@ abstract class MaterialStateColor extends Color { ...@@ -141,33 +142,24 @@ abstract class MaterialStateColor extends Color {
/// {@end-tool} /// {@end-tool}
const MaterialStateColor(int defaultValue) : super(defaultValue); const MaterialStateColor(int defaultValue) : super(defaultValue);
/// Creates a [MaterialStateColor] from a [MaterialStateColorResolver] callback function. /// Creates a [MaterialStateColor] from a [MaterialPropertyResolver<Color>]
/// callback function.
/// ///
/// If used as a regular color, the color resolved in the default state (the /// If used as a regular color, the color resolved in the default state (the
/// empty set of states) will be used. /// empty set of states) will be used.
/// ///
/// The given callback parameter must return a non-null color in the default /// The given callback parameter must return a non-null color in the default
/// state. /// state.
factory MaterialStateColor.resolveWith(MaterialStateColorResolver callback) => _MaterialStateColor(callback); static MaterialStateColor resolveWith(MaterialPropertyResolver<Color> callback) => _MaterialStateColor(callback);
/// Returns a [Color] that's to be used when a Material component is in the /// Returns a [Color] that's to be used when a Material component is in the
/// specified state. /// specified state.
@override
Color resolve(Set<MaterialState> states); Color resolve(Set<MaterialState> 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<MaterialState> 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<Color>]
/// callback alone.
/// ///
/// If used as a regular color, the color resolved in the default state will /// If used as a regular color, the color resolved in the default state will
/// be used. /// be used.
...@@ -176,7 +168,7 @@ abstract class MaterialStateColor extends Color { ...@@ -176,7 +168,7 @@ abstract class MaterialStateColor extends Color {
class _MaterialStateColor extends MaterialStateColor { class _MaterialStateColor extends MaterialStateColor {
_MaterialStateColor(this._resolve) : super(_resolve(_defaultStates).value); _MaterialStateColor(this._resolve) : super(_resolve(_defaultStates).value);
final MaterialStateColorResolver _resolve; final MaterialPropertyResolver<Color> _resolve;
/// The default state for a Material component, the empty set of interaction states. /// The default state for a Material component, the empty set of interaction states.
static const Set<MaterialState> _defaultStates = <MaterialState>{}; static const Set<MaterialState> _defaultStates = <MaterialState>{};
...@@ -184,3 +176,46 @@ class _MaterialStateColor extends MaterialStateColor { ...@@ -184,3 +176,46 @@ class _MaterialStateColor extends MaterialStateColor {
@override @override
Color resolve(Set<MaterialState> states) => _resolve(states); Color resolve(Set<MaterialState> 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<Color>`
/// because it has a `resolve` method that returns a different [Color] depending
/// on the given set of [MaterialState]s.
abstract class MaterialStateProperty<T> {
/// 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<MaterialState> 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<Color>].
static T resolveAs<T>(T value, Set<MaterialState> states) {
if (value is MaterialStateProperty<T>) {
final MaterialStateProperty<T> property = value;
return property.resolve(states);
}
return value;
}
/// Convenience method for creating a [MaterialStateProperty] from a
/// [MaterialPropertyResolver] function alone.
static MaterialStateProperty<T> resolveWith<T>(MaterialPropertyResolver<T> callback) => _MaterialStateProperty<T>(callback);
}
class _MaterialStateProperty<T> implements MaterialStateProperty<T> {
_MaterialStateProperty(this._resolve);
final MaterialPropertyResolver<T> _resolve;
@override
T resolve(Set<MaterialState> states) => _resolve(states);
}
...@@ -8,6 +8,7 @@ import 'package:flutter/widgets.dart'; ...@@ -8,6 +8,7 @@ import 'package:flutter/widgets.dart';
import 'button_theme.dart'; import 'button_theme.dart';
import 'colors.dart'; import 'colors.dart';
import 'material_button.dart'; import 'material_button.dart';
import 'material_state.dart';
import 'raised_button.dart'; import 'raised_button.dart';
import 'theme.dart'; import 'theme.dart';
...@@ -132,12 +133,16 @@ class OutlineButton extends MaterialButton { ...@@ -132,12 +133,16 @@ class OutlineButton extends MaterialButton {
/// ///
/// By default the border's color does not change when the button /// By default the border's color does not change when the button
/// is pressed. /// is pressed.
///
/// This field is ignored if [borderSide.color] is a [MaterialStateProperty<Color>].
final Color highlightedBorderColor; final Color highlightedBorderColor;
/// The outline border's color when the button is not [enabled]. /// The outline border's color when the button is not [enabled].
/// ///
/// By default the outline border's color does not change when the /// By default the outline border's color does not change when the
/// button is disabled. /// button is disabled.
///
/// This field is ignored if [borderSide.color] is a [MaterialStateProperty<Color>].
final Color disabledBorderColor; final Color disabledBorderColor;
/// Defines the color of the border when the button is enabled but not /// Defines the color of the border when the button is enabled but not
...@@ -148,6 +153,10 @@ class OutlineButton extends MaterialButton { ...@@ -148,6 +153,10 @@ class OutlineButton extends MaterialButton {
/// ///
/// If null the default border's style is [BorderStyle.solid], its /// 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. /// [BorderSide.width] is 1.0, and its color is a light shade of grey.
///
/// If [borderSide.color] is a [MaterialStateProperty<Color>], [MaterialStateProperty.resolve]
/// is used in all states and both [highlightedBorderColor] and [disabledBorderColor]
/// are ignored.
final BorderSide borderSide; final BorderSide borderSide;
@override @override
...@@ -370,18 +379,26 @@ class _OutlineButtonState extends State<_OutlineButton> with SingleTickerProvide ...@@ -370,18 +379,26 @@ class _OutlineButtonState extends State<_OutlineButton> with SingleTickerProvide
return colorTween.evaluate(_fillAnimation); 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<Color>)
return widget.borderSide.color;
if (!widget.enabled)
return widget.disabledBorderColor;
if (_pressed)
return widget.highlightedBorderColor;
return widget.borderSide?.color;
}
BorderSide _getOutline() { BorderSide _getOutline() {
if (widget.borderSide?.style == BorderStyle.none) if (widget.borderSide?.style == BorderStyle.none)
return widget.borderSide; 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); final Color themeColor = Theme.of(context).colorScheme.onSurface.withOpacity(0.12);
return BorderSide( return BorderSide(
color: specifiedColor ?? themeColor, color: _outlineColor ?? themeColor,
width: widget.borderSide?.width ?? 1.0, width: widget.borderSide?.width ?? 1.0,
); );
} }
...@@ -433,7 +450,7 @@ class _OutlineButtonState extends State<_OutlineButton> with SingleTickerProvide ...@@ -433,7 +450,7 @@ class _OutlineButtonState extends State<_OutlineButton> with SingleTickerProvide
// Render the button's outline border using using the OutlineButton's // Render the button's outline border using using the OutlineButton's
// border parameters and the button or buttonTheme's shape. // border parameters and the button or buttonTheme's shape.
class _OutlineBorder extends ShapeBorder { class _OutlineBorder extends ShapeBorder implements MaterialStateProperty<ShapeBorder>{
const _OutlineBorder({ const _OutlineBorder({
@required this.shape, @required this.shape,
@required this.side, @required this.side,
...@@ -512,4 +529,12 @@ class _OutlineBorder extends ShapeBorder { ...@@ -512,4 +529,12 @@ class _OutlineBorder extends ShapeBorder {
@override @override
int get hashCode => hashValues(side, shape); int get hashCode => hashValues(side, shape);
@override
ShapeBorder resolve(Set<MaterialState> states) {
return _OutlineBorder(
shape: shape,
side: side.copyWith(color: MaterialStateProperty.resolveAs<Color>(side.color, states),
));
}
} }
...@@ -318,6 +318,138 @@ void main() { ...@@ -318,6 +318,138 @@ void main() {
expect(textColor(), isNot(unusedDisabledTextColor)); 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<MaterialState> 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<MaterialState> 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<MaterialState> 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 { testWidgets('Outline button responds to tap when enabled', (WidgetTester tester) async {
int pressedCount = 0; int pressedCount = 0;
......
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