From dc31d89c5686f27cc7d5ff43c4593f8528d68ee5 Mon Sep 17 00:00:00 2001 From: Hans Muller Date: Thu, 9 Jul 2020 13:41:03 -0700 Subject: [PATCH] New Button Universe (#59702) --- packages/flutter/lib/material.dart | 8 + .../lib/src/material/button_style.dart | 439 ++++++ .../lib/src/material/button_style_button.dart | 470 +++++++ .../lib/src/material/contained_button.dart | 425 ++++++ .../src/material/contained_button_theme.dart | 127 ++ .../lib/src/material/material_state.dart | 3 + .../lib/src/material/outlined_button.dart | 349 +++++ .../src/material/outlined_button_theme.dart | 127 ++ .../flutter/lib/src/material/text_button.dart | 387 ++++++ .../lib/src/material/text_button_theme.dart | 127 ++ .../flutter/lib/src/material/theme_data.dart | 49 +- .../test/material/button_style_test.dart | 153 +++ .../test/material/contained_button_test.dart | 930 +++++++++++++ .../material/contained_button_theme_test.dart | 182 +++ .../test/material/outlined_button_test.dart | 1204 +++++++++++++++++ .../material/outlined_button_theme_test.dart | 183 +++ .../test/material/text_button_test.dart | 980 ++++++++++++++ .../test/material/text_button_theme_test.dart | 180 +++ .../test/material/theme_data_test.dart | 12 + 19 files changed, 6334 insertions(+), 1 deletion(-) create mode 100644 packages/flutter/lib/src/material/button_style.dart create mode 100644 packages/flutter/lib/src/material/button_style_button.dart create mode 100644 packages/flutter/lib/src/material/contained_button.dart create mode 100644 packages/flutter/lib/src/material/contained_button_theme.dart create mode 100644 packages/flutter/lib/src/material/outlined_button.dart create mode 100644 packages/flutter/lib/src/material/outlined_button_theme.dart create mode 100644 packages/flutter/lib/src/material/text_button.dart create mode 100644 packages/flutter/lib/src/material/text_button_theme.dart create mode 100644 packages/flutter/test/material/button_style_test.dart create mode 100644 packages/flutter/test/material/contained_button_test.dart create mode 100644 packages/flutter/test/material/contained_button_theme_test.dart create mode 100644 packages/flutter/test/material/outlined_button_test.dart create mode 100644 packages/flutter/test/material/outlined_button_theme_test.dart create mode 100644 packages/flutter/test/material/text_button_test.dart create mode 100644 packages/flutter/test/material/text_button_theme_test.dart diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index 8a9f32df73..a10b5ce9c1 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -36,6 +36,8 @@ export 'src/material/bottom_sheet_theme.dart'; export 'src/material/button.dart'; export 'src/material/button_bar.dart'; export 'src/material/button_bar_theme.dart'; +export 'src/material/button_style.dart'; +export 'src/material/button_style_button.dart'; export 'src/material/button_theme.dart'; export 'src/material/card.dart'; export 'src/material/card_theme.dart'; @@ -47,6 +49,8 @@ export 'src/material/circle_avatar.dart'; export 'src/material/color_scheme.dart'; export 'src/material/colors.dart'; export 'src/material/constants.dart'; +export 'src/material/contained_button.dart'; +export 'src/material/contained_button_theme.dart'; export 'src/material/data_table.dart'; export 'src/material/data_table_source.dart'; export 'src/material/debug.dart'; @@ -88,6 +92,8 @@ export 'src/material/mergeable_material.dart'; export 'src/material/navigation_rail.dart'; export 'src/material/navigation_rail_theme.dart'; export 'src/material/outline_button.dart'; +export 'src/material/outlined_button.dart'; +export 'src/material/outlined_button_theme.dart'; export 'src/material/page.dart'; export 'src/material/page_transitions_theme.dart'; export 'src/material/paginated_data_table.dart'; @@ -117,6 +123,8 @@ export 'src/material/tab_bar_theme.dart'; export 'src/material/tab_controller.dart'; export 'src/material/tab_indicator.dart'; export 'src/material/tabs.dart'; +export 'src/material/text_button.dart'; +export 'src/material/text_button_theme.dart'; export 'src/material/text_field.dart'; export 'src/material/text_form_field.dart'; export 'src/material/text_selection.dart'; diff --git a/packages/flutter/lib/src/material/button_style.dart b/packages/flutter/lib/src/material/button_style.dart new file mode 100644 index 0000000000..85db4c106a --- /dev/null +++ b/packages/flutter/lib/src/material/button_style.dart @@ -0,0 +1,439 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.8 + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'material_state.dart'; +import 'theme_data.dart'; + +/// The visual properties that most buttons have in common. +/// +/// Buttons and their themes have a ButtonStyle property which defines the visual +/// properties whose default values are to be overidden. The default values are +/// defined by the invidual button widgets and are typically based on overall +/// theme's [ThemeData.colorScheme] and [ThemeData.textTheme]. +/// +/// All of the ButtonStyle properties are null by default. +/// +/// Many of the ButtonStyle properties are [MaterialStateProperty] objects which +/// resolve to different values depending on the button's state. For example +/// the [Color] properties are defined with `MaterialStateProperty` and +/// can resolve to different colors depending on if the button is pressed, +/// hovered, focused, disabled, etc. +/// +/// These properties can override the default value for just one state or all of +/// them. For example to create a [ContainedButton] whose background color is the +/// color scheme’s primary color with 50% opacity, but only when the button is +/// pressed, one could write: +/// +/// ```dart +/// ContainedButton( +/// style: ButtonStyle( +/// backgroundColor: MaterialStateProperty.resolveWith( +/// (Set states) { +/// if (states.contains(MaterialState.pressed)) +/// return Theme.of(context).colorScheme.primary.withOpacity(0.5); +/// return null; // Use the component's default. +/// }, +/// ), +/// ), +/// ) +///``` +/// +/// In this case the background color for all other button states would fallback +/// to the ContainedButton’s default values. To unconditionally set the button's +/// [backgroundColor] for all states one could write: +/// +/// ```dart +/// ContainedButton( +/// style: ButtonStyle( +/// backgroundColor: MaterialStateProperty.all(Colors.green), +/// ), +/// ) +///``` +/// +/// Configuring a ButtonStyle directly makes it possible to very +/// precisely control the button’s visual attributes for all states. +/// This level of control is typically required when a custom +/// “branded” look and feel is desirable. However, in many cases it’s +/// useful to make relatively sweeping changes based on a few initial +/// parameters with simple values. The button styleFrom() methods +/// enable such sweeping changes. See for example: +/// [TextButton.styleFrom], [ContainedButton.styleFrom], +/// [OutlinedButton.styleFrom]. +/// +/// For example, to override the default text and icon colors for a +/// [TextButton], as well as its overlay color, with all of the +/// standard opacity adjustments for the pressed, focused, and +/// hovered states, one could write: +/// +/// ```dart +/// TextButton( +/// style: TextButton.styleFrom(primary: Colors.green), +/// ) +///``` +/// +/// To configure all of the application's text buttons in the same +/// way, specify the overall theme's `textButtonTheme`: +/// ```dart +/// MaterialApp( +/// theme: ThemeData( +/// textButtonTheme: TextButtonThemeData( +/// style: TextButton.styleFrom(primary: Colors.green), +/// ), +/// ), +/// home: MyAppHome(), +/// ) +///``` +/// See also: +/// +/// * [TextButtonTheme], the theme for [TextButton]s. +/// * [ContainedButtonTheme], the theme for [ContainedButton]s. +/// * [OutlinedButtonTheme], the theme for [OutlinedButton]s. +@immutable +class ButtonStyle with Diagnosticable { + /// Create a [ButtonStyle]. + const ButtonStyle({ + this.textStyle, + this.backgroundColor, + this.foregroundColor, + this.overlayColor, + this.shadowColor, + this.elevation, + this.padding, + this.minimumSize, + this.side, + this.shape, + this.mouseCursor, + this.visualDensity, + this.tapTargetSize, + this.animationDuration, + this.enableFeedback, + }); + + /// The style for a button's [Text] widget descendants. + /// + /// The color of the [textStyle] is typically not used directly, the + /// [foreground] color is used instead. + final MaterialStateProperty textStyle; + + /// The button's background fill color. + final MaterialStateProperty backgroundColor; + + /// The color for the button's [Text] and [Icon] widget descendants. + /// + /// This color is typically used instead of the color of the [textStyle]. All + /// of the components that compute defaults from [ButtonStyle] values + /// compute a default [foregroundColor] and use that instead of the + /// [textStyle]'s color. + final MaterialStateProperty foregroundColor; + + /// The highlight color that's typically used to indicate that + /// the button is focused, hovered, or pressed. + final MaterialStateProperty overlayColor; + + /// The shadow color of the button's [Material]. + /// + /// The material's elevation shadow can be difficult to see for + /// dark themes, so by default the button classes add a + /// semi-transparent overlay to indicate elevation. See + /// [ThemeData.applyElevationOverlayColor]. + final MaterialStateProperty shadowColor; + + /// The elevation of the button's [Material]. + final MaterialStateProperty elevation; + + /// The padding between the button's boundary and its child. + final MaterialStateProperty padding; + + /// The minimum size of the button itself. + /// + /// The size of the rectangle the button lies within may be larger + /// per [tapTargetSize]. + final MaterialStateProperty minimumSize; + + /// The color and weight of the button's outline. + /// + /// This value is combined with [shape] to create a shape decorated + /// with an outline. + final MaterialStateProperty side; + + /// The shape of the button's underlying [Material]. + /// + /// This shape is combined with [side] to create a shape decorated + /// with an outline. + final MaterialStateProperty shape; + + /// The cursor for a mouse pointer when it enters or is hovering over + /// this button's [InkWell]. + final MaterialStateProperty mouseCursor; + + /// Defines how compact the button's layout will be. + /// + /// {@macro flutter.material.themedata.visualDensity} + /// + /// See also: + /// + /// * [ThemeData.visualDensity], which specifies the [visualDensity] for all widgets + /// within a [Theme]. + final VisualDensity visualDensity; + + /// Configures the minimum size of the area within which the button may be pressed. + /// + /// If the [tapTargetSize] is larger than [minimumSize], the button will include + /// a transparent margin that responds to taps. + /// + /// Always defaults to [ThemeData.materialTapTargetSize]. + final MaterialTapTargetSize tapTargetSize; + + /// Defines the duration of animated changes for [shape] and [elevation]. + /// + /// Typically the component default value is [kThemeChangeDuration]. + final Duration animationDuration; + + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a + /// long-press will produce a short vibration, when feedback is enabled. + /// + /// Typically the component default value is true. + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool enableFeedback; + + /// Returns a copy of this ButtonStyle with the given fields replaced with + /// the new values. + ButtonStyle copyWith({ + MaterialStateProperty textStyle, + MaterialStateProperty backgroundColor, + MaterialStateProperty foregroundColor, + MaterialStateProperty overlayColor, + MaterialStateProperty shadowColor, + MaterialStateProperty elevation, + MaterialStateProperty padding, + MaterialStateProperty minimumSize, + MaterialStateProperty side, + MaterialStateProperty shape, + MaterialStateProperty mouseCursor, + VisualDensity visualDensity, + MaterialTapTargetSize tapTargetSize, + Duration animationDuration, + bool enableFeedback, + }) { + return ButtonStyle( + textStyle: textStyle ?? this.textStyle, + backgroundColor: backgroundColor ?? this.backgroundColor, + foregroundColor: foregroundColor ?? this.foregroundColor, + overlayColor: overlayColor ?? this.overlayColor, + shadowColor: shadowColor ?? this.shadowColor, + elevation: elevation ?? this.elevation, + padding: padding ?? this.padding, + minimumSize: minimumSize ?? this.minimumSize, + side: side ?? this.side, + shape: shape ?? this.shape, + mouseCursor: mouseCursor ?? this.mouseCursor, + visualDensity: visualDensity ?? this.visualDensity, + tapTargetSize: tapTargetSize ?? this.tapTargetSize, + animationDuration: animationDuration ?? this.animationDuration, + enableFeedback: enableFeedback ?? this.enableFeedback, + ); + } + + /// Returns a copy of this ButtonStyle where the non-null fields in [style] + /// have replaced the corresponding null fields in this ButtonStyle. + /// + /// In other words, [style] is used to fill in unspecified (null) fields + /// this ButtonStyle. + ButtonStyle merge(ButtonStyle style) { + if (style == null) + return this; + return copyWith( + textStyle: textStyle ?? style.textStyle, + backgroundColor: backgroundColor ?? style.backgroundColor, + foregroundColor: foregroundColor ?? style.foregroundColor, + overlayColor: overlayColor ?? style.overlayColor, + shadowColor: shadowColor ?? style.shadowColor, + elevation: elevation ?? style.elevation, + padding: padding ?? style.padding, + minimumSize: minimumSize ?? style.minimumSize, + side: side ?? style.side, + shape: shape ?? style.shape, + mouseCursor: mouseCursor ?? style.mouseCursor, + visualDensity: visualDensity ?? style.visualDensity, + tapTargetSize: tapTargetSize ?? style.tapTargetSize, + animationDuration: animationDuration ?? style.animationDuration, + enableFeedback: enableFeedback ?? style.enableFeedback, + ); + } + + @override + int get hashCode { + return hashValues( + textStyle, + backgroundColor, + foregroundColor, + overlayColor, + shadowColor, + elevation, + padding, + minimumSize, + side, + shape, + mouseCursor, + visualDensity, + tapTargetSize, + animationDuration, + enableFeedback, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) + return true; + if (other.runtimeType != runtimeType) + return false; + return other is ButtonStyle + && other.textStyle == textStyle + && other.backgroundColor == backgroundColor + && other.foregroundColor == foregroundColor + && other.overlayColor == overlayColor + && other.shadowColor == shadowColor + && other.elevation == elevation + && other.padding == padding + && other.minimumSize == minimumSize + && other.side == side + && other.shape == shape + && other.mouseCursor == mouseCursor + && other.visualDensity == visualDensity + && other.tapTargetSize == tapTargetSize + && other.animationDuration == animationDuration + && other.enableFeedback == enableFeedback; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty>('textStyle', textStyle, defaultValue: null)); + properties.add(DiagnosticsProperty>('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(DiagnosticsProperty>('foregroundColor', foregroundColor, defaultValue: null)); + properties.add(DiagnosticsProperty>('overlayColor', overlayColor, defaultValue: null)); + properties.add(DiagnosticsProperty>('shadowColor', shadowColor, defaultValue: null)); + properties.add(DiagnosticsProperty>('elevation', elevation, defaultValue: null)); + properties.add(DiagnosticsProperty>('padding', padding, defaultValue: null)); + properties.add(DiagnosticsProperty>('minimumSize', minimumSize, defaultValue: null)); + properties.add(DiagnosticsProperty>('side', side, defaultValue: null)); + properties.add(DiagnosticsProperty>('shape', shape, defaultValue: null)); + properties.add(DiagnosticsProperty>('mouseCursor', mouseCursor, defaultValue: null)); + properties.add(DiagnosticsProperty('visualDensity', visualDensity, defaultValue: null)); + properties.add(EnumProperty('tapTargetSize', tapTargetSize, defaultValue: null)); + properties.add(DiagnosticsProperty('animationDuration', animationDuration, defaultValue: null)); + properties.add(DiagnosticsProperty('enableFeedback', enableFeedback, defaultValue: null)); + } + + /// Linearly interpolate between two [ButtonStyle]s. + static ButtonStyle lerp(ButtonStyle a, ButtonStyle b, double t) { + assert (t != null); + if (a == null && b == null) + return null; + return ButtonStyle( + textStyle: _lerpProperties(a?.textStyle, b?.textStyle, t, TextStyle.lerp), + backgroundColor: _lerpProperties(a?.backgroundColor, b?.backgroundColor, t, Color.lerp), + foregroundColor: _lerpProperties(a?.foregroundColor, b?.foregroundColor, t, Color.lerp), + overlayColor: _lerpProperties(a?.overlayColor, b?.overlayColor, t, Color.lerp), + shadowColor: _lerpProperties(a?.shadowColor, b?.shadowColor, t, Color.lerp), + elevation: _lerpProperties(a?.elevation, b?.elevation, t, lerpDouble), + padding: _lerpProperties(a?.padding, b?.padding, t, EdgeInsetsGeometry.lerp), + minimumSize: _lerpProperties(a?.minimumSize, b?.minimumSize, t, Size.lerp), + side: _lerpSides(a?.side, b?.side, t), + shape: _lerpShapes(a?.shape, b?.shape, t), + mouseCursor: t < 0.5 ? a.mouseCursor : b.mouseCursor, + visualDensity: t < 0.5 ? a.visualDensity : b.visualDensity, + tapTargetSize: t < 0.5 ? a.tapTargetSize : b.tapTargetSize, + animationDuration: t < 0.5 ? a.animationDuration : b.animationDuration, + enableFeedback: t < 0.5 ? a.enableFeedback : b.enableFeedback, + ); + } + + static MaterialStateProperty _lerpProperties(MaterialStateProperty a, MaterialStateProperty b, double t, T Function(T, T, double) lerpFunction ) { + // Avoid creating a _LerpProperties object for a common case. + if (a == null && b == null) + return null; + return _LerpProperties(a, b, t, lerpFunction); + } + + // Special case because BorderSide.lerp() doesn't support null arguments + static MaterialStateProperty _lerpSides(MaterialStateProperty a, MaterialStateProperty b, double t) { + if (a == null && b == null) + return null; + return _LerpSides(a, b, t); + } + + // TODO(hansmuller): OutlinedBorder needs a lerp method - https://github.com/flutter/flutter/issues/60555. + static MaterialStateProperty _lerpShapes(MaterialStateProperty a, MaterialStateProperty b, double t) { + if (a == null && b == null) + return null; + return _LerpShapes(a, b, t); + } +} + +class _LerpProperties implements MaterialStateProperty { + const _LerpProperties(this.a, this.b, this.t, this.lerpFunction); + + final MaterialStateProperty a; + final MaterialStateProperty b; + final double t; + final T Function(T, T, double) lerpFunction; + + @override + T resolve(Set states) { + final T resolvedA = a?.resolve(states); + final T resolvedB = b?.resolve(states); + return lerpFunction(resolvedA, resolvedB, t); + } +} + +class _LerpSides implements MaterialStateProperty { + const _LerpSides(this.a, this.b, this.t); + + final MaterialStateProperty a; + final MaterialStateProperty b; + final double t; + + @override + BorderSide resolve(Set states) { + final BorderSide resolvedA = a?.resolve(states); + final BorderSide resolvedB = b?.resolve(states); + if (resolvedA == null && resolvedB == null) + return null; + if (resolvedA == null) + return BorderSide.lerp(BorderSide(width: 0, color: resolvedB.color.withAlpha(0)), resolvedB, t); + if (resolvedB == null) + return BorderSide.lerp(BorderSide(width: 0, color: resolvedA.color.withAlpha(0)), resolvedA, t); + return BorderSide.lerp(resolvedA, resolvedB, t); + } +} + +class _LerpShapes implements MaterialStateProperty { + const _LerpShapes(this.a, this.b, this.t); + + final MaterialStateProperty a; + final MaterialStateProperty b; + final double t; + + @override + OutlinedBorder resolve(Set states) { + final OutlinedBorder resolvedA = a?.resolve(states); + final OutlinedBorder resolvedB = b?.resolve(states); + return ShapeBorder.lerp(resolvedA, resolvedB, t) as OutlinedBorder; + } +} diff --git a/packages/flutter/lib/src/material/button_style_button.dart b/packages/flutter/lib/src/material/button_style_button.dart new file mode 100644 index 0000000000..6fa8f21b29 --- /dev/null +++ b/packages/flutter/lib/src/material/button_style_button.dart @@ -0,0 +1,470 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.8 + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'ink_ripple.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'material_state.dart'; +import 'theme_data.dart'; + +/// The base [StatefulWidget] class for buttons whose style is defined by a [ButtonStyle] object. +/// +/// Concrete subclasses must override [defaultStyleOf] and [themeStyleOf]. +/// +/// See also: +/// +/// * [TextButton], a simple ButtonStyleButton without a shadow. +/// * [ContainedButton], a filled ButtonStyleButton whose material elevates when pressed. +/// * [OutlinedButton], similar to [TextButton], but with an outline. +abstract class ButtonStyleButton extends StatefulWidget { + /// Create a [ButtonStyleButton]. + const ButtonStyleButton({ + Key key, + @required this.onPressed, + @required this.onLongPress, + @required this.style, + @required this.focusNode, + @required this.autofocus, + @required this.clipBehavior, + @required this.child, + }) : assert(autofocus != null), + assert(clipBehavior != null), + super(key: key); + + /// Called when the button is tapped or otherwise activated. + /// + /// If this callback and [onLongPress] are null, then the button will be disabled. + /// + /// See also: + /// + /// * [enabled], which is true if the button is enabled. + final VoidCallback onPressed; + + /// Called when the button is long-pressed. + /// + /// If this callback and [onPressed] are null, then the button will be disabled. + /// + /// See also: + /// + /// * [enabled], which is true if the button is enabled. + final VoidCallback onLongPress; + + /// Customizes this button's appearance. + /// + /// Non-null properties of this style override the corresponding + /// properties in [themeStyleOf] and [defaultStyleOf]. [MaterialStateProperty]s + /// that resolve to non-null values will similarly override the corresponding + /// [MaterialStateProperty]s in [themeStyleOf] and [defaultStyleOf]. + /// + /// Null by default. + final ButtonStyle style; + + /// {@macro flutter.widgets.Clip} + /// + /// Defaults to [Clip.none], and must not be null. + final Clip clipBehavior; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// Typically the button's label. + final Widget child; + + /// Returns a non-null [ButtonStyle] that's based primarily on the [Theme]'s + /// [ThemeData.textTheme] and [ThemeData.colorScheme]. + /// + /// The returned style can be overriden by the [style] parameter and + /// by the style returned by [themeStyleOf]. For example the default + /// style of the [TextButton] subclass can be overidden with its + /// [TextButton.style] constructor parameter, or with a + /// [TextButtonTheme]. + /// + /// Concrete button subclasses should return a ButtonStyle that + /// has no null properties, and where all of the [MaterialStateProperty] + /// properties resolve to non-null values. + /// + /// See also: + /// + /// * [themeStyleOf], Returns the ButtonStyle of this button's component theme. + @protected + ButtonStyle defaultStyleOf(BuildContext context); + + /// Returns the ButtonStyle that belongs to the button's component theme. + /// + /// The returned style can be overriden by the [style] parameter. + /// + /// Concrete button subclasses should return the ButtonStyle for the + /// nearest subclass-specific inherited theme, and if no such theme + /// exists, then the same value from the overall [Theme]. + /// + /// See also: + /// + /// * [defaultStyleOf], Returns the default [ButtonStyle] for this button. + @protected + ButtonStyle themeStyleOf(BuildContext context); + + /// Whether the button is enabled or disabled. + /// + /// Buttons are disabled by default. To enable a button, set its [onPressed] + /// or [onLongPress] properties to a non-null value. + bool get enabled => onPressed != null || onLongPress != null; + + @override + _ButtonStyleState createState() => _ButtonStyleState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(FlagProperty('enabled', value: enabled, ifFalse: 'disabled')); + properties.add(DiagnosticsProperty('style', style, defaultValue: null)); + properties.add(DiagnosticsProperty('focusNode', focusNode, defaultValue: null)); + } + + /// Returns null if [value] is null, otherwise `MaterialStateProperty.all(value)`. + /// + /// A convenience method for subclasses. + static MaterialStateProperty allOrNull(T value) => value == null ? null : MaterialStateProperty.all(value); + + /// Returns an interpolated value based on the [textScaleFactor] parameter: + /// + /// * 0 - 1 [geometry1x] + /// * 1 - 2 lerp([geometry1x], [geometry2x], [textScaleFactor] - 1) + /// * 2 - 3 lerp([geometry2x], [geometry3x], [textScaleFactor] - 2) + /// * otherwise [geometry3x] + /// + /// A convenience method for subclasses. + static EdgeInsetsGeometry scaledPadding( + EdgeInsetsGeometry geometry1x, + EdgeInsetsGeometry geometry2x, + EdgeInsetsGeometry geometry3x, + double textScaleFactor, + ) { + assert(geometry1x != null); + assert(geometry2x != null); + assert(geometry3x != null); + assert(textScaleFactor != null); + + if (textScaleFactor <= 1) { + return geometry1x; + } else if (textScaleFactor >= 3) { + return geometry3x; + } else if (textScaleFactor <= 2) { + return EdgeInsetsGeometry.lerp(geometry1x, geometry2x, textScaleFactor - 1); + } + return EdgeInsetsGeometry.lerp(geometry2x, geometry3x, textScaleFactor - 2); + } +} + +/// The base [State] class for buttons whose style is defined by a [ButtonStyle] object. +/// +/// See also: +/// +/// * [ButtonStyleButton], the [StatefulWidget] subclass for which this class is the [State]. +/// * [TextButton], a simple button without a shadow. +/// * [ContainedButton], a filled button whose material elevates when pressed. +/// * [OutlinedButton], similar to [TextButton], but with an outline. +class _ButtonStyleState extends State { + 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 (_pressed != value) { + setState(() { + _updateState(MaterialState.pressed, value); + }); + } + } + + 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(ButtonStyleButton oldWidget) { + super.didUpdateWidget(oldWidget); + _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); + } + } + + @override + Widget build(BuildContext context) { + final ButtonStyle widgetStyle = widget.style; + final ButtonStyle themeStyle = widget.themeStyleOf(context); + final ButtonStyle defaultStyle = widget.defaultStyleOf(context); + assert(defaultStyle != null); + + T effectiveValue(T Function(ButtonStyle style) getProperty) { + final T widgetValue = getProperty(widgetStyle); + final T themeValue = getProperty(themeStyle); + final T defaultValue = getProperty(defaultStyle); + return widgetValue ?? themeValue ?? defaultValue; + } + + T resolve(MaterialStateProperty Function(ButtonStyle style) getProperty) { + return effectiveValue( + (ButtonStyle style) => getProperty(style)?.resolve(_states), + ); + } + + final TextStyle resolvedTextStyle = resolve((ButtonStyle style) => style?.textStyle); + final Color resolvedBackgroundColor = resolve((ButtonStyle style) => style?.backgroundColor); + final Color resolvedForegroundColor = resolve((ButtonStyle style) => style?.foregroundColor); + final Color resolvedShadowColor = resolve((ButtonStyle style) => style?.shadowColor); + final double resolvedElevation = resolve((ButtonStyle style) => style?.elevation); + final EdgeInsetsGeometry resolvedPadding = resolve((ButtonStyle style) => style?.padding); + final Size resolvedMinimumSize = resolve((ButtonStyle style) => style?.minimumSize); + final BorderSide resolvedSide = resolve((ButtonStyle style) => style?.side); + final OutlinedBorder resolvedShape = resolve((ButtonStyle style) => style?.shape); + + final MaterialStateMouseCursor resolvedMouseCursor = _MouseCursor( + (Set states) => effectiveValue((ButtonStyle style) => style?.mouseCursor?.resolve(states)), + ); + + final MaterialStateProperty overlayColor = MaterialStateProperty.resolveWith( + (Set states) => effectiveValue((ButtonStyle style) => style?.overlayColor?.resolve(states)), + ); + + final VisualDensity resolvedVisualDensity = effectiveValue((ButtonStyle style) => style?.visualDensity); + final MaterialTapTargetSize resolvedTapTargetSize = effectiveValue((ButtonStyle style) => style?.tapTargetSize); + final Duration resolvedAnimationDuration = effectiveValue((ButtonStyle style) => style?.animationDuration); + final bool resolvedEnableFeedback = effectiveValue((ButtonStyle style) => style?.enableFeedback); + final Offset densityAdjustment = resolvedVisualDensity.baseSizeAdjustment; + final BoxConstraints effectiveConstraints = resolvedVisualDensity.effectiveConstraints( + BoxConstraints( + minWidth: resolvedMinimumSize.width, + minHeight: resolvedMinimumSize.height, + ), + ); + final EdgeInsetsGeometry padding = resolvedPadding.add( + EdgeInsets.only( + left: densityAdjustment.dx, + top: densityAdjustment.dy, + right: densityAdjustment.dx, + bottom: densityAdjustment.dy, + ), + ).clamp(EdgeInsets.zero, EdgeInsetsGeometry.infinity); + + final Widget result = ConstrainedBox( + constraints: effectiveConstraints, + child: Material( + elevation: resolvedElevation, + textStyle: resolvedTextStyle?.copyWith(color: resolvedForegroundColor), + shape: resolvedShape.copyWith(side: resolvedSide), + color: resolvedBackgroundColor, + shadowColor: resolvedShadowColor, + type: resolvedBackgroundColor == null ? MaterialType.transparency : MaterialType.button, + animationDuration: resolvedAnimationDuration, + clipBehavior: widget.clipBehavior, + child: InkWell( + onTap: widget.onPressed, + onLongPress: widget.onLongPress, + onHighlightChanged: _handleHighlightChanged, + onHover: _handleHoveredChanged, + mouseCursor: resolvedMouseCursor, + enableFeedback: resolvedEnableFeedback, + focusNode: widget.focusNode, + canRequestFocus: widget.enabled, + onFocusChange: _handleFocusedChanged, + autofocus: widget.autofocus, + splashFactory: InkRipple.splashFactory, + overlayColor: overlayColor, + highlightColor: Colors.transparent, + customBorder: resolvedShape, + child: IconTheme.merge( + data: IconThemeData(color: resolvedForegroundColor), + child: Padding( + padding: padding, + child: Center( + widthFactor: 1.0, + heightFactor: 1.0, + child: widget.child, + ), + ), + ), + ), + ), + ); + + Size minSize; + switch (resolvedTapTargetSize) { + case MaterialTapTargetSize.padded: + minSize = Size( + kMinInteractiveDimension + densityAdjustment.dx, + kMinInteractiveDimension + densityAdjustment.dy, + ); + assert(minSize.width >= 0.0); + assert(minSize.height >= 0.0); + break; + case MaterialTapTargetSize.shrinkWrap: + minSize = Size.zero; + break; + } + + return Semantics( + container: true, + button: true, + enabled: widget.enabled, + child: _InputPadding( + minSize: minSize, + child: result, + ), + ); + } +} + +class _MouseCursor extends MaterialStateMouseCursor { + const _MouseCursor(this.resolveCallback); + + final MaterialPropertyResolver resolveCallback; + + @override + MouseCursor resolve(Set states) => resolveCallback(states); + + @override + String get debugDescription => 'ButtonStyleButton_MouseCursor'; +} + +/// A widget to pad the area around a [MaterialButton]'s inner [Material]. +/// +/// Redirect taps that occur in the padded area around the child to the center +/// of the child. This increases the size of the button and the button's +/// "tap target", but not its material or its ink splashes. +class _InputPadding extends SingleChildRenderObjectWidget { + const _InputPadding({ + Key key, + Widget child, + this.minSize, + }) : super(key: key, child: child); + + final Size minSize; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderInputPadding(minSize); + } + + @override + void updateRenderObject(BuildContext context, covariant _RenderInputPadding renderObject) { + renderObject.minSize = minSize; + } +} + +class _RenderInputPadding extends RenderShiftedBox { + _RenderInputPadding(this._minSize, [RenderBox child]) : super(child); + + Size get minSize => _minSize; + Size _minSize; + set minSize(Size value) { + if (_minSize == value) + return; + _minSize = value; + markNeedsLayout(); + } + + @override + double computeMinIntrinsicWidth(double height) { + if (child != null) + return math.max(child.getMinIntrinsicWidth(height), minSize.width); + return 0.0; + } + + @override + double computeMinIntrinsicHeight(double width) { + if (child != null) + return math.max(child.getMinIntrinsicHeight(width), minSize.height); + return 0.0; + } + + @override + double computeMaxIntrinsicWidth(double height) { + if (child != null) + return math.max(child.getMaxIntrinsicWidth(height), minSize.width); + return 0.0; + } + + @override + double computeMaxIntrinsicHeight(double width) { + if (child != null) + return math.max(child.getMaxIntrinsicHeight(width), minSize.height); + return 0.0; + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + if (child != null) { + child.layout(constraints, parentUsesSize: true); + final double height = math.max(child.size.width, minSize.width); + final double width = math.max(child.size.height, minSize.height); + size = constraints.constrain(Size(height, width)); + final BoxParentData childParentData = child.parentData as BoxParentData; + childParentData.offset = Alignment.center.alongOffset(size - child.size as Offset); + } else { + size = Size.zero; + } + } + + @override + bool hitTest(BoxHitTestResult result, { Offset position }) { + if (super.hitTest(result, position: position)) { + return true; + } + final Offset center = child.size.center(Offset.zero); + return result.addWithRawTransform( + transform: MatrixUtils.forceToPoint(center), + position: center, + hitTest: (BoxHitTestResult result, Offset position) { + assert(position == center); + return child.hitTest(result, position: center); + }, + ); + } +} diff --git a/packages/flutter/lib/src/material/contained_button.dart b/packages/flutter/lib/src/material/contained_button.dart new file mode 100644 index 0000000000..5eb0fad5be --- /dev/null +++ b/packages/flutter/lib/src/material/contained_button.dart @@ -0,0 +1,425 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.8 + +import 'dart:math' as math; +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'button_style_button.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'contained_button_theme.dart'; +import 'material_state.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +/// A Material Design "contained button". +/// +/// Use contained buttons to add dimension to otherwise mostly flat +/// layouts, e.g. in long busy lists of content, or in wide +/// spaces. Avoid using contained buttons on already-contained content +/// such as dialogs or cards. +/// +/// A contained button is a label [child] displayed on a [Material] +/// widget whose [Material.elevation] increases when the button is +/// pressed. The label's [Text] and [Icon] widgets are displayed in +/// [style]'s [ButtonStyle.onForegroundColor] and the button's filled +/// background is the [ButtonStyle.backgroundColor]. +/// +/// The contained button's default style is defined by +/// [defaultStyleOf]. The style of this contained button can be +/// overridden with its [style] parameter. The style of all contained +/// buttons in a subtree can be overridden with the +/// [ContainedButtonTheme], and the style of all of the contained +/// buttons in an app can be overridden with the [Theme]'s +/// [ThemeData.containedButtonTheme] property. +/// +/// The static [styleFrom] method is a convenient way to create a +/// contained button [ButtonStyle] from simple values. +/// +/// If [onPressed] and [onLongPress] callbacks are null, then the +/// button will be disabled. +/// +/// See also: +/// +/// * [TextButton], a simple flat button without a shadow. +/// * [OutlinedButton], a [TextButton] with a border outline. +/// * +class ContainedButton extends ButtonStyleButton { + /// Create a ContainedButton. + /// + /// The [autofocus] and [clipBehavior] arguments must not be null. + const ContainedButton({ + Key key, + @required VoidCallback onPressed, + VoidCallback onLongPress, + ButtonStyle style, + FocusNode focusNode, + bool autofocus = false, + Clip clipBehavior = Clip.none, + @required Widget child, + }) : super( + key: key, + onPressed: onPressed, + onLongPress: onLongPress, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clipBehavior, + child: child, + ); + + /// Create a contained button from a pair of widgets that serve as the button's + /// [icon] and [label]. + /// + /// The icon and label are arranged in a row and padded by 12 logical pixels + /// at the start, and 16 at the end, with an 8 pixel gap in between. + /// + /// The [icon] and [label] arguments must not be null. + factory ContainedButton.icon({ + Key key, + @required VoidCallback onPressed, + VoidCallback onLongPress, + ButtonStyle style, + FocusNode focusNode, + bool autofocus, + Clip clipBehavior, + @required Widget icon, + @required Widget label, + }) = _ContainedButtonWithIcon; + + /// A static convenience method that constructs a contained button + /// [ButtonStyle] given simple values. + /// + /// The [onPrimary], and [onSurface] colors are used to to create a + /// [MaterialStateProperty] [foreground] value in the same way that + /// [defaultStyleOf] uses the [ColorScheme] colors with the same + /// names. Specify a value for [onPrimary] to specify the color of the + /// button's text and icons as well as the overlay colors used to + /// indicate the hover, focus, and pressed states. Use primary for + /// the button's background fill color and [onSurface] + /// to specify the button's disabled text, icon, and fill color. + /// + /// The button's elevations are defined relative to the [elevation] + /// parameter. The disabled elevation is the same as the parameter + /// value, [elevation] + 2 is used when the button is hovered + /// or focused, and elevation + 6 is used when the button is pressed. + /// + /// Similarly, the [enabledMouseCursor] and [disabledMouseCursor] + /// parameters are used to construct [ButtonStyle].mouseCursor. + /// + /// All of the other parameters are either used directly or used to + /// create a [MaterialStateProperty] with a single value for all + /// states. + /// + /// All parameters default to null, by default this method returns + /// a [ButtonStyle] that doesn't override anything. + /// + /// For example, to override the default text and icon colors for a + /// [ContainedButton], as well as its overlay color, with all of the + /// standard opacity adjustments for the pressed, focused, and + /// hovered states, one could write: + /// + /// ```dart + /// ContainedButton( + /// style: TextButton.styleFrom(primary: Colors.green), + /// ) + /// ``` + static ButtonStyle styleFrom({ + Color primary, + Color onPrimary, + Color onSurface, + Color shadowColor, + double elevation, + TextStyle textStyle, + EdgeInsetsGeometry padding, + Size minimumSize, + BorderSide side, + OutlinedBorder shape, + MouseCursor enabledMouseCursor, + MouseCursor disabledMouseCursor, + VisualDensity visualDensity, + MaterialTapTargetSize tapTargetSize, + Duration animationDuration, + bool enableFeedback, + }) { + final MaterialStateProperty backgroundColor = (onSurface == null && primary == null) + ? null + : _ContainedButtonDefaultBackground(primary, onSurface); + final MaterialStateProperty foregroundColor = (onSurface == null && onPrimary == null) + ? null + : _ContainedButtonDefaultForeground(onPrimary, onSurface); + final MaterialStateProperty overlayColor = (onPrimary == null) + ? null + : _ContainedButtonDefaultOverlay(onPrimary); + final MaterialStateProperty elevationValue = (elevation == null) + ? null + : _ContainedButtonDefaultElevation(elevation); + final MaterialStateProperty mouseCursor = (enabledMouseCursor == null && disabledMouseCursor == null) + ? null + : _ContainedButtonDefaultMouseCursor(enabledMouseCursor, disabledMouseCursor); + + return ButtonStyle( + textStyle: MaterialStateProperty.all(textStyle), + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + overlayColor: overlayColor, + shadowColor: ButtonStyleButton.allOrNull(shadowColor), + elevation: elevationValue, + padding: ButtonStyleButton.allOrNull(padding), + minimumSize: ButtonStyleButton.allOrNull(minimumSize), + side: ButtonStyleButton.allOrNull(side), + shape: ButtonStyleButton.allOrNull(shape), + mouseCursor: mouseCursor, + visualDensity: visualDensity, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + ); + } + + /// Defines the button's default appearance. + /// + /// The button [child]'s [Text] and [Icon] widgets are rendered with + /// the [ButtonStyle]'s foreground color. The button's [InkWell] adds + /// the style's overlay color when the button is focused, hovered + /// or pressed. The button's background color becomes its [Material] + /// color. + /// + /// All of the ButtonStyle's defaults appear below. In this list + /// "Theme.foo" is shorthand for `Theme.of(context).foo`. Color + /// scheme values like "onSurface(0.38)" are shorthand for + /// `onSurface.withOpacity(0.38)`. [MaterialStateProperty] valued + /// properties that are not followed by by a sublist have the same + /// value for all states, otherwise the values are as specified for + /// each state, and "others" means all other states. + /// + /// The `textScaleFactor` is the value of + /// `MediaQuery.of(context).textScaleFactor` and the names of the + /// EdgeInsets constructors and `EdgeInsetsGeometry.lerp` have been + /// abbreviated for readability. + /// + /// The color of the [textStyle] is not used, the [foreground] color + /// is used instead. + /// + /// * `textStyle` - Theme.textTheme.button + /// * `backgroundColor` + /// * disabled - Theme.colorScheme.onSurface(0.12) + /// * others - Theme.colorScheme.primary + /// * `foregroundColor` + /// * disabled - Theme.colorScheme.onSurface(0.38) + /// * others - Theme.colorScheme.onPrimary + /// * `overlayColor` + /// * hovered - Theme.colorScheme.onPrimary(0.08) + /// * focused or pressed - Theme.colorScheme.onPrimary(0.24) + /// * `shadowColor` - Colors.black + /// * `elevation` + /// * disabled - 0 + /// * hovered or focused - 2 + /// * pressed - 6 + /// * `padding` + /// * textScaleFactor <= 1 - horizontal(16) + /// * `1 < textScaleFactor <= 2` - lerp(horizontal(16), horizontal(8)) + /// * `2 < textScaleFactor <= 3` - lerp(horizontal(8), horizontal(4)) + /// * `3 < textScaleFactor` - horizontal(4) + /// * `minimumSize` - Size(64, 36) + /// * `side` - BorderSide.none + /// * `shape` - RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)) + /// * `mouseCursor` + /// * disabled - SystemMouseCursors.forbidden + /// * others - SystemMouseCursors.click + /// * `visualDensity` - theme.visualDensity + /// * `tapTargetSize` - theme.materialTapTargetSize + /// * `animationDuration` - kThemeChangeDuration + /// * `enableFeedback` - true + /// + /// The default padding values for the [ContainedButton.icon] factory are slightly different: + /// + /// * `padding` + /// * `textScaleFactor <= 1` - start(12) end(16) + /// * `1 < textScaleFactor <= 2` - lerp(start(12) end(16), horizontal(8)) + /// * `2 < textScaleFactor <= 3` - lerp(horizontal(8), horizontal(4)) + /// * `3 < textScaleFactor` - horizontal(4) + @override + ButtonStyle defaultStyleOf(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + + final EdgeInsetsGeometry scaledPadding = ButtonStyleButton.scaledPadding( + const EdgeInsets.symmetric(horizontal: 16), + const EdgeInsets.symmetric(horizontal: 8), + const EdgeInsets.symmetric(horizontal: 4), + MediaQuery.of(context, nullOk: true)?.textScaleFactor ?? 1, + ); + + return styleFrom( + primary: colorScheme.primary, + onPrimary: colorScheme.onPrimary, + onSurface: colorScheme.onSurface, + shadowColor: Colors.black, + elevation: 2, + textStyle: theme.textTheme.button, + padding: scaledPadding, + minimumSize: const Size(64, 36), + side: BorderSide.none, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4))), + enabledMouseCursor: SystemMouseCursors.click, + disabledMouseCursor: SystemMouseCursors.forbidden, + visualDensity: theme.visualDensity, + tapTargetSize: theme.materialTapTargetSize, + animationDuration: kThemeChangeDuration, + enableFeedback: true, + ); + } + + /// Returns the [ContainedButtonThemeData.style] of the closest + /// [ContainedButtonTheme] ancestor. + @override + ButtonStyle themeStyleOf(BuildContext context) { + return ContainedButtonTheme.of(context)?.style; + } +} + +@immutable +class _ContainedButtonDefaultBackground extends MaterialStateProperty with Diagnosticable { + _ContainedButtonDefaultBackground(this.primary, this.onSurface); + + final Color primary; + final Color onSurface; + + @override + Color resolve(Set states) { + if (states.contains(MaterialState.disabled)) + return onSurface?.withOpacity(0.12); + return primary; + } +} + +@immutable +class _ContainedButtonDefaultForeground extends MaterialStateProperty with Diagnosticable { + _ContainedButtonDefaultForeground(this.onPrimary, this.onSurface); + + final Color onPrimary; + final Color onSurface; + + @override + Color resolve(Set states) { + if (states.contains(MaterialState.disabled)) + return onSurface?.withOpacity(0.38); + return onPrimary; + } +} + +@immutable +class _ContainedButtonDefaultOverlay extends MaterialStateProperty with Diagnosticable { + _ContainedButtonDefaultOverlay(this.onPrimary); + + final Color onPrimary; + + @override + Color resolve(Set states) { + if (states.contains(MaterialState.hovered)) + return onPrimary?.withOpacity(0.08); + if (states.contains(MaterialState.focused) || states.contains(MaterialState.pressed)) + return onPrimary?.withOpacity(0.24); + return null; + } +} + +@immutable +class _ContainedButtonDefaultElevation extends MaterialStateProperty with Diagnosticable { + _ContainedButtonDefaultElevation(this.elevation); + + final double elevation; + + @override + double resolve(Set states) { + if (states.contains(MaterialState.disabled)) + return 0; + if (states.contains(MaterialState.hovered)) + return elevation + 2; + if (states.contains(MaterialState.focused)) + return elevation + 2; + if (states.contains(MaterialState.pressed)) + return elevation + 6; + return elevation; + } +} + +@immutable +class _ContainedButtonDefaultMouseCursor extends MaterialStateProperty with Diagnosticable { + _ContainedButtonDefaultMouseCursor(this.enabledCursor, this.disabledCursor); + + final MouseCursor enabledCursor; + final MouseCursor disabledCursor; + + @override + MouseCursor resolve(Set states) { + if (states.contains(MaterialState.disabled)) + return disabledCursor; + return enabledCursor; + } +} + +class _ContainedButtonWithIcon extends ContainedButton { + _ContainedButtonWithIcon({ + Key key, + @required VoidCallback onPressed, + VoidCallback onLongPress, + ButtonStyle style, + FocusNode focusNode, + bool autofocus, + Clip clipBehavior, + @required Widget icon, + @required Widget label, + }) : assert(icon != null), + assert(label != null), + super( + key: key, + onPressed: onPressed, + onLongPress: onLongPress, + style: style, + focusNode: focusNode, + autofocus: autofocus ?? false, + clipBehavior: clipBehavior ?? Clip.none, + child: _ContainedButtonWithIconChild(icon: icon, label: label), + ); + + @override + ButtonStyle defaultStyleOf(BuildContext context) { + final EdgeInsetsGeometry scaledPadding = ButtonStyleButton.scaledPadding( + const EdgeInsetsDirectional.fromSTEB(12, 0, 16, 0), + const EdgeInsets.symmetric(horizontal: 8), + const EdgeInsetsDirectional.fromSTEB(8, 0, 4, 0), + MediaQuery.of(context, nullOk: true)?.textScaleFactor ?? 1, + ); + return super.defaultStyleOf(context).copyWith( + padding: MaterialStateProperty.all(scaledPadding) + ); + } +} + +class _ContainedButtonWithIconChild extends StatelessWidget { + const _ContainedButtonWithIconChild({ Key key, this.label, this.icon }) : super(key: key); + + final Widget label; + final Widget icon; + + @override + Widget build(BuildContext context) { + final double scale = MediaQuery.of(context, nullOk: true)?.textScaleFactor ?? 1; + final double gap = scale <= 1 ? 8 : lerpDouble(8, 4, math.min(scale - 1, 1)); + return Row( + mainAxisSize: MainAxisSize.min, + children: [icon, SizedBox(width: gap), label], + ); + } +} diff --git a/packages/flutter/lib/src/material/contained_button_theme.dart b/packages/flutter/lib/src/material/contained_button_theme.dart new file mode 100644 index 0000000000..4f9b83e2b9 --- /dev/null +++ b/packages/flutter/lib/src/material/contained_button_theme.dart @@ -0,0 +1,127 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.8 + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'theme.dart'; + +/// A [ButtonStyle] that overrides the default appearance of +/// [ContainedButton]s when it's used with [ContainedButtonTheme] or with the +/// overall [Theme]'s [ThemeData.containedButtonTheme]. +/// +/// The [style]'s properties override [ContainedButton]'s default style, +/// i.e. the [ButtonStyle] returned by [ContainedButton.defaultStyleOf]. Only +/// the style's non-null property values or resolved non-null +/// [MaterialStateProperty] values are used. +/// +/// See also: +/// +/// * [ContainedButtonTheme], the theme which is configured with this class. +/// * [ContainedButton.defaultStyleOf], which returns the default [ButtonStyle] +/// for text buttons. +/// * [ContainedButton.styleOf], which converts simple values into a +/// [ButtonStyle] that's consistent with [ContainedButton]'s defaults. +/// * [MaterialStateProperty.resolve], "resolve" a material state property +/// to a simple value based on a set of [MaterialState]s. +/// * [ThemeData.containedButtonTheme], which can be used to override the default +/// [ButtonStyle] for [ContainedButton]s below the overall [Theme]. +@immutable +class ContainedButtonThemeData with Diagnosticable { + /// Creates a [ContainedButtonThemeData]. + /// + /// The [style] may be null. + const ContainedButtonThemeData({ this.style }); + + /// Overrides for [ContainedButton]'s default style. + /// + /// Non-null properties or non-null resolved [MaterialStateProperty] + /// values override the [ButtonStyle] returned by + /// [ContainedButton.defaultStyleOf]. + /// + /// If [style] is null, then this theme doesn't override anything. + final ButtonStyle style; + + /// Linearly interpolate between two contained button themes. + static ContainedButtonThemeData lerp(ContainedButtonThemeData a, ContainedButtonThemeData b, double t) { + assert (t != null); + if (a == null && b == null) + return null; + return ContainedButtonThemeData( + style: ButtonStyle.lerp(a?.style, b?.style, t), + ); + } + + @override + int get hashCode { + return style.hashCode; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) + return true; + if (other.runtimeType != runtimeType) + return false; + return other is ContainedButtonThemeData && other.style == style; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('style', style, defaultValue: null)); + } +} + +/// Overrides the default [ButtonStyle] of its [ContainedButton] descendants. +/// +/// See also: +/// +/// * [ContainedButtonThemeData], which is used to configure this theme. +/// * [ContainedButton.defaultStyleOf], which returns the default [ButtonStyle] +/// for text buttons. +/// * [ContainedButton.styleOf], which converts simple values into a +/// [ButtonStyle] that's consistent with [ContainedButton]'s defaults. +/// * [ThemeData.containedButtonTheme], which can be used to override the default +/// [ButtonStyle] for [ContainedButton]s below the overall [Theme]. +class ContainedButtonTheme extends InheritedTheme { + /// Create a [ContainedButtonTheme]. + /// + /// The [data] parameter must not be null. + const ContainedButtonTheme({ + Key key, + @required this.data, + Widget child, + }) : assert(data != null), super(key: key, child: child); + + /// The configuration of this theme. + final ContainedButtonThemeData data; + + /// The closest instance of this class that encloses the given context. + /// + /// If there is no enclosing [ContainedButtonsTheme] widget, then + /// [ThemeData.containedButtonTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// ContainedButtonTheme theme = ContainedButtonTheme.of(context); + /// ``` + static ContainedButtonThemeData of(BuildContext context) { + final ContainedButtonTheme buttonTheme = context.dependOnInheritedWidgetOfExactType(); + return buttonTheme?.data ?? Theme.of(context).containedButtonTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + final ContainedButtonTheme ancestorTheme = context.findAncestorWidgetOfExactType(); + return identical(this, ancestorTheme) ? child : ContainedButtonTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(ContainedButtonTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/flutter/lib/src/material/material_state.dart b/packages/flutter/lib/src/material/material_state.dart index 5bd4c1e181..ab0d3391c8 100644 --- a/packages/flutter/lib/src/material/material_state.dart +++ b/packages/flutter/lib/src/material/material_state.dart @@ -362,4 +362,7 @@ class _MaterialStatePropertyAll implements MaterialStateProperty { @override T resolve(Set states) => value; + + @override + String toString() => 'MaterialStateProperty.all($value)'; } diff --git a/packages/flutter/lib/src/material/outlined_button.dart b/packages/flutter/lib/src/material/outlined_button.dart new file mode 100644 index 0000000000..97d21a9862 --- /dev/null +++ b/packages/flutter/lib/src/material/outlined_button.dart @@ -0,0 +1,349 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.8 + +import 'dart:math' as math; +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'button_style_button.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'material_state.dart'; +import 'outlined_button_theme.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +/// A Material Design "Outlined Button"; essentially a [TextButton] +/// with an outlined border. +/// +/// Outlined buttons are medium-emphasis buttons. They contain actions +/// that are important, but they aren’t the primary action in an app. +/// +/// An outlined button is a label [child] displayed on a (zero +/// elevation) [Material] widget. The label's [Text] and [Icon] +/// widgets are displayed in the [style]'s +/// [ButtonStyle.foregroundColor] and the outline's weight and color +/// are defined by [ButtonStyle.side]. The button reacts to touches +/// by filling with the [style]'s [ButtonStyle.backgroundColor]. +/// +/// The outlined button's default style is defined by [defaultStyleOf]. +/// The style of this outline button can be overridden with its [style] +/// parameter. The style of all text buttons in a subtree can be +/// overridden with the [OutlinedButtonTheme] and the style of all of the +/// outlined buttons in an app can be overridden with the [Theme]'s +/// [ThemeData.outlinedButtonTheme] property. +/// +/// The static [styleFrom] method is a convenient way to create a +/// outlined button [ButtonStyle] from simple values. +/// +/// See also: +/// +/// * [ContainedButton], a filled material design button with a shadow. +/// * [TextButton], a material design button without a shadow. +/// * +class OutlinedButton extends ButtonStyleButton { + /// Create an OutlinedButton. + /// + /// The [autofocus] and [clipBehavior] arguments must not be null. + const OutlinedButton({ + Key key, + @required VoidCallback onPressed, + VoidCallback onLongPress, + ButtonStyle style, + FocusNode focusNode, + bool autofocus = false, + Clip clipBehavior = Clip.none, + @required Widget child, + }) : super( + key: key, + onPressed: onPressed, + onLongPress: onLongPress, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clipBehavior, + child: child, + ); + + /// Create a text button from a pair of widgets that serve as the button's + /// [icon] and [label]. + /// + /// The icon and label are arranged in a row and padded by 12 logical pixels + /// at the start, and 16 at the end, with an 8 pixel gap in between. + /// + /// The [icon] and [label] arguments must not be null. + factory OutlinedButton.icon({ + Key key, + @required VoidCallback onPressed, + VoidCallback onLongPress, + ButtonStyle style, + FocusNode focusNode, + bool autofocus, + Clip clipBehavior, + @required Widget icon, + @required Widget label, + }) = _OutlinedButtonWithIcon; + + /// A static convenience method that constructs an outlined button + /// [ButtonStyle] given simple values. + /// + /// The [primary], and [onSurface] colors are used to to create a + /// [MaterialStateProperty] [foreground] value in the same way that + /// [defaultStyleOf] uses the [ColorScheme] colors with the same + /// names. Specify a value for [primary] to specify the color of the + /// button's text and icons as well as the overlay colors used to + /// indicate the hover, focus, and pressed states. Use [onSurface] + /// to specify the button's disabled text and icon color. + /// + /// Similarly, the [enabledMouseCursor] and [disabledMouseCursor] + /// parameters are used to construct [ButtonStyle].mouseCursor. + /// + /// All of the other parameters are either used directly or used to + /// create a [MaterialStateProperty] with a single value for all + /// states. + /// + /// All parameters default to null, by default this method returns + /// a [ButtonStyle] that doesn't override anything. + /// + /// For example, to override the default shape and outline for an + /// [OutlinedButton], one could write: + /// + /// ```dart + /// OutlinedButton( + /// style: OutlinedButton.styleFrom( + /// shape: StadiumBorder(), + /// side: BorderSide(width: 2, color: Colors.green), + /// ), + /// ) + /// ``` + static ButtonStyle styleFrom({ + Color primary, + Color onSurface, + Color backgroundColor, + Color shadowColor, + double elevation, + TextStyle textStyle, + EdgeInsetsGeometry padding, + Size minimumSize, + BorderSide side, + OutlinedBorder shape, + MouseCursor enabledMouseCursor, + MouseCursor disabledMouseCursor, + VisualDensity visualDensity, + MaterialTapTargetSize tapTargetSize, + Duration animationDuration, + bool enableFeedback, + }) { + final MaterialStateProperty foregroundColor = (onSurface == null && primary == null) + ? null + : _OutlinedButtonDefaultForeground(primary, onSurface); + final MaterialStateProperty overlayColor = (primary == null) + ? null + : _OutlinedButtonDefaultOverlay(primary); + final MaterialStateProperty mouseCursor = (enabledMouseCursor == null && disabledMouseCursor == null) + ? null + : _OutlinedButtonDefaultMouseCursor(enabledMouseCursor, disabledMouseCursor); + + return ButtonStyle( + textStyle: ButtonStyleButton.allOrNull(textStyle), + foregroundColor: foregroundColor, + backgroundColor: ButtonStyleButton.allOrNull(backgroundColor), + overlayColor: overlayColor, + shadowColor: ButtonStyleButton.allOrNull(shadowColor), + elevation: ButtonStyleButton.allOrNull(elevation), + padding: ButtonStyleButton.allOrNull(padding), + minimumSize: ButtonStyleButton.allOrNull(minimumSize), + side: ButtonStyleButton.allOrNull(side), + shape: ButtonStyleButton.allOrNull(shape), + mouseCursor: mouseCursor, + visualDensity: visualDensity, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + ); + } + + /// Defines the button's default appearance. + /// + /// With the exception of [ButtonStyle.side], which defines the + /// outline, and [ButtonStyle.padding], the returned style is the + /// same as for [TextButton]. + /// + /// The button [child]'s [Text] and [Icon] widgets are rendered with + /// the [ButtonStyle]'s foreground color. The button's [InkWell] adds + /// the style's overlay color when the button is focused, hovered + /// or pressed. The button's background color becomes its [Material] + /// color and is transparent by default. + /// + /// All of the ButtonStyle's defaults appear below. In this list + /// "Theme.foo" is shorthand for `Theme.of(context).foo`. Color + /// scheme values like "onSurface(0.38)" are shorthand for + /// `onSurface.withOpacity(0.38)`. [MaterialStateProperty] valued + /// properties that are not followed by by a sublist have the same + /// value for all states, otherwise the values are as specified for + /// each state and "others" means all other states. + /// + /// The color of the [textStyle] is not used, the [foreground] color + /// is used instead. + /// + /// * `textStyle` - Theme.textTheme.button + /// * `backgroundColor` - transparent + /// * `foregroundColor` + /// * disabled - Theme.colorScheme.onSurface(0.38) + /// * others - Theme.colorScheme.primary + /// * `overlayColor` + /// * hovered - Theme.colorScheme.primary(0.04) + /// * focused or pressed - Theme.colorScheme.primary(0.12) + /// * `shadowColor` - Colors.black + /// * `elevation` - 0 + /// * `padding` + /// * `textScaleFactor <= 1` - horizontal(16) + /// * `1 < textScaleFactor <= 2` - lerp(horizontal(16), horizontal(8)) + /// * `2 < textScaleFactor <= 3` - lerp(horizontal(8), horizontal(4)) + /// * `3 < textScaleFactor` - horizontal(4) + /// * `minimumSize` - Size(64, 36) + /// * `side` - BorderSide(width: 1, color: Theme.colorScheme.onSurface(0.12)) + /// * `shape` - RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)) + /// * `mouseCursor` + /// * disabled - SystemMouseCursors.forbidden + /// * others - SystemMouseCursors.click + /// * `visualDensity` - theme.visualDensity + /// * `tapTargetSize` - theme.materialTapTargetSize + /// * `animationDuration` - kThemeChangeDuration + /// * `enableFeedback` - true + @override + ButtonStyle defaultStyleOf(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + + final EdgeInsetsGeometry scaledPadding = ButtonStyleButton.scaledPadding( + const EdgeInsets.symmetric(horizontal: 16), + const EdgeInsets.symmetric(horizontal: 8), + const EdgeInsets.symmetric(horizontal: 4), + MediaQuery.of(context, nullOk: true)?.textScaleFactor ?? 1, + ); + + return styleFrom( + primary: colorScheme.primary, + onSurface: colorScheme.onSurface, + backgroundColor: Colors.transparent, + shadowColor: Colors.black, + elevation: 0, + textStyle: theme.textTheme.button, + padding: scaledPadding, + minimumSize: const Size(64, 36), + side: BorderSide( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.12), + width: 1, + ), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4))), + enabledMouseCursor: SystemMouseCursors.click, + disabledMouseCursor: SystemMouseCursors.forbidden, + visualDensity: theme.visualDensity, + tapTargetSize: theme.materialTapTargetSize, + animationDuration: kThemeChangeDuration, + enableFeedback: true, + ); + } + + @override + ButtonStyle themeStyleOf(BuildContext context) { + return OutlinedButtonTheme.of(context)?.style; + } +} + +@immutable +class _OutlinedButtonDefaultForeground extends MaterialStateProperty with Diagnosticable { + _OutlinedButtonDefaultForeground(this.primary, this.onSurface); + + final Color primary; + final Color onSurface; + + @override + Color resolve(Set states) { + if (states.contains(MaterialState.disabled)) + return onSurface?.withOpacity(0.38); + return primary; + } +} + +@immutable +class _OutlinedButtonDefaultOverlay extends MaterialStateProperty with Diagnosticable { + _OutlinedButtonDefaultOverlay(this.primary); + + final Color primary; + + @override + Color resolve(Set states) { + if (states.contains(MaterialState.hovered)) + return primary?.withOpacity(0.04); + if (states.contains(MaterialState.focused) || states.contains(MaterialState.pressed)) + return primary?.withOpacity(0.12); + return null; + } +} + +@immutable +class _OutlinedButtonDefaultMouseCursor extends MaterialStateProperty with Diagnosticable { + _OutlinedButtonDefaultMouseCursor(this.enabledCursor, this.disabledCursor); + + final MouseCursor enabledCursor; + final MouseCursor disabledCursor; + + @override + MouseCursor resolve(Set states) { + if (states.contains(MaterialState.disabled)) + return disabledCursor; + return enabledCursor; + } +} + +class _OutlinedButtonWithIcon extends OutlinedButton { + _OutlinedButtonWithIcon({ + Key key, + @required VoidCallback onPressed, + VoidCallback onLongPress, + ButtonStyle style, + FocusNode focusNode, + bool autofocus, + Clip clipBehavior, + @required Widget icon, + @required Widget label, + }) : assert(icon != null), + assert(label != null), + super( + key: key, + onPressed: onPressed, + onLongPress: onLongPress, + style: style, + focusNode: focusNode, + autofocus: autofocus ?? false, + clipBehavior: clipBehavior ?? Clip.none, + child: _OutlinedButtonWithIconChild(icon: icon, label: label), + ); +} + +class _OutlinedButtonWithIconChild extends StatelessWidget { + const _OutlinedButtonWithIconChild({ Key key, this.label, this.icon }) : super(key: key); + + final Widget label; + final Widget icon; + + @override + Widget build(BuildContext context) { + final double scale = MediaQuery.of(context, nullOk: true)?.textScaleFactor ?? 1; + final double gap = scale <= 1 ? 8 : lerpDouble(8, 4, math.min(scale - 1, 1)); + return Row( + mainAxisSize: MainAxisSize.min, + children: [icon, SizedBox(width: gap), label], + ); + } +} diff --git a/packages/flutter/lib/src/material/outlined_button_theme.dart b/packages/flutter/lib/src/material/outlined_button_theme.dart new file mode 100644 index 0000000000..2c7117efb7 --- /dev/null +++ b/packages/flutter/lib/src/material/outlined_button_theme.dart @@ -0,0 +1,127 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.8 + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'theme.dart'; + +/// A [ButtonStyle] that overrides the default appearance of +/// [OutlinedButton]s when it's used with [OutlinedButtonTheme] or with the +/// overall [Theme]'s [ThemeData.outlinedButtonTheme]. +/// +/// The [style]'s properties override [OutlinedButton]'s default style, +/// i.e. the [ButtonStyle] returned by [OutlinedButton.defaultStyleOf]. Only +/// the style's non-null property values or resolved non-null +/// [MaterialStateProperty] values are used. +/// +/// See also: +/// +/// * [OutlinedButtonTheme], the theme which is configured with this class. +/// * [OutlinedButton.defaultStyleOf], which returns the default [ButtonStyle] +/// for text buttons. +/// * [OutlinedButton.styleOf], which converts simple values into a +/// [ButtonStyle] that's consistent with [OutlinedButton]'s defaults. +/// * [MaterialStateProperty.resolve], "resolve" a material state property +/// to a simple value based on a set of [MaterialState]s. +/// * [ThemeData.outlinedButtonTheme], which can be used to override the default +/// [ButtonStyle] for [OutlinedButton]s below the overall [Theme]. +@immutable +class OutlinedButtonThemeData with Diagnosticable { + /// Creates a [OutlinedButtonThemeData]. + /// + /// The [style] may be null. + const OutlinedButtonThemeData({ this.style }); + + /// Overrides for [OutlinedButton]'s default style. + /// + /// Non-null properties or non-null resolved [MaterialStateProperty] + /// values override the [ButtonStyle] returned by + /// [OutlinedButton.defaultStyleOf]. + /// + /// If [style] is null, then this theme doesn't override anything. + final ButtonStyle style; + + /// Linearly interpolate between two outlined button themes. + static OutlinedButtonThemeData lerp(OutlinedButtonThemeData a, OutlinedButtonThemeData b, double t) { + assert (t != null); + if (a == null && b == null) + return null; + return OutlinedButtonThemeData( + style: ButtonStyle.lerp(a?.style, b?.style, t), + ); + } + + @override + int get hashCode { + return style.hashCode; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) + return true; + if (other.runtimeType != runtimeType) + return false; + return other is OutlinedButtonThemeData && other.style == style; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('style', style, defaultValue: null)); + } +} + +/// Overrides the default [ButtonStyle] of its [OutlinedButton] descendants. +/// +/// See also: +/// +/// * [OutlinedButtonThemeData], which is used to configure this theme. +/// * [OutlinedButton.defaultStyleOf], which returns the default [ButtonStyle] +/// for text buttons. +/// * [OutlinedButton.styleOf], which converts simple values into a +/// [ButtonStyle] that's consistent with [OutlinedButton]'s defaults. +/// * [ThemeData.outlinedButtonTheme], which can be used to override the default +/// [ButtonStyle] for [OutlinedButton]s below the overall [Theme]. +class OutlinedButtonTheme extends InheritedTheme { + /// Create a [OutlinedButtonTheme]. + /// + /// The [data] parameter must not be null. + const OutlinedButtonTheme({ + Key key, + @required this.data, + Widget child, + }) : assert(data != null), super(key: key, child: child); + + /// The configuration of this theme. + final OutlinedButtonThemeData data; + + /// The closest instance of this class that encloses the given context. + /// + /// If there is no enclosing [OutlinedButtonsTheme] widget, then + /// [ThemeData.outlinedButtonTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// OutlinedButtonTheme theme = OutlinedButtonTheme.of(context); + /// ``` + static OutlinedButtonThemeData of(BuildContext context) { + final OutlinedButtonTheme buttonTheme = context.dependOnInheritedWidgetOfExactType(); + return buttonTheme?.data ?? Theme.of(context).outlinedButtonTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + final OutlinedButtonTheme ancestorTheme = context.findAncestorWidgetOfExactType(); + return identical(this, ancestorTheme) ? child : OutlinedButtonTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(OutlinedButtonTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/flutter/lib/src/material/text_button.dart b/packages/flutter/lib/src/material/text_button.dart new file mode 100644 index 0000000000..e2ff44effe --- /dev/null +++ b/packages/flutter/lib/src/material/text_button.dart @@ -0,0 +1,387 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.8 + +import 'dart:math' as math; +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'button_style_button.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'material_state.dart'; +import 'text_button_theme.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +/// A Material Design "Text Button". +/// +/// Use text buttons on toolbars, in dialogs, or inline with other +/// content but offset from that content with padding so that the +/// button's presence is obvious. Text buttons do not have visible +/// borders and must therefore rely on their position relative to +/// other content for context. In dialogs and cards, they should be +/// grouped together in one of the bottom corners. Avoid using text +/// buttons where they would blend in with other content, for example +/// in the middle of lists. +/// +/// A text button is a label [child] displayed on a (zero elevation) +/// [Material] widget. The label's [Text] and [Icon] widgets are +/// displayed in the [style]'s [ButtonStyle.foregroundColor]. The +/// button reacts to touches by filling with the [style]'s +/// [ButtonStyle.backgroundColor]. +/// +/// The text button's default style is defined by [defaultStyleOf]. +/// The style of this text button can be overridden with its [style] +/// parameter. The style of all text buttons in a subtree can be +/// overridden with the [TextButtonTheme] and the style of all of the +/// text buttons in an app can be overridden with the [Theme]'s +/// [ThemeData.textButtonTheme] property. +/// +/// The static [styleFrom] method is a convenient way to create a +/// text button [ButtonStyle] from simple values. +/// +/// If the [onPressed] and [onLongPress] callbacks are null, then this +/// button will be disabled, it will not react to touch. +/// +/// See also: +/// +/// * [OutlinedButton], a [TextButton] with a border outline. +/// * [ContainedButton], a filled button whose material elevates when pressed. +/// * +class TextButton extends ButtonStyleButton { + /// Create a TextButton. + /// + /// The [autofocus] and [clipBehavior] arguments must not be null. + const TextButton({ + Key key, + @required VoidCallback onPressed, + VoidCallback onLongPress, + ButtonStyle style, + FocusNode focusNode, + bool autofocus = false, + Clip clipBehavior = Clip.none, + @required Widget child, + }) : super( + key: key, + onPressed: onPressed, + onLongPress: onLongPress, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clipBehavior, + child: child, + ); + + /// Create a text button from a pair of widgets that serve as the button's + /// [icon] and [label]. + /// + /// The icon and label are arranged in a row and padded by 8 logical pixels + /// at the ends, with an 8 pixel gap in between. + /// + /// The [icon] and [label] arguments must not be null. + factory TextButton.icon({ + Key key, + @required VoidCallback onPressed, + VoidCallback onLongPress, + ButtonStyle style, + FocusNode focusNode, + bool autofocus, + Clip clipBehavior, + @required Widget icon, + @required Widget label, + }) = _TextButtonWithIcon; + + /// A static convenience method that constructs a text button + /// [ButtonStyle] given simple values. + /// + /// The [primary], and [onSurface] colors are used to to create a + /// [MaterialStateProperty] [foreground] value in the same way that + /// [defaultStyleOf] uses the [ColorScheme] colors with the same + /// names. Specify a value for [primary] to specify the color of the + /// button's text and icons as well as the overlay colors used to + /// indicate the hover, focus, and pressed states. Use [onSurface] + /// to specify the button's disabled text and icon color. + /// + /// Similarly, the [enabledMouseCursor] and [disabledMouseCursor] + /// parameters are used to construct [ButtonStyle].mouseCursor. + /// + /// All of the other parameters are either used directly or used to + /// create a [MaterialStateProperty] with a single value for all + /// states. + /// + /// All parameters default to null. By default this method returns + /// a [ButtonStyle] that doesn't override anything. + /// + /// For example, to override the default text and icon colors for a + /// [TextButton], as well as its overlay color, with all of the + /// standard opacity adjustments for the pressed, focused, and + /// hovered states, one could write: + /// + /// ```dart + /// TextButton( + /// style: TextButton.styleFrom(primary: Colors.green), + /// ) + /// ``` + static ButtonStyle styleFrom({ + Color primary, + Color onSurface, + Color backgroundColor, + Color shadowColor, + double elevation, + TextStyle textStyle, + EdgeInsetsGeometry padding, + Size minimumSize, + BorderSide side, + OutlinedBorder shape, + MouseCursor enabledMouseCursor, + MouseCursor disabledMouseCursor, + VisualDensity visualDensity, + MaterialTapTargetSize tapTargetSize, + Duration animationDuration, + bool enableFeedback, + }) { + final MaterialStateProperty foregroundColor = (onSurface == null && primary == null) + ? null + : _TextButtonDefaultForeground(primary, onSurface); + final MaterialStateProperty overlayColor = (primary == null) + ? null + : _TextButtonDefaultOverlay(primary); + final MaterialStateProperty mouseCursor = (enabledMouseCursor == null && disabledMouseCursor == null) + ? null + : _TextButtonDefaultMouseCursor(enabledMouseCursor, disabledMouseCursor); + + return ButtonStyle( + textStyle: ButtonStyleButton.allOrNull(textStyle), + backgroundColor: ButtonStyleButton.allOrNull(backgroundColor), + foregroundColor: foregroundColor, + overlayColor: overlayColor, + shadowColor: ButtonStyleButton.allOrNull(shadowColor), + elevation: ButtonStyleButton.allOrNull(elevation), + padding: ButtonStyleButton.allOrNull(padding), + minimumSize: ButtonStyleButton.allOrNull(minimumSize), + side: ButtonStyleButton.allOrNull(side), + shape: ButtonStyleButton.allOrNull(shape), + mouseCursor: mouseCursor, + visualDensity: visualDensity, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + ); + } + + /// Defines the button's default appearance. + /// + /// The button [child]'s [Text] and [Icon] widgets are rendered with + /// the [ButtonStyle]'s foreground color. The button's [InkWell] adds + /// the style's overlay color when the button is focused, hovered + /// or pressed. The button's background color becomes its [Material] + /// color and is transparent by default. + /// + /// All of the ButtonStyle's defaults appear below. + /// + /// In this list "Theme.foo" is shorthand for + /// `Theme.of(context).foo`. Color scheme values like + /// "onSurface(0.38)" are shorthand for + /// `onSurface.withOpacity(0.38)`. [MaterialStateProperty] valued + /// properties that are not followed by by a sublist have the same + /// value for all states, otherwise the values are as specified for + /// each state and "others" means all other states. + /// + /// The `textScaleFactor` is the value of + /// `MediaQuery.of(context).textScaleFactor` and the names of the + /// EdgeInsets constructors and `EdgeInsetsGeometry.lerp` have been + /// abbreviated for readability. + /// + /// The color of the [textStyle] is not used, the [foreground] color + /// is used instead. + /// + /// * `textStyle` - Theme.textTheme.button + /// * `backgroundColor` - transparent + /// * `foregroundColor` + /// * disabled - Theme.colorScheme.onSurface(0.38) + /// * others - Theme.colorScheme.primary + /// * `overlayColor` + /// * hovered - Theme.colorScheme.primary(0.04) + /// * focused or pressed - Theme.colorScheme.primary(0.12) + /// * `shadowColor` - Colors.black + /// * `elevation` - 0 + /// * `padding` + /// * `textScaleFactor <= 1` - all(8) + /// * `1 < textScaleFactor <= 2` - lerp(all(8), horizontal(8)) + /// * `2 < textScaleFactor <= 3` - lerp(horizontal(8), horizontal(4)) + /// * `3 < textScaleFactor` - horizontal(4) + /// * `minimumSize` - Size(64, 36) + /// * `side` - BorderSide.none + /// * `shape` - RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)) + /// * `mouseCursor` + /// * disabled - SystemMouseCursors.forbidden + /// * others - SystemMouseCursors.click + /// * `visualDensity` - theme.visualDensity + /// * `tapTargetSize` - theme.materialTapTargetSize + /// * `animationDuration` - kThemeChangeDuration + /// * `enableFeedback` - true + /// + /// The default padding values for the [TextButton.icon] factory are slightly different: + /// + /// * `padding` + /// * `textScaleFactor <= 1` - all(8) + /// * `1 < textScaleFactor <= 2 `- lerp(all(8), horizontal(4)) + /// * `2 < textScaleFactor` - horizontal(4) + @override + ButtonStyle defaultStyleOf(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + + final EdgeInsetsGeometry scaledPadding = ButtonStyleButton.scaledPadding( + const EdgeInsets.all(8), + const EdgeInsets.symmetric(horizontal: 8), + const EdgeInsets.symmetric(horizontal: 4), + MediaQuery.of(context, nullOk: true)?.textScaleFactor ?? 1, + ); + + return styleFrom( + primary: colorScheme.primary, + onSurface: colorScheme.onSurface, + backgroundColor: Colors.transparent, + shadowColor: Colors.black, + elevation: 0, + textStyle: theme.textTheme.button, + padding: scaledPadding, + minimumSize: const Size(64, 36), + side: BorderSide.none, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4))), + enabledMouseCursor: SystemMouseCursors.click, + disabledMouseCursor: SystemMouseCursors.forbidden, + visualDensity: theme.visualDensity, + tapTargetSize: theme.materialTapTargetSize, + animationDuration: kThemeChangeDuration, + enableFeedback: true, + ); + } + + /// Returns the [TextButtonThemeData.style] of the closest + /// [TextButtonTheme] ancestor. + @override + ButtonStyle themeStyleOf(BuildContext context) { + return TextButtonTheme.of(context)?.style; + } +} + +@immutable +class _TextButtonDefaultForeground extends MaterialStateProperty { + _TextButtonDefaultForeground(this.primary, this.onSurface); + + final Color primary; + final Color onSurface; + + @override + Color resolve(Set states) { + if (states.contains(MaterialState.disabled)) + return onSurface?.withOpacity(0.38); + return primary; + } + + @override + String toString() { + return '{disabled: ${onSurface?.withOpacity(0.38)}, otherwise: $primary}'; + } +} + +@immutable +class _TextButtonDefaultOverlay extends MaterialStateProperty { + _TextButtonDefaultOverlay(this.primary); + + final Color primary; + + @override + Color resolve(Set states) { + if (states.contains(MaterialState.hovered)) + return primary?.withOpacity(0.04); + if (states.contains(MaterialState.focused) || states.contains(MaterialState.pressed)) + return primary?.withOpacity(0.12); + return null; + } + + @override + String toString() { + return '{hovered: ${primary?.withOpacity(0.04)}, focused,pressed: ${primary?.withOpacity(0.12)}, otherwise: null}'; + } +} + +@immutable +class _TextButtonDefaultMouseCursor extends MaterialStateProperty with Diagnosticable { + _TextButtonDefaultMouseCursor(this.enabledCursor, this.disabledCursor); + + final MouseCursor enabledCursor; + final MouseCursor disabledCursor; + + @override + MouseCursor resolve(Set states) { + if (states.contains(MaterialState.disabled)) + return disabledCursor; + return enabledCursor; + } +} + +class _TextButtonWithIcon extends TextButton { + _TextButtonWithIcon({ + Key key, + @required VoidCallback onPressed, + VoidCallback onLongPress, + ButtonStyle style, + FocusNode focusNode, + bool autofocus, + Clip clipBehavior, + @required Widget icon, + @required Widget label, + }) : assert(icon != null), + assert(label != null), + super( + key: key, + onPressed: onPressed, + onLongPress: onLongPress, + style: style, + focusNode: focusNode, + autofocus: autofocus ?? false, + clipBehavior: clipBehavior ?? Clip.none, + child: _TextButtonWithIconChild(icon: icon, label: label), + ); + + @override + ButtonStyle defaultStyleOf(BuildContext context) { + final EdgeInsetsGeometry scaledPadding = ButtonStyleButton.scaledPadding( + const EdgeInsets.all(8), + const EdgeInsets.symmetric(horizontal: 4), + const EdgeInsets.symmetric(horizontal: 4), + MediaQuery.of(context, nullOk: true)?.textScaleFactor ?? 1, + ); + return super.defaultStyleOf(context).copyWith( + padding: MaterialStateProperty.all(scaledPadding) + ); + } +} + +class _TextButtonWithIconChild extends StatelessWidget { + const _TextButtonWithIconChild({ Key key, this.label, this.icon }) : super(key: key); + + final Widget label; + final Widget icon; + + @override + Widget build(BuildContext context) { + final double scale = MediaQuery.of(context, nullOk: true)?.textScaleFactor ?? 1; + final double gap = scale <= 1 ? 8 : lerpDouble(8, 4, math.min(scale - 1, 1)); + return Row( + mainAxisSize: MainAxisSize.min, + children: [icon, SizedBox(width: gap), label], + ); + } +} diff --git a/packages/flutter/lib/src/material/text_button_theme.dart b/packages/flutter/lib/src/material/text_button_theme.dart new file mode 100644 index 0000000000..32bef7d933 --- /dev/null +++ b/packages/flutter/lib/src/material/text_button_theme.dart @@ -0,0 +1,127 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.8 + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'theme.dart'; + +/// A [ButtonStyle] that overrides the default appearance of +/// [TextButton]s when it's used with [TextButtonTheme] or with the +/// overall [Theme]'s [ThemeData.textButtonTheme]. +/// +/// The [style]'s properties override [TextButton]'s default style, +/// i.e. the [ButtonStyle] returned by [TextButton.defaultStyleOf]. Only +/// the style's non-null property values or resolved non-null +/// [MaterialStateProperty] values are used. +/// +/// See also: +/// +/// * [TextButtonTheme], the theme which is configured with this class. +/// * [TextButton.defaultStyleOf], which returns the default [ButtonStyle] +/// for text buttons. +/// * [TextButton.styleOf], which converts simple values into a +/// [ButtonStyle] that's consistent with [TextButton]'s defaults. +/// * [MaterialStateProperty.resolve], "resolve" a material state property +/// to a simple value based on a set of [MaterialState]s. +/// * [ThemeData.textButtonTheme], which can be used to override the default +/// [ButtonStyle] for [TextButton]s below the overall [Theme]. +@immutable +class TextButtonThemeData with Diagnosticable { + /// Creates a [TextButtonThemeData]. + /// + /// The [style] may be null. + const TextButtonThemeData({ this.style }); + + /// Overrides for [TextButton]'s default style. + /// + /// Non-null properties or non-null resolved [MaterialStateProperty] + /// values override the [ButtonStyle] returned by + /// [TextButton.defaultStyleOf]. + /// + /// If [style] is null, then this theme doesn't override anything. + final ButtonStyle style; + + /// Linearly interpolate between two text button themes. + static TextButtonThemeData lerp(TextButtonThemeData a, TextButtonThemeData b, double t) { + assert (t != null); + if (a == null && b == null) + return null; + return TextButtonThemeData( + style: ButtonStyle.lerp(a?.style, b?.style, t), + ); + } + + @override + int get hashCode { + return style.hashCode; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) + return true; + if (other.runtimeType != runtimeType) + return false; + return other is TextButtonThemeData && other.style == style; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('style', style, defaultValue: null)); + } +} + +/// Overrides the default [ButtonStyle] of its [TextButton] descendants. +/// +/// See also: +/// +/// * [TextButtonThemeData], which is used to configure this theme. +/// * [TextButton.defaultStyleOf], which returns the default [ButtonStyle] +/// for text buttons. +/// * [TextButton.styleOf], which converts simple values into a +/// [ButtonStyle] that's consistent with [TextButton]'s defaults. +/// * [ThemeData.textButtonTheme], which can be used to override the default +/// [ButtonStyle] for [TextButton]s below the overall [Theme]. +class TextButtonTheme extends InheritedTheme { + /// Create a [TextButtonTheme]. + /// + /// The [data] parameter must not be null. + const TextButtonTheme({ + Key key, + @required this.data, + Widget child, + }) : assert(data != null), super(key: key, child: child); + + /// The configuration of this theme. + final TextButtonThemeData data; + + /// The closest instance of this class that encloses the given context. + /// + /// If there is no enclosing [TextButtonsTheme] widget, then + /// [ThemeData.textButtonTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// TextButtonTheme theme = TextButtonTheme.of(context); + /// ``` + static TextButtonThemeData of(BuildContext context) { + final TextButtonTheme buttonTheme = context.dependOnInheritedWidgetOfExactType(); + return buttonTheme?.data ?? Theme.of(context).textButtonTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + final TextButtonTheme ancestorTheme = context.findAncestorWidgetOfExactType(); + return identical(this, ancestorTheme) ? child : TextButtonTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(TextButtonTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart index 0b0181d6ca..bde92b0f0b 100644 --- a/packages/flutter/lib/src/material/theme_data.dart +++ b/packages/flutter/lib/src/material/theme_data.dart @@ -22,6 +22,7 @@ import 'card_theme.dart'; import 'chip_theme.dart'; import 'color_scheme.dart'; import 'colors.dart'; +import 'contained_button_theme.dart'; import 'dialog_theme.dart'; import 'divider_theme.dart'; import 'floating_action_button_theme.dart'; @@ -29,11 +30,13 @@ import 'ink_splash.dart'; import 'ink_well.dart' show InteractiveInkFeatureFactory; import 'input_decorator.dart'; import 'navigation_rail_theme.dart'; +import 'outlined_button_theme.dart'; import 'page_transitions_theme.dart'; import 'popup_menu_theme.dart'; import 'slider_theme.dart'; import 'snack_bar_theme.dart'; import 'tab_bar_theme.dart'; +import 'text_button_theme.dart'; import 'text_theme.dart'; import 'time_picker_theme.dart'; import 'toggle_buttons_theme.dart'; @@ -271,6 +274,9 @@ class ThemeData with Diagnosticable { ButtonBarThemeData buttonBarTheme, BottomNavigationBarThemeData bottomNavigationBarTheme, TimePickerThemeData timePickerTheme, + TextButtonThemeData textButtonTheme, + ContainedButtonThemeData containedButtonTheme, + OutlinedButtonThemeData outlinedButtonTheme, bool fixTextFieldOutlineLabel, }) { assert(colorScheme?.brightness == null || brightness == null || colorScheme.brightness == brightness); @@ -383,7 +389,9 @@ class ThemeData with Diagnosticable { buttonBarTheme ??= const ButtonBarThemeData(); bottomNavigationBarTheme ??= const BottomNavigationBarThemeData(); timePickerTheme ??= const TimePickerThemeData(); - + textButtonTheme ??= const TextButtonThemeData(); + containedButtonTheme ??= const ContainedButtonThemeData(); + outlinedButtonTheme ??= const OutlinedButtonThemeData(); fixTextFieldOutlineLabel ??= false; return ThemeData.raw( @@ -452,6 +460,9 @@ class ThemeData with Diagnosticable { buttonBarTheme: buttonBarTheme, bottomNavigationBarTheme: bottomNavigationBarTheme, timePickerTheme: timePickerTheme, + textButtonTheme: textButtonTheme, + containedButtonTheme: containedButtonTheme, + outlinedButtonTheme: outlinedButtonTheme, fixTextFieldOutlineLabel: fixTextFieldOutlineLabel, ); } @@ -532,6 +543,9 @@ class ThemeData with Diagnosticable { @required this.buttonBarTheme, @required this.bottomNavigationBarTheme, @required this.timePickerTheme, + @required this.textButtonTheme, + @required this.containedButtonTheme, + @required this.outlinedButtonTheme, @required this.fixTextFieldOutlineLabel, }) : assert(visualDensity != null), assert(primaryColor != null), @@ -595,6 +609,9 @@ class ThemeData with Diagnosticable { assert(buttonBarTheme != null), assert(bottomNavigationBarTheme != null), assert(timePickerTheme != null), + assert(textButtonTheme != null), + assert(containedButtonTheme != null), + assert(outlinedButtonTheme != null), assert(fixTextFieldOutlineLabel != null); /// Create a [ThemeData] based on the colors in the given [colorScheme] and @@ -1044,6 +1061,18 @@ class ThemeData with Diagnosticable { /// A theme for customizing the appearance and layout of time picker widgets. final TimePickerThemeData timePickerTheme; + /// A theme for customizing the appearance and internal layout of + /// [TextButton]s. + final TextButtonThemeData textButtonTheme; + + /// A theme for customizing the appearance and internal layout of + /// [ContainedButton]s + final ContainedButtonThemeData containedButtonTheme; + + /// A theme for customizing the appearance and internal layout of + /// [OutlinedButton]s. + final OutlinedButtonThemeData outlinedButtonTheme; + /// A temporary flag to allow apps to opt-in to a /// [small fix](https://github.com/flutter/flutter/issues/54028) for the Y /// coordinate of the floating label in a [TextField] [OutlineInputBorder]. @@ -1126,6 +1155,9 @@ class ThemeData with Diagnosticable { ButtonBarThemeData buttonBarTheme, BottomNavigationBarThemeData bottomNavigationBarTheme, TimePickerThemeData timePickerTheme, + TextButtonThemeData textButtonTheme, + ContainedButtonThemeData containedButtonTheme, + OutlinedButtonThemeData outlinedButtonTheme, bool fixTextFieldOutlineLabel, }) { cupertinoOverrideTheme = cupertinoOverrideTheme?.noDefault(); @@ -1195,6 +1227,9 @@ class ThemeData with Diagnosticable { buttonBarTheme: buttonBarTheme ?? this.buttonBarTheme, bottomNavigationBarTheme: bottomNavigationBarTheme ?? this.bottomNavigationBarTheme, timePickerTheme: timePickerTheme ?? this.timePickerTheme, + textButtonTheme: textButtonTheme ?? this.textButtonTheme, + containedButtonTheme: containedButtonTheme ?? this.containedButtonTheme, + outlinedButtonTheme: outlinedButtonTheme ?? this.outlinedButtonTheme, fixTextFieldOutlineLabel: fixTextFieldOutlineLabel ?? this.fixTextFieldOutlineLabel, ); } @@ -1342,6 +1377,9 @@ class ThemeData with Diagnosticable { buttonBarTheme: ButtonBarThemeData.lerp(a.buttonBarTheme, b.buttonBarTheme, t), bottomNavigationBarTheme: BottomNavigationBarThemeData.lerp(a.bottomNavigationBarTheme, b.bottomNavigationBarTheme, t), timePickerTheme: TimePickerThemeData.lerp(a.timePickerTheme, b.timePickerTheme, t), + textButtonTheme: TextButtonThemeData.lerp(a.textButtonTheme, b.textButtonTheme, t), + containedButtonTheme: ContainedButtonThemeData.lerp(a.containedButtonTheme, b.containedButtonTheme, t), + outlinedButtonTheme: OutlinedButtonThemeData.lerp(a.outlinedButtonTheme, b.outlinedButtonTheme, t), fixTextFieldOutlineLabel: t < 0.5 ? a.fixTextFieldOutlineLabel : b.fixTextFieldOutlineLabel, ); } @@ -1417,6 +1455,9 @@ class ThemeData with Diagnosticable { && other.buttonBarTheme == buttonBarTheme && other.bottomNavigationBarTheme == bottomNavigationBarTheme && other.timePickerTheme == timePickerTheme + && other.textButtonTheme == textButtonTheme + && other.containedButtonTheme == containedButtonTheme + && other.outlinedButtonTheme == outlinedButtonTheme && other.fixTextFieldOutlineLabel == fixTextFieldOutlineLabel; } @@ -1491,6 +1532,9 @@ class ThemeData with Diagnosticable { buttonBarTheme, bottomNavigationBarTheme, timePickerTheme, + textButtonTheme, + containedButtonTheme, + outlinedButtonTheme, fixTextFieldOutlineLabel, ]; return hashList(values); @@ -1562,6 +1606,9 @@ class ThemeData with Diagnosticable { properties.add(DiagnosticsProperty('buttonBarTheme', buttonBarTheme, defaultValue: defaultData.buttonBarTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('timePickerTheme', timePickerTheme, defaultValue: defaultData.timePickerTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('bottomNavigationBarTheme', bottomNavigationBarTheme, defaultValue: defaultData.bottomNavigationBarTheme, level: DiagnosticLevel.debug)); + properties.add(DiagnosticsProperty('textButtonTheme', textButtonTheme, defaultValue: defaultData.textButtonTheme, level: DiagnosticLevel.debug)); + properties.add(DiagnosticsProperty('containedButtonTheme', containedButtonTheme, defaultValue: defaultData.containedButtonTheme, level: DiagnosticLevel.debug)); + properties.add(DiagnosticsProperty('outlinedButtonTheme', outlinedButtonTheme, defaultValue: defaultData.outlinedButtonTheme, level: DiagnosticLevel.debug)); } } diff --git a/packages/flutter/test/material/button_style_test.dart b/packages/flutter/test/material/button_style_test.dart new file mode 100644 index 0000000000..4addf40665 --- /dev/null +++ b/packages/flutter/test/material/button_style_test.dart @@ -0,0 +1,153 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.8 + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('ButtonStyle copyWith, merge, ==, hashCode basics', () { + expect(const ButtonStyle(), const ButtonStyle().copyWith()); + expect(const ButtonStyle().merge(const ButtonStyle()), const ButtonStyle()); + expect(const ButtonStyle().hashCode, const ButtonStyle().copyWith().hashCode); + }); + + test('ButtonStyle defaults', () { + const ButtonStyle style = ButtonStyle(); + expect(style.textStyle, null); + expect(style.backgroundColor, null); + expect(style.foregroundColor, null); + expect(style.overlayColor, null); + expect(style.elevation, null); + expect(style.padding, null); + expect(style.minimumSize, null); + expect(style.side, null); + expect(style.shape, null); + expect(style.mouseCursor, null); + expect(style.visualDensity, null); + expect(style.tapTargetSize, null); + expect(style.animationDuration, null); + expect(style.enableFeedback, null); + }); + + testWidgets('Default ButtonStyle debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + const ButtonStyle().debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, []); + }); + + testWidgets('ButtonStyle debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + ButtonStyle( + textStyle: MaterialStateProperty.all(const TextStyle(fontSize: 10.0)), + backgroundColor: MaterialStateProperty.all(const Color(0xfffffff1)), + foregroundColor: MaterialStateProperty.all(const Color(0xfffffff2)), + overlayColor: MaterialStateProperty.all(const Color(0xfffffff3)), + elevation: MaterialStateProperty.all(1.5), + padding: MaterialStateProperty.all(const EdgeInsets.all(1.0)), + minimumSize: MaterialStateProperty.all(const Size(1.0, 2.0)), + side: MaterialStateProperty.all(const BorderSide(width: 4.0, color: Color(0xfffffff4))), + shape: MaterialStateProperty.all(const StadiumBorder()), + mouseCursor: MaterialStateProperty.all(SystemMouseCursors.forbidden), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + animationDuration: const Duration(seconds: 1), + enableFeedback: true, + ).debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, [ + 'textStyle: MaterialStateProperty.all(TextStyle(inherit: true, size: 10.0))', + 'backgroundColor: MaterialStateProperty.all(Color(0xfffffff1))', + 'foregroundColor: MaterialStateProperty.all(Color(0xfffffff2))', + 'overlayColor: MaterialStateProperty.all(Color(0xfffffff3))', + 'elevation: MaterialStateProperty.all(1.5)', + 'padding: MaterialStateProperty.all(EdgeInsets.all(1.0))', + 'minimumSize: MaterialStateProperty.all(Size(1.0, 2.0))', + 'side: MaterialStateProperty.all(BorderSide(Color(0xfffffff4), 4.0, BorderStyle.solid))', + 'shape: MaterialStateProperty.all(StadiumBorder(BorderSide(Color(0xff000000), 0.0, BorderStyle.none)))', + 'mouseCursor: MaterialStateProperty.all(SystemMouseCursor(forbidden))', + 'tapTargetSize: shrinkWrap', + 'animationDuration: 0:00:01.000000', + 'enableFeedback: true', + ]); + }); + + testWidgets('ButtonStyle copyWith, merge', (WidgetTester tester) async { + final MaterialStateProperty textStyle = MaterialStateProperty.all(const TextStyle(fontSize: 10)); + final MaterialStateProperty backgroundColor = MaterialStateProperty.all(const Color(0xfffffff1)); + final MaterialStateProperty foregroundColor = MaterialStateProperty.all(const Color(0xfffffff2)); + final MaterialStateProperty overlayColor = MaterialStateProperty.all(const Color(0xfffffff3)); + final MaterialStateProperty elevation = MaterialStateProperty.all(1); + final MaterialStateProperty padding = MaterialStateProperty.all(const EdgeInsets.all(1)); + final MaterialStateProperty minimumSize = MaterialStateProperty.all(const Size(1, 2)); + final MaterialStateProperty side = MaterialStateProperty.all(const BorderSide()); + final MaterialStateProperty shape = MaterialStateProperty.all(const StadiumBorder()); + final MaterialStateProperty mouseCursor = MaterialStateProperty.all(SystemMouseCursors.forbidden); + const VisualDensity visualDensity = VisualDensity.compact; + const MaterialTapTargetSize tapTargetSize = MaterialTapTargetSize.shrinkWrap; + const Duration animationDuration = Duration(seconds: 1); + const bool enableFeedback = true; + + final ButtonStyle style = ButtonStyle( + textStyle: textStyle, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + overlayColor: overlayColor, + elevation: elevation, + padding: padding, + minimumSize: minimumSize, + side: side, + shape: shape, + mouseCursor: mouseCursor, + visualDensity: visualDensity, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + ); + + expect( + style, + const ButtonStyle().copyWith( + textStyle: textStyle, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + overlayColor: overlayColor, + elevation: elevation, + padding: padding, + minimumSize: minimumSize, + side: side, + shape: shape, + mouseCursor: mouseCursor, + visualDensity: visualDensity, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + ), + ); + + expect( + style, + const ButtonStyle().merge(style), + ); + + expect( + style.copyWith(), + style.merge(const ButtonStyle()) + ); + }); +} diff --git a/packages/flutter/test/material/contained_button_test.dart b/packages/flutter/test/material/contained_button_test.dart new file mode 100644 index 0000000000..92be52f5e5 --- /dev/null +++ b/packages/flutter/test/material/contained_button_test.dart @@ -0,0 +1,930 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.8 + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/rendering.dart'; + +import '../rendering/mock_canvas.dart'; +import '../widgets/semantics_tester.dart'; + +void main() { + testWidgets('ContainedButton defaults', (WidgetTester tester) async { + final Finder rawButtonMaterial = find.descendant( + of: find.byType(ContainedButton), + matching: find.byType(Material), + ); + + const ColorScheme colorScheme = ColorScheme.light(); + + // Enabled ContainedButton + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme), + home: Center( + child: ContainedButton( + onPressed: () { }, + child: const Text('button'), + ), + ), + ), + ); + + Material material = tester.widget(rawButtonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, true); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.primary); + expect(material.elevation, 2); + expect(material.shadowColor, const Color(0xff000000)); + expect(material.shape, RoundedRectangleBorder(borderRadius: BorderRadius.circular(4))); + expect(material.textStyle.color, colorScheme.onPrimary); + expect(material.textStyle.fontFamily, 'Roboto'); + expect(material.textStyle.fontSize, 14); + expect(material.textStyle.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + + final Offset center = tester.getCenter(find.byType(ContainedButton)); + await tester.startGesture(center); + await tester.pumpAndSettle(); + + // Only elevation changes when enabled and pressed. + material = tester.widget(rawButtonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, true); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.primary); + expect(material.elevation, 8); + expect(material.shadowColor, const Color(0xff000000)); + expect(material.shape, RoundedRectangleBorder(borderRadius: BorderRadius.circular(4))); + expect(material.textStyle.color, colorScheme.onPrimary); + expect(material.textStyle.fontFamily, 'Roboto'); + expect(material.textStyle.fontSize, 14); + expect(material.textStyle.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + + // Disabled ContainedButton + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme), + home: const Center( + child: ContainedButton( + onPressed: null, + child: Text('button'), + ), + ), + ), + ); + + material = tester.widget(rawButtonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, true); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.onSurface.withOpacity(0.12)); + expect(material.elevation, 0.0); + expect(material.shadowColor, const Color(0xff000000)); + expect(material.shape, RoundedRectangleBorder(borderRadius: BorderRadius.circular(4))); + expect(material.textStyle.color, colorScheme.onSurface.withOpacity(0.38)); + expect(material.textStyle.fontFamily, 'Roboto'); + expect(material.textStyle.fontSize, 14); + expect(material.textStyle.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + }); + + testWidgets('Default ContainedButton meets a11y contrast guidelines', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + body: Center( + child: ContainedButton( + child: const Text('ContainedButton'), + onPressed: () { }, + focusNode: focusNode, + ), + ), + ), + ), + ); + + // 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(ContainedButton)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + }, + skip: isBrowser, // https://github.com/flutter/flutter/issues/44115 + semanticsEnabled: true, + ); + + + testWidgets('ContainedButton uses stateful color for text 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); + + 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: ContainedButtonTheme( + data: ContainedButtonThemeData( + style: ButtonStyle( + foregroundColor: MaterialStateProperty.resolveWith(getTextColor), + ), + ), + child: Builder( + builder: (BuildContext context) { + return ContainedButton( + child: const Text('ContainedButton'), + onPressed: () {}, + focusNode: focusNode, + ); + }, + ), + ), + ), + ), + ), + ); + + Color textColor() { + return tester.renderObject(find.text('ContainedButton')).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(ContainedButton)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + 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); + }); + + + testWidgets('ContainedButton uses stateful color for icon color in different states', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + final Key buttonKey = UniqueKey(); + + const Color pressedColor = Color(0x00000001); + const Color hoverColor = Color(0x00000002); + const Color focusedColor = Color(0x00000003); + const Color defaultColor = Color(0x00000004); + + 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: ContainedButtonTheme( + data: ContainedButtonThemeData( + style: ButtonStyle( + foregroundColor: MaterialStateProperty.resolveWith(getTextColor), + ), + ), + child: Builder( + builder: (BuildContext context) { + return ContainedButton.icon( + key: buttonKey, + icon: const Icon(Icons.add), + label: const Text('ContainedButton'), + onPressed: () {}, + focusNode: focusNode, + ); + }, + ), + ), + ), + ), + ), + ); + + 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(); + addTearDown(gesture.removePointer); + 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); + }); + + testWidgets('ContainedButton onPressed and onLongPress callbacks are correctly called when non-null', (WidgetTester tester) async { + bool wasPressed; + Finder containedButton; + + Widget buildFrame({ VoidCallback onPressed, VoidCallback onLongPress }) { + return Directionality( + textDirection: TextDirection.ltr, + child: ContainedButton( + child: const Text('button'), + onPressed: onPressed, + onLongPress: onLongPress, + ), + ); + } + + // onPressed not null, onLongPress null. + wasPressed = false; + await tester.pumpWidget( + buildFrame(onPressed: () { wasPressed = true; }, onLongPress: null), + ); + containedButton = find.byType(ContainedButton); + expect(tester.widget(containedButton).enabled, true); + await tester.tap(containedButton); + expect(wasPressed, true); + + // onPressed null, onLongPress not null. + wasPressed = false; + await tester.pumpWidget( + buildFrame(onPressed: null, onLongPress: () { wasPressed = true; }), + ); + containedButton = find.byType(ContainedButton); + expect(tester.widget(containedButton).enabled, true); + await tester.longPress(containedButton); + expect(wasPressed, true); + + // onPressed null, onLongPress null. + await tester.pumpWidget( + buildFrame(onPressed: null, onLongPress: null), + ); + containedButton = find.byType(ContainedButton); + expect(tester.widget(containedButton).enabled, false); + }); + + testWidgets('ContainedButton onPressed and onLongPress callbacks are distinctly recognized', (WidgetTester tester) async { + bool didPressButton = false; + bool didLongPressButton = false; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ContainedButton( + onPressed: () { + didPressButton = true; + }, + onLongPress: () { + didLongPressButton = true; + }, + child: const Text('button'), + ), + ), + ); + + final Finder containedButton = find.byType(ContainedButton); + expect(tester.widget(containedButton).enabled, true); + + expect(didPressButton, isFalse); + await tester.tap(containedButton); + expect(didPressButton, isTrue); + + expect(didLongPressButton, isFalse); + await tester.longPress(containedButton); + expect(didLongPressButton, isTrue); + }); + + testWidgets('Does ContainedButton work with hover', (WidgetTester tester) async { + const Color hoverColor = Color(0xff001122); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ContainedButton( + style: ButtonStyle( + overlayColor: MaterialStateProperty.resolveWith((Set states) { + return states.contains(MaterialState.hovered) ? hoverColor : null; + }), + ), + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(ContainedButton))); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + expect(inkFeatures, paints..rect(color: hoverColor)); + + await gesture.removePointer(); + }); + + testWidgets('Does ContainedButton work with focus', (WidgetTester tester) async { + const Color focusColor = Color(0xff001122); + + final FocusNode focusNode = FocusNode(debugLabel: 'ContainedButton Node'); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ContainedButton( + style: ButtonStyle( + overlayColor: MaterialStateProperty.resolveWith((Set states) { + return states.contains(MaterialState.focused) ? focusColor : null; + }), + ), + focusNode: focusNode, + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + expect(inkFeatures, paints..rect(color: focusColor)); + }); + + testWidgets('Does ContainedButton work with autofocus', (WidgetTester tester) async { + const Color focusColor = Color(0xff001122); + + Color getOverlayColor(Set states) { + return states.contains(MaterialState.focused) ? focusColor : null; + } + + final FocusNode focusNode = FocusNode(debugLabel: 'ContainedButton Node'); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ContainedButton( + autofocus: true, + style: ButtonStyle( + overlayColor: MaterialStateProperty.resolveWith(getOverlayColor), + ), + focusNode: focusNode, + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + expect(inkFeatures, paints..rect(color: focusColor)); + }); + + testWidgets('Does ContainedButton contribute semantics', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: ContainedButton( + style: ButtonStyle( + // Specifying minimumSize to mimic the original minimumSize for + // RaisedButton so that the semantics tree's rect and transform + // match the original version of this test. + minimumSize: MaterialStateProperty.all(const Size(88, 36)), + ), + onPressed: () { }, + child: const Text('ABC'), + ), + ), + ), + ), + ); + + expect(semantics, hasSemantics( + TestSemantics.root( + children: [ + TestSemantics.rootChild( + actions: [ + SemanticsAction.tap, + ], + label: 'ABC', + rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + transform: Matrix4.translationValues(356.0, 276.0, 0.0), + flags: [ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + ), + ], + ), + ignoreId: true, + )); + + semantics.dispose(); + }); + + testWidgets('ContainedButton size is configurable by ThemeData.materialTapTargetSize', (WidgetTester tester) async { + final ButtonStyle style = ButtonStyle( + // Specifying minimumSize to mimic the original minimumSize for + // RaisedButton so that the corresponding button size matches + // the original version of this test. + minimumSize: MaterialStateProperty.all(const Size(88, 36)), + ); + + Widget buildFrame(MaterialTapTargetSize tapTargetSize, Key key) { + return Theme( + data: ThemeData(materialTapTargetSize: tapTargetSize), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: ContainedButton( + key: key, + style: style, + child: const SizedBox(width: 50.0, height: 8.0), + onPressed: () { }, + ), + ), + ), + ), + ); + } + + final Key key1 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.padded, key1)); + expect(tester.getSize(find.byKey(key1)), const Size(88.0, 48.0)); + + final Key key2 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.shrinkWrap, key2)); + expect(tester.getSize(find.byKey(key2)), const Size(88.0, 36.0)); + }); + + testWidgets('ContainedButton has no clip by default', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: ContainedButton( + onPressed: () { /* to make sure the button is enabled */ }, + child: const Text('button'), + ), + ), + ), + ); + + expect( + tester.renderObject(find.byType(ContainedButton)), + paintsExactlyCountTimes(#clipPath, 0), + ); + }); + + testWidgets('ContainedButton responds to density changes.', (WidgetTester tester) async { + const Key key = Key('test'); + const Key childKey = Key('test child'); + + Future buildTest(VisualDensity visualDensity, {bool useText = false}) async { + return await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: ContainedButton( + style: ButtonStyle( + visualDensity: visualDensity, + // Specifying minimumSize to mimic the original minimumSize for + // RaisedButton so that the corresponding button size matches + // the original version of this test. + minimumSize: MaterialStateProperty.all(const Size(88, 36)), + ), + key: key, + onPressed: () {}, + child: useText + ? const Text('Text', key: childKey) + : Container(key: childKey, width: 100, height: 100, color: const Color(0xffff0000)), + ), + ), + ), + ), + ); + } + + await buildTest(const VisualDensity()); + final RenderBox box = tester.renderObject(find.byKey(key)); + Rect childRect = tester.getRect(find.byKey(childKey)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(132, 100))); + expect(childRect, equals(const Rect.fromLTRB(350, 250, 450, 350))); + + await buildTest(const VisualDensity(horizontal: 3.0, vertical: 3.0)); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byKey(childKey)); + expect(box.size, equals(const Size(156, 124))); + expect(childRect, equals(const Rect.fromLTRB(350, 250, 450, 350))); + + await buildTest(const VisualDensity(horizontal: -3.0, vertical: -3.0)); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byKey(childKey)); + expect(box.size, equals(const Size(108, 100))); + expect(childRect, equals(const Rect.fromLTRB(350, 250, 450, 350))); + + await buildTest(const VisualDensity(), useText: true); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byKey(childKey)); + expect(box.size, equals(const Size(88, 48))); + expect(childRect, equals(const Rect.fromLTRB(372.0, 293.0, 428.0, 307.0))); + + await buildTest(const VisualDensity(horizontal: 3.0, vertical: 3.0), useText: true); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byKey(childKey)); + expect(box.size, equals(const Size(112, 60))); + expect(childRect, equals(const Rect.fromLTRB(372.0, 293.0, 428.0, 307.0))); + + await buildTest(const VisualDensity(horizontal: -3.0, vertical: -3.0), useText: true); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byKey(childKey)); + expect(box.size, equals(const Size(76, 36))); + expect(childRect, equals(const Rect.fromLTRB(372.0, 293.0, 428.0, 307.0))); + }); + + testWidgets('ContainedButton.icon responds to applied padding', (WidgetTester tester) async { + const Key buttonKey = Key('test'); + const Key labelKey = Key('label'); + await tester.pumpWidget( + // When textDirection is set to TextDirection.ltr, the label appears on the + // right side of the icon. This is important in determining whether the + // horizontal padding is applied correctly later on + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: ContainedButton.icon( + key: buttonKey, + style: ButtonStyle( + padding: MaterialStateProperty.all(const EdgeInsets.fromLTRB(16, 5, 10, 12)), + ), + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text( + 'Hello', + key: labelKey, + ), + ), + ), + ), + ), + ); + + final Rect paddingRect = tester.getRect(find.byType(Padding)); + final Rect labelRect = tester.getRect(find.byKey(labelKey)); + final Rect iconRect = tester.getRect(find.byType(Icon)); + + // The right padding should be applied on the right of the label, whereas the + // left padding should be applied on the left side of the icon. + expect(paddingRect.right, labelRect.right + 10); + expect(paddingRect.left, iconRect.left - 16); + // Use the taller widget to check the top and bottom padding. + final Rect tallerWidget = iconRect.height > labelRect.height ? iconRect : labelRect; + expect(paddingRect.top, tallerWidget.top - 5); + expect(paddingRect.bottom, tallerWidget.bottom + 12); + }); + + group('Default ContainedButton padding for textScaleFactor, textDirection', () { + const ValueKey buttonKey = ValueKey('button'); + const ValueKey labelKey = ValueKey('label'); + const ValueKey iconKey = ValueKey('icon'); + + const List textScaleFactorOptions = [0.5, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0, 4.0]; + const List textDirectionOptions = [TextDirection.ltr, TextDirection.rtl]; + const List iconOptions = [null, Icon(Icons.add, size: 18, key: iconKey)]; + + // Expected values for each textScaleFactor. + final Map paddingWithoutIconStart = { + 0.5: 16, + 1: 16, + 1.25: 14, + 1.5: 12, + 2: 8, + 2.5: 6, + 3: 4, + 4: 4, + }; + final Map paddingWithoutIconEnd = { + 0.5: 16, + 1: 16, + 1.25: 14, + 1.5: 12, + 2: 8, + 2.5: 6, + 3: 4, + 4: 4, + }; + final Map paddingWithIconStart = { + 0.5: 12, + 1: 12, + 1.25: 11, + 1.5: 10, + 2: 8, + 2.5: 8, + 3: 8, + 4: 8, + }; + final Map paddingWithIconEnd = { + 0.5: 16, + 1: 16, + 1.25: 14, + 1.5: 12, + 2: 8, + 2.5: 6, + 3: 4, + 4: 4, + }; + final Map paddingWithIconGap = { + 0.5: 8, + 1: 8, + 1.25: 7, + 1.5: 6, + 2: 4, + 2.5: 4, + 3: 4, + 4: 4, + }; + + Rect globalBounds(RenderBox renderBox) { + final Offset topLeft = renderBox.localToGlobal(Offset.zero); + return topLeft & renderBox.size; + } + + /// Computes the padding between two [Rect]s, one inside the other. + EdgeInsets paddingBetween({ Rect parent, Rect child }) { + assert (parent.intersect(child) == child); + return EdgeInsets.fromLTRB( + child.left - parent.left, + child.top - parent.top, + parent.right - child.right, + parent.bottom - child.bottom, + ); + } + + for (final double textScaleFactor in textScaleFactorOptions) { + for (final TextDirection textDirection in textDirectionOptions) { + for (final Widget icon in iconOptions) { + final String testName = 'ContainedButton' + ', text scale $textScaleFactor' + '${icon != null ? ", with icon" : ""}' + '${textDirection == TextDirection.rtl ? ", RTL" : ""}'; + + testWidgets(testName, (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaleFactor: textScaleFactor, + ), + child: Directionality( + textDirection: textDirection, + child: Scaffold( + body: Center( + child: icon == null + ? ContainedButton( + key: buttonKey, + onPressed: () {}, + child: const Text('button', key: labelKey), + ) + : ContainedButton.icon( + key: buttonKey, + onPressed: () {}, + icon: icon, + label: const Text('button', key: labelKey), + ), + ), + ), + ), + ); + }, + ), + ), + ); + + final Element paddingElement = tester.element( + find.descendant( + of: find.byKey(buttonKey), + matching: find.byType(Padding), + ), + ); + expect(Directionality.of(paddingElement), textDirection); + final Padding paddingWidget = paddingElement.widget as Padding; + + // Compute expected padding, and check. + + final double expectedStart = icon != null + ? paddingWithIconStart[textScaleFactor] + : paddingWithoutIconStart[textScaleFactor]; + final double expectedEnd = icon != null + ? paddingWithIconEnd[textScaleFactor] + : paddingWithoutIconEnd[textScaleFactor]; + final EdgeInsets expectedPadding = EdgeInsetsDirectional.fromSTEB(expectedStart, 0, expectedEnd, 0) + .resolve(textDirection); + + expect(paddingWidget.padding.resolve(textDirection), expectedPadding); + + // Measure padding in terms of the difference between the button and its label child + // and check that. + + final RenderBox labelRenderBox = tester.renderObject(find.byKey(labelKey)); + final Rect labelBounds = globalBounds(labelRenderBox); + final RenderBox iconRenderBox = icon == null ? null : tester.renderObject(find.byKey(iconKey)); + final Rect iconBounds = icon == null ? null : globalBounds(iconRenderBox); + final Rect childBounds = icon == null ? labelBounds : labelBounds.expandToInclude(iconBounds); + + // We measure the `InkResponse` descendant of the button + // element, because the button has a larger `RenderBox` + // which accommodates the minimum tap target with a height + // of 48. + final RenderBox buttonRenderBox = tester.renderObject( + find.descendant( + of: find.byKey(buttonKey), + matching: find.byWidgetPredicate( + (Widget widget) => widget is InkResponse, + ), + ), + ); + final Rect buttonBounds = globalBounds(buttonRenderBox); + final EdgeInsets visuallyMeasuredPadding = paddingBetween( + parent: buttonBounds, + child: childBounds, + ); + + // Since there is a requirement of a minimum width of 64 + // and a minimum height of 36 on material buttons, the visual + // padding of smaller buttons may not match their settings. + // Therefore, we only test buttons that are large enough. + if (buttonBounds.width > 64) { + expect( + visuallyMeasuredPadding.left, + expectedPadding.left, + ); + expect( + visuallyMeasuredPadding.right, + expectedPadding.right, + ); + } + + if (buttonBounds.height > 36) { + expect( + visuallyMeasuredPadding.top, + expectedPadding.top, + ); + expect( + visuallyMeasuredPadding.bottom, + expectedPadding.bottom, + ); + } + + // Check the gap between the icon and the label + if (icon != null) { + final double gapWidth = textDirection == TextDirection.ltr + ? labelBounds.left - iconBounds.right + : iconBounds.left - labelBounds.right; + expect(gapWidth, paddingWithIconGap[textScaleFactor]); + } + + // Check the text's height - should be consistent with the textScaleFactor. + final RenderBox textRenderObject = tester.renderObject( + find.descendant( + of: find.byKey(labelKey), + matching: find.byElementPredicate( + (Element element) => element.widget is RichText, + ), + ), + ); + final double textHeight = textRenderObject.paintBounds.size.height; + final double expectedTextHeight = 14 * textScaleFactor; + expect(textHeight, moreOrLessEquals(expectedTextHeight, epsilon: 0.5)); + }); + } + } + } + }); + + testWidgets('Override ContainedButton default padding', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaleFactor: 2, + ), + child: Scaffold( + body: Center( + child: ContainedButton( + style: ContainedButton.styleFrom(padding: const EdgeInsets.all(22)), + onPressed: () {}, + child: const Text('ContainedButton') + ), + ), + ), + ); + }, + ), + ), + ); + + final Padding paddingWidget = tester.widget( + find.descendant( + of: find.byType(ContainedButton), + matching: find.byType(Padding), + ), + ); + expect(paddingWidget.padding, const EdgeInsets.all(22)); + }); +} + +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/contained_button_theme_test.dart b/packages/flutter/test/material/contained_button_theme_test.dart new file mode 100644 index 0000000000..284dde9eab --- /dev/null +++ b/packages/flutter/test/material/contained_button_theme_test.dart @@ -0,0 +1,182 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.8 + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Passing no ContainedButtonTheme returns defaults', (WidgetTester tester) async { + const ColorScheme colorScheme = ColorScheme.light(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme), + home: Scaffold( + body: Center( + child: ContainedButton( + onPressed: () { }, + child: const Text('button'), + ), + ), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(ContainedButton), + matching: find.byType(Material), + ); + + final Material material = tester.widget(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, colorScheme.primary); + expect(material.elevation, 2); + expect(material.shadowColor, const Color(0xff000000)); + expect(material.shape, RoundedRectangleBorder(borderRadius: BorderRadius.circular(4.0))); + expect(material.textStyle.color, colorScheme.onPrimary); + expect(material.textStyle.fontFamily, 'Roboto'); + expect(material.textStyle.fontSize, 14); + expect(material.textStyle.fontWeight, FontWeight.w500); + }); + + group('[Theme, TextTheme, ContainedButton style overrides]', () { + const Color primaryColor = Color(0xff000001); + const Color onSurfaceColor = Color(0xff000002); + const Color shadowColor = Color(0xff000004); + const Color onPrimaryColor = Color(0xff000005); + const double elevation = 1; + const TextStyle textStyle = TextStyle(fontSize: 12.0); + const EdgeInsets padding = EdgeInsets.all(3); + const Size minimumSize = Size(200, 200); + const BorderSide side = BorderSide(color: Colors.green, width: 2); + const OutlinedBorder shape = RoundedRectangleBorder(side: side, borderRadius: BorderRadius.all(Radius.circular(2))); + const MouseCursor enabledMouseCursor = SystemMouseCursors.text; + const MouseCursor disabledMouseCursor = SystemMouseCursors.grab; + const MaterialTapTargetSize tapTargetSize = MaterialTapTargetSize.shrinkWrap; + const Duration animationDuration = Duration(milliseconds: 25); + const bool enableFeedback = false; + + final ButtonStyle style = ContainedButton.styleFrom( + primary: primaryColor, + onPrimary: onPrimaryColor, + onSurface: onSurfaceColor, + shadowColor: shadowColor, + elevation: elevation, + textStyle: textStyle, + padding: padding, + minimumSize: minimumSize, + side: side, + shape: shape, + enabledMouseCursor: enabledMouseCursor, + disabledMouseCursor: disabledMouseCursor, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + ); + + Widget buildFrame({ ButtonStyle buttonStyle, ButtonStyle themeStyle, ButtonStyle overallStyle }) { + final Widget child = Builder( + builder: (BuildContext context) { + return ContainedButton( + style: buttonStyle, + onPressed: () { }, + child: const Text('button'), + ); + }, + ); + return MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()).copyWith( + containedButtonTheme: ContainedButtonThemeData(style: overallStyle), + ), + home: Scaffold( + body: Center( + // If the ContainedButtonTheme widget is present, it's used + // instead of the Theme's ThemeData.containedButtonTheme. + child: themeStyle == null ? child : ContainedButtonTheme( + data: ContainedButtonThemeData(style: themeStyle), + child: child, + ), + ), + ), + ); + } + + final Finder findMaterial = find.descendant( + of: find.byType(ContainedButton), + matching: find.byType(Material), + ); + + final Finder findInkWell = find.descendant( + of: find.byType(ContainedButton), + matching: find.byType(InkWell), + ); + + const Set enabled = {}; + const Set disabled = { MaterialState.disabled }; + const Set hovered = { MaterialState.hovered }; + const Set focused = { MaterialState.focused }; + const Set pressed = { MaterialState.pressed }; + + void checkButton(WidgetTester tester) { + final Material material = tester.widget(findMaterial); + final InkWell inkWell = tester.widget(findInkWell); + expect(material.textStyle.color, onPrimaryColor); + expect(material.textStyle.fontSize, 12); + expect(material.color, primaryColor); + expect(material.shadowColor, shadowColor); + expect(material.elevation, elevation); + expect(MaterialStateProperty.resolveAs(inkWell.mouseCursor, enabled), enabledMouseCursor); + expect(MaterialStateProperty.resolveAs(inkWell.mouseCursor, disabled), disabledMouseCursor); + expect(inkWell.overlayColor.resolve(hovered), onPrimaryColor.withOpacity(0.08)); + expect(inkWell.overlayColor.resolve(focused), onPrimaryColor.withOpacity(0.24)); + expect(inkWell.overlayColor.resolve(pressed), onPrimaryColor.withOpacity(0.24)); + expect(inkWell.enableFeedback, enableFeedback); + expect(material.borderRadius, null); + expect(material.shape, shape); + expect(material.animationDuration, animationDuration); + expect(tester.getSize(find.byType(ContainedButton)), const Size(200, 200)); + } + + testWidgets('Button style overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(buttonStyle: style)); + await tester.pumpAndSettle(); // allow the animations to finish + checkButton(tester); + }); + + testWidgets('Button theme style overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(themeStyle: style)); + await tester.pumpAndSettle(); + checkButton(tester); + }); + + testWidgets('Overall Theme button theme style overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(overallStyle: style)); + await tester.pumpAndSettle(); + checkButton(tester); + }); + + // Same as the previous tests with empty ButtonStyle's instead of null. + + testWidgets('Button style overrides defaults, empty theme and overall styles', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(buttonStyle: style, themeStyle: const ButtonStyle(), overallStyle: const ButtonStyle())); + await tester.pumpAndSettle(); // allow the animations to finish + checkButton(tester); + }); + + testWidgets('Button theme style overrides defaults, empty button and overall styles', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(buttonStyle: const ButtonStyle(), themeStyle: style, overallStyle: const ButtonStyle())); + await tester.pumpAndSettle(); // allow the animations to finish + checkButton(tester); + }); + + testWidgets('Overall Theme button theme style overrides defaults, null theme and empty overall style', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(buttonStyle: const ButtonStyle(), themeStyle: null, overallStyle: style)); + await tester.pumpAndSettle(); // allow the animations to finish + checkButton(tester); + }); + }); +} diff --git a/packages/flutter/test/material/outlined_button_test.dart b/packages/flutter/test/material/outlined_button_test.dart new file mode 100644 index 0000000000..c63e8bd4cc --- /dev/null +++ b/packages/flutter/test/material/outlined_button_test.dart @@ -0,0 +1,1204 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.8 + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/rendering.dart'; + +import '../rendering/mock_canvas.dart'; +import '../widgets/semantics_tester.dart'; + +void main() { + testWidgets('OutlinedButton defaults', (WidgetTester tester) async { + final Finder rawButtonMaterial = find.descendant( + of: find.byType(OutlinedButton), + matching: find.byType(Material), + ); + + const ColorScheme colorScheme = ColorScheme.light(); + + // Enabled OutlinedButton + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme), + home: Center( + child: OutlinedButton( + onPressed: () { }, + child: const Text('button'), + ), + ), + ), + ); + + Material material = tester.widget(rawButtonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, true); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, const Color(0xff000000)); + expect(material.shape, RoundedRectangleBorder( + side: BorderSide( + width: 1, + color: colorScheme.onSurface.withOpacity(0.12), + ), + borderRadius: BorderRadius.circular(4.0), + )); + expect(material.textStyle.color, colorScheme.primary); + expect(material.textStyle.fontFamily, 'Roboto'); + expect(material.textStyle.fontSize, 14); + expect(material.textStyle.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + + final Offset center = tester.getCenter(find.byType(OutlinedButton)); + await tester.startGesture(center); + await tester.pumpAndSettle(); + + // No change vs enabled and not pressed. + material = tester.widget(rawButtonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, true); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, const Color(0xff000000)); + expect(material.shape, RoundedRectangleBorder( + side: BorderSide( + width: 1, + color: colorScheme.onSurface.withOpacity(0.12), + ), + borderRadius: BorderRadius.circular(4.0), + )); + expect(material.textStyle.color, colorScheme.primary); + expect(material.textStyle.fontFamily, 'Roboto'); + expect(material.textStyle.fontSize, 14); + expect(material.textStyle.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + + // Disabled OutlinedButton + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme), + home: const Center( + child: OutlinedButton( + onPressed: null, + child: Text('button'), + ), + ), + ), + ); + + material = tester.widget(rawButtonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, true); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, const Color(0xff000000)); + expect(material.shape, RoundedRectangleBorder( + side: BorderSide( + width: 1, + color: colorScheme.onSurface.withOpacity(0.12), + ), + borderRadius: BorderRadius.circular(4.0), + )); + expect(material.textStyle.color, colorScheme.onSurface.withOpacity(0.38)); + expect(material.textStyle.fontFamily, 'Roboto'); + expect(material.textStyle.fontSize, 14); + expect(material.textStyle.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + }); + + testWidgets('Does OutlinedButton work with hover', (WidgetTester tester) async { + const Color hoverColor = Color(0xff001122); + + Color getOverlayColor(Set states) { + return states.contains(MaterialState.hovered) ? hoverColor : null; + } + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton( + style: ButtonStyle( + overlayColor: MaterialStateProperty.resolveWith(getOverlayColor), + ), + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(OutlinedButton))); + await tester.pumpAndSettle(); + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + expect(inkFeatures, paints..rect(color: hoverColor)); + + gesture.removePointer(); + }); + + testWidgets('Does OutlinedButton work with focus', (WidgetTester tester) async { + const Color focusColor = Color(0xff001122); + + Color getOverlayColor(Set states) { + return states.contains(MaterialState.focused) ? focusColor : null; + } + + final FocusNode focusNode = FocusNode(debugLabel: 'OutlinedButton Node'); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton( + style: ButtonStyle( + overlayColor: MaterialStateProperty.resolveWith(getOverlayColor), + ), + focusNode: focusNode, + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + expect(inkFeatures, paints..rect(color: focusColor)); + }); + + testWidgets('Does OutlinedButton work with autofocus', (WidgetTester tester) async { + const Color focusColor = Color(0xff001122); + + Color getOverlayColor(Set states) { + return states.contains(MaterialState.focused) ? focusColor : null; + } + + final FocusNode focusNode = FocusNode(debugLabel: 'OutlinedButton Node'); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton( + autofocus: true, + style: ButtonStyle( + overlayColor: MaterialStateProperty.resolveWith(getOverlayColor), + ), + focusNode: focusNode, + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + expect(inkFeatures, paints..rect(color: focusColor)); + }); + + testWidgets('Default OutlinedButton meets a11y contrast guidelines', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + body: Center( + child: OutlinedButton( + child: const Text('OutlinedButton'), + onPressed: () {}, + focusNode: focusNode, + ), + ), + ), + ), + ); + + // 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(OutlinedButton)); + 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(); + }, + skip: isBrowser, // https://github.com/flutter/flutter/issues/44115 + semanticsEnabled: true, + ); + + testWidgets('OutlinedButton with colored theme meets a11y contrast guidelines', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + + 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( + theme: ThemeData.from(colorScheme: ColorScheme.fromSwatch(primarySwatch: Colors.blue)), + home: Scaffold( + backgroundColor: Colors.white, + body: Center( + child: OutlinedButtonTheme( + data: OutlinedButtonThemeData( + style: ButtonStyle( + foregroundColor: MaterialStateProperty.resolveWith(getTextColor), + ), + ), + child: Builder( + builder: (BuildContext context) { + return OutlinedButton( + child: const Text('OutlinedButton'), + onPressed: () {}, + focusNode: focusNode, + ); + }, + ), + ), + ), + ), + ), + ); + + // 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(OutlinedButton)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + 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)); + }, + skip: isBrowser, // https://github.com/flutter/flutter/issues/44115 + semanticsEnabled: true, + ); + + testWidgets('OutlinedButton uses stateful color for text 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); + + 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: OutlinedButton( + style: ButtonStyle( + foregroundColor: MaterialStateProperty.resolveWith(getTextColor), + ), + onPressed: () {}, + focusNode: focusNode, + child: const Text('OutlinedButton'), + ), + ), + ), + ), + ); + + Color textColor() { + return tester.renderObject(find.text('OutlinedButton')).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(OutlinedButton)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + 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); + }); + + testWidgets('OutlinedButton uses stateful color for icon color in different states', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + final Key buttonKey = UniqueKey(); + + const Color pressedColor = Color(0x00000001); + const Color hoverColor = Color(0x00000002); + const Color focusedColor = Color(0x00000003); + const Color defaultColor = Color(0x00000004); + + Color getIconColor(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: OutlinedButton.icon( + key: buttonKey, + style: ButtonStyle( + foregroundColor: MaterialStateProperty.resolveWith(getIconColor), + ), + icon: const Icon(Icons.add), + label: const Text('OutlinedButton'), + onPressed: () {}, + focusNode: focusNode, + ), + ), + ), + ), + ); + + 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(); + addTearDown(gesture.removePointer); + 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); + }); + + testWidgets('OutlinedButton uses stateful color for border 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); + + BorderSide getBorderSide(Set states) { + if (states.contains(MaterialState.pressed)) { + return const BorderSide(color: pressedColor, width: 1); + } + if (states.contains(MaterialState.hovered)) { + return const BorderSide(color: hoverColor, width: 1); + } + if (states.contains(MaterialState.focused)) { + return const BorderSide(color: focusedColor, width: 1); + } + return const BorderSide(color: defaultColor, width: 1); + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: OutlinedButton( + style: ButtonStyle( + side: MaterialStateProperty.resolveWith(getBorderSide), + ), + onPressed: () {}, + focusNode: focusNode, + child: const Text('OutlinedButton'), + ), + ), + ), + ), + ); + + final Finder outlinedButton = find.byType(OutlinedButton); + + // Default, not disabled. + expect(outlinedButton, paints..drrect(color: defaultColor)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(outlinedButton, paints..drrect(color: focusedColor)); + + // Hovered. + final Offset center = tester.getCenter(find.byType(OutlinedButton)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(outlinedButton, paints..drrect(color: hoverColor)); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect(outlinedButton, paints..drrect(color: pressedColor)); + }); + + testWidgets('OutlinedButton onPressed and onLongPress callbacks are correctly called when non-null', (WidgetTester tester) async { + + bool wasPressed; + Finder outlinedButton; + + Widget buildFrame({ VoidCallback onPressed, VoidCallback onLongPress }) { + return Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton( + child: const Text('button'), + onPressed: onPressed, + onLongPress: onLongPress, + ), + ); + } + + // onPressed not null, onLongPress null. + wasPressed = false; + await tester.pumpWidget( + buildFrame(onPressed: () { wasPressed = true; }, onLongPress: null), + ); + outlinedButton = find.byType(OutlinedButton); + expect(tester.widget(outlinedButton).enabled, true); + await tester.tap(outlinedButton); + expect(wasPressed, true); + + // onPressed null, onLongPress not null. + wasPressed = false; + await tester.pumpWidget( + buildFrame(onPressed: null, onLongPress: () { wasPressed = true; }), + ); + outlinedButton = find.byType(OutlinedButton); + expect(tester.widget(outlinedButton).enabled, true); + await tester.longPress(outlinedButton); + expect(wasPressed, true); + + // onPressed null, onLongPress null. + await tester.pumpWidget( + buildFrame(onPressed: null, onLongPress: null), + ); + outlinedButton = find.byType(OutlinedButton); + expect(tester.widget(outlinedButton).enabled, false); + }); + + testWidgets("Outline button doesn't crash if disabled during a gesture", (WidgetTester tester) async { + Widget buildFrame(VoidCallback onPressed) { + return Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData(), + child: Center( + child: OutlinedButton(onPressed: onPressed, child: const Text('button')), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(() {})); + await tester.press(find.byType(OutlinedButton)); + await tester.pumpAndSettle(); + await tester.pumpWidget(buildFrame(null)); + await tester.pumpAndSettle(); + }); + + testWidgets('OutlinedButton shape and border component overrides', (WidgetTester tester) async { + const Color fillColor = Color(0xFF00FF00); + const BorderSide disabledBorderSide = BorderSide(color: Color(0xFFFF0000), width: 3); + const BorderSide enabledBorderSide = BorderSide(color: Color(0xFFFF00FF), width: 4); + const BorderSide pressedBorderSide = BorderSide(color: Color(0xFF0000FF), width: 5); + + Widget buildFrame({ VoidCallback onPressed }) { + return Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData(materialTapTargetSize: MaterialTapTargetSize.shrinkWrap), + child: Container( + alignment: Alignment.topLeft, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + shape: const RoundedRectangleBorder(), // default border radius is 0 + backgroundColor: fillColor, + ).copyWith( + side: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) + return disabledBorderSide; + if (states.contains(MaterialState.pressed)) + return pressedBorderSide; + return enabledBorderSide; + }), + ), + clipBehavior: Clip.antiAlias, + onPressed: onPressed, + child: const Text('button'), + ), + ), + ), + ); + } + + // 116 = 16 + 'button'.length * 14 + 16, horizontal padding = 16 + const Rect clipRect = Rect.fromLTRB(0.0, 0.0, 116.0, 36.0); + final Path clipPath = Path()..addRect(clipRect); + final Finder outlinedButton = find.byType(OutlinedButton); + + BorderSide getBorderSide() { + final OutlinedBorder border = tester.widget( + find.descendant(of: outlinedButton, matching: find.byType(Material)) + ).shape as OutlinedBorder; + return border.side; + } + + // Pump a button with a null onPressed callback to make it disabled. + await tester.pumpWidget( + buildFrame(onPressed: null), + ); + + // Expect that the button is disabled and painted with the disabled border color. + expect(tester.widget(outlinedButton).enabled, false); + expect(getBorderSide(), disabledBorderSide); + _checkPhysicalLayer( + tester.element(outlinedButton), + fillColor, + clipPath: clipPath, + clipRect: clipRect, + ); + + // Pump a new button with a no-op onPressed callback to make it enabled. + await tester.pumpWidget( + buildFrame(onPressed: () {}), + ); + + // Wait for the border color to change from disabled to enabled. + await tester.pumpAndSettle(); + expect(getBorderSide(), enabledBorderSide); + + final Offset center = tester.getCenter(outlinedButton); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start gesture + + // Wait for the border's color to change to pressed + await tester.pump(const Duration(milliseconds: 200)); + expect(getBorderSide(), pressedBorderSide); + _checkPhysicalLayer( + tester.element(outlinedButton), + fillColor, + clipPath: clipPath, + clipRect: clipRect, + ); + + // Tap gesture completes, button returns to its initial configuration. + await gesture.up(); + await tester.pumpAndSettle(); + expect(getBorderSide(), enabledBorderSide); + _checkPhysicalLayer( + tester.element(outlinedButton), + fillColor, + clipPath: clipPath, + clipRect: clipRect, + ); + }); + + testWidgets('OutlinedButton has no clip by default', (WidgetTester tester) async { + final GlobalKey buttonKey = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: OutlinedButton( + key: buttonKey, + onPressed: () {}, + child: const Text('ABC'), + ), + ), + ), + ), + ); + + expect( + tester.renderObject(find.byKey(buttonKey)), + paintsExactlyCountTimes(#clipPath, 0), + ); + }); + + + testWidgets('OutlinedButton contributes semantics', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: OutlinedButton( + style: ButtonStyle( + // Specifying minimumSize to mimic the original minimumSize for + // RaisedButton so that the corresponding button size matches + // the original version of this test. + minimumSize: MaterialStateProperty.all(const Size(88, 36)), + ), + onPressed: () {}, + child: const Text('ABC'), + ), + ), + ), + ), + ); + + expect(semantics, hasSemantics( + TestSemantics.root( + children: [ + TestSemantics.rootChild( + actions: [ + SemanticsAction.tap, + ], + label: 'ABC', + rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + transform: Matrix4.translationValues(356.0, 276.0, 0.0), + flags: [ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + ), + ], + ), + ignoreId: true, + )); + + semantics.dispose(); + }); + + testWidgets('OutlinedButton scales textScaleFactor', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: MediaQuery( + data: const MediaQueryData(textScaleFactor: 1.0), + child: Center( + child: OutlinedButton( + style: ButtonStyle( + // Specifying minimumSize to mimic the original minimumSize for + // RaisedButton so that the corresponding button size matches + // the original version of this test. + minimumSize: MaterialStateProperty.all(const Size(88, 36)), + ), + onPressed: () {}, + child: const Text('ABC'), + ), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(OutlinedButton)), equals(const Size(88.0, 48.0))); + expect(tester.getSize(find.byType(Text)), equals(const Size(42.0, 14.0))); + + // textScaleFactor expands text, but not button. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: MediaQuery( + data: const MediaQueryData(textScaleFactor: 1.3), + child: Center( + child: OutlinedButton( + style: ButtonStyle( + // Specifying minimumSize to mimic the original minimumSize for + // RaisedButton so that the corresponding button size matches + // the original version of this test. + minimumSize: MaterialStateProperty.all(const Size(88, 36)), + ), + onPressed: () {}, + child: const Text('ABC'), + ), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(OutlinedButton)), equals(const Size(88.0, 48.0))); + // Scaled text rendering is different on Linux and Mac by one pixel. + // TODO(gspencergoog): Figure out why this is, and fix it. https://github.com/flutter/flutter/issues/12357 + expect(tester.getSize(find.byType(Text)).width, isIn([54.0, 55.0])); + expect(tester.getSize(find.byType(Text)).height, isIn([18.0, 19.0])); + + // Set text scale large enough to expand text and button. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: MediaQuery( + data: const MediaQueryData(textScaleFactor: 3.0), + child: Center( + child: OutlinedButton( + onPressed: () {}, + child: const Text('ABC'), + ), + ), + ), + ), + ), + ); + + // Scaled text rendering is different on Linux and Mac by one pixel. + // TODO(gspencergoog): Figure out why this is, and fix it. https://github.com/flutter/flutter/issues/12357 + expect(tester.getSize(find.byType(OutlinedButton)).width, isIn([133.0, 134.0])); + expect(tester.getSize(find.byType(OutlinedButton)).height, equals(48.0)); + expect(tester.getSize(find.byType(Text)).width, isIn([126.0, 127.0])); + expect(tester.getSize(find.byType(Text)).height, equals(42.0)); + }); + + + testWidgets('OutlinedButton onPressed and onLongPress callbacks are distinctly recognized', (WidgetTester tester) async { + bool didPressButton = false; + bool didLongPressButton = false; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton( + onPressed: () { + didPressButton = true; + }, + onLongPress: () { + didLongPressButton = true; + }, + child: const Text('button'), + ), + ), + ); + + final Finder outlinedButton = find.byType(OutlinedButton); + expect(tester.widget(outlinedButton).enabled, true); + + expect(didPressButton, isFalse); + await tester.tap(outlinedButton); + expect(didPressButton, isTrue); + + expect(didLongPressButton, isFalse); + await tester.longPress(outlinedButton); + expect(didLongPressButton, isTrue); + }); + + testWidgets('OutlinedButton responds to density changes.', (WidgetTester tester) async { + const Key key = Key('test'); + const Key childKey = Key('test child'); + + Future buildTest(VisualDensity visualDensity, {bool useText = false}) async { + return await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: OutlinedButton( + style: ButtonStyle(visualDensity: visualDensity), + key: key, + onPressed: () {}, + child: useText + ? const Text('Text', key: childKey) + : Container(key: childKey, width: 100, height: 100, color: const Color(0xffff0000)), + ), + ), + ), + ), + ); + } + + await buildTest(const VisualDensity()); + final RenderBox box = tester.renderObject(find.byKey(key)); + Rect childRect = tester.getRect(find.byKey(childKey)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(132, 100))); + expect(childRect, equals(const Rect.fromLTRB(350, 250, 450, 350))); + + await buildTest(const VisualDensity(horizontal: 3.0, vertical: 3.0)); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byKey(childKey)); + expect(box.size, equals(const Size(156, 124))); + expect(childRect, equals(const Rect.fromLTRB(350, 250, 450, 350))); + + await buildTest(const VisualDensity(horizontal: -3.0, vertical: -3.0)); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byKey(childKey)); + expect(box.size, equals(const Size(108, 100))); + expect(childRect, equals(const Rect.fromLTRB(350, 250, 450, 350))); + + await buildTest(const VisualDensity(), useText: true); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byKey(childKey)); + expect(box.size, equals(const Size(88, 48))); + expect(childRect, equals(const Rect.fromLTRB(372.0, 293.0, 428.0, 307.0))); + + await buildTest(const VisualDensity(horizontal: 3.0, vertical: 3.0), useText: true); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byKey(childKey)); + expect(box.size, equals(const Size(112, 60))); + expect(childRect, equals(const Rect.fromLTRB(372.0, 293.0, 428.0, 307.0))); + + await buildTest(const VisualDensity(horizontal: -3.0, vertical: -3.0), useText: true); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byKey(childKey)); + expect(box.size, equals(const Size(64, 36))); + expect(childRect, equals(const Rect.fromLTRB(372.0, 293.0, 428.0, 307.0))); + }); + + group('Default OutlinedButton padding for textScaleFactor, textDirection', () { + const ValueKey buttonKey = ValueKey('button'); + const ValueKey labelKey = ValueKey('label'); + const ValueKey iconKey = ValueKey('icon'); + + const List textScaleFactorOptions = [0.5, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0, 4.0]; + const List textDirectionOptions = [TextDirection.ltr, TextDirection.rtl]; + const List iconOptions = [null, Icon(Icons.add, size: 18, key: iconKey)]; + + // Expected values for each textScaleFactor. + final Map paddingVertical = { + 0.5: 0, + 1: 0, + 1.25: 0, + 1.5: 0, + 2: 0, + 2.5: 0, + 3: 0, + 4: 0, + }; + final Map paddingWithIconGap = { + 0.5: 8, + 1: 8, + 1.25: 7, + 1.5: 6, + 2: 4, + 2.5: 4, + 3: 4, + 4: 4, + }; + final Map paddingHorizontal = { + 0.5: 16, + 1: 16, + 1.25: 14, + 1.5: 12, + 2: 8, + 2.5: 6, + 3: 4, + 4: 4, + }; + + Rect globalBounds(RenderBox renderBox) { + final Offset topLeft = renderBox.localToGlobal(Offset.zero); + return topLeft & renderBox.size; + } + + /// Computes the padding between two [Rect]s, one inside the other. + EdgeInsets paddingBetween({ Rect parent, Rect child }) { + assert (parent.intersect(child) == child); + return EdgeInsets.fromLTRB( + child.left - parent.left, + child.top - parent.top, + parent.right - child.right, + parent.bottom - child.bottom, + ); + } + + for (final double textScaleFactor in textScaleFactorOptions) { + for (final TextDirection textDirection in textDirectionOptions) { + for (final Widget icon in iconOptions) { + final String testName = 'OutlinedButton' + ', text scale $textScaleFactor' + '${icon != null ? ", with icon" : ""}' + '${textDirection == TextDirection.rtl ? ", RTL" : ""}'; + + testWidgets(testName, (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaleFactor: textScaleFactor, + ), + child: Directionality( + textDirection: textDirection, + child: Scaffold( + body: Center( + child: icon == null + ? OutlinedButton( + key: buttonKey, + onPressed: () {}, + child: const Text('button', key: labelKey), + ) + : OutlinedButton.icon( + key: buttonKey, + onPressed: () {}, + icon: icon, + label: const Text('button', key: labelKey), + ), + ), + ), + ), + ); + }, + ), + ), + ); + + final Element paddingElement = tester.element( + find.descendant( + of: find.byKey(buttonKey), + matching: find.byType(Padding), + ), + ); + expect(Directionality.of(paddingElement), textDirection); + final Padding paddingWidget = paddingElement.widget as Padding; + + // Compute expected padding, and check. + + final double expectedPaddingTop = paddingVertical[textScaleFactor]; + final double expectedPaddingBottom = paddingVertical[textScaleFactor]; + final double expectedPaddingStart = paddingHorizontal[textScaleFactor]; + final double expectedPaddingEnd = expectedPaddingStart; + + final EdgeInsets expectedPadding = EdgeInsetsDirectional.fromSTEB( + expectedPaddingStart, + expectedPaddingTop, + expectedPaddingEnd, + expectedPaddingBottom, + ).resolve(textDirection); + + expect(paddingWidget.padding.resolve(textDirection), expectedPadding); + + // Measure padding in terms of the difference between the button and its label child + // and check that. + + final RenderBox labelRenderBox = tester.renderObject(find.byKey(labelKey)); + final Rect labelBounds = globalBounds(labelRenderBox); + final RenderBox iconRenderBox = icon == null ? null : tester.renderObject(find.byKey(iconKey)); + final Rect iconBounds = icon == null ? null : globalBounds(iconRenderBox); + final Rect childBounds = icon == null ? labelBounds : labelBounds.expandToInclude(iconBounds); + + // We measure the `InkResponse` descendant of the button + // element, because the button has a larger `RenderBox` + // which accommodates the minimum tap target with a height + // of 48. + final RenderBox buttonRenderBox = tester.renderObject( + find.descendant( + of: find.byKey(buttonKey), + matching: find.byWidgetPredicate( + (Widget widget) => widget is InkResponse, + ), + ), + ); + final Rect buttonBounds = globalBounds(buttonRenderBox); + final EdgeInsets visuallyMeasuredPadding = paddingBetween( + parent: buttonBounds, + child: childBounds, + ); + + // Since there is a requirement of a minimum width of 64 + // and a minimum height of 36 on material buttons, the visual + // padding of smaller buttons may not match their settings. + // Therefore, we only test buttons that are large enough. + if (buttonBounds.width > 64) { + expect( + visuallyMeasuredPadding.left, + expectedPadding.left, + ); + expect( + visuallyMeasuredPadding.right, + expectedPadding.right, + ); + } + + if (buttonBounds.height > 36) { + expect( + visuallyMeasuredPadding.top, + expectedPadding.top, + ); + expect( + visuallyMeasuredPadding.bottom, + expectedPadding.bottom, + ); + } + + // Check the gap between the icon and the label + if (icon != null) { + final double gapWidth = textDirection == TextDirection.ltr + ? labelBounds.left - iconBounds.right + : iconBounds.left - labelBounds.right; + expect(gapWidth, paddingWithIconGap[textScaleFactor]); + } + + // Check the text's height - should be consistent with the textScaleFactor. + final RenderBox textRenderObject = tester.renderObject( + find.descendant( + of: find.byKey(labelKey), + matching: find.byElementPredicate( + (Element element) => element.widget is RichText, + ), + ), + ); + final double textHeight = textRenderObject.paintBounds.size.height; + final double expectedTextHeight = 14 * textScaleFactor; + expect(textHeight, moreOrLessEquals(expectedTextHeight, epsilon: 0.5)); + }); + } + } + } + }); + + testWidgets('Override OutlinedButton default padding', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaleFactor: 2, + ), + child: Scaffold( + body: Center( + child: OutlinedButton( + style: OutlinedButton.styleFrom(padding: const EdgeInsets.all(22)), + onPressed: () {}, + child: const Text('OutlinedButton') + ), + ), + ), + ); + }, + ), + ), + ); + + final Padding paddingWidget = tester.widget( + find.descendant( + of: find.byType(OutlinedButton), + matching: find.byType(Padding), + ), + ); + expect(paddingWidget.padding, const EdgeInsets.all(22)); + }); +} + +PhysicalModelLayer _findPhysicalLayer(Element element) { + expect(element, isNotNull); + RenderObject object = element.renderObject; + while (object != null && object is! RenderRepaintBoundary && object is! RenderView) { + object = object.parent as RenderObject; + } + expect(object.debugLayer, isNotNull); + expect(object.debugLayer.firstChild, isA()); + final PhysicalModelLayer layer = object.debugLayer.firstChild as PhysicalModelLayer; + final Layer child = layer.firstChild; + return child is PhysicalModelLayer ? child : layer; +} + +void _checkPhysicalLayer(Element element, Color expectedColor, { Path clipPath, Rect clipRect }) { + final PhysicalModelLayer expectedLayer = _findPhysicalLayer(element); + expect(expectedLayer.elevation, 0.0); + expect(expectedLayer.color, expectedColor); + if (clipPath != null) { + expect(clipRect, isNotNull); + 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/outlined_button_theme_test.dart b/packages/flutter/test/material/outlined_button_theme_test.dart new file mode 100644 index 0000000000..d377c8dd49 --- /dev/null +++ b/packages/flutter/test/material/outlined_button_theme_test.dart @@ -0,0 +1,183 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.8 + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Passing no OutlinedButtonTheme returns defaults', (WidgetTester tester) async { + const ColorScheme colorScheme = ColorScheme.light(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme), + home: Scaffold( + body: Center( + child: OutlinedButton( + onPressed: () { }, + child: const Text('button'), + ), + ), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(OutlinedButton), + matching: find.byType(Material), + ); + + final Material material = tester.widget(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, const Color(0xff000000)); + expect(material.shape, RoundedRectangleBorder( + side: BorderSide(width: 1, color: colorScheme.onSurface.withOpacity(0.12)), + borderRadius: BorderRadius.circular(4.0), + )); + expect(material.textStyle.color, colorScheme.primary); + expect(material.textStyle.fontFamily, 'Roboto'); + expect(material.textStyle.fontSize, 14); + expect(material.textStyle.fontWeight, FontWeight.w500); + }); + + group('[Theme, TextTheme, OutlinedButton style overrides]', () { + const Color primaryColor = Color(0xff000001); + const Color onSurfaceColor = Color(0xff000002); + const Color backgroundColor = Color(0xff000003); + const Color shadowColor = Color(0xff000004); + const double elevation = 3; + const TextStyle textStyle = TextStyle(fontSize: 12.0); + const EdgeInsets padding = EdgeInsets.all(3); + const Size minimumSize = Size(200, 200); + const BorderSide side = BorderSide(color: Colors.green, width: 2); + const OutlinedBorder shape = RoundedRectangleBorder(side: side, borderRadius: BorderRadius.all(Radius.circular(2))); + const MouseCursor enabledMouseCursor = SystemMouseCursors.text; + const MouseCursor disabledMouseCursor = SystemMouseCursors.grab; + const MaterialTapTargetSize tapTargetSize = MaterialTapTargetSize.shrinkWrap; + const Duration animationDuration = Duration(milliseconds: 25); + const bool enableFeedback = false; + + final ButtonStyle style = OutlinedButton.styleFrom( + primary: primaryColor, + onSurface: onSurfaceColor, + backgroundColor: backgroundColor, + shadowColor: shadowColor, + elevation: elevation, + textStyle: textStyle, + padding: padding, + minimumSize: minimumSize, + side: side, + shape: shape, + enabledMouseCursor: enabledMouseCursor, + disabledMouseCursor: disabledMouseCursor, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + ); + + Widget buildFrame({ ButtonStyle buttonStyle, ButtonStyle themeStyle, ButtonStyle overallStyle }) { + final Widget child = Builder( + builder: (BuildContext context) { + return OutlinedButton( + style: buttonStyle, + onPressed: () { }, + child: const Text('button'), + ); + }, + ); + return MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()).copyWith( + outlinedButtonTheme: OutlinedButtonThemeData(style: overallStyle), + ), + home: Scaffold( + body: Center( + // If the OutlinedButtonTheme widget is present, it's used + // instead of the Theme's ThemeData.outlinedButtonTheme. + child: themeStyle == null ? child : OutlinedButtonTheme( + data: OutlinedButtonThemeData(style: themeStyle), + child: child, + ), + ), + ), + ); + } + + final Finder findMaterial = find.descendant( + of: find.byType(OutlinedButton), + matching: find.byType(Material), + ); + + final Finder findInkWell = find.descendant( + of: find.byType(OutlinedButton), + matching: find.byType(InkWell), + ); + + const Set enabled = {}; + const Set disabled = { MaterialState.disabled }; + const Set hovered = { MaterialState.hovered }; + const Set focused = { MaterialState.focused }; + + void checkButton(WidgetTester tester) { + final Material material = tester.widget(findMaterial); + final InkWell inkWell = tester.widget(findInkWell); + expect(material.textStyle.color, primaryColor); + expect(material.textStyle.fontSize, 12); + expect(material.color, backgroundColor); + expect(material.shadowColor, shadowColor); + expect(material.elevation, elevation); + expect(MaterialStateProperty.resolveAs(inkWell.mouseCursor, enabled), enabledMouseCursor); + expect(MaterialStateProperty.resolveAs(inkWell.mouseCursor, disabled), disabledMouseCursor); + expect(inkWell.overlayColor.resolve(hovered), primaryColor.withOpacity(0.04)); + expect(inkWell.overlayColor.resolve(focused), primaryColor.withOpacity(0.12)); + expect(inkWell.enableFeedback, enableFeedback); + expect(material.borderRadius, null); + expect(material.shape, shape); + expect(material.animationDuration, animationDuration); + expect(tester.getSize(find.byType(OutlinedButton)), const Size(200, 200)); + } + + testWidgets('Button style overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(buttonStyle: style)); + await tester.pumpAndSettle(); // allow the animations to finish + checkButton(tester); + }); + + testWidgets('Button theme style overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(themeStyle: style)); + await tester.pumpAndSettle(); + checkButton(tester); + }); + + testWidgets('Overall Theme button theme style overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(overallStyle: style)); + await tester.pumpAndSettle(); + checkButton(tester); + }); + + // Same as the previous tests with empty ButtonStyle's instead of null. + + testWidgets('Button style overrides defaults, empty theme and overall styles', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(buttonStyle: style, themeStyle: const ButtonStyle(), overallStyle: const ButtonStyle())); + await tester.pumpAndSettle(); // allow the animations to finish + checkButton(tester); + }); + + testWidgets('Button theme style overrides defaults, empty button and overall styles', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(buttonStyle: const ButtonStyle(), themeStyle: style, overallStyle: const ButtonStyle())); + await tester.pumpAndSettle(); // allow the animations to finish + checkButton(tester); + }); + + testWidgets('Overall Theme button theme style overrides defaults, null theme and empty overall style', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(buttonStyle: const ButtonStyle(), themeStyle: null, overallStyle: style)); + await tester.pumpAndSettle(); // allow the animations to finish + checkButton(tester); + }); + }); +} diff --git a/packages/flutter/test/material/text_button_test.dart b/packages/flutter/test/material/text_button_test.dart new file mode 100644 index 0000000000..76e78971bd --- /dev/null +++ b/packages/flutter/test/material/text_button_test.dart @@ -0,0 +1,980 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.8 + +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'; +import '../widgets/semantics_tester.dart'; + +void main() { + testWidgets('TextButton defaults', (WidgetTester tester) async { + const ColorScheme colorScheme = ColorScheme.light(); + + // Enabled TextButton + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme), + home: Center( + child: TextButton( + onPressed: () { }, + child: const Text('button'), + ), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(TextButton), + matching: find.byType(Material), + ); + + Material material = tester.widget(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, true); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, const Color(0xff000000)); + expect(material.shape, RoundedRectangleBorder(borderRadius: BorderRadius.circular(4.0))); + expect(material.textStyle.color, colorScheme.primary); + expect(material.textStyle.fontFamily, 'Roboto'); + expect(material.textStyle.fontSize, 14); + expect(material.textStyle.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + + final Offset center = tester.getCenter(find.byType(TextButton)); + await tester.startGesture(center); + await tester.pumpAndSettle(); + + material = tester.widget(buttonMaterial); + // No change vs enabled and not pressed. + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, true); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, const Color(0xff000000)); + expect(material.shape, RoundedRectangleBorder(borderRadius: BorderRadius.circular(4.0))); + expect(material.textStyle.color, colorScheme.primary); + expect(material.textStyle.fontFamily, 'Roboto'); + expect(material.textStyle.fontSize, 14); + expect(material.textStyle.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + + // Disabled TextButton + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme), + home: const Center( + child: TextButton( + onPressed: null, + child: Text('button'), + ), + ), + ), + ); + + material = tester.widget(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, true); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, const Color(0xff000000)); + expect(material.shape, RoundedRectangleBorder(borderRadius: BorderRadius.circular(4.0))); + expect(material.textStyle.color, colorScheme.onSurface.withOpacity(0.38)); + expect(material.textStyle.fontFamily, 'Roboto'); + expect(material.textStyle.fontSize, 14); + expect(material.textStyle.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + }); + + testWidgets('Default TextButton meets a11y contrast guidelines', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + body: Center( + child: TextButton( + child: const Text('TextButton'), + onPressed: () { }, + focusNode: focusNode, + ), + ), + ), + ), + ); + + // 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(TextButton)); + 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(); + }, + skip: isBrowser, // https://github.com/flutter/flutter/issues/44115 + semanticsEnabled: true, + ); + + testWidgets('TextButton with colored theme meets a11y contrast guidelines', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + + 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: TextButtonTheme( + data: TextButtonThemeData( + style: ButtonStyle( + foregroundColor: MaterialStateProperty.resolveWith(getTextColor), + ), + ), + child: Builder( + builder: (BuildContext context) { + return TextButton( + child: const Text('TextButton'), + onPressed: () {}, + focusNode: focusNode, + ); + }, + ), + ), + ), + ), + ), + ); + + // 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(TextButton)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + 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)); + }, + skip: isBrowser, // https://github.com/flutter/flutter/issues/44115 + semanticsEnabled: true, + ); + + testWidgets('TextButton uses stateful color for text 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); + + 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: TextButton( + style: ButtonStyle( + foregroundColor: MaterialStateProperty.resolveWith(getTextColor), + ), + onPressed: () {}, + focusNode: focusNode, + child: const Text('TextButton'), + ), + ), + ), + ), + ); + + Color textColor() { + return tester.renderObject(find.text('TextButton')).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(TextButton)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + 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); + }); + + testWidgets('TextButton uses stateful color for icon color in different states', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + final Key buttonKey = UniqueKey(); + + const Color pressedColor = Color(0x00000001); + const Color hoverColor = Color(0x00000002); + const Color focusedColor = Color(0x00000003); + const Color defaultColor = Color(0x00000004); + + 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: TextButton.icon( + style: ButtonStyle( + foregroundColor: MaterialStateProperty.resolveWith(getTextColor), + ), + key: buttonKey, + icon: const Icon(Icons.add), + label: const Text('TextButton'), + onPressed: () {}, + focusNode: focusNode, + ), + ), + ), + ), + ); + + 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(); + addTearDown(gesture.removePointer); + 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); + }); + + testWidgets('TextButton has no clip by default', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: TextButton( + child: Container(), + onPressed: () { /* to make sure the button is enabled */ }, + ), + ), + ), + ); + + expect( + tester.renderObject(find.byType(TextButton)), + paintsExactlyCountTimes(#clipPath, 0), + ); + }); + + testWidgets('Does TextButton work with hover', (WidgetTester tester) async { + const Color hoverColor = Color(0xff001122); + + Color getOverlayColor(Set states) { + return states.contains(MaterialState.hovered) ? hoverColor : null; + } + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: TextButton( + style: ButtonStyle( + overlayColor: MaterialStateProperty.resolveWith(getOverlayColor), + ), + child: Container(), + onPressed: () { /* to make sure the button is enabled */ }, + ), + ), + ), + ); + + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(TextButton))); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + expect(inkFeatures, paints..rect(color: hoverColor)); + }); + + testWidgets('Does TextButton work with focus', (WidgetTester tester) async { + const Color focusColor = Color(0xff001122); + + Color getOverlayColor(Set states) { + return states.contains(MaterialState.focused) ? focusColor : null; + } + + final FocusNode focusNode = FocusNode(debugLabel: 'TextButton Node'); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TextButton( + style: ButtonStyle( + overlayColor: MaterialStateProperty.resolveWith(getOverlayColor), + ), + focusNode: focusNode, + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + WidgetsBinding.instance.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + expect(inkFeatures, paints..rect(color: focusColor)); + }); + + testWidgets('Does TextButton contribute semantics', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: TextButton( + style: ButtonStyle( + // Specifying minimumSize to mimic the original minimumSize for + // RaisedButton so that the semantics tree's rect and transform + // match the original version of this test. + minimumSize: MaterialStateProperty.all(const Size(88, 36)), + ), + onPressed: () { }, + child: const Text('ABC'), + ), + ), + ), + ), + ); + + expect(semantics, hasSemantics( + TestSemantics.root( + children: [ + TestSemantics.rootChild( + actions: [ + SemanticsAction.tap, + ], + label: 'ABC', + rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + transform: Matrix4.translationValues(356.0, 276.0, 0.0), + flags: [ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + ), + ], + ), + ignoreId: true, + )); + + semantics.dispose(); + }); + + testWidgets('Does TextButton scale with font scale changes', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: MediaQuery( + data: const MediaQueryData(textScaleFactor: 1.0), + child: Center( + child: TextButton( + onPressed: () { }, + child: const Text('ABC'), + ), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(TextButton)), equals(const Size(64.0, 48.0))); + expect(tester.getSize(find.byType(Text)), equals(const Size(42.0, 14.0))); + + // textScaleFactor expands text, but not button. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: MediaQuery( + data: const MediaQueryData(textScaleFactor: 1.3), + child: Center( + child: TextButton( + onPressed: () { }, + child: const Text('ABC'), + ), + ), + ), + ), + ), + ); + + // Scaled text rendering is different on Linux and Mac by one pixel. + // TODO(gspencergoog): Figure out why this is, and fix it. https://github.com/flutter/flutter/issues/12357 + expect(tester.getSize(find.byType(TextButton)).width, isIn([70.0, 71.0])); + expect(tester.getSize(find.byType(TextButton)).height, isIn([47.0, 48.0])); + expect(tester.getSize(find.byType(Text)).width, isIn([54.0, 55.0])); + expect(tester.getSize(find.byType(Text)).height, isIn([18.0, 19.0])); + + // Set text scale large enough to expand text and button. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: MediaQuery( + data: const MediaQueryData(textScaleFactor: 3.0), + child: Center( + child: TextButton( + onPressed: () { }, + child: const Text('ABC'), + ), + ), + ), + ), + ), + ); + + // Scaled text rendering is different on Linux and Mac by one pixel. + // TODO(gspencergoog): Figure out why this is, and fix it. https://github.com/flutter/flutter/issues/12357 + expect(tester.getSize(find.byType(TextButton)).width, isIn([133.0, 134.0])); + expect(tester.getSize(find.byType(TextButton)).height, equals(48.0)); + expect(tester.getSize(find.byType(Text)).width, isIn([126.0, 127.0])); + expect(tester.getSize(find.byType(Text)).height, equals(42.0)); + }); + + + testWidgets('TextButton size is configurable by ThemeData.materialTapTargetSize', (WidgetTester tester) async { + Widget buildFrame(MaterialTapTargetSize tapTargetSize, Key key) { + return Theme( + data: ThemeData(materialTapTargetSize: tapTargetSize), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: TextButton( + key: key, + child: const SizedBox(width: 50.0, height: 8.0), + onPressed: () { }, + ), + ), + ), + ), + ); + } + + final Key key1 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.padded, key1)); + expect(tester.getSize(find.byKey(key1)), const Size(66.0, 48.0)); + + final Key key2 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.shrinkWrap, key2)); + expect(tester.getSize(find.byKey(key2)), const Size(66.0, 36.0)); + }); + + testWidgets('TextButton onPressed and onLongPress callbacks are correctly called when non-null', (WidgetTester tester) async { + bool wasPressed; + Finder textButton; + + Widget buildFrame({ VoidCallback onPressed, VoidCallback onLongPress }) { + return Directionality( + textDirection: TextDirection.ltr, + child: TextButton( + child: const Text('button'), + onPressed: onPressed, + onLongPress: onLongPress, + ), + ); + } + + // onPressed not null, onLongPress null. + wasPressed = false; + await tester.pumpWidget( + buildFrame(onPressed: () { wasPressed = true; }, onLongPress: null), + ); + textButton = find.byType(TextButton); + expect(tester.widget(textButton).enabled, true); + await tester.tap(textButton); + expect(wasPressed, true); + + // onPressed null, onLongPress not null. + wasPressed = false; + await tester.pumpWidget( + buildFrame(onPressed: null, onLongPress: () { wasPressed = true; }), + ); + textButton = find.byType(TextButton); + expect(tester.widget(textButton).enabled, true); + await tester.longPress(textButton); + expect(wasPressed, true); + + // onPressed null, onLongPress null. + await tester.pumpWidget( + buildFrame(onPressed: null, onLongPress: null), + ); + textButton = find.byType(TextButton); + expect(tester.widget(textButton).enabled, false); + }); + + testWidgets('TextButton onPressed and onLongPress callbacks are distinctly recognized', (WidgetTester tester) async { + bool didPressButton = false; + bool didLongPressButton = false; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TextButton( + onPressed: () { + didPressButton = true; + }, + onLongPress: () { + didLongPressButton = true; + }, + child: const Text('button'), + ), + ), + ); + + final Finder textButton = find.byType(TextButton); + expect(tester.widget(textButton).enabled, true); + + expect(didPressButton, isFalse); + await tester.tap(textButton); + expect(didPressButton, isTrue); + + expect(didLongPressButton, isFalse); + await tester.longPress(textButton); + expect(didLongPressButton, isTrue); + }); + + testWidgets('TextButton responds to density changes.', (WidgetTester tester) async { + const Key key = Key('test'); + const Key childKey = Key('test child'); + + Future buildTest(VisualDensity visualDensity, { bool useText = false }) async { + return await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: TextButton( + style: ButtonStyle( + visualDensity: visualDensity, + ), + key: key, + onPressed: () {}, + child: useText + ? const Text('Text', key: childKey) + : Container(key: childKey, width: 100, height: 100, color: const Color(0xffff0000)), + ), + ), + ), + ), + ); + } + + await buildTest(const VisualDensity()); + final RenderBox box = tester.renderObject(find.byKey(key)); + Rect childRect = tester.getRect(find.byKey(childKey)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(116, 116))); + expect(childRect, equals(const Rect.fromLTRB(350, 250, 450, 350))); + + await buildTest(const VisualDensity(horizontal: 3.0, vertical: 3.0)); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byKey(childKey)); + expect(box.size, equals(const Size(140, 140))); + expect(childRect, equals(const Rect.fromLTRB(350, 250, 450, 350))); + + await buildTest(const VisualDensity(horizontal: -3.0, vertical: -3.0)); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byKey(childKey)); + expect(box.size, equals(const Size(100, 100))); + expect(childRect, equals(const Rect.fromLTRB(350, 250, 450, 350))); + + await buildTest(const VisualDensity(), useText: true); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byKey(childKey)); + expect(box.size, equals(const Size(72, 48))); + expect(childRect, equals(const Rect.fromLTRB(372.0, 293.0, 428.0, 307.0))); + + await buildTest(const VisualDensity(horizontal: 3.0, vertical: 3.0), useText: true); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byKey(childKey)); + expect(box.size, equals(const Size(96, 60))); + expect(childRect, equals(const Rect.fromLTRB(372.0, 293.0, 428.0, 307.0))); + + await buildTest(const VisualDensity(horizontal: -3.0, vertical: -3.0), useText: true); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byKey(childKey)); + expect(box.size, equals(const Size(56, 36))); + expect(childRect, equals(const Rect.fromLTRB(372.0, 293.0, 428.0, 307.0))); + }); + + group('Default TextButton padding for textScaleFactor, textDirection', () { + const ValueKey buttonKey = ValueKey('button'); + const ValueKey labelKey = ValueKey('label'); + const ValueKey iconKey = ValueKey('icon'); + + const List textScaleFactorOptions = [0.5, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0, 4.0]; + const List textDirectionOptions = [TextDirection.ltr, TextDirection.rtl]; + const List iconOptions = [null, Icon(Icons.add, size: 18, key: iconKey)]; + + // Expected values for each textScaleFactor. + final Map paddingVertical = { + 0.5: 8, + 1: 8, + 1.25: 6, + 1.5: 4, + 2: 0, + 2.5: 0, + 3: 0, + 4: 0, + }; + final Map paddingWithIconGap = { + 0.5: 8, + 1: 8, + 1.25: 7, + 1.5: 6, + 2: 4, + 2.5: 4, + 3: 4, + 4: 4, + }; + final Map textPaddingWithoutIconHorizontal = { + 0.5: 8, + 1: 8, + 1.25: 8, + 1.5: 8, + 2: 8, + 2.5: 6, + 3: 4, + 4: 4, + }; + final Map textPaddingWithIconHorizontal = { + 0.5: 8, + 1: 8, + 1.25: 7, + 1.5: 6, + 2: 4, + 2.5: 4, + 3: 4, + 4: 4, + }; + + Rect globalBounds(RenderBox renderBox) { + final Offset topLeft = renderBox.localToGlobal(Offset.zero); + return topLeft & renderBox.size; + } + + /// Computes the padding between two [Rect]s, one inside the other. + EdgeInsets paddingBetween({ Rect parent, Rect child }) { + assert (parent.intersect(child) == child); + return EdgeInsets.fromLTRB( + child.left - parent.left, + child.top - parent.top, + parent.right - child.right, + parent.bottom - child.bottom, + ); + } + + for (final double textScaleFactor in textScaleFactorOptions) { + for (final TextDirection textDirection in textDirectionOptions) { + for (final Widget icon in iconOptions) { + final String testName = 'TextButton' + ', text scale $textScaleFactor' + '${icon != null ? ", with icon" : ""}' + '${textDirection == TextDirection.rtl ? ", RTL" : ""}'; + + testWidgets(testName, (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaleFactor: textScaleFactor, + ), + child: Directionality( + textDirection: textDirection, + child: Scaffold( + body: Center( + child: icon == null + ? TextButton( + key: buttonKey, + onPressed: () {}, + child: const Text('button', key: labelKey), + ) + : TextButton.icon( + key: buttonKey, + onPressed: () {}, + icon: icon, + label: const Text('button', key: labelKey), + ), + ), + ), + ), + ); + }, + ), + ), + ); + + final Element paddingElement = tester.element( + find.descendant( + of: find.byKey(buttonKey), + matching: find.byType(Padding), + ), + ); + expect(Directionality.of(paddingElement), textDirection); + final Padding paddingWidget = paddingElement.widget as Padding; + + // Compute expected padding, and check. + + final double expectedPaddingTop = paddingVertical[textScaleFactor]; + final double expectedPaddingBottom = paddingVertical[textScaleFactor]; + + final double expectedPaddingStart = icon != null + ? textPaddingWithIconHorizontal[textScaleFactor] + : textPaddingWithoutIconHorizontal[textScaleFactor]; + final double expectedPaddingEnd = expectedPaddingStart; + + final EdgeInsets expectedPadding = EdgeInsetsDirectional.fromSTEB( + expectedPaddingStart, + expectedPaddingTop, + expectedPaddingEnd, + expectedPaddingBottom, + ).resolve(textDirection); + + expect(paddingWidget.padding.resolve(textDirection), expectedPadding); + + // Measure padding in terms of the difference between the button and its label child + // and check that. + + final RenderBox labelRenderBox = tester.renderObject(find.byKey(labelKey)); + final Rect labelBounds = globalBounds(labelRenderBox); + final RenderBox iconRenderBox = icon == null ? null : tester.renderObject(find.byKey(iconKey)); + final Rect iconBounds = icon == null ? null : globalBounds(iconRenderBox); + final Rect childBounds = icon == null ? labelBounds : labelBounds.expandToInclude(iconBounds); + + // We measure the `InkResponse` descendant of the button + // element, because the button has a larger `RenderBox` + // which accommodates the minimum tap target with a height + // of 48. + final RenderBox buttonRenderBox = tester.renderObject( + find.descendant( + of: find.byKey(buttonKey), + matching: find.byWidgetPredicate( + (Widget widget) => widget is InkResponse, + ), + ), + ); + final Rect buttonBounds = globalBounds(buttonRenderBox); + final EdgeInsets visuallyMeasuredPadding = paddingBetween( + parent: buttonBounds, + child: childBounds, + ); + + // Since there is a requirement of a minimum width of 64 + // and a minimum height of 36 on material buttons, the visual + // padding of smaller buttons may not match their settings. + // Therefore, we only test buttons that are large enough. + if (buttonBounds.width > 64) { + expect( + visuallyMeasuredPadding.left, + expectedPadding.left, + ); + expect( + visuallyMeasuredPadding.right, + expectedPadding.right, + ); + } + + if (buttonBounds.height > 36) { + expect( + visuallyMeasuredPadding.top, + expectedPadding.top, + ); + expect( + visuallyMeasuredPadding.bottom, + expectedPadding.bottom, + ); + } + + // Check the gap between the icon and the label + if (icon != null) { + final double gapWidth = textDirection == TextDirection.ltr + ? labelBounds.left - iconBounds.right + : iconBounds.left - labelBounds.right; + expect(gapWidth, paddingWithIconGap[textScaleFactor]); + } + + // Check the text's height - should be consistent with the textScaleFactor. + final RenderBox textRenderObject = tester.renderObject( + find.descendant( + of: find.byKey(labelKey), + matching: find.byElementPredicate( + (Element element) => element.widget is RichText, + ), + ), + ); + final double textHeight = textRenderObject.paintBounds.size.height; + final double expectedTextHeight = 14 * textScaleFactor; + expect(textHeight, moreOrLessEquals(expectedTextHeight, epsilon: 0.5)); + }); + } + } + } + }); + + testWidgets('Override TextButton default padding', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaleFactor: 2, + ), + child: Scaffold( + body: Center( + child: TextButton( + style: TextButton.styleFrom(padding: const EdgeInsets.all(22)), + onPressed: () {}, + child: const Text('TextButton') + ), + ), + ), + ); + }, + ), + ), + ); + + final Padding paddingWidget = tester.widget( + find.descendant( + of: find.byType(TextButton), + matching: find.byType(Padding), + ), + ); + expect(paddingWidget.padding, const EdgeInsets.all(22)); + }); +} + + +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/text_button_theme_test.dart b/packages/flutter/test/material/text_button_theme_test.dart new file mode 100644 index 0000000000..43bed1647a --- /dev/null +++ b/packages/flutter/test/material/text_button_theme_test.dart @@ -0,0 +1,180 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.8 + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Passing no TextButtonTheme returns defaults', (WidgetTester tester) async { + const ColorScheme colorScheme = ColorScheme.light(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme), + home: Scaffold( + body: Center( + child: TextButton( + onPressed: () { }, + child: const Text('button'), + ), + ), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(TextButton), + matching: find.byType(Material), + ); + + final Material material = tester.widget(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, const Color(0xff000000)); + expect(material.shape, RoundedRectangleBorder(borderRadius: BorderRadius.circular(4.0))); + expect(material.textStyle.color, colorScheme.primary); + expect(material.textStyle.fontFamily, 'Roboto'); + expect(material.textStyle.fontSize, 14); + expect(material.textStyle.fontWeight, FontWeight.w500); + }); + + group('[Theme, TextTheme, TextButton style overrides]', () { + const Color primaryColor = Color(0xff000001); + const Color onSurfaceColor = Color(0xff000002); + const Color backgroundColor = Color(0xff000003); + const Color shadowColor = Color(0xff000004); + const double elevation = 3; + const TextStyle textStyle = TextStyle(fontSize: 12.0); + const EdgeInsets padding = EdgeInsets.all(3); + const Size minimumSize = Size(200, 200); + const BorderSide side = BorderSide(color: Colors.green, width: 2); + const OutlinedBorder shape = RoundedRectangleBorder(side: side, borderRadius: BorderRadius.all(Radius.circular(2))); + const MouseCursor enabledMouseCursor = SystemMouseCursors.text; + const MouseCursor disabledMouseCursor = SystemMouseCursors.grab; + const MaterialTapTargetSize tapTargetSize = MaterialTapTargetSize.shrinkWrap; + const Duration animationDuration = Duration(milliseconds: 25); + const bool enableFeedback = false; + + final ButtonStyle style = TextButton.styleFrom( + primary: primaryColor, + onSurface: onSurfaceColor, + backgroundColor: backgroundColor, + shadowColor: shadowColor, + elevation: elevation, + textStyle: textStyle, + padding: padding, + minimumSize: minimumSize, + side: side, + shape: shape, + enabledMouseCursor: enabledMouseCursor, + disabledMouseCursor: disabledMouseCursor, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + ); + + Widget buildFrame({ ButtonStyle buttonStyle, ButtonStyle themeStyle, ButtonStyle overallStyle }) { + final Widget child = Builder( + builder: (BuildContext context) { + return TextButton( + style: buttonStyle, + onPressed: () { }, + child: const Text('button'), + ); + }, + ); + return MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()).copyWith( + textButtonTheme: TextButtonThemeData(style: overallStyle), + ), + home: Scaffold( + body: Center( + // If the TextButtonTheme widget is present, it's used + // instead of the Theme's ThemeData.textButtonTheme. + child: themeStyle == null ? child : TextButtonTheme( + data: TextButtonThemeData(style: themeStyle), + child: child, + ), + ), + ), + ); + } + + final Finder findMaterial = find.descendant( + of: find.byType(TextButton), + matching: find.byType(Material), + ); + + final Finder findInkWell = find.descendant( + of: find.byType(TextButton), + matching: find.byType(InkWell), + ); + + const Set enabled = {}; + const Set disabled = { MaterialState.disabled }; + const Set hovered = { MaterialState.hovered }; + const Set focused = { MaterialState.focused }; + + void checkButton(WidgetTester tester) { + final Material material = tester.widget(findMaterial); + final InkWell inkWell = tester.widget(findInkWell); + expect(material.textStyle.color, primaryColor); + expect(material.textStyle.fontSize, 12); + expect(material.color, backgroundColor); + expect(material.shadowColor, shadowColor); + expect(material.elevation, elevation); + expect(MaterialStateProperty.resolveAs(inkWell.mouseCursor, enabled), enabledMouseCursor); + expect(MaterialStateProperty.resolveAs(inkWell.mouseCursor, disabled), disabledMouseCursor); + expect(inkWell.overlayColor.resolve(hovered), primaryColor.withOpacity(0.04)); + expect(inkWell.overlayColor.resolve(focused), primaryColor.withOpacity(0.12)); + expect(inkWell.enableFeedback, enableFeedback); + expect(material.borderRadius, null); + expect(material.shape, shape); + expect(material.animationDuration, animationDuration); + expect(tester.getSize(find.byType(TextButton)), const Size(200, 200)); + } + + testWidgets('Button style overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(buttonStyle: style)); + await tester.pumpAndSettle(); // allow the animations to finish + checkButton(tester); + }); + + testWidgets('Button theme style overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(themeStyle: style)); + await tester.pumpAndSettle(); + checkButton(tester); + }); + + testWidgets('Overall Theme button theme style overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(overallStyle: style)); + await tester.pumpAndSettle(); + checkButton(tester); + }); + + // Same as the previous tests with empty ButtonStyle's instead of null. + + testWidgets('Button style overrides defaults, empty theme and overall styles', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(buttonStyle: style, themeStyle: const ButtonStyle(), overallStyle: const ButtonStyle())); + await tester.pumpAndSettle(); // allow the animations to finish + checkButton(tester); + }); + + testWidgets('Button theme style overrides defaults, empty button and overall styles', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(buttonStyle: const ButtonStyle(), themeStyle: style, overallStyle: const ButtonStyle())); + await tester.pumpAndSettle(); // allow the animations to finish + checkButton(tester); + }); + + testWidgets('Overall Theme button theme style overrides defaults, null theme and empty overall style', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(buttonStyle: const ButtonStyle(), themeStyle: null, overallStyle: style)); + await tester.pumpAndSettle(); // allow the animations to finish + checkButton(tester); + }); + }); +} diff --git a/packages/flutter/test/material/theme_data_test.dart b/packages/flutter/test/material/theme_data_test.dart index 6e69ceb6a1..e604555622 100644 --- a/packages/flutter/test/material/theme_data_test.dart +++ b/packages/flutter/test/material/theme_data_test.dart @@ -283,6 +283,9 @@ void main() { buttonBarTheme: const ButtonBarThemeData(alignment: MainAxisAlignment.start), bottomNavigationBarTheme: const BottomNavigationBarThemeData(type: BottomNavigationBarType.fixed), timePickerTheme: const TimePickerThemeData(backgroundColor: Colors.black), + textButtonTheme: TextButtonThemeData(style: TextButton.styleFrom(primary: Colors.red)), + containedButtonTheme: ContainedButtonThemeData(style: ContainedButton.styleFrom(primary: Colors.green)), + outlinedButtonTheme: OutlinedButtonThemeData(style: OutlinedButton.styleFrom(primary: Colors.blue)), fixTextFieldOutlineLabel: false, ); @@ -365,6 +368,9 @@ void main() { buttonBarTheme: const ButtonBarThemeData(alignment: MainAxisAlignment.end), bottomNavigationBarTheme: const BottomNavigationBarThemeData(type: BottomNavigationBarType.shifting), timePickerTheme: const TimePickerThemeData(backgroundColor: Colors.white), + textButtonTheme: const TextButtonThemeData(), + containedButtonTheme: const ContainedButtonThemeData(), + outlinedButtonTheme: const OutlinedButtonThemeData(), fixTextFieldOutlineLabel: true, ); @@ -433,6 +439,9 @@ void main() { buttonBarTheme: otherTheme.buttonBarTheme, bottomNavigationBarTheme: otherTheme.bottomNavigationBarTheme, timePickerTheme: otherTheme.timePickerTheme, + textButtonTheme: otherTheme.textButtonTheme, + containedButtonTheme: otherTheme.containedButtonTheme, + outlinedButtonTheme: otherTheme.outlinedButtonTheme, fixTextFieldOutlineLabel: otherTheme.fixTextFieldOutlineLabel, ); @@ -503,6 +512,9 @@ void main() { expect(themeDataCopy.buttonBarTheme, equals(otherTheme.buttonBarTheme)); expect(themeDataCopy.bottomNavigationBarTheme, equals(otherTheme.bottomNavigationBarTheme)); expect(themeDataCopy.timePickerTheme, equals(otherTheme.timePickerTheme)); + expect(themeDataCopy.textButtonTheme, equals(otherTheme.textButtonTheme)); + expect(themeDataCopy.containedButtonTheme, equals(otherTheme.containedButtonTheme)); + expect(themeDataCopy.outlinedButtonTheme, equals(otherTheme.outlinedButtonTheme)); expect(themeDataCopy.fixTextFieldOutlineLabel, equals(otherTheme.fixTextFieldOutlineLabel)); });