From 2e43b47008cb71ebe1ede5ec029ce7775fb58d5d Mon Sep 17 00:00:00 2001 From: Shi-Hao Hong Date: Fri, 26 Jul 2019 09:26:15 -0700 Subject: [PATCH] [Material] ToggleButtons (#34599) * Introduces ToggleButtons widget * Introduces ToggleButtonsTheme inherited widget * Introduces ThemeData.toggleButtonsTheme property --- packages/flutter/lib/material.dart | 2 + .../flutter/lib/src/material/theme_data.dart | 15 + .../lib/src/material/toggle_buttons.dart | 1190 +++++++++++++++ .../src/material/toggle_buttons_theme.dart | 272 ++++ .../test/material/toggle_buttons_test.dart | 1306 +++++++++++++++++ .../material/toggle_buttons_theme_test.dart | 446 ++++++ 6 files changed, 3231 insertions(+) create mode 100644 packages/flutter/lib/src/material/toggle_buttons.dart create mode 100644 packages/flutter/lib/src/material/toggle_buttons_theme.dart create mode 100644 packages/flutter/test/material/toggle_buttons_test.dart create mode 100644 packages/flutter/test/material/toggle_buttons_theme_test.dart diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index ca49d7ffa2c..d9ec8c072ad 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -114,6 +114,8 @@ export 'src/material/theme.dart'; export 'src/material/theme_data.dart'; export 'src/material/time.dart'; export 'src/material/time_picker.dart'; +export 'src/material/toggle_buttons.dart'; +export 'src/material/toggle_buttons_theme.dart'; export 'src/material/toggleable.dart'; export 'src/material/tooltip.dart'; export 'src/material/tooltip_theme.dart'; diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart index 1e23da22b0b..c36b355877b 100644 --- a/packages/flutter/lib/src/material/theme_data.dart +++ b/packages/flutter/lib/src/material/theme_data.dart @@ -27,6 +27,7 @@ import 'slider_theme.dart'; import 'snack_bar_theme.dart'; import 'tab_bar_theme.dart'; import 'text_theme.dart'; +import 'toggle_buttons_theme.dart'; import 'tooltip_theme.dart'; import 'typography.dart'; @@ -136,6 +137,7 @@ class ThemeData extends Diagnosticable { Color disabledColor, Color buttonColor, ButtonThemeData buttonTheme, + ToggleButtonsThemeData toggleButtonsTheme, Color secondaryHeaderColor, Color textSelectionColor, Color cursorColor, @@ -253,6 +255,7 @@ class ThemeData extends Diagnosticable { splashColor: splashColor, materialTapTargetSize: materialTapTargetSize, ); + toggleButtonsTheme ??= const ToggleButtonsThemeData(); disabledColor ??= isDark ? Colors.white38 : Colors.black38; highlightColor ??= isDark ? _kDarkThemeHighlightColor : _kLightThemeHighlightColor; splashColor ??= isDark ? _kDarkThemeSplashColor : _kLightThemeSplashColor; @@ -297,6 +300,7 @@ class ThemeData extends Diagnosticable { disabledColor: disabledColor, buttonTheme: buttonTheme, buttonColor: buttonColor, + toggleButtonsTheme: toggleButtonsTheme, toggleableActiveColor: toggleableActiveColor, secondaryHeaderColor: secondaryHeaderColor, textSelectionColor: textSelectionColor, @@ -368,6 +372,7 @@ class ThemeData extends Diagnosticable { @required this.disabledColor, @required this.buttonTheme, @required this.buttonColor, + @required this.toggleButtonsTheme, @required this.secondaryHeaderColor, @required this.textSelectionColor, @required this.cursorColor, @@ -425,6 +430,7 @@ class ThemeData extends Diagnosticable { assert(disabledColor != null), assert(toggleableActiveColor != null), assert(buttonTheme != null), + assert(toggleButtonsTheme != null), assert(secondaryHeaderColor != null), assert(textSelectionColor != null), assert(cursorColor != null), @@ -593,6 +599,9 @@ class ThemeData extends Diagnosticable { /// and [FlatButton]. final ButtonThemeData buttonTheme; + /// Defines the default configuration of [ToggleButtons] widgets. + final ToggleButtonsThemeData toggleButtonsTheme; + /// The default fill color of the [Material] used in [RaisedButton]s. final Color buttonColor; @@ -802,6 +811,7 @@ class ThemeData extends Diagnosticable { Color unselectedWidgetColor, Color disabledColor, ButtonThemeData buttonTheme, + ToggleButtonsTheme toggleButtonsTheme, Color buttonColor, Color secondaryHeaderColor, Color textSelectionColor, @@ -863,6 +873,7 @@ class ThemeData extends Diagnosticable { disabledColor: disabledColor ?? this.disabledColor, buttonColor: buttonColor ?? this.buttonColor, buttonTheme: buttonTheme ?? this.buttonTheme, + toggleButtonsTheme: toggleButtonsTheme ?? this.toggleButtonsTheme, secondaryHeaderColor: secondaryHeaderColor ?? this.secondaryHeaderColor, textSelectionColor: textSelectionColor ?? this.textSelectionColor, cursorColor: cursorColor ?? this.cursorColor, @@ -1000,6 +1011,7 @@ class ThemeData extends Diagnosticable { unselectedWidgetColor: Color.lerp(a.unselectedWidgetColor, b.unselectedWidgetColor, t), disabledColor: Color.lerp(a.disabledColor, b.disabledColor, t), buttonTheme: t < 0.5 ? a.buttonTheme : b.buttonTheme, + toggleButtonsTheme: ToggleButtonsThemeData.lerp(a.toggleButtonsTheme, b.toggleButtonsTheme, t), buttonColor: Color.lerp(a.buttonColor, b.buttonColor, t), secondaryHeaderColor: Color.lerp(a.secondaryHeaderColor, b.secondaryHeaderColor, t), textSelectionColor: Color.lerp(a.textSelectionColor, b.textSelectionColor, t), @@ -1067,6 +1079,7 @@ class ThemeData extends Diagnosticable { (otherData.disabledColor == disabledColor) && (otherData.buttonTheme == buttonTheme) && (otherData.buttonColor == buttonColor) && + (otherData.toggleButtonsTheme == toggleButtonsTheme) && (otherData.secondaryHeaderColor == secondaryHeaderColor) && (otherData.textSelectionColor == textSelectionColor) && (otherData.cursorColor == cursorColor) && @@ -1132,6 +1145,7 @@ class ThemeData extends Diagnosticable { disabledColor, buttonTheme, buttonColor, + toggleButtonsTheme, toggleableActiveColor, secondaryHeaderColor, textSelectionColor, @@ -1205,6 +1219,7 @@ class ThemeData extends Diagnosticable { properties.add(ColorProperty('errorColor', errorColor, defaultValue: defaultData.errorColor)); properties.add(ColorProperty('toggleableActiveColor', toggleableActiveColor, defaultValue: defaultData.toggleableActiveColor)); properties.add(DiagnosticsProperty('buttonTheme', buttonTheme)); + properties.add(DiagnosticsProperty('toggleButtonsTheme', toggleButtonsTheme)); properties.add(DiagnosticsProperty('textTheme', textTheme)); properties.add(DiagnosticsProperty('primaryTextTheme', primaryTextTheme)); properties.add(DiagnosticsProperty('accentTextTheme', accentTextTheme)); diff --git a/packages/flutter/lib/src/material/toggle_buttons.dart b/packages/flutter/lib/src/material/toggle_buttons.dart new file mode 100644 index 00000000000..a25f9d3d1dc --- /dev/null +++ b/packages/flutter/lib/src/material/toggle_buttons.dart @@ -0,0 +1,1190 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'button.dart'; +import 'debug.dart'; +import 'theme.dart'; +import 'theme_data.dart'; +import 'toggle_buttons_theme.dart'; + +/// A horizontal set of toggle buttons. +/// +/// The list of [children] are laid out in a row. The state of each button +/// is controlled by [isSelected], which is a list of bools that determine +/// if a button is in an unselected or selected state. They are both +/// correlated by their index in the list. The length of [isSelected] has to +/// match the length of the [children] list. +/// +/// ## Customizing toggle buttons +/// Each toggle's behavior can be configured by the [onPressed] callback, which +/// can update the [isSelected] list however it wants to. +/// +/// Here is an implementation that allows for multiple buttons to be +/// simultaneously selected, while requiring none of the buttons to be +/// selected. +/// ```dart +/// ToggleButtons( +/// children: [ +/// Icon(Icons.ac_unit), +/// Icon(Icons.call), +/// Icon(Icons.cake), +/// ], +/// onPressed: (int index) { +/// setState(() { +/// isSelected[index] = !isSelected[index]; +/// }); +/// }, +/// isSelected: isSelected, +/// ), +/// ``` +/// +/// Here is an implementation that requires mutually exclusive selection +/// while requiring at least one selection. Note that this assumes that +/// [isSelected] was properly initialized with one selection. +/// ```dart +/// ToggleButtons( +/// children: [ +/// Icon(Icons.ac_unit), +/// Icon(Icons.call), +/// Icon(Icons.cake), +/// ], +/// onPressed: (int index) { +/// setState(() { +/// for (int buttonIndex = 0; buttonIndex < isSelected.length; buttonIndex++) { +/// if (buttonIndex == index) { +/// isSelected[buttonIndex] = true; +/// } else { +/// isSelected[buttonIndex] = false; +/// } +/// } +/// }); +/// }, +/// isSelected: isSelected, +/// ), +/// ``` +/// +/// Here is an implementation that requires mutually exclusive selection, +/// but allows for none of the buttons to be selected. +/// ```dart +/// ToggleButtons( +/// children: [ +/// Icon(Icons.ac_unit), +/// Icon(Icons.call), +/// Icon(Icons.cake), +/// ], +/// onPressed: (int index) { +/// setState(() { +/// for (int buttonIndex = 0; buttonIndex < isSelected.length; buttonIndex++) { +/// if (buttonIndex == index) { +/// isSelected[buttonIndex] = !isSelected[buttonIndex]; +/// } else { +/// isSelected[buttonIndex] = false; +/// } +/// } +/// }); +/// }, +/// isSelected: isSelected, +/// ), +/// ``` +/// +/// Here is an implementation that allows for multiple buttons to be +/// simultaneously selected, while requiring at least one selection. Note +/// that this assumes that [isSelected] was properly initialized with one +/// selection. +/// ```dart +/// ToggleButtons( +/// children: [ +/// Icon(Icons.ac_unit), +/// Icon(Icons.call), +/// Icon(Icons.cake), +/// ], +/// onPressed: (int index) { +/// int count = 0; +/// isSelected.forEach((bool val) { +/// if (val) count++; +/// }); +/// +/// if (isSelected[index] && count < 2) +/// return; +/// +/// setState(() { +/// isSelected[index] = !isSelected[index]; +/// }); +/// }, +/// isSelected: isSelected, +/// ), +/// ``` +/// +/// ## ToggleButton Borders +/// The toggle buttons, by default, have a solid, 1 logical pixel border +/// surrounding itself and separating each button. The toggle button borders' +/// color, width, and corner radii are configurable. +/// +/// The [selectedBorderColor] determines the border's color when the button is +/// selected, while [disabledBorderColor] determines the border's color when +/// the button is disabled. [borderColor] is used when the button is enabled. +/// +/// To remove the border, set [borderWidth] to null. Setting [borderWidth] to +/// 0.0 results in a hairline border. For more information on hairline borders, +/// see [BorderSide.width]. +/// +/// See also: +/// +/// * +class ToggleButtons extends StatelessWidget { + /// Creates a horizontal set of toggle buttons. + /// + /// It displays its widgets provided in a [List] of [children] horizontally. + /// The state of each button is controlled by [isSelected], which is a list + /// of bools that determine if a button is in an active, disabled, or + /// selected state. They are both correlated by their index in the list. + /// The length of [isSelected] has to match the length of the [children] + /// list. + /// + /// Both [children] and [isSelected] properties arguments are required. + /// + /// [isSelected] values must be non-null. [focusNodes] must be null or a + /// list of non-null nodes. [renderBorder] must not be null. + const ToggleButtons({ + Key key, + @required this.children, + @required this.isSelected, + this.onPressed, + this.color, + this.selectedColor, + this.disabledColor, + this.fillColor, + this.focusColor, + this.highlightColor, + this.hoverColor, + this.splashColor, + this.focusNodes, + this.renderBorder = true, + this.borderColor, + this.selectedBorderColor, + this.disabledBorderColor, + this.borderRadius, + this.borderWidth, + }) : + assert(children != null), + assert(isSelected != null), + assert(children.length == isSelected.length), + super(key: key); + + static const double _defaultBorderWidth = 1.0; + + /// The toggle button widgets. + /// + /// These are typically [Icon] or [Text] widgets. The boolean selection + /// state of each widget is defined by the corresponding [isSelected] + /// list item. + /// + /// The length of children has to match the length of [isSelected]. If + /// [focusNodes] is not null, the length of children has to also match + /// the length of [focusNodes]. + final List children; + + /// The corresponding selection state of each toggle button. + /// + /// Each value in this list represents the selection state of the [children] + /// widget at the same index. + /// + /// The length of [isSelected] has to match the length of [children]. + final List isSelected; + + /// The callback that is called when a button is tapped. + /// + /// The index parameter of the callback is the index of the button that is + /// tapped or otherwise activated. + /// + /// When the callback is null, all toggle buttons will be disabled. + final void Function(int index) onPressed; + + /// The color for descendant [Text] and [Icon] widgets if the button is + /// enabled and not selected. + /// + /// If [onPressed] is not null, this color will be used for values in + /// [isSelected] that are false. + /// + /// If this property is null, then ToggleButtonTheme.of(context).color + /// is used. If [ToggleButtonsThemeData.color] is also null, then + /// Theme.of(context).colorScheme.onSurface is used. + final Color color; + + /// The color for descendant [Text] and [Icon] widgets if the button is + /// selected. + /// + /// If [onPressed] is not null, this color will be used for values in + /// [isSelected] that are true. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).selectedColor is used. If + /// [ToggleButtonsThemeData.selectedColor] is also null, then + /// Theme.of(context).colorScheme.primary is used. + final Color selectedColor; + + /// The color for descendant [Text] and [Icon] widgets if the button is + /// disabled. + /// + /// If [onPressed] is null, this color will be used. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).disabledColor is used. If + /// [ToggleButtonsThemeData.disabledColor] is also null, then + /// Theme.of(context).colorScheme.onSurface.withOpacity(0.38) is used. + final Color disabledColor; + + /// The fill color for selected toggle buttons. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).fillColor is used. If + /// [ToggleButtonsThemeData.fillColor] is also null, then + /// the fill color is null. + final Color fillColor; + + /// The color to use for filling the button when the button has input focus. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).focusColor is used. If + /// [ToggleButtonsThemeData.focusColor] is also null, then + /// Theme.of(context).focusColor is used. + final Color focusColor; + + /// The highlight color for the button's [InkWell]. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).highlightColor is used. If + /// [ToggleButtonsThemeData.highlightColor] is also null, then + /// Theme.of(context).highlightColor is used. + final Color highlightColor; + + /// The splash color for the button's [InkWell]. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).splashColor is used. If + /// [ToggleButtonsThemeData.splashColor] is also null, then + /// Theme.of(context).splashColor is used. + final Color splashColor; + + /// The color to use for filling the button when the button has a pointer + /// hovering over it. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).hoverColor is used. If + /// [ToggleButtonsThemeData.hoverColor] is also null, then + /// Theme.of(context).hoverColor is used. + final Color hoverColor; + + /// The list of [FocusNode]s, corresponding to each toggle button. + /// + /// Focus is used to determine which widget should be affected by keyboard + /// events. The focus tree keeps track of which widget is currently focused + /// on by the user. + /// + /// If not null, the length of focusNodes has to match the length of + /// [children]. + /// + /// See [FocusNode] for more information about how focus nodes are used. + final List focusNodes; + + /// Whether or not to render a border around each toggle button. + /// + /// When true, a border with [borderWidth], [borderRadius] and the + /// appropriate border color will render. Otherwise, no border will be + /// rendered. + final bool renderBorder; + + /// The border color to display when the toggle button is enabled and not + /// selected. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).borderColor is used. If + /// [ToggleButtonsThemeData.borderColor] is also null, then + /// Theme.of(context).colorScheme.onSurface is used. + final Color borderColor; + + /// The border color to display when the toggle button is selected. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).selectedBorderColor is used. If + /// [ToggleButtonsThemeData.selectedBorderColor] is also null, then + /// Theme.of(context).colorScheme.primary is used. + final Color selectedBorderColor; + + /// The border color to display when the toggle button is disabled. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).disabledBorderColor is used. If + /// [ToggleButtonsThemeData.disabledBorderColor] is also null, then + /// Theme.of(context).disabledBorderColor is used. + final Color disabledBorderColor; + + /// The width of the border surrounding each toggle button. + /// + /// This applies to both the greater surrounding border, as well as the + /// borders rendered between toggle buttons. + /// + /// To render a hairline border (one physical pixel), set borderWidth to 0.0. + /// See [BorderSide.width] for more details on hairline borders. + /// + /// To omit the border entirely, set [renderBorder] to false. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).borderWidth is used. If + /// [ToggleButtonsThemeData.borderWidth] is also null, then + /// a width of 1.0 is used. + final double borderWidth; + + /// The radii of the border's corners. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).borderRadius is used. If + /// [ToggleButtonsThemeData.borderRadius] is also null, then + /// the buttons default to non-rounded borders. + final BorderRadius borderRadius; + + bool _isFirstIndex(int index, int length, TextDirection textDirection) { + return index == 0 && textDirection == TextDirection.ltr + || index == length - 1 && textDirection == TextDirection.rtl; + } + + bool _isLastIndex(int index, int length, TextDirection textDirection) { + return index == length - 1 && textDirection == TextDirection.ltr + || index == 0 && textDirection == TextDirection.rtl; + } + + BorderRadius _getEdgeBorderRadius( + int index, + int length, + TextDirection textDirection, + ToggleButtonsThemeData toggleButtonsTheme, + ) { + final BorderRadius resultingBorderRadius = borderRadius + ?? toggleButtonsTheme.borderRadius + ?? BorderRadius.zero; + + if (_isFirstIndex(index, length, textDirection)) { + return BorderRadius.only( + topLeft: resultingBorderRadius.topLeft, + bottomLeft: resultingBorderRadius.bottomLeft, + ); + } else if (_isLastIndex(index, length, textDirection)) { + return BorderRadius.only( + topRight: resultingBorderRadius.topRight, + bottomRight: resultingBorderRadius.bottomRight, + ); + } + return BorderRadius.zero; + } + + BorderRadius _getClipBorderRadius( + int index, + int length, + TextDirection textDirection, + ToggleButtonsThemeData toggleButtonsTheme, + ) { + final BorderRadius resultingBorderRadius = borderRadius + ?? toggleButtonsTheme.borderRadius + ?? BorderRadius.zero; + final double resultingBorderWidth = borderWidth + ?? toggleButtonsTheme.borderWidth + ?? _defaultBorderWidth; + + if (_isFirstIndex(index, length, textDirection)) { + return BorderRadius.only( + topLeft: resultingBorderRadius.topLeft - Radius.circular(resultingBorderWidth / 2.0), + bottomLeft: resultingBorderRadius.bottomLeft - Radius.circular(resultingBorderWidth / 2.0), + ); + } else if (_isLastIndex(index, length, textDirection)) { + return BorderRadius.only( + topRight: resultingBorderRadius.topRight - Radius.circular(resultingBorderWidth / 2.0), + bottomRight: resultingBorderRadius.bottomRight - Radius.circular(resultingBorderWidth / 2.0), + ); + } + return BorderRadius.zero; + } + + BorderSide _getLeadingBorderSide( + int index, + ThemeData theme, + ToggleButtonsThemeData toggleButtonsTheme, + ) { + if (!renderBorder) + return BorderSide.none; + + final double resultingBorderWidth = borderWidth + ?? toggleButtonsTheme.borderWidth + ?? _defaultBorderWidth; + if (onPressed != null && (isSelected[index] || (index != 0 && isSelected[index - 1]))) { + return BorderSide( + color: selectedBorderColor + ?? toggleButtonsTheme.selectedBorderColor + ?? theme.colorScheme.onSurface.withOpacity(0.12), + width: resultingBorderWidth, + ); + } else if (onPressed != null && !isSelected[index]) { + return BorderSide( + color: borderColor + ?? toggleButtonsTheme.borderColor + ?? theme.colorScheme.onSurface.withOpacity(0.12), + width: resultingBorderWidth, + ); + } else { + return BorderSide( + color: disabledBorderColor + ?? toggleButtonsTheme.disabledBorderColor + ?? theme.colorScheme.onSurface.withOpacity(0.12), + width: resultingBorderWidth, + ); + } + } + + BorderSide _getHorizontalBorderSide( + int index, + ThemeData theme, + ToggleButtonsThemeData toggleButtonsTheme, + ) { + if (!renderBorder) + return BorderSide.none; + + final double resultingBorderWidth = borderWidth + ?? toggleButtonsTheme.borderWidth + ?? _defaultBorderWidth; + if (onPressed != null && isSelected[index]) { + return BorderSide( + color: selectedBorderColor + ?? toggleButtonsTheme.selectedBorderColor + ?? theme.colorScheme.onSurface.withOpacity(0.12), + width: resultingBorderWidth, + ); + } else if (onPressed != null && !isSelected[index]) { + return BorderSide( + color: borderColor + ?? toggleButtonsTheme.borderColor + ?? theme.colorScheme.onSurface.withOpacity(0.12), + width: resultingBorderWidth, + ); + } else { + return BorderSide( + color: disabledBorderColor + ?? toggleButtonsTheme.disabledBorderColor + ?? theme.colorScheme.onSurface.withOpacity(0.12), + width: resultingBorderWidth, + ); + } + } + + BorderSide _getTrailingBorderSide( + int index, + ThemeData theme, + ToggleButtonsThemeData toggleButtonsTheme, + ) { + if (!renderBorder) + return BorderSide.none; + + if (index != children.length - 1) + return null; + + final double resultingBorderWidth = borderWidth + ?? toggleButtonsTheme.borderWidth + ?? _defaultBorderWidth; + if (onPressed != null && (isSelected[index])) { + return BorderSide( + color: selectedBorderColor + ?? toggleButtonsTheme.selectedBorderColor + ?? theme.colorScheme.onSurface.withOpacity(0.12), + width: resultingBorderWidth, + ); + } else if (onPressed != null && !isSelected[index]) { + return BorderSide( + color: borderColor + ?? toggleButtonsTheme.borderColor + ?? theme.colorScheme.onSurface.withOpacity(0.12), + width: resultingBorderWidth, + ); + } else { + return BorderSide( + color: disabledBorderColor + ?? toggleButtonsTheme.disabledBorderColor + ?? theme.colorScheme.onSurface.withOpacity(0.12), + width: resultingBorderWidth, + ); + } + } + + @override + Widget build(BuildContext context) { + assert( + !isSelected.any((bool val) => val == null), + 'All elements of isSelected must be non-null.\n' + 'The current list of isSelected values is as follows:\n' + '$isSelected' + ); + assert( + focusNodes == null || !focusNodes.any((FocusNode val) => val == null), + 'All elements of focusNodes must be non-null.\n' + 'The current list of focus node values is as follows:\n' + '$focusNodes' + ); + assert( + () { + if (focusNodes != null) + return focusNodes.length == children.length; + return true; + }(), + 'focusNodes.length must match children.length.\n' + 'There are ${focusNodes.length} focus nodes, while' + 'there are ${children.length} children.' + ); + final ThemeData theme = Theme.of(context); + final ToggleButtonsThemeData toggleButtonsTheme = ToggleButtonsTheme.of(context); + final TextDirection textDirection = Directionality.of(context); + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: List.generate(children.length, (int index) { + final BorderRadius edgeBorderRadius = _getEdgeBorderRadius(index, children.length, textDirection, toggleButtonsTheme); + final BorderRadius clipBorderRadius = _getClipBorderRadius(index, children.length, textDirection, toggleButtonsTheme); + + final BorderSide leadingBorderSide = _getLeadingBorderSide(index, theme, toggleButtonsTheme); + final BorderSide horizontalBorderSide = _getHorizontalBorderSide(index, theme, toggleButtonsTheme); + final BorderSide trailingBorderSide = _getTrailingBorderSide(index, theme, toggleButtonsTheme); + + return _ToggleButton( + selected: isSelected[index], + color: color, + selectedColor: selectedColor, + disabledColor: disabledColor, + fillColor: fillColor ?? toggleButtonsTheme.fillColor, + focusColor: focusColor ?? toggleButtonsTheme.focusColor, + highlightColor: highlightColor ?? toggleButtonsTheme.highlightColor, + hoverColor: hoverColor ?? toggleButtonsTheme.hoverColor, + splashColor: splashColor ?? toggleButtonsTheme.splashColor, + focusNode: focusNodes != null ? focusNodes[index] : null, + onPressed: onPressed != null + ? () { onPressed(index); } + : null, + leadingBorderSide: leadingBorderSide, + horizontalBorderSide: horizontalBorderSide, + trailingBorderSide: trailingBorderSide, + borderRadius: edgeBorderRadius, + clipRadius: clipBorderRadius, + isFirstButton: index == 0, + isLastButton: index == children.length - 1, + child: children[index], + ); + }), + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(FlagProperty('disabled', + value: onPressed == null, + ifTrue: 'Buttons are disabled', + ifFalse: 'Buttons are enabled', + )); + properties.add(ColorProperty('color', color, defaultValue: null)); + properties.add(ColorProperty('selectedColor', selectedColor, defaultValue: null)); + properties.add(ColorProperty('disabledColor', disabledColor, defaultValue: null)); + properties.add(ColorProperty('fillColor', fillColor, defaultValue: null)); + properties.add(ColorProperty('focusColor', focusColor, defaultValue: null)); + properties.add(ColorProperty('highlightColor', highlightColor, defaultValue: null)); + properties.add(ColorProperty('hoverColor', hoverColor, defaultValue: null)); + properties.add(ColorProperty('splashColor', splashColor, defaultValue: null)); + properties.add(ColorProperty('borderColor', borderColor, defaultValue: null)); + properties.add(ColorProperty('selectedBorderColor', selectedBorderColor, defaultValue: null)); + properties.add(ColorProperty('disabledBorderColor', disabledBorderColor, defaultValue: null)); + properties.add(DiagnosticsProperty('borderRadius', borderRadius, defaultValue: null)); + properties.add(DoubleProperty('borderWidth', borderWidth, defaultValue: null)); + } +} + +/// An individual toggle button, otherwise known as a segmented button. +/// +/// This button is used by [ToggleButtons] to implement a set of segmented controls. +class _ToggleButton extends StatelessWidget { + /// Creates a toggle button based on [RawMaterialButton]. + /// + /// This class adds some logic to distinguish between enabled, active, and + /// disabled states, to determine the appropriate colors to use. + /// + /// It takes in a [shape] property to modify the borders of the button, + /// which is used by [ToggleButtons] to customize borders based on the + /// order in which this button appears in the list. + const _ToggleButton({ + Key key, + this.selected = false, + this.color, + this.selectedColor, + this.disabledColor, + this.fillColor, + this.focusColor, + this.highlightColor, + this.hoverColor, + this.splashColor, + this.focusNode, + this.onPressed, + this.leadingBorderSide, + this.horizontalBorderSide, + this.trailingBorderSide, + this.borderRadius, + this.clipRadius, + this.isFirstButton, + this.isLastButton, + this.child, + }) : super(key: key); + + /// Determines if the button is displayed as active/selected or enabled. + final bool selected; + + /// The color for [Text] and [Icon] widgets if the button is enabled. + /// + /// If [selected] is false and [onPressed] is not null, this color will be used. + final Color color; + + /// The color for [Text] and [Icon] widgets if the button is selected. + /// + /// If [selected] is true and [onPressed] is not null, this color will be used. + final Color selectedColor; + + /// The color for [Text] and [Icon] widgets if the button is disabled. + /// + /// If [onPressed] is null, this color will be used. + final Color disabledColor; + + /// The color of the button's [Material]. + final Color fillColor; + + /// The color for the button's [Material] when it has the input focus. + final Color focusColor; + + /// The color for the button's [Material] when a pointer is hovering over it. + final Color hoverColor; + + /// The highlight color for the button's [InkWell]. + final Color highlightColor; + + /// The splash color for the button's [InkWell]. + final Color splashColor; + + /// A leaf node in the focus tree for this button. + /// + /// Focus is used to determine which widget should be affected by keyboard + /// events. The focus tree keeps track of which widget is currently focused + /// on by the user. + /// + /// See [FocusNode] for more information about how focus nodes are used. + final FocusNode focusNode; + + /// Called when the button is tapped or otherwise activated. + /// + /// If this is null, the button will be disabled, see [enabled]. + final VoidCallback onPressed; + + /// The width and color of the button's leading side border. + final BorderSide leadingBorderSide; + + /// The width and color of the button's top and bottom side borders. + final BorderSide horizontalBorderSide; + + /// The width and color of the button's trailing side border. + final BorderSide trailingBorderSide; + + /// The border radii of each corner of the button. + final BorderRadius borderRadius; + + /// The corner radii used to clip the button's contents. + /// + /// This is used to have the button's contents be properly clipped taking + /// the [borderRadius] and the border's width into account. + final BorderRadius clipRadius; + + /// Whether or not this toggle button is the first button in the list. + final bool isFirstButton; + + /// Whether or not this toggle button is the last button in the list. + final bool isLastButton; + + /// The button's label, which is usually an [Icon] or a [Text] widget. + final Widget child; + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + Color currentColor; + Color currentFillColor; + Color currentFocusColor; + Color currentHoverColor; + Color currentSplashColor; + final ThemeData theme = Theme.of(context); + final ToggleButtonsThemeData toggleButtonsTheme = ToggleButtonsTheme.of(context); + + if (onPressed != null && selected) { + currentColor = selectedColor + ?? toggleButtonsTheme.selectedColor + ?? theme.colorScheme.primary; + currentFillColor = fillColor + ?? theme.colorScheme.primary.withOpacity(0.12); + currentFocusColor = focusColor + ?? toggleButtonsTheme.focusColor + ?? theme.colorScheme.primary.withOpacity(0.12); + currentHoverColor = hoverColor + ?? toggleButtonsTheme.hoverColor + ?? theme.colorScheme.primary.withOpacity(0.04); + currentSplashColor = splashColor + ?? toggleButtonsTheme.splashColor + ?? theme.colorScheme.primary.withOpacity(0.16); + } else if (onPressed != null && !selected) { + currentColor = color + ?? toggleButtonsTheme.color + ?? theme.colorScheme.onSurface.withOpacity(0.87); + currentFillColor = theme.colorScheme.surface.withOpacity(0.0); + currentFocusColor = focusColor + ?? toggleButtonsTheme.focusColor + ?? theme.colorScheme.onSurface.withOpacity(0.12); + currentHoverColor = hoverColor + ?? toggleButtonsTheme.hoverColor + ?? theme.colorScheme.onSurface.withOpacity(0.04); + currentSplashColor = splashColor + ?? toggleButtonsTheme.splashColor + ?? theme.colorScheme.onSurface.withOpacity(0.16); + } else { + currentColor = disabledColor + ?? toggleButtonsTheme.disabledColor + ?? theme.colorScheme.onSurface.withOpacity(0.38); + currentFillColor = theme.colorScheme.surface.withOpacity(0.0); + } + + final Widget result = ClipRRect( + borderRadius: clipRadius, + child: RawMaterialButton( + textStyle: TextStyle( + color: currentColor, + ), + elevation: 0.0, + highlightElevation: 0.0, + fillColor: currentFillColor, + focusColor: currentFocusColor, + highlightColor: highlightColor + ?? theme.colorScheme.surface.withOpacity(0.0), + hoverColor: currentHoverColor, + splashColor: currentSplashColor, + focusNode: focusNode, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onPressed: onPressed, + child: child, + ), + ); + + return _SelectToggleButton( + key: key, + leadingBorderSide: leadingBorderSide, + horizontalBorderSide: horizontalBorderSide, + trailingBorderSide: trailingBorderSide, + borderRadius: borderRadius, + isFirstButton: isFirstButton, + isLastButton: isLastButton, + child: result, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(FlagProperty('selected', + value: selected, + ifTrue: 'Button is selected', + ifFalse: 'Button is unselected', + )); + } +} + +class _SelectToggleButton extends SingleChildRenderObjectWidget { + const _SelectToggleButton({ + Key key, + Widget child, + this.leadingBorderSide, + this.horizontalBorderSide, + this.trailingBorderSide, + this.borderRadius, + this.isFirstButton, + this.isLastButton, + }) : super( + key: key, + child: child, + ); + + // The width and color of the button's leading side border. + final BorderSide leadingBorderSide; + + // The width and color of the button's top and bottom side borders. + final BorderSide horizontalBorderSide; + + // The width and color of the button's trailing side border. + final BorderSide trailingBorderSide; + + // The border radii of each corner of the button. + final BorderRadius borderRadius; + + // Whether or not this toggle button is the first button in the list. + final bool isFirstButton; + + // Whether or not this toggle button is the last button in the list. + final bool isLastButton; + + @override + _SelectToggleButtonRenderObject createRenderObject(BuildContext context) => _SelectToggleButtonRenderObject( + leadingBorderSide, + horizontalBorderSide, + trailingBorderSide, + borderRadius, + isFirstButton, + isLastButton, + Directionality.of(context), + ); + + @override + void updateRenderObject(BuildContext context, _SelectToggleButtonRenderObject renderObject) { + renderObject + ..leadingBorderSide = leadingBorderSide + ..horizontalBorderSide = horizontalBorderSide + ..trailingBorderSide = trailingBorderSide + ..borderRadius = borderRadius + ..isFirstButton = isFirstButton + ..isLastButton = isLastButton + ..textDirection = Directionality.of(context); + } +} + +class _SelectToggleButtonRenderObject extends RenderShiftedBox { + _SelectToggleButtonRenderObject( + this._leadingBorderSide, + this._horizontalBorderSide, + this._trailingBorderSide, + this._borderRadius, + this._isFirstButton, + this._isLastButton, + this._textDirection, + [RenderBox child] + ) : super(child); + + // The width and color of the button's leading side border. + BorderSide get leadingBorderSide => _leadingBorderSide; + BorderSide _leadingBorderSide; + set leadingBorderSide(BorderSide value) { + if (_leadingBorderSide == value) + return; + _leadingBorderSide = value; + markNeedsLayout(); + } + + // The width and color of the button's top and bottom side borders. + BorderSide get horizontalBorderSide => _horizontalBorderSide; + BorderSide _horizontalBorderSide; + set horizontalBorderSide(BorderSide value) { + if (_horizontalBorderSide == value) + return; + _horizontalBorderSide = value; + markNeedsLayout(); + } + + // The width and color of the button's trailing side border. + BorderSide get trailingBorderSide => _trailingBorderSide; + BorderSide _trailingBorderSide; + set trailingBorderSide(BorderSide value) { + if (_trailingBorderSide == value) + return; + _trailingBorderSide = value; + markNeedsLayout(); + } + + // The border radii of each corner of the button. + BorderRadius get borderRadius => _borderRadius; + BorderRadius _borderRadius; + set borderRadius(BorderRadius value) { + if (_borderRadius == value) + return; + _borderRadius = value; + markNeedsLayout(); + } + + // Whether or not this toggle button is the first button in the list. + bool get isFirstButton => _isFirstButton; + bool _isFirstButton; + set isFirstButton(bool value) { + if (_isFirstButton == value) + return; + _isFirstButton = value; + markNeedsLayout(); + } + + // Whether or not this toggle button is the last button in the list. + bool get isLastButton => _isLastButton; + bool _isLastButton; + set isLastButton(bool value) { + if (_isLastButton == value) + return; + _isLastButton = value; + markNeedsLayout(); + } + + // The direction in which text flows for this application. + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + set textDirection(TextDirection value) { + if (_textDirection == value) + return; + _textDirection = value; + markNeedsLayout(); + } + + static double _maxHeight(RenderBox box, double width) { + return box == null ? 0.0 : box.getMaxIntrinsicHeight(width); + } + + static double _minWidth(RenderBox box, double height) { + return box == null ? 0.0 : box.getMinIntrinsicWidth(height); + } + + static double _maxWidth(RenderBox box, double height) { + return box == null ? 0.0 : box.getMaxIntrinsicWidth(height); + } + + @override + double computeDistanceToActualBaseline(TextBaseline baseline) { + // The baseline of this widget is the baseline of its child + return child.computeDistanceToActualBaseline(baseline) + + horizontalBorderSide.width; + } + + @override + double computeMaxIntrinsicHeight(double width) { + return horizontalBorderSide.width + + _maxHeight(child, width) + + horizontalBorderSide.width; + } + + @override + double computeMinIntrinsicHeight(double width) => computeMaxIntrinsicHeight(width); + + @override + double computeMaxIntrinsicWidth(double height) { + final double trailingWidth = trailingBorderSide == null ? 0.0 : trailingBorderSide.width; + return leadingBorderSide.width + + _maxWidth(child, height) + + trailingWidth; + } + + @override + double computeMinIntrinsicWidth(double height) { + final double trailingWidth = trailingBorderSide == null ? 0.0 : trailingBorderSide.width; + return leadingBorderSide.width + + _minWidth(child, height) + + trailingWidth; + } + + @override + void performLayout() { + if (child == null) { + size = constraints.constrain(Size( + leadingBorderSide.width + trailingBorderSide.width, + horizontalBorderSide.width * 2.0, + )); + return; + } + + final double trailingBorderOffset = isLastButton ? trailingBorderSide.width : 0.0; + double leftConstraint; + double rightConstraint; + + switch (textDirection) { + case TextDirection.ltr: + rightConstraint = trailingBorderOffset; + leftConstraint = leadingBorderSide.width; + + final BoxConstraints innerConstraints = constraints.deflate( + EdgeInsets.only( + left: leftConstraint, + top: horizontalBorderSide.width, + right: rightConstraint, + bottom: horizontalBorderSide.width, + ), + ); + + child.layout(innerConstraints, parentUsesSize: true); + final BoxParentData childParentData = child.parentData; + childParentData.offset = Offset(leadingBorderSide.width, leadingBorderSide.width); + + size = constraints.constrain(Size( + leftConstraint + child.size.width + rightConstraint, + horizontalBorderSide.width * 2.0 + child.size.height, + )); + break; + case TextDirection.rtl: + rightConstraint = leadingBorderSide.width; + leftConstraint = trailingBorderOffset; + + final BoxConstraints innerConstraints = constraints.deflate( + EdgeInsets.only( + left: leftConstraint, + top: horizontalBorderSide.width, + right: rightConstraint, + bottom: horizontalBorderSide.width, + ), + ); + + child.layout(innerConstraints, parentUsesSize: true); + final BoxParentData childParentData = child.parentData; + + if (isLastButton) { + childParentData.offset = Offset(trailingBorderOffset, trailingBorderOffset); + } else { + childParentData.offset = Offset(0, horizontalBorderSide.width); + } + + size = constraints.constrain(Size( + leftConstraint + child.size.width + rightConstraint, + horizontalBorderSide.width * 2.0 + child.size.height, + )); + break; + } + } + + @override + void paint(PaintingContext context, Offset offset) { + super.paint(context, offset); + final Offset bottomRight = size.bottomRight(offset); + final Rect outer = Rect.fromLTRB(offset.dx, offset.dy, bottomRight.dx, bottomRight.dy); + final Rect center = outer.deflate(horizontalBorderSide.width / 2.0); + const double sweepAngle = math.pi / 2.0; + + final RRect rrect = RRect.fromRectAndCorners( + center, + topLeft: borderRadius.topLeft, + topRight: borderRadius.topRight, + bottomLeft: borderRadius.bottomLeft, + bottomRight: borderRadius.bottomRight, + ).scaleRadii(); + + final Rect tlCorner = Rect.fromLTWH( + rrect.left, + rrect.top, + rrect.tlRadiusX * 2.0, + rrect.tlRadiusY * 2.0, + ); + final Rect blCorner = Rect.fromLTWH( + rrect.left, + rrect.bottom - (rrect.blRadiusY * 2.0), + rrect.blRadiusX * 2.0, + rrect.blRadiusY * 2.0, + ); + final Rect trCorner = Rect.fromLTWH( + rrect.right - (rrect.trRadiusX * 2), + rrect.top, + rrect.trRadiusX * 2, + rrect.trRadiusY * 2, + ); + final Rect brCorner = Rect.fromLTWH( + rrect.right - (rrect.brRadiusX * 2), + rrect.bottom - (rrect.brRadiusY * 2), + rrect.brRadiusX * 2, + rrect.brRadiusY * 2, + ); + + final Paint leadingPaint = leadingBorderSide.toPaint(); + switch (textDirection) { + case TextDirection.ltr: + if (isLastButton) { + final Path leftPath = Path() + ..moveTo(rrect.left, rrect.bottom + leadingBorderSide.width / 2) + ..lineTo(rrect.left, rrect.top - leadingBorderSide.width / 2); + context.canvas.drawPath(leftPath, leadingPaint); + + final Paint endingPaint = trailingBorderSide.toPaint(); + final Path endingPath = Path() + ..moveTo(rrect.left + horizontalBorderSide.width / 2.0, rrect.top) + ..lineTo(rrect.right - rrect.trRadiusX, rrect.top) + ..addArc(trCorner, math.pi * 3.0 / 2.0, sweepAngle) + ..lineTo(rrect.right, rrect.bottom - rrect.brRadiusY) + ..addArc(brCorner, 0, sweepAngle) + ..lineTo(rrect.left + horizontalBorderSide.width / 2.0, rrect.bottom); + context.canvas.drawPath(endingPath, endingPaint); + } else if (isFirstButton) { + final Path leadingPath = Path() + ..moveTo(outer.right, rrect.bottom) + ..lineTo(rrect.left + rrect.blRadiusX, rrect.bottom) + ..addArc(blCorner, math.pi / 2.0, sweepAngle) + ..lineTo(rrect.left, rrect.top + rrect.tlRadiusY) + ..addArc(tlCorner, math.pi, sweepAngle) + ..lineTo(outer.right, rrect.top); + context.canvas.drawPath(leadingPath, leadingPaint); + } else { + final Path leadingPath = Path() + ..moveTo(rrect.left, rrect.bottom + leadingBorderSide.width / 2) + ..lineTo(rrect.left, rrect.top - leadingBorderSide.width / 2); + context.canvas.drawPath(leadingPath, leadingPaint); + + final Paint horizontalPaint = horizontalBorderSide.toPaint(); + final Path horizontalPaths = Path() + ..moveTo(rrect.left + horizontalBorderSide.width / 2.0, rrect.top) + ..lineTo(outer.right - rrect.trRadiusX, rrect.top) + ..moveTo(rrect.left + horizontalBorderSide.width / 2.0 + rrect.tlRadiusX, rrect.bottom) + ..lineTo(outer.right - rrect.trRadiusX, rrect.bottom); + context.canvas.drawPath(horizontalPaths, horizontalPaint); + } + break; + case TextDirection.rtl: + if (isLastButton) { + final Path leadingPath = Path() + ..moveTo(rrect.right, rrect.bottom + leadingBorderSide.width / 2) + ..lineTo(rrect.right, rrect.top - leadingBorderSide.width / 2); + context.canvas.drawPath(leadingPath, leadingPaint); + + final Paint endingPaint = trailingBorderSide.toPaint(); + final Path endingPath = Path() + ..moveTo(rrect.right - horizontalBorderSide.width / 2.0, rrect.top) + ..lineTo(rrect.left + rrect.tlRadiusX, rrect.top) + ..addArc(tlCorner, math.pi * 3.0 / 2.0, -sweepAngle) + ..lineTo(rrect.left, rrect.bottom - rrect.blRadiusY) + ..addArc(blCorner, math.pi, -sweepAngle) + ..lineTo(rrect.right - horizontalBorderSide.width / 2.0, rrect.bottom); + context.canvas.drawPath(endingPath, endingPaint); + } else if (isFirstButton) { + final Path leadingPath = Path() + ..moveTo(outer.left, rrect.bottom) + ..lineTo(rrect.right - rrect.brRadiusX, rrect.bottom) + ..addArc(brCorner, math.pi / 2.0, -sweepAngle) + ..lineTo(rrect.right, rrect.top + rrect.trRadiusY) + ..addArc(trCorner, 0, -sweepAngle) + ..lineTo(outer.left, rrect.top); + context.canvas.drawPath(leadingPath, leadingPaint); + } else { + final Path leadingPath = Path() + ..moveTo(rrect.right, rrect.bottom + leadingBorderSide.width / 2) + ..lineTo(rrect.right, rrect.top - leadingBorderSide.width / 2); + context.canvas.drawPath(leadingPath, leadingPaint); + + final Paint horizontalPaint = horizontalBorderSide.toPaint(); + final Path horizontalPaths = Path() + ..moveTo(rrect.right - horizontalBorderSide.width / 2.0, rrect.top) + ..lineTo(outer.left - rrect.tlRadiusX, rrect.top) + ..moveTo(rrect.right - horizontalBorderSide.width / 2.0 + rrect.trRadiusX, rrect.bottom) + ..lineTo(outer.left - rrect.tlRadiusX, rrect.bottom); + context.canvas.drawPath(horizontalPaths, horizontalPaint); + } + break; + } + } +} diff --git a/packages/flutter/lib/src/material/toggle_buttons_theme.dart b/packages/flutter/lib/src/material/toggle_buttons_theme.dart new file mode 100644 index 00000000000..c845c0801f1 --- /dev/null +++ b/packages/flutter/lib/src/material/toggle_buttons_theme.dart @@ -0,0 +1,272 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +/// Defines the color and border properties of [ToggleButtons] widgets. +/// +/// Used by [ToggleButtonsTheme] to control the color and border properties +/// of toggle buttons in a widget subtree. +/// +/// To obtain the current [ToggleButtonsTheme], use [ToggleButtonsTheme.of]. +/// +/// Values specified here are used for [ToggleButtons] properties that are not +/// given an explicit non-null value. +/// +/// See also: +/// +/// * [ToggleButtonsTheme], which describes the actual configuration of a +/// toggle buttons theme. +class ToggleButtonsThemeData extends Diagnosticable { + /// Creates the set of color and border properties used to configure + /// [ToggleButtons]. + const ToggleButtonsThemeData({ + this.color, + this.selectedColor, + this.disabledColor, + this.fillColor, + this.focusColor, + this.highlightColor, + this.hoverColor, + this.splashColor, + this.borderColor, + this.selectedBorderColor, + this.disabledBorderColor, + this.borderRadius, + this.borderWidth, + }); + + /// The color for descendant [Text] and [Icon] widgets if the toggle button + /// is enabled. + final Color color; + + /// The color for descendant [Text] and [Icon] widgets if the toggle button + /// is selected. + final Color selectedColor; + + /// The color for descendant [Text] and [Icon] widgets if the toggle button + /// is disabled. + final Color disabledColor; + + /// The fill color for selected toggle buttons. + final Color fillColor; + + /// The color to use for filling the button when the button has input focus. + final Color focusColor; + + /// The highlight color for the toggle button's [InkWell]. + final Color highlightColor; + + /// The splash color for the toggle button's [InkWell]. + final Color splashColor; + + /// The color to use for filling the toggle button when the button has a + /// pointer hovering over it. + final Color hoverColor; + + /// The border color to display when the toggle button is enabled. + final Color borderColor; + + /// The border color to display when the toggle button is selected. + final Color selectedBorderColor; + + /// The border color to display when the toggle button is disabled. + final Color disabledBorderColor; + + /// The width of the border surrounding each toggle button. + /// + /// This applies to both the greater surrounding border, as well as the + /// borders dividing each toggle button. + /// + /// To render a hairline border (one physical pixel), set borderWidth to 0.0. + /// See [BorderSide.width] for more details on hairline borders. + final double borderWidth; + + /// The radii of the border's corners. + final BorderRadius borderRadius; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + ToggleButtonsThemeData copyWith({ + Color color, + Color selectedColor, + Color disabledColor, + Color fillColor, + Color focusColor, + Color highlightColor, + Color hoverColor, + Color splashColor, + Color borderColor, + Color selectedBorderColor, + Color disabledBorderColor, + BorderRadius borderRadius, + double borderWidth, + }) { + return ToggleButtonsThemeData( + color: color ?? this.color, + selectedColor: selectedColor ?? this.selectedColor, + disabledColor: disabledColor ?? this.disabledColor, + fillColor: fillColor ?? this.fillColor, + focusColor: focusColor ?? this.focusColor, + highlightColor: highlightColor ?? this.highlightColor, + hoverColor: hoverColor ?? this.hoverColor, + splashColor: splashColor ?? this.splashColor, + borderColor: borderColor ?? this.borderColor, + selectedBorderColor: selectedBorderColor ?? this.selectedBorderColor, + disabledBorderColor: disabledBorderColor ?? this.disabledBorderColor, + borderRadius: borderRadius ?? this.borderRadius, + borderWidth: borderWidth ?? this.borderWidth, + ); + } + + /// Linearly interpolate between two toggle buttons themes. + static ToggleButtonsThemeData lerp(ToggleButtonsThemeData a, ToggleButtonsThemeData b, double t) { + assert (t != null); + if (a == null && b == null) + return null; + return ToggleButtonsThemeData( + color: Color.lerp(a?.color, b?.color, t), + selectedColor: Color.lerp(a?.selectedColor, b?.selectedColor, t), + disabledColor: Color.lerp(a?.disabledColor, b?.disabledColor, t), + fillColor: Color.lerp(a?.fillColor, b?.fillColor, t), + focusColor: Color.lerp(a?.focusColor, b?.focusColor, t), + highlightColor: Color.lerp(a?.highlightColor, b?.highlightColor, t), + hoverColor: Color.lerp(a?.hoverColor, b?.hoverColor, t), + splashColor: Color.lerp(a?.splashColor, b?.splashColor, t), + borderColor: Color.lerp(a?.borderColor, b?.borderColor, t), + selectedBorderColor: Color.lerp(a?.selectedBorderColor, b?.selectedBorderColor, t), + disabledBorderColor: Color.lerp(a?.disabledBorderColor, b?.disabledBorderColor, t), + borderRadius: BorderRadius.lerp(a?.borderRadius, b?.borderRadius, t), + borderWidth: lerpDouble(a?.borderWidth, b?.borderWidth, t), + ); + } + + @override + int get hashCode { + return hashValues( + color, + selectedColor, + disabledColor, + fillColor, + focusColor, + highlightColor, + hoverColor, + splashColor, + borderColor, + selectedBorderColor, + disabledBorderColor, + borderRadius, + borderWidth, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) + return true; + if (other.runtimeType != runtimeType) + return false; + final ToggleButtonsThemeData typedOther = other; + return typedOther.color == color + && typedOther.selectedColor == selectedColor + && typedOther.disabledColor == disabledColor + && typedOther.fillColor == fillColor + && typedOther.focusColor == focusColor + && typedOther.highlightColor == highlightColor + && typedOther.hoverColor == hoverColor + && typedOther.splashColor == splashColor + && typedOther.borderColor == borderColor + && typedOther.selectedBorderColor == selectedBorderColor + && typedOther.disabledBorderColor == disabledBorderColor + && typedOther.borderRadius == borderRadius + && typedOther.borderWidth == borderWidth; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('color', color, defaultValue: null)); + properties.add(ColorProperty('selectedColor', selectedColor, defaultValue: null)); + properties.add(ColorProperty('disabledColor', disabledColor, defaultValue: null)); + properties.add(ColorProperty('fillColor', fillColor, defaultValue: null)); + properties.add(ColorProperty('focusColor', focusColor, defaultValue: null)); + properties.add(ColorProperty('highlightColor', highlightColor, defaultValue: null)); + properties.add(ColorProperty('hoverColor', hoverColor, defaultValue: null)); + properties.add(ColorProperty('splashColor', splashColor, defaultValue: null)); + properties.add(ColorProperty('borderColor', borderColor, defaultValue: null)); + properties.add(ColorProperty('selectedBorderColor', selectedBorderColor, defaultValue: null)); + properties.add(ColorProperty('disabledBorderColor', disabledBorderColor, defaultValue: null)); + properties.add(DiagnosticsProperty('borderRadius', borderRadius, defaultValue: null)); + properties.add(DoubleProperty('borderWidth', borderWidth, defaultValue: null)); + } +} + +/// An inherited widget that defines color and border parameters for +/// [ToggleButtons] in this widget's subtree. +/// +/// Values specified here are used for [ToggleButtons] properties that are not +/// given an explicit non-null value. +class ToggleButtonsTheme extends InheritedWidget { + /// Creates a toggle buttons theme that controls the color and border + /// parameters for [ToggleButtons]. + ToggleButtonsTheme({ + Key key, + Color color, + Color selectedColor, + Color disabledColor, + Color fillColor, + Color focusColor, + Color highlightColor, + Color hoverColor, + Color splashColor, + Color borderColor, + Color selectedBorderColor, + Color disabledBorderColor, + BorderRadius borderRadius, + double borderWidth, + Widget child, + }) : data = ToggleButtonsThemeData( + color: color, + selectedColor: selectedColor, + disabledColor: disabledColor, + fillColor: fillColor, + focusColor: focusColor, + highlightColor: highlightColor, + hoverColor: hoverColor, + splashColor: splashColor, + borderColor: borderColor, + selectedBorderColor: selectedBorderColor, + disabledBorderColor: disabledBorderColor, + borderRadius: borderRadius, + borderWidth: borderWidth, + ), + super(key: key, child: child); + + /// Specifies the color and border values for descendant [ToggleButtons] widgets. + final ToggleButtonsThemeData data; + + /// The closest instance of this class that encloses the given context. + /// + /// If there is no enclosing [ToggleButtonsTheme] widget, then + /// [ThemeData.toggleButtonsTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// ToggleButtonsTheme theme = ToggleButtonsTheme.of(context); + /// ``` + static ToggleButtonsThemeData of(BuildContext context) { + final ToggleButtonsTheme toggleButtonsTheme = context.inheritFromWidgetOfExactType(ToggleButtonsTheme); + return toggleButtonsTheme?.data ?? Theme.of(context).toggleButtonsTheme; + } + + @override + bool updateShouldNotify(ToggleButtonsTheme oldWidget) => data != oldWidget.data; +} \ No newline at end of file diff --git a/packages/flutter/test/material/toggle_buttons_test.dart b/packages/flutter/test/material/toggle_buttons_test.dart new file mode 100644 index 00000000000..929e187a4ad --- /dev/null +++ b/packages/flutter/test/material/toggle_buttons_test.dart @@ -0,0 +1,1306 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../rendering/mock_canvas.dart'; + +const double _defaultBorderWidth = 1.0; + +Widget boilerplate({Widget child}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center(child: child), + ); +} + +void main() { + testWidgets('Initial toggle state is reflected', (WidgetTester tester) async { + final ThemeData theme = ThemeData(); + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + onPressed: (int index) {}, + isSelected: const [false, true], + children: const [ + Text('First child'), + Text('Second child'), + ], + ), + ), + ), + ); + + final DefaultTextStyle textStyleOne = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'First child'), + ); + expect( + textStyleOne.style.color, + theme.colorScheme.onSurface.withOpacity(0.87), + ); + final DefaultTextStyle textStyleTwo = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'Second child'), + ); + expect(textStyleTwo.style.color, theme.colorScheme.primary); + }); + + testWidgets( + 'onPressed is triggered on button tap', + (WidgetTester tester) async { + final List _isSelected = [false, true]; + final ThemeData theme = ThemeData(); + await tester.pumpWidget( + Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: ToggleButtons( + onPressed: (int index) { + setState(() { + _isSelected[index] = !_isSelected[index]; + }); + }, + isSelected: _isSelected, + children: const [ + Text('First child'), + Text('Second child'), + ], + ), + ); + }, + ), + ), + ); + + DefaultTextStyle textStyleOne; + DefaultTextStyle textStyleTwo; + + expect(_isSelected[0], isFalse); + expect(_isSelected[1], isTrue); + textStyleOne = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'First child'), + ); + expect( + textStyleOne.style.color, + theme.colorScheme.onSurface.withOpacity(0.87), + ); + textStyleTwo = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'Second child'), + ); + expect(textStyleTwo.style.color, theme.colorScheme.primary); + + await tester.tap(find.text('Second child')); + await tester.pumpAndSettle(); + + expect(_isSelected[0], isFalse); + expect(_isSelected[1], isFalse); + textStyleOne = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'First child'), + ); + expect( + textStyleOne.style.color, + theme.colorScheme.onSurface.withOpacity(0.87), + ); + textStyleTwo = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'Second child'), + ); + expect( + textStyleTwo.style.color, + theme.colorScheme.onSurface.withOpacity(0.87), + ); + }, + ); + + testWidgets( + 'onPressed that is null disables buttons', + (WidgetTester tester) async { + final List _isSelected = [false, true]; + final ThemeData theme = ThemeData(); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + isSelected: _isSelected, + children: const [ + Text('First child'), + Text('Second child'), + ], + ), + ), + ), + ); + + DefaultTextStyle textStyleOne; + DefaultTextStyle textStyleTwo; + + expect(_isSelected[0], isFalse); + expect(_isSelected[1], isTrue); + textStyleOne = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'First child'), + ); + expect( + textStyleOne.style.color, + theme.colorScheme.onSurface.withOpacity(0.38), + ); + textStyleTwo = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'Second child'), + ); + expect( + textStyleTwo.style.color, + theme.colorScheme.onSurface.withOpacity(0.38), + ); + + await tester.tap(find.text('Second child')); + await tester.pumpAndSettle(); + + // nothing should change + expect(_isSelected[0], isFalse); + expect(_isSelected[1], isTrue); + textStyleOne = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'First child'), + ); + expect( + textStyleOne.style.color, + theme.colorScheme.onSurface.withOpacity(0.38), + ); + textStyleTwo = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'Second child'), + ); + expect( + textStyleTwo.style.color, + theme.colorScheme.onSurface.withOpacity(0.38), + ); + }, + ); + + testWidgets('children property cannot be null', (WidgetTester tester) async { + try { + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + isSelected: const [false, true], + onPressed: (int index) {}, + children: null, + ), + ), + ), + ); + fail( + 'Should not be possible to create a toggle button with no children.'); + } on AssertionError catch (e) { + expect(e.toString(), contains('children != null')); + } + }); + + testWidgets( + 'isSelected property cannot be null', + (WidgetTester tester) async { + try { + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + isSelected: null, + onPressed: (int index) {}, + children: const [ + Text('First child'), + Text('Second child'), + ], + ), + ), + ), + ); + fail( + 'Should not be possible to create a toggle button with no isSelected.'); + } on AssertionError catch (e) { + expect(e.toString(), contains('isSelected != null')); + } + }, + ); + + testWidgets( + 'children and isSelected properties have to be the same length', + (WidgetTester tester) async { + try { + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + isSelected: const [false], + children: const [ + Text('First child'), + Text('Second child'), + ], + ), + ), + ), + ); + fail('Should not be possible to create a toggle button with mismatching' + 'children.length and isSelected.length.'); + } on AssertionError catch (e) { + expect(e.toString(), contains('children.length')); + expect(e.toString(), contains('isSelected.length')); + } + }, + ); + + testWidgets( + 'Default text/icon colors for enabled, selected and disabled states', + (WidgetTester tester) async { + final ThemeData theme = ThemeData(); + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + isSelected: const [false], + onPressed: (int index) {}, + children: [ + Row(children: const [ + Text('First child'), + Icon(Icons.check), + ]), + ], + ), + ), + ), + ); + + DefaultTextStyle textStyle; + IconTheme iconTheme; + + // default enabled color + textStyle = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'First child'), + ); + expect( + textStyle.style.color, + theme.colorScheme.onSurface.withOpacity(0.87), + ); + iconTheme = tester.firstWidget( + find.widgetWithIcon(IconTheme, Icons.check), + ); + expect( + iconTheme.data.color, + theme.colorScheme.onSurface.withOpacity(0.87), + ); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + isSelected: const [true], + onPressed: (int index) {}, + children: [ + Row(children: const [ + Text('First child'), + Icon(Icons.check), + ]), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + // default selected color + textStyle = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'First child'), + ); + expect(textStyle.style.color, theme.colorScheme.primary); + iconTheme = tester.firstWidget( + find.widgetWithIcon(IconTheme, Icons.check), + ); + expect(iconTheme.data.color, theme.colorScheme.primary); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + isSelected: const [true], + children: [ + Row(children: const [ + Text('First child'), + Icon(Icons.check), + ]), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + // default disabled color + textStyle = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'First child'), + ); + expect( + textStyle.style.color, + theme.colorScheme.onSurface.withOpacity(0.38), + ); + iconTheme = tester.firstWidget( + find.widgetWithIcon(IconTheme, Icons.check), + ); + expect( + iconTheme.data.color, + theme.colorScheme.onSurface.withOpacity(0.38), + ); + }, + ); + + testWidgets( + 'Custom text/icon colors for enabled, selected and disabled states', + (WidgetTester tester) async { + final ThemeData theme = ThemeData(); + const Color enabledColor = Colors.lime; + const Color selectedColor = Colors.green; + const Color disabledColor = Colors.yellow; + + // tests are ineffective if the custom colors are the same as the theme's + expect(theme.colorScheme.onSurface, isNot(enabledColor)); + expect(theme.colorScheme.primary, isNot(selectedColor)); + expect(theme.colorScheme.onSurface.withOpacity(0.38), isNot(disabledColor)); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + color: enabledColor, + isSelected: const [false], + onPressed: (int index) {}, + children: [ + Row(children: const [ + Text('First child'), + Icon(Icons.check), + ]), + ], + ), + ), + ), + ); + + DefaultTextStyle textStyle; + IconTheme iconTheme; + + // custom enabled color + textStyle = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'First child'), + ); + expect(textStyle.style.color, enabledColor); + iconTheme = tester.firstWidget( + find.widgetWithIcon(IconTheme, Icons.check), + ); + expect(iconTheme.data.color, enabledColor); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + selectedColor: selectedColor, + isSelected: const [true], + onPressed: (int index) {}, + children: [ + Row(children: const [ + Text('First child'), + Icon(Icons.check), + ]), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + // custom selected color + textStyle = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'First child'), + ); + expect(textStyle.style.color, selectedColor); + iconTheme = tester.firstWidget( + find.widgetWithIcon(IconTheme, Icons.check), + ); + expect(iconTheme.data.color, selectedColor); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + disabledColor: disabledColor, + isSelected: const [true], + children: [ + Row(children: const [ + Text('First child'), + Icon(Icons.check), + ]), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + // custom disabled color + textStyle = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'First child'), + ); + expect(textStyle.style.color, disabledColor); + iconTheme = tester.firstWidget( + find.widgetWithIcon(IconTheme, Icons.check), + ); + expect(iconTheme.data.color, disabledColor); + }, + ); + + testWidgets('Default button fillColor - unselected', (WidgetTester tester) async { + final ThemeData theme = ThemeData(); + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + isSelected: const [false], + onPressed: (int index) {}, + children: [ + Row(children: const [ + Text('First child'), + ]), + ], + ), + ), + ), + ); + + final Material material = tester.firstWidget( + find.descendant( + of: find.byType(RawMaterialButton), + matching: find.byType(Material), + ), + ); + expect( + material.color, + theme.colorScheme.surface.withOpacity(0.0), + ); + expect(material.type, MaterialType.button); + }); + + testWidgets('Default button fillColor - selected', (WidgetTester tester) async { + final ThemeData theme = ThemeData(); + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + isSelected: const [true], + onPressed: (int index) {}, + children: [ + Row(children: const [ + Text('First child'), + ]), + ], + ), + ), + ), + ); + + final Material material = tester.firstWidget( + find.descendant( + of: find.byType(RawMaterialButton), + matching: find.byType(Material), + ), + ); + expect( + material.color, + theme.colorScheme.primary.withOpacity(0.12), + ); + expect(material.type, MaterialType.button); + }); + + testWidgets('Default button fillColor - disabled', (WidgetTester tester) async { + final ThemeData theme = ThemeData(); + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + isSelected: const [true], + onPressed: null, + children: [ + Row(children: const [ + Text('First child'), + ]), + ], + ), + ), + ), + ); + + final Material material = tester.firstWidget( + find.descendant( + of: find.byType(RawMaterialButton), + matching: find.byType(Material), + ), + ); + expect( + material.color, + theme.colorScheme.surface.withOpacity(0.0), + ); + expect(material.type, MaterialType.button); + }); + + testWidgets('Custom button fillColor', (WidgetTester tester) async { + const Color customFillColor = Colors.green; + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + fillColor: customFillColor, + isSelected: const [true], + onPressed: (int index) {}, + children: [ + Row(children: const [ + Text('First child'), + ]), + ], + ), + ), + ), + ); + + final Material material = tester.firstWidget( + find.descendant( + of: find.byType(RawMaterialButton), + matching: find.byType(Material), + ), + ); + expect(material.color, customFillColor); + expect(material.type, MaterialType.button); + }); + + testWidgets('Default InkWell colors - unselected', (WidgetTester tester) async { + final ThemeData theme = ThemeData(); + final FocusNode focusNode = FocusNode(); + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + isSelected: const [false], + onPressed: (int index) {}, + focusNodes: [focusNode], + children: const [ + Text('First child'), + ], + ), + ), + ), + ); + + final Offset center = tester.getCenter(find.text('First child')); + + // splashColor + final TestGesture touchGesture = await tester.createGesture(); + await touchGesture.down(center); + await tester.pumpAndSettle(); + + RenderObject inkFeatures; + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect( + inkFeatures, + paints + ..circle(color: theme.colorScheme.onSurface.withOpacity(0.16)), + ); + + await touchGesture.up(); + await tester.pumpAndSettle(); + + // hoverColor + final TestGesture hoverGesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await hoverGesture.addPointer(); + await hoverGesture.moveTo(center); + await tester.pumpAndSettle(); + + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect( + inkFeatures, + paints..rect(color: theme.colorScheme.onSurface.withOpacity(0.04)), + ); + await hoverGesture.removePointer(); + + // focusColor + focusNode.requestFocus(); + await tester.pumpAndSettle(); + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect(inkFeatures, paints..rect(color: theme.colorScheme.onSurface.withOpacity(0.12))); + }); + + testWidgets('Default InkWell colors - selected', (WidgetTester tester) async { + final ThemeData theme = ThemeData(); + final FocusNode focusNode = FocusNode(); + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + isSelected: const [true], + onPressed: (int index) {}, + focusNodes: [focusNode], + children: const [ + Text('First child'), + ], + ), + ), + ), + ); + + final Offset center = tester.getCenter(find.text('First child')); + + // splashColor + final TestGesture touchGesture = await tester.createGesture(); + await touchGesture.down(center); + await tester.pumpAndSettle(); + + RenderObject inkFeatures; + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect( + inkFeatures, + paints + ..circle(color: theme.colorScheme.primary.withOpacity(0.16)), + ); + + await touchGesture.up(); + await tester.pumpAndSettle(); + + // hoverColor + final TestGesture hoverGesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await hoverGesture.addPointer(); + await hoverGesture.moveTo(center); + await tester.pumpAndSettle(); + + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect( + inkFeatures, + paints..rect(color: theme.colorScheme.primary.withOpacity(0.04)), + ); + await hoverGesture.removePointer(); + + // focusColor + focusNode.requestFocus(); + await tester.pumpAndSettle(); + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect(inkFeatures, paints..rect(color: theme.colorScheme.primary.withOpacity(0.12))); + }); + + testWidgets('Custom InkWell colors', (WidgetTester tester) async { + const Color splashColor = Color(0xff4caf50); + const Color highlightColor = Color(0xffcddc39); + const Color hoverColor = Color(0xffffeb3b); + const Color focusColor = Color(0xffffff00); + final FocusNode focusNode = FocusNode(); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + splashColor: splashColor, + highlightColor: highlightColor, + hoverColor: hoverColor, + focusColor: focusColor, + isSelected: const [true], + onPressed: (int index) {}, + focusNodes: [focusNode], + children: const [ + Text('First child'), + ], + ), + ), + ), + ); + + final Offset center = tester.getCenter(find.text('First child')); + + // splashColor + final TestGesture touchGesture = await tester.createGesture(); + await touchGesture.down(center); + await tester.pumpAndSettle(); + + RenderObject inkFeatures; + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect( + inkFeatures, + paints + ..circle(color: splashColor) + ); + + await touchGesture.up(); + await tester.pumpAndSettle(); + + // hoverColor + final TestGesture hoverGesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await hoverGesture.addPointer(); + await hoverGesture.moveTo(center); + await tester.pumpAndSettle(); + + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect(inkFeatures, paints..rect(color: hoverColor)); + await hoverGesture.removePointer(); + + // focusColor + focusNode.requestFocus(); + await tester.pumpAndSettle(); + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect(inkFeatures, paints..rect(color: focusColor)); + }); + + testWidgets( + 'Default border width and border colors for enabled, selected and disabled states', + (WidgetTester tester) async { + final ThemeData theme = ThemeData(); + const double defaultBorderWidth = 1.0; + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + isSelected: const [false], + onPressed: (int index) {}, + children: const [ + Text('First child'), + ], + ), + ), + ), + ); + + RenderObject toggleButtonRenderObject; + toggleButtonRenderObject = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }); + expect( + toggleButtonRenderObject, + paints + // trailing side + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: defaultBorderWidth, + ) + // leading side, top and bottom + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: defaultBorderWidth, + ), + ); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + isSelected: const [true], + onPressed: (int index) {}, + children: const [ + Text('First child'), + ], + ), + ), + ), + ); + + toggleButtonRenderObject = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }); + expect( + toggleButtonRenderObject, + paints + // trailing side + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: defaultBorderWidth, + ) + // leading side, top and bottom + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: defaultBorderWidth, + ), + ); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + isSelected: const [false], + children: const [ + Text('First child'), + ], + ), + ), + ), + ); + + toggleButtonRenderObject = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }); + expect( + toggleButtonRenderObject, + paints + // trailing side + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: defaultBorderWidth, + ) + // leading side, top and bottom + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: defaultBorderWidth, + ), + ); + }, + ); + + testWidgets( + 'Custom border width and border colors for enabled, selected and disabled states', + (WidgetTester tester) async { + const Color borderColor = Color(0xff4caf50); + const Color selectedBorderColor = Color(0xffcddc39); + const Color disabledBorderColor = Color(0xffffeb3b); + const double customWidth = 2.0; + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + borderColor: borderColor, + borderWidth: customWidth, + isSelected: const [false], + onPressed: (int index) {}, + children: const [ + Text('First child'), + ], + ), + ), + ), + ); + + RenderObject toggleButtonRenderObject; + toggleButtonRenderObject = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }); + expect( + toggleButtonRenderObject, + paints + // trailing side + ..path( + style: PaintingStyle.stroke, + color: borderColor, + strokeWidth: customWidth, + ) + // leading side, top and bottom + ..path( + style: PaintingStyle.stroke, + color: borderColor, + strokeWidth: customWidth, + ), + ); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + selectedBorderColor: selectedBorderColor, + borderWidth: customWidth, + isSelected: const [true], + onPressed: (int index) {}, + children: const [ + Text('First child'), + ], + ), + ), + ), + ); + + toggleButtonRenderObject = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }); + expect( + toggleButtonRenderObject, + paints + // trailing side + ..path( + style: PaintingStyle.stroke, + color: selectedBorderColor, + strokeWidth: customWidth, + ) + // leading side, top and bottom + ..path( + style: PaintingStyle.stroke, + color: selectedBorderColor, + strokeWidth: customWidth, + ), + ); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + disabledBorderColor: disabledBorderColor, + borderWidth: customWidth, + isSelected: const [false], + children: const [ + Text('First child'), + ], + ), + ), + ), + ); + + toggleButtonRenderObject = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }); + expect( + toggleButtonRenderObject, + paints + // trailing side + ..path( + style: PaintingStyle.stroke, + color: disabledBorderColor, + strokeWidth: customWidth, + ) + // leading side, top and bottom + ..path( + style: PaintingStyle.stroke, + color: disabledBorderColor, + strokeWidth: customWidth, + ), + ); + }, + ); + + testWidgets('Height of segmented control is determined by tallest widget', (WidgetTester tester) async { + final List children = [ + Container( + constraints: const BoxConstraints.tightFor(height: 100.0), + ), + Container( + constraints: const BoxConstraints.tightFor(height: 400.0), // tallest widget + ), + Container( + constraints: const BoxConstraints.tightFor(height: 200.0), + ), + ]; + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + isSelected: const [false, true, false], + children: children, + ), + ), + ), + ); + + final List toggleButtons = tester.allWidgets.where((Widget widget) { + return widget.runtimeType.toString() == '_SelectToggleButton'; + }).toList(); + + for (int i = 0; i < toggleButtons.length; i++) { + final Rect rect = tester.getRect(find.byWidget(toggleButtons[i])); + expect(rect.height, 400.0 + 2 * _defaultBorderWidth); + } + }); + + testWidgets('Sizes of toggle buttons rebuilds with the correct dimensions', (WidgetTester tester) async { + final List children = [ + Container( + constraints: const BoxConstraints.tightFor( + width: 100.0, + height: 100.0, + ), + ), + Container( + constraints: const BoxConstraints.tightFor( + width: 100.0, + height: 100.0, + ), + ), + Container( + constraints: const BoxConstraints.tightFor( + width: 100.0, + height: 100.0, + ), + ), + ]; + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + isSelected: const [false, true, false], + children: children, + ), + ), + ), + ); + + List toggleButtons; + toggleButtons = tester.allWidgets.where((Widget widget) { + return widget.runtimeType.toString() == '_SelectToggleButton'; + }).toList(); + + for (int i = 0; i < toggleButtons.length; i++) { + final Rect rect = tester.getRect(find.byWidget(toggleButtons[i])); + expect(rect.height, 100.0 + 2 * _defaultBorderWidth); + + // Only the last button paints both leading and trailing borders. + // Other buttons only paint the leading border. + if (i == toggleButtons.length - 1) { + expect(rect.width, 100.0 + 2 * _defaultBorderWidth); + } else { + expect(rect.width, 100.0 + 1 * _defaultBorderWidth); + } + } + + final List childrenRebuilt = [ + Container( + constraints: const BoxConstraints.tightFor( + width: 200.0, + height: 200.0, + ), + ), + Container( + constraints: const BoxConstraints.tightFor( + width: 200.0, + height: 200.0, + ), + ), + Container( + constraints: const BoxConstraints.tightFor( + width: 200.0, + height: 200.0, + ), + ), + ]; + + // Update border width and widget sized to verify layout updates correctly + const double customBorderWidth = 5.0; + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + borderWidth: customBorderWidth, + isSelected: const [false, true, false], + children: childrenRebuilt, + ), + ), + ), + ); + + toggleButtons = tester.allWidgets.where((Widget widget) { + return widget.runtimeType.toString() == '_SelectToggleButton'; + }).toList(); + + // Only the last button paints both leading and trailing borders. + // Other buttons only paint the leading border. + for (int i = 0; i < toggleButtons.length; i++) { + final Rect rect = tester.getRect(find.byWidget(toggleButtons[i])); + expect(rect.height, 200.0 + 2 * customBorderWidth); + if (i == toggleButtons.length - 1) { + expect(rect.width, 200.0 + 2 * customBorderWidth); + } else { + expect(rect.width, 200.0 + 1 * customBorderWidth); + } + } + }); + + testWidgets('ToggleButtons text baseline alignment', (WidgetTester tester) async { + await tester.pumpWidget( + Material( + child: boilerplate( + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + ToggleButtons( + borderWidth: 5.0, + isSelected: const [false, true], + children: const [ + Text('First child', style: TextStyle(fontFamily: 'Ahem', fontSize: 10.0)), + Text('Second child', style: TextStyle(fontFamily: 'Ahem', fontSize: 10.0)), + ], + ), + const MaterialButton( + onPressed: null, + child: Text('Material Button', style: TextStyle(fontFamily: 'Ahem', fontSize: 20.0)), + ), + const Text('Text', style: TextStyle(fontFamily: 'Ahem', fontSize: 30.0)), + ], + ), + ), + ), + ); + + // The Ahem font extends 0.2 * fontSize below the baseline. + // So the three row elements line up like this: + // + // ToggleButton MaterialButton Text + // ------------------------------------ baseline + // 2 4 6 space below the baseline = 0.2 * fontSize + // ------------------------------------ widget text dy values + + final double firstToggleButtonDy = tester.getBottomLeft(find.text('First child')).dy; + final double secondToggleButtonDy = tester.getBottomLeft(find.text('Second child')).dy; + final double materialButtonDy = tester.getBottomLeft(find.text('Material Button')).dy; + final double textDy = tester.getBottomLeft(find.text('Text')).dy; + + expect(firstToggleButtonDy, secondToggleButtonDy); + expect(firstToggleButtonDy, closeTo(materialButtonDy - 2.0, 0.001)); + expect(firstToggleButtonDy, closeTo(textDy - 4.0, 0.001)); + }); + + testWidgets('Directionality test', (WidgetTester tester) async { + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: ToggleButtons( + onPressed: (int index) {}, + isSelected: const [false, true], + children: const [ + Text('First child'), + Text('Second child'), + ], + ), + ), + ), + ), + ); + + expect( + tester.getTopRight(find.text('First child')).dx < tester.getTopRight(find.text('Second child')).dx, + isTrue, + ); + + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: ToggleButtons( + onPressed: (int index) {}, + isSelected: const [false, true], + children: const [ + Text('First child'), + Text('Second child'), + ], + ), + ), + ), + ), + ); + + expect( + tester.getTopRight(find.text('First child')).dx > tester.getTopRight(find.text('Second child')).dx, + isTrue, + ); + }); + + testWidgets( + 'Properly draws borders based on state', + (WidgetTester tester) async { + final ThemeData theme = ThemeData(); + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtons( + isSelected: const [false, true, false], + onPressed: (int index) {}, + children: const [ + Text('First child'), + Text('Second child'), + Text('Third child'), + ], + ), + ), + ), + ); + + final List toggleButtonRenderObject = tester.allRenderObjects.where((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }).toSet().toList(); + + // The first button paints the leading, top and bottom sides with a path + expect( + toggleButtonRenderObject[0], + paints + // leading side, top and bottom - enabled + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: _defaultBorderWidth, + ), + ); + + // The middle buttons paint a leading side path first, followed by a + // top and bottom side path + expect( + toggleButtonRenderObject[1], + paints + // leading side - selected + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: _defaultBorderWidth, + ) + // top and bottom - selected + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: _defaultBorderWidth, + ), + ); + + // The last button paints a leading side path first, followed by + // a trailing, top and bottom side path + expect( + toggleButtonRenderObject[2], + paints + // leading side - selected, since previous button is selected + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: _defaultBorderWidth, + ) + // trailing side, top and bottom - enabled + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: _defaultBorderWidth, + ), + ); + }, + ); +} diff --git a/packages/flutter/test/material/toggle_buttons_theme_test.dart b/packages/flutter/test/material/toggle_buttons_theme_test.dart new file mode 100644 index 00000000000..107cfde2c11 --- /dev/null +++ b/packages/flutter/test/material/toggle_buttons_theme_test.dart @@ -0,0 +1,446 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../rendering/mock_canvas.dart'; + +Widget boilerplate({Widget child}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center(child: child), + ); +} + +void main() { + test('ToggleButtonsThemeData copyWith, ==, hashCode basics', () { + expect(const ToggleButtonsThemeData(), const ToggleButtonsThemeData().copyWith()); + expect(const ToggleButtonsThemeData().hashCode, const ToggleButtonsThemeData().copyWith().hashCode); + }); + + test('ToggleButtonsThemeData defaults', () { + const ToggleButtonsThemeData themeData = ToggleButtonsThemeData(); + expect(themeData.color, null); + expect(themeData.selectedColor, null); + expect(themeData.disabledColor, null); + expect(themeData.fillColor, null); + expect(themeData.focusColor, null); + expect(themeData.highlightColor, null); + expect(themeData.hoverColor, null); + expect(themeData.splashColor, null); + expect(themeData.borderColor, null); + expect(themeData.selectedBorderColor, null); + expect(themeData.disabledBorderColor, null); + expect(themeData.borderRadius, null); + expect(themeData.borderWidth, null); + + final ToggleButtonsTheme theme = ToggleButtonsTheme(); + expect(theme.data.color, null); + expect(theme.data.selectedColor, null); + expect(theme.data.disabledColor, null); + expect(theme.data.fillColor, null); + expect(theme.data.focusColor, null); + expect(theme.data.highlightColor, null); + expect(theme.data.hoverColor, null); + expect(theme.data.splashColor, null); + expect(theme.data.borderColor, null); + expect(theme.data.selectedBorderColor, null); + expect(theme.data.disabledBorderColor, null); + expect(theme.data.borderRadius, null); + expect(theme.data.borderWidth, null); + }); + + testWidgets('Default ToggleButtonsThemeData debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + const ToggleButtonsThemeData().debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, []); + }); + + testWidgets('ToggleButtonsThemeData implements debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + const ToggleButtonsThemeData( + color: Color(0xfffffff0), + selectedColor: Color(0xfffffff1), + disabledColor: Color(0xfffffff2), + fillColor: Color(0xfffffff3), + focusColor: Color(0xfffffff4), + highlightColor: Color(0xfffffff5), + hoverColor: Color(0xfffffff6), + splashColor: Color(0xfffffff7), + borderColor: Color(0xfffffff8), + selectedBorderColor: Color(0xfffffff9), + disabledBorderColor: Color(0xfffffffa), + borderRadius: BorderRadius.all(Radius.circular(4.0)), + borderWidth: 2.0, + ).debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, [ + 'color: Color(0xfffffff0)', + 'selectedColor: Color(0xfffffff1)', + 'disabledColor: Color(0xfffffff2)', + 'fillColor: Color(0xfffffff3)', + 'focusColor: Color(0xfffffff4)', + 'highlightColor: Color(0xfffffff5)', + 'hoverColor: Color(0xfffffff6)', + 'splashColor: Color(0xfffffff7)', + 'borderColor: Color(0xfffffff8)', + 'selectedBorderColor: Color(0xfffffff9)', + 'disabledBorderColor: Color(0xfffffffa)', + 'borderRadius: BorderRadius.circular(4.0)', + 'borderWidth: 2.0', + ]); + }); + + testWidgets( + 'Theme text/icon colors for enabled, selected and disabled states', + (WidgetTester tester) async { + final ThemeData theme = ThemeData(); + const Color enabledColor = Colors.lime; + const Color selectedColor = Colors.green; + const Color disabledColor = Colors.yellow; + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + child: ToggleButtons( + color: enabledColor, + isSelected: const [false], + onPressed: (int index) {}, + children: [ + // This Row is used like this to test for both TextStyle + // and IconTheme for Text and Icon widgets respectively. + Row(children: const [ + Text('First child'), + Icon(Icons.check), + ]), + ], + ), + ), + ), + ), + ); + + DefaultTextStyle textStyle; + IconTheme iconTheme; + + // custom theme enabled color + expect(theme.colorScheme.onSurface, isNot(enabledColor)); + textStyle = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'First child'), + ); + expect(textStyle.style.color, enabledColor); + iconTheme = tester.firstWidget( + find.widgetWithIcon(IconTheme, Icons.check), + ); + expect(iconTheme.data.color, enabledColor); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + selectedColor: selectedColor, + child: ToggleButtons( + color: enabledColor, + isSelected: const [true], + onPressed: (int index) {}, + children: [ + Row(children: const [ + Text('First child'), + Icon(Icons.check), + ]), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + // custom theme selected color + expect(theme.colorScheme.primary, isNot(selectedColor)); + textStyle = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'First child'), + ); + expect(textStyle.style.color, selectedColor); + iconTheme = tester.firstWidget( + find.widgetWithIcon(IconTheme, Icons.check), + ); + expect(iconTheme.data.color, selectedColor); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + disabledColor: disabledColor, + child: ToggleButtons( + color: enabledColor, + isSelected: const [false], + children: [ + Row(children: const [ + Text('First child'), + Icon(Icons.check), + ]), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + // custom theme disabled color + expect(theme.disabledColor, isNot(disabledColor)); + textStyle = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'First child'), + ); + expect(textStyle.style.color, disabledColor); + iconTheme = tester.firstWidget( + find.widgetWithIcon(IconTheme, Icons.check), + ); + expect(iconTheme.data.color, disabledColor); + }, + ); + + testWidgets('Theme button fillColor', (WidgetTester tester) async { + const Color customFillColor = Colors.green; + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + fillColor: customFillColor, + child: ToggleButtons( + isSelected: const [true], + onPressed: (int index) {}, + children: [ + Row(children: const [ + Text('First child'), + ]), + ], + ), + ), + ), + ), + ); + + final Material material = tester.firstWidget( + find.descendant( + of: find.byType(RawMaterialButton), + matching: find.byType(Material), + ), + ); + expect(material.color, customFillColor); + expect(material.type, MaterialType.button); + }); + + testWidgets('Theme InkWell colors', (WidgetTester tester) async { + const Color splashColor = Color(0xff4caf50); + const Color highlightColor = Color(0xffcddc39); + const Color hoverColor = Color(0xffffeb3b); + const Color focusColor = Color(0xffffff00); + final FocusNode focusNode = FocusNode(); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + splashColor: splashColor, + highlightColor: highlightColor, + hoverColor: hoverColor, + focusColor: focusColor, + child: ToggleButtons( + isSelected: const [true], + onPressed: (int index) {}, + focusNodes: [focusNode], + children: const [ + Text('First child'), + ], + ), + ), + ), + ), + ); + + final Offset center = tester.getCenter(find.text('First child')); + + // splashColor + // highlightColor + final TestGesture touchGesture = await tester.createGesture(); + await touchGesture.down(center); + await tester.pumpAndSettle(); + + RenderObject inkFeatures; + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect( + inkFeatures, + paints + ..circle(color: splashColor) + ..rect(color: highlightColor), + ); + + await touchGesture.up(); + await tester.pumpAndSettle(); + + // hoverColor + final TestGesture hoverGesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await hoverGesture.addPointer(); + await hoverGesture.moveTo(center); + await tester.pumpAndSettle(); + + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect(inkFeatures, paints..rect(color: hoverColor)); + await hoverGesture.removePointer(); + + // focusColor + focusNode.requestFocus(); + await tester.pumpAndSettle(); + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect(inkFeatures, paints..rect(color: focusColor)); + }); + + + testWidgets( + 'Theme border width and border colors for enabled, selected and disabled states', + (WidgetTester tester) async { + const Color borderColor = Color(0xff4caf50); + const Color selectedBorderColor = Color(0xffcddc39); + const Color disabledBorderColor = Color(0xffffeb3b); + const double customWidth = 2.0; + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + borderColor: borderColor, + borderWidth: customWidth, + child: ToggleButtons( + isSelected: const [false], + onPressed: (int index) {}, + children: const [ + Text('First child'), + ], + ), + ), + ), + ), + ); + + RenderObject toggleButtonRenderObject; + toggleButtonRenderObject = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }); + expect( + toggleButtonRenderObject, + paints + // trailing side + ..path( + style: PaintingStyle.stroke, + color: borderColor, + strokeWidth: customWidth, + ) + // leading side, top and bottom + ..path( + style: PaintingStyle.stroke, + color: borderColor, + strokeWidth: customWidth, + ), + ); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + selectedBorderColor: selectedBorderColor, + borderWidth: customWidth, + child: ToggleButtons( + isSelected: const [true], + onPressed: (int index) {}, + children: const [ + Text('First child'), + ], + ), + ), + ), + ), + ); + + toggleButtonRenderObject = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }); + expect( + toggleButtonRenderObject, + paints + // trailing side + ..path( + style: PaintingStyle.stroke, + color: selectedBorderColor, + strokeWidth: customWidth, + ) + // leading side, top and bottom + ..path( + style: PaintingStyle.stroke, + color: selectedBorderColor, + strokeWidth: customWidth, + ), + ); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + disabledBorderColor: disabledBorderColor, + borderWidth: customWidth, + child: ToggleButtons( + isSelected: const [false], + children: const [ + Text('First child'), + ], + ), + ), + ), + ), + ); + + toggleButtonRenderObject = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }); + expect( + toggleButtonRenderObject, + paints + // trailing side + ..path( + style: PaintingStyle.stroke, + color: disabledBorderColor, + strokeWidth: customWidth, + ) + // leading side, top and bottom + ..path( + style: PaintingStyle.stroke, + color: disabledBorderColor, + strokeWidth: customWidth, + ), + ); + }, + ); +} \ No newline at end of file