diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index 97d44317093f3..e9ec07581451f 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -76,6 +76,7 @@ export 'src/material/list_tile.dart'; export 'src/material/material.dart'; export 'src/material/material_button.dart'; export 'src/material/material_localizations.dart'; +export 'src/material/material_state.dart'; export 'src/material/mergeable_material.dart'; export 'src/material/outline_button.dart'; export 'src/material/page.dart'; diff --git a/packages/flutter/lib/src/material/button.dart b/packages/flutter/lib/src/material/button.dart index 84b30dfeec099..aea49ce2b8b42 100644 --- a/packages/flutter/lib/src/material/button.dart +++ b/packages/flutter/lib/src/material/button.dart @@ -13,6 +13,7 @@ import 'button_theme.dart'; import 'constants.dart'; import 'ink_well.dart'; import 'material.dart'; +import 'material_state.dart'; import 'theme.dart'; import 'theme_data.dart'; @@ -85,6 +86,14 @@ 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] + /// is used for the following [MaterialState]s: + /// + /// * [MaterialState.pressed]. + /// * [MaterialState.hovered]. + /// * [MaterialState.focused]. + /// * [MaterialState.disabled]. final TextStyle textStyle; /// The color of the button's [Material]. @@ -231,14 +240,21 @@ class RawMaterialButton extends StatefulWidget { } class _RawMaterialButtonState extends State { - bool _highlight = false; - bool _focused = false; - bool _hovering = false; + final Set _states = {}; + + bool get _hovered => _states.contains(MaterialState.hovered); + bool get _focused => _states.contains(MaterialState.focused); + bool get _pressed => _states.contains(MaterialState.pressed); + bool get _disabled => _states.contains(MaterialState.disabled); + + void _updateState(MaterialState state, bool value) { + value ? _states.add(state) : _states.remove(state); + } void _handleHighlightChanged(bool value) { - if (_highlight != value) { + if (_pressed != value) { setState(() { - _highlight = value; + _updateState(MaterialState.pressed, value); if (widget.onHighlightChanged != null) { widget.onHighlightChanged(value); } @@ -246,46 +262,71 @@ class _RawMaterialButtonState extends State { } } + void _handleHoveredChanged(bool value) { + if (_hovered != value) { + setState(() { + _updateState(MaterialState.hovered, value); + }); + } + } + + void _handleFocusedChanged(bool value) { + if (_focused != value) { + setState(() { + _updateState(MaterialState.focused, value); + }); + } + } + + @override + void initState() { + super.initState(); + _updateState(MaterialState.disabled, !widget.enabled); + } + @override void didUpdateWidget(RawMaterialButton oldWidget) { super.didUpdateWidget(oldWidget); - if (_highlight && !widget.enabled) { - _highlight = false; - if (widget.onHighlightChanged != null) { - widget.onHighlightChanged(false); - } + _updateState(MaterialState.disabled, !widget.enabled); + // If the button is disabled while a press gesture is currently ongoing, + // InkWell makes a call to handleHighlightChanged. This causes an exception + // because it calls setState in the middle of a build. To preempt this, we + // manually update pressed to false when this situation occurs. + if (_disabled && _pressed) { + _handleHighlightChanged(false); } } - double _effectiveElevation() { - if (widget.enabled) { - // These conditionals are in order of precedence, so be careful about - // reorganizing them. - if (_highlight) { - return widget.highlightElevation; - } - if (_hovering) { - return widget.hoverElevation; - } - if (_focused) { - return widget.focusElevation; - } - return widget.elevation; - } else { + double get _effectiveElevation { + // These conditionals are in order of precedence, so be careful about + // reorganizing them. + if (_disabled) { return widget.disabledElevation; } + if (_pressed) { + return widget.highlightElevation; + } + if (_hovered) { + return widget.hoverElevation; + } + if (_focused) { + return widget.focusElevation; + } + return widget.elevation; } @override Widget build(BuildContext context) { + final Color effectiveTextColor = MaterialStateColor.resolveColor(widget.textStyle?.color, _states); + final Widget result = Focus( focusNode: widget.focusNode, - onFocusChange: (bool focused) => setState(() { _focused = focused; }), + onFocusChange: _handleFocusedChanged, child: ConstrainedBox( constraints: widget.constraints, child: Material( - elevation: _effectiveElevation(), - textStyle: widget.textStyle, + elevation: _effectiveElevation, + textStyle: widget.textStyle?.copyWith(color: effectiveTextColor), shape: widget.shape, color: widget.fillColor, type: widget.fillColor == null ? MaterialType.transparency : MaterialType.button, @@ -297,11 +338,11 @@ class _RawMaterialButtonState extends State { highlightColor: widget.highlightColor, focusColor: widget.focusColor, hoverColor: widget.hoverColor, - onHover: (bool hovering) => setState(() => _hovering = hovering), + onHover: _handleHoveredChanged, onTap: widget.onPressed, customBorder: widget.shape, child: IconTheme.merge( - data: IconThemeData(color: widget.textStyle?.color), + data: IconThemeData(color: effectiveTextColor), child: Container( padding: widget.padding, child: Center( diff --git a/packages/flutter/lib/src/material/button_theme.dart b/packages/flutter/lib/src/material/button_theme.dart index fa7aed901aa7b..8407ab2a34afd 100644 --- a/packages/flutter/lib/src/material/button_theme.dart +++ b/packages/flutter/lib/src/material/button_theme.dart @@ -10,6 +10,7 @@ import 'colors.dart'; import 'constants.dart'; import 'flat_button.dart'; import 'material_button.dart'; +import 'material_state.dart'; import 'outline_button.dart'; import 'raised_button.dart'; import 'theme.dart'; @@ -479,7 +480,13 @@ class ButtonThemeData extends Diagnosticable { /// Returns the button's [MaterialButton.disabledColor] if it is non-null. /// 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. Color getDisabledTextColor(MaterialButton button) { + if (button.textColor is MaterialStateColor) + return button.textColor; if (button.disabledTextColor != null) return button.disabledTextColor; return _getDisabledColor(button); diff --git a/packages/flutter/lib/src/material/material_button.dart b/packages/flutter/lib/src/material/material_button.dart index 4a2be4d128409..d07ccb15ac306 100644 --- a/packages/flutter/lib/src/material/material_button.dart +++ b/packages/flutter/lib/src/material/material_button.dart @@ -100,6 +100,9 @@ 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. + /// /// See also: /// /// * [disabledTextColor], the text color to use when the button has been @@ -114,6 +117,9 @@ class MaterialButton extends StatelessWidget { /// The default value is the theme's disabled color, /// [ThemeData.disabledColor]. /// + /// If [textColor] is a [MaterialStateColor], [disabledTextColor] will be + /// ignored. + /// /// See also: /// /// * [textColor] - The color to use for this button's text when the button is [enabled]. diff --git a/packages/flutter/lib/src/material/material_state.dart b/packages/flutter/lib/src/material/material_state.dart new file mode 100644 index 0000000000000..0cadb2fa9a3a5 --- /dev/null +++ b/packages/flutter/lib/src/material/material_state.dart @@ -0,0 +1,186 @@ +// 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 Color; + +/// Interactive states that some of the Material widgets can take on when +/// receiving input from the user. +/// +/// States are defined by https://material.io/design/interaction/states.html#usage. +/// +/// Some Material widgets track their current state in a `Set`. +/// +/// See also: +/// * [MaterialStateColor], a color that has a `resolve` method that can +/// return a different color depending on the state of the widget that it +/// is used in. +enum MaterialState { + /// The state when the user drags their mouse cursor over the given widget. + /// + /// See: https://material.io/design/interaction/states.html#hover. + hovered, + + /// The state when the user navigates with the keyboard to a given widget. + /// + /// This can also sometimes be triggered when a widget is tapped. For example, + /// when a [TextField] is tapped, it becomes [focused]. + /// + /// See: https://material.io/design/interaction/states.html#focus. + focused, + + /// The state when the user is actively pressing down on the given widget. + /// + /// See: https://material.io/design/interaction/states.html#pressed. + pressed, + + /// The state when this widget is being dragged from one place to another by + /// the user. + /// + /// https://material.io/design/interaction/states.html#dragged. + dragged, + + /// The state when this item has been selected. + /// + /// This applies to things that can be toggled (such as chips and checkboxes) + /// and things that are selected from a set of options (such as tabs and radio buttons). + /// + /// See: https://material.io/design/interaction/states.html#selected. + selected, + + /// The state when this widget disabled and can not be interacted with. + /// + /// Disabled widgets should not respond to hover, focus, press, or drag + /// interactions. + /// + /// See: https://material.io/design/interaction/states.html#disabled. + disabled, + + /// The state when the widget has entered some form of invalid state. + /// + /// See https://material.io/design/interaction/states.html#usage. + error, +} + +/// Signature for the function that returns a color based on a given set of states. +typedef MaterialStateColorResolver = Color Function(Set states); + +/// Defines a [Color] whose value depends on a set of [MaterialState]s which +/// represent the interactive state of a component. +/// +/// This is useful for improving the accessibility of text in different states +/// of a component. For example, in a [FlatButton] with blue text, the text will +/// become more difficult to read when the button is hovered, focused, or pressed, +/// because the contrast ratio between the button and the text will decrease. To +/// solve this, you can use [MaterialStateColor] to make the text darker when the +/// [FlatButton] is hovered, focused, or pressed. +/// +/// To use a [MaterialStateColor], you can either: +/// 1. Create a subclass of [MaterialStateColor] and implement the abstract `resolve` method. +/// 2. Use [MaterialStateColor.resolveWith] and pass in a callback that +/// will be used to resolve the color in the given states. +/// +/// This should only be used as parameters when they are documented to take +/// [MaterialStateColor], otherwise only the default state will be used. +/// +/// {@tool sample} +/// +/// This example shows how you could pass a `MaterialStateColor` to `FlatButton.textColor`. +/// Here, the text color will be `Colors.blue[900]` when the button is being +/// pressed, hovered, or focused. Otherwise, the text color will be `Colors.blue[600]`. +/// +/// ```dart +/// Color getTextColor(Set states) { +/// const Set interactiveStates = { +/// MaterialState.pressed, +/// MaterialState.hovered, +/// MaterialState.focused, +/// }; +/// if (states.any(interactiveStates.contains)) { +/// return Colors.blue[900]; +/// } +/// return Colors.blue[600]; +/// } +/// +/// FlatButton( +/// child: Text('FlatButton'), +/// onPressed: () {}, +/// textColor: MaterialStateColor.resolveWith(getTextColor), +/// ), +/// ``` +/// {@end-tool} +abstract class MaterialStateColor extends Color { + /// Creates a [MaterialStateColor]. + /// + /// If you want a `const` [MaterialStateColor], you'll need to extend + /// [MaterialStateColor] and override the [resolve] method. You'll also need + /// to provide a `defaultValue` to the super constructor, so that we can know + /// at compile-time what the value of the default [Color] is. + /// + /// {@tool sample} + /// + /// In this next example, we see how you can create a `MaterialStateColor` by + /// extending the abstract class and overriding the `resolve` method. + /// + /// ```dart + /// class TextColor extends MaterialStateColor { + /// static const int _defaultColor = 0xcafefeed; + /// static const int _pressedColor = 0xdeadbeef; + /// + /// const TextColor() : super(_defaultColor); + /// + /// @override + /// Color resolve(Set states) { + /// if (states.contains(MaterialState.pressed)) { + /// return const Color(_pressedColor); + /// } + /// return const Color(_defaultColor); + /// } + /// } + /// ``` + /// {@end-tool} + const MaterialStateColor(int defaultValue) : super(defaultValue); + + /// Creates a [MaterialStateColor] from a [MaterialStateColorResolver] 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); + + /// Returns a [Color] that's to be used when a Material component is in the + /// specified state. + 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. +/// +/// If used as a regular color, the color resolved in the default state will +/// be used. +/// +/// Used by [MaterialStateColor.resolveWith]. +class _MaterialStateColor extends MaterialStateColor { + _MaterialStateColor(this._resolve) : super(_resolve(_defaultStates).value); + + final MaterialStateColorResolver _resolve; + + /// The default state for a Material component, the empty set of interaction states. + static const Set _defaultStates = {}; + + @override + Color resolve(Set states) => _resolve(states); +} diff --git a/packages/flutter/test/material/button_theme_test.dart b/packages/flutter/test/material/button_theme_test.dart index eab8a93cc0493..f0fabbe570647 100644 --- a/packages/flutter/test/material/button_theme_test.dart +++ b/packages/flutter/test/material/button_theme_test.dart @@ -2,7 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -325,4 +327,79 @@ void main() { expect(fooText, findsNWidgets(2)); expect(tester.getRect(fooText.at(0)), tester.getRect(fooText.at(1))); }); + + testWidgets('button theme with stateful color changes color in 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 getTextColor(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; + } + + const ColorScheme colorScheme = ColorScheme.light(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: ButtonTheme( + colorScheme: colorScheme.copyWith( + primary: MaterialStateColor.resolveWith(getTextColor), + ), + textTheme: ButtonTextTheme.primary, + child: FlatButton( + child: const Text('FlatButton'), + onPressed: () {}, + focusNode: focusNode, + ), + ), + ), + ), + ), + ); + + Color textColor() { + return tester.renderObject(find.text('FlatButton')).text.style.color; + } + + // Default, not disabled. + expect(textColor(), equals(defaultColor)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(textColor(), focusedColor); + + // Hovered. + final Offset center = tester.getCenter(find.byType(FlatButton)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(textColor(), hoverColor); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pump(); // Start the splash and highlight animations. + await tester.pump(const Duration(milliseconds: 800)); // Wait for splash and highlight to be well under way. + expect(textColor(), pressedColor); + await gesture.removePointer(); + }, + semanticsEnabled: true, + ); } diff --git a/packages/flutter/test/material/flat_button_test.dart b/packages/flutter/test/material/flat_button_test.dart index 9323b8a47a443..3837ca5b3e923 100644 --- a/packages/flutter/test/material/flat_button_test.dart +++ b/packages/flutter/test/material/flat_button_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import '../rendering/mock_canvas.dart'; @@ -33,6 +34,8 @@ void main() { }); testWidgets('Default FlatButton meets a11y contrast guidelines', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -40,6 +43,7 @@ void main() { child: FlatButton( child: const Text('FlatButton'), onPressed: () { }, + focusNode: focusNode, ), ), ), @@ -49,16 +53,266 @@ void main() { // Default, not disabled. await expectLater(tester, meetsGuideline(textContrastGuideline)); + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // Hovered. + final Offset center = tester.getCenter(find.byType(FlatButton)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + // Highlighted (pressed). + await gesture.down(center); + await tester.pump(); // Start the splash and highlight animations. + await tester.pump(const Duration(milliseconds: 800)); // Wait for splash and highlight to be well under way. + await expectLater(tester, meetsGuideline(textContrastGuideline)); + await gesture.removePointer(); + }, + semanticsEnabled: true, + ); + + testWidgets('FlatButton with colored theme meets a11y contrast guidelines', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + + final ColorScheme colorScheme = ColorScheme.fromSwatch(primarySwatch: Colors.blue); + + Color getTextColor(Set states) { + final Set interactiveStates = { + MaterialState.pressed, + MaterialState.hovered, + MaterialState.focused, + }; + if (states.any(interactiveStates.contains)) { + return Colors.blue[900]; + } + return Colors.blue[800]; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: ButtonTheme( + colorScheme: colorScheme, + textTheme: ButtonTextTheme.primary, + child: FlatButton( + child: const Text('FlatButton'), + onPressed: () {}, + focusNode: focusNode, + textColor: MaterialStateColor.resolveWith(getTextColor), + ), + ), + ), + ), + ), + ); + + // Default, not disabled. + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // Hovered. final Offset center = tester.getCenter(find.byType(FlatButton)); - await tester.startGesture(center); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // Highlighted (pressed). + await gesture.down(center); await tester.pump(); // Start the splash and highlight animations. await tester.pump(const Duration(milliseconds: 800)); // Wait for splash and highlight to be well under way. await expectLater(tester, meetsGuideline(textContrastGuideline)); + await gesture.removePointer(); }, semanticsEnabled: true, ); + testWidgets('FlatButton uses stateful color for text 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 getTextColor(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: FlatButton( + child: const Text('FlatButton'), + onPressed: () {}, + focusNode: focusNode, + textColor: MaterialStateColor.resolveWith(getTextColor), + ), + ), + ), + ), + ); + + Color textColor() { + return tester.renderObject(find.text('FlatButton')).text.style.color; + } + + // Default, not disabled. + expect(textColor(), equals(defaultColor)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(textColor(), focusedColor); + + // Hovered. + final Offset center = tester.getCenter(find.byType(FlatButton)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(textColor(), hoverColor); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pump(); // Start the splash and highlight animations. + await tester.pump(const Duration(milliseconds: 800)); // Wait for splash and highlight to be well under way. + expect(textColor(), pressedColor); + await gesture.removePointer(); + }); + + testWidgets('FlatButton uses stateful color for icon color in different states', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + final Key buttonKey = UniqueKey(); + + const Color pressedColor = Color(1); + const Color hoverColor = Color(2); + const Color focusedColor = Color(3); + const Color defaultColor = Color(4); + + Color getTextColor(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: FlatButton.icon( + key: buttonKey, + icon: const Icon(Icons.add), + label: const Text('FlatButton'), + onPressed: () {}, + focusNode: focusNode, + textColor: MaterialStateColor.resolveWith(getTextColor), + ), + ), + ), + ), + ); + + Color iconColor() => _iconStyle(tester, Icons.add).color; + // Default, not disabled. + expect(iconColor(), equals(defaultColor)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(iconColor(), focusedColor); + + // Hovered. + final Offset center = tester.getCenter(find.byKey(buttonKey)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(iconColor(), hoverColor); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pump(); // Start the splash and highlight animations. + await tester.pump(const Duration(milliseconds: 800)); // Wait for splash and highlight to be well under way. + expect(iconColor(), pressedColor); + await gesture.removePointer(); + }); + + testWidgets('FlatButton ignores disabled text color if text color is stateful', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + + const Color disabledColor = Color(1); + const Color defaultColor = Color(2); + const Color unusedDisabledTextColor = Color(3); + + Color getTextColor(Set states) { + if (states.contains(MaterialState.disabled)) { + return disabledColor; + } + return defaultColor; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: FlatButton( + onPressed: null, + child: const Text('FlatButton'), + focusNode: focusNode, + textColor: MaterialStateColor.resolveWith(getTextColor), + disabledTextColor: unusedDisabledTextColor, + ), + ), + ), + ), + ); + + Color textColor() { + return tester.renderObject(find.text('FlatButton')).text.style.color; + } + + // Disabled. + expect(textColor(), equals(disabledColor)); + expect(textColor(), isNot(unusedDisabledTextColor)); + }); + testWidgets('FlatButton has no clip by default', (WidgetTester tester) async { await tester.pumpWidget( Directionality( @@ -78,3 +332,10 @@ void main() { ); }); } + +TextStyle _iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style; +} diff --git a/packages/flutter/test/material/outline_button_test.dart b/packages/flutter/test/material/outline_button_test.dart index c9f186764cc93..475381c85df50 100644 --- a/packages/flutter/test/material/outline_button_test.dart +++ b/packages/flutter/test/material/outline_button_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/rendering.dart'; @@ -36,6 +37,8 @@ void main() { }); testWidgets('Default OutlineButton meets a11y contrast guidelines', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -43,6 +46,7 @@ void main() { child: OutlineButton( child: const Text('OutlineButton'), onPressed: () {}, + focusNode: focusNode, ), ), ), @@ -52,16 +56,266 @@ void main() { // Default, not disabled. await expectLater(tester, meetsGuideline(textContrastGuideline)); + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // 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(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + // Highlighted (pressed). + await gesture.down(center); + await tester.pump(); // Start the splash and highlight animations. + await tester.pump(const Duration(milliseconds: 800)); // Wait for splash and highlight to be well under way. + await expectLater(tester, meetsGuideline(textContrastGuideline)); + await gesture.removePointer(); + }, + semanticsEnabled: true, + ); + + testWidgets('OutlineButton with colored theme meets a11y contrast guidelines', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + + final ColorScheme colorScheme = ColorScheme.fromSwatch(primarySwatch: Colors.blue); + + Color getTextColor(Set states) { + final Set interactiveStates = { + MaterialState.pressed, + MaterialState.hovered, + MaterialState.focused, + }; + if (states.any(interactiveStates.contains)) { + return Colors.blue[900]; + } + return Colors.blue[800]; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: ButtonTheme( + colorScheme: colorScheme, + textTheme: ButtonTextTheme.primary, + child: OutlineButton( + child: const Text('OutlineButton'), + onPressed: () {}, + focusNode: focusNode, + textColor: MaterialStateColor.resolveWith(getTextColor), + ), + ), + ), + ), + ), + ); + + // Default, not disabled. + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // Hovered. final Offset center = tester.getCenter(find.byType(OutlineButton)); - await tester.startGesture(center); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // Highlighted (pressed). + await gesture.down(center); await tester.pump(); // Start the splash and highlight animations. await tester.pump(const Duration(milliseconds: 800)); // Wait for splash and highlight to be well under way. await expectLater(tester, meetsGuideline(textContrastGuideline)); + await gesture.removePointer(); }, semanticsEnabled: true, ); + testWidgets('OutlineButton uses stateful color for text 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 getTextColor(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, + textColor: MaterialStateColor.resolveWith(getTextColor), + ), + ), + ), + ), + ); + + Color textColor() { + return tester.renderObject(find.text('OutlineButton')).text.style.color; + } + + // Default, not disabled. + expect(textColor(), equals(defaultColor)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(textColor(), 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(textColor(), hoverColor); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pump(); // Start the splash and highlight animations. + await tester.pump(const Duration(milliseconds: 800)); // Wait for splash and highlight to be well under way. + expect(textColor(), pressedColor); + await gesture.removePointer(); + }); + + testWidgets('OutlineButton uses stateful color for icon color in different states', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + final Key buttonKey = UniqueKey(); + + const Color pressedColor = Color(1); + const Color hoverColor = Color(2); + const Color focusedColor = Color(3); + const Color defaultColor = Color(4); + + Color getTextColor(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.icon( + key: buttonKey, + icon: const Icon(Icons.add), + label: const Text('OutlineButton'), + onPressed: () {}, + focusNode: focusNode, + textColor: MaterialStateColor.resolveWith(getTextColor), + ), + ), + ), + ), + ); + + Color iconColor() => _iconStyle(tester, Icons.add).color; + // Default, not disabled. + expect(iconColor(), equals(defaultColor)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(iconColor(), focusedColor); + + // Hovered. + final Offset center = tester.getCenter(find.byKey(buttonKey)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(iconColor(), hoverColor); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pump(); // Start the splash and highlight animations. + await tester.pump(const Duration(milliseconds: 800)); // Wait for splash and highlight to be well under way. + expect(iconColor(), pressedColor); + await gesture.removePointer(); + }); + + testWidgets('OutlineButton ignores disabled text color if text color is stateful', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + + const Color disabledColor = Color(1); + const Color defaultColor = Color(2); + const Color unusedDisabledTextColor = Color(3); + + Color getTextColor(Set states) { + if (states.contains(MaterialState.disabled)) { + return disabledColor; + } + return defaultColor; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: OutlineButton( + onPressed: null, + child: const Text('OutlineButton'), + focusNode: focusNode, + textColor: MaterialStateColor.resolveWith(getTextColor), + disabledTextColor: unusedDisabledTextColor, + ), + ), + ), + ), + ); + + Color textColor() { + return tester.renderObject(find.text('OutlineButton')).text.style.color; + } + + // Disabled. + expect(textColor(), equals(disabledColor)); + expect(textColor(), isNot(unusedDisabledTextColor)); + }); + testWidgets('Outline button responds to tap when enabled', (WidgetTester tester) async { int pressedCount = 0; @@ -448,3 +702,10 @@ void _checkPhysicalLayer(Element element, Color expectedColor, { Path clipPath, expect(expectedLayer.clipPath, coversSameAreaAs(clipPath, areaToCompare: clipRect.inflate(10.0))); } } + +TextStyle _iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style; +} diff --git a/packages/flutter/test/material/raised_button_test.dart b/packages/flutter/test/material/raised_button_test.dart index 67e173206f11c..b9d248717279d 100644 --- a/packages/flutter/test/material/raised_button_test.dart +++ b/packages/flutter/test/material/raised_button_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/rendering.dart'; @@ -33,6 +34,8 @@ void main() { }); testWidgets('Default RaisedButton meets a11y contrast guidelines', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -40,6 +43,7 @@ void main() { child: RaisedButton( child: const Text('RaisedButton'), onPressed: () { }, + focusNode: focusNode, ), ), ), @@ -49,15 +53,207 @@ void main() { // Default, not disabled. await expectLater(tester, meetsGuideline(textContrastGuideline)); - // Highlighted (pressed). + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // Hovered. final Offset center = tester.getCenter(find.byType(RaisedButton)); - await tester.startGesture(center); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // Highlighted (pressed). + await gesture.down(center); await tester.pump(); // Start the splash and highlight animations. await tester.pump(const Duration(milliseconds: 800)); // Wait for splash and highlight to be well under way. await expectLater(tester, meetsGuideline(textContrastGuideline)); + await gesture.removePointer(); }, semanticsEnabled: true, ); + testWidgets('RaisedButton uses stateful color for text 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 getTextColor(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: RaisedButton( + child: const Text('RaisedButton'), + onPressed: () {}, + focusNode: focusNode, + textColor: MaterialStateColor.resolveWith(getTextColor), + ), + ), + ), + ), + ); + + Color textColor() { + return tester.renderObject(find.text('RaisedButton')).text.style.color; + } + + // Default, not disabled. + expect(textColor(), equals(defaultColor)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(textColor(), focusedColor); + + // Hovered. + final Offset center = tester.getCenter(find.byType(RaisedButton)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(textColor(), hoverColor); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pump(); // Start the splash and highlight animations. + await tester.pump(const Duration(milliseconds: 800)); // Wait for splash and highlight to be well under way. + expect(textColor(), pressedColor); + await gesture.removePointer(); + }); + + + testWidgets('RaisedButton uses stateful color for icon color in different states', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + final Key buttonKey = UniqueKey(); + + const Color pressedColor = Color(1); + const Color hoverColor = Color(2); + const Color focusedColor = Color(3); + const Color defaultColor = Color(4); + + Color getTextColor(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: RaisedButton.icon( + key: buttonKey, + icon: const Icon(Icons.add), + label: const Text('RaisedButton'), + onPressed: () {}, + focusNode: focusNode, + textColor: MaterialStateColor.resolveWith(getTextColor), + ), + ), + ), + ), + ); + + Color iconColor() => _iconStyle(tester, Icons.add).color; + // Default, not disabled. + expect(iconColor(), equals(defaultColor)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(iconColor(), focusedColor); + + // Hovered. + final Offset center = tester.getCenter(find.byKey(buttonKey)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(iconColor(), hoverColor); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pump(); // Start the splash and highlight animations. + await tester.pump(const Duration(milliseconds: 800)); // Wait for splash and highlight to be well under way. + expect(iconColor(), pressedColor); + await gesture.removePointer(); + }); + + testWidgets('RaisedButton ignores disabled text color if text color is stateful', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + const Color disabledColor = Color(1); + const Color defaultColor = Color(2); + const Color unusedDisabledTextColor = Color(3); + + Color getTextColor(Set states) { + if (states.contains(MaterialState.disabled)) { + return disabledColor; + } + return defaultColor; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: RaisedButton( + onPressed: null, + child: const Text('RaisedButton'), + focusNode: focusNode, + textColor: MaterialStateColor.resolveWith(getTextColor), + disabledTextColor: unusedDisabledTextColor, + ), + ), + ), + ), + ); + + Color textColor() { + return tester.renderObject(find.text('RaisedButton')).text.style.color; + } + + // Disabled. + expect(textColor(), equals(disabledColor)); + expect(textColor(), isNot(unusedDisabledTextColor)); + }); +} + +TextStyle _iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style; }