diff --git a/dev/bots/check_code_samples.dart b/dev/bots/check_code_samples.dart index 8d947f57a4d..41e4554649a 100644 --- a/dev/bots/check_code_samples.dart +++ b/dev/bots/check_code_samples.dart @@ -374,7 +374,6 @@ final Set _knownMissingTests = { 'examples/api/test/material/checkbox/checkbox.1_test.dart', 'examples/api/test/material/checkbox/checkbox.0_test.dart', 'examples/api/test/material/navigation_rail/navigation_rail.extended_animation.0_test.dart', - 'examples/api/test/material/text_button/text_button.0_test.dart', 'examples/api/test/rendering/growth_direction/growth_direction.0_test.dart', 'examples/api/test/rendering/sliver_grid/sliver_grid_delegate_with_fixed_cross_axis_count.0_test.dart', 'examples/api/test/rendering/sliver_grid/sliver_grid_delegate_with_fixed_cross_axis_count.1_test.dart', diff --git a/examples/api/lib/material/text_button/text_button.0.dart b/examples/api/lib/material/text_button/text_button.0.dart index 98fa6bc770e..0f7f0209289 100644 --- a/examples/api/lib/material/text_button/text_button.0.dart +++ b/examples/api/lib/material/text_button/text_button.0.dart @@ -6,78 +6,461 @@ import 'package:flutter/material.dart'; /// Flutter code sample for [TextButton]. -void main() => runApp(const TextButtonExampleApp()); +void main() { + runApp(const TextButtonExampleApp()); +} -class TextButtonExampleApp extends StatelessWidget { - const TextButtonExampleApp({super.key}); +class TextButtonExampleApp extends StatefulWidget { + const TextButtonExampleApp({ super.key }); + + @override + State createState() => _TextButtonExampleAppState(); +} + +class _TextButtonExampleAppState extends State { + bool darkMode = false; @override Widget build(BuildContext context) { return MaterialApp( + themeMode: darkMode ? ThemeMode.dark : ThemeMode.light, + theme: ThemeData(brightness: Brightness.light), + darkTheme: ThemeData(brightness: Brightness.dark), home: Scaffold( - appBar: AppBar(title: const Text('TextButton Sample')), - body: const TextButtonExample(), + body: Padding( + padding: const EdgeInsets.all(16), + child: TextButtonExample( + darkMode: darkMode, + updateDarkMode: (bool value) { + setState(() { darkMode = value; }); + }, + ), + ), ), ); } } -class TextButtonExample extends StatelessWidget { - const TextButtonExample({super.key}); +class TextButtonExample extends StatefulWidget { + const TextButtonExample({ super.key, required this.darkMode, required this.updateDarkMode }); + + final bool darkMode; + final ValueChanged updateDarkMode; + + @override + State createState() => _TextButtonExampleState(); +} + +class _TextButtonExampleState extends State { + TextDirection textDirection = TextDirection.ltr; + ThemeMode themeMode = ThemeMode.light; + late final ScrollController scrollController; + + static const Widget verticalSpacer = SizedBox(height: 16); + static const Widget horizontalSpacer = SizedBox(width: 32); + + @override + void initState() { + scrollController = ScrollController(); + super.initState(); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - style: TextButton.styleFrom( - textStyle: const TextStyle(fontSize: 20), + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + + // Adapt colors that are not part of the color scheme to + // the current dark/light mode. Used to define TextButton #7's + // gradients. + final (Color color1, Color color2, Color color3) = switch (colorScheme.brightness) { + Brightness.light => (Colors.blue.withOpacity(1.0), Colors.orange.withOpacity(1.0), Colors.yellow.withOpacity(1.0)), + Brightness.dark => (Colors.purple.withOpacity(1.0), Colors.cyan.withOpacity(1.0), Colors.yellow.withOpacity(1.0)), + }; + + // This gradient's appearance reflects the button's state. + // Always return a gradient decoration so that AnimatedContainer + // can interpolorate in between. Used by TextButton #7. + Decoration? statesToDecoration(Set states) { + if (states.contains(MaterialState.pressed)) { + return BoxDecoration( + gradient: LinearGradient(colors: [color2, color2]), // solid fill + ); + } + return BoxDecoration( + gradient: LinearGradient( + colors: switch (states.contains(MaterialState.hovered)) { + true => [color1, color2], + false => [color2, color1], + }, + ), + ); + } + + // To make this method a little easier to read, the buttons that + // appear in the two columns to the right of the demo switches + // Card are broken out below. + + final List columnOneButtons = [ + TextButton( + onPressed: () {}, + child: const Text('Enabled'), + ), + verticalSpacer, + + const TextButton( + onPressed: null, + child: Text('Disabled'), + ), + verticalSpacer, + + TextButton.icon( + onPressed: () {}, + icon: const Icon(Icons.access_alarm), + label: const Text('TextButton.icon #1'), + ), + verticalSpacer, + + // Override the foreground and background colors. + // + // In this example, and most of the ones that follow, we're using + // the TextButton.styleFrom() convenience method to create a ButtonStyle. + // The styleFrom method is a little easier because it creates + // ButtonStyle MaterialStateProperty parameters for you. + // In this case, Specifying foregroundColor overrides the text, + // icon and overlay (splash and highlight) colors a little differently + // depending on the button's state. BackgroundColor is just the background + // color for all states. + TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: colorScheme.onError, + backgroundColor: colorScheme.error, + ), + onPressed: () { }, + icon: const Icon(Icons.access_alarm), + label: const Text('TextButton.icon #2'), + ), + verticalSpacer, + + // Override the button's shape and its border. + // + // In this case we've specified a shape that has border - the + // RoundedRectangleBorder's side parameter. If the styleFrom + // side parameter was also specified, or if the TextButtonTheme + // defined above included a side parameter, then that would + // override the RoundedRectangleBorder's side. + TextButton( + style: TextButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(8)), + side: BorderSide( + color: colorScheme.primary, + width: 5, ), - onPressed: null, - child: const Text('Disabled'), ), - const SizedBox(height: 30), - TextButton( - style: TextButton.styleFrom( - textStyle: const TextStyle(fontSize: 20), - ), - onPressed: () {}, - child: const Text('Enabled'), - ), - const SizedBox(height: 30), - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Stack( - children: [ - Positioned.fill( - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [ - Color(0xFF0D47A1), - Color(0xFF1976D2), - Color(0xFF42A5F5), - ], - ), + ), + onPressed: () { }, + child: const Text('TextButton #3'), + ), + verticalSpacer, + + // Override overlay: the ink splash and highlight colors. + // + // The styleFrom method turns the specified overlayColor + // into a value MaterialStyleProperty ButtonStyle.overlay + // value that uses opacities depending on the button's state. + // If the overlayColor was Colors.transparent, no splash + // or highlights would be shown. + TextButton( + style: TextButton.styleFrom( + overlayColor: Colors.yellow, + ), + onPressed: () { }, + child: const Text('TextButton #4'), + ), + ]; + + final List columnTwoButtons = [ + // Override the foregroundBuilder: apply a ShaderMask. + // + // Apply a ShaderMask to the button's child. This kind of thing + // can be applied to one button easily enough by just wrapping the + // button's child directly. However to affect all buttons in this + // way you can specify a similar foregroundBuilder in a TextButton + // theme or the MaterialApp theme's ThemeData.textButtonTheme. + TextButton( + style: TextButton.styleFrom( + foregroundBuilder: (BuildContext context, Set states, Widget? child) { + return ShaderMask( + shaderCallback: (Rect bounds) { + return LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + colorScheme.primary, + colorScheme.onPrimary, + ], + ).createShader(bounds); + }, + blendMode: BlendMode.srcATop, + child: child, + ); + }, + ), + onPressed: () { }, + child: const Text('TextButton #5'), + ), + verticalSpacer, + + // Override the foregroundBuilder: add an underline. + // + // Add a border around button's child. In this case the + // border only appears when the button is hovered or pressed + // (if it's pressed it's always hovered too). Not that this + // border is different than the one specified with the styleFrom + // side parameter (or the ButtonStyle.side property). The foregroundBuilder + // is applied to a widget that contains the child and has already + // included the button's padding. It is unaffected by the button's shape. + // The styleFrom side parameter controls the button's outermost border and it + // outlines the button's shape. + TextButton( + style: TextButton.styleFrom( + foregroundBuilder: (BuildContext context, Set states, Widget? child) { + return DecoratedBox( + decoration: BoxDecoration( + border: states.contains(MaterialState.hovered) + ? Border(bottom: BorderSide(color: colorScheme.primary)) + : const Border(), // essentially "no border" + ), + child: child, + ); + }, + ), + onPressed: () { }, + child: const Text('TextButton #6'), + ), + verticalSpacer, + + // Override the backgroundBuilder to add a state specific gradient background + // and add an outline that only appears when the button is hovered or pressed. + // + // The gradient background decoration is computed by the statesToDecoration() + // method. The gradient flips horizontally when the button is hovered (watch + // closely). Because we want the outline to only appear when the button is hovered + // we can't use the styleFrom() side parameter, because that creates the same + // outline for all states. The ButtonStyle.copyWith() method is used to add + // a MaterialState property that does the right thing. + // + // The gradient background is translucent - all of the colors have opacity 0.5 - + // so the overlay's splash and highlight colors are visible even though they're + // drawn on the Material widget that's effectively behind the background. The + // border is also translucent, so if you look carefully, you'll see that the + // background - which is part of the button's Material but is drawn on top of the + // the background gradient - shows through the border. + TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + overlayColor: color2, + backgroundBuilder: (BuildContext context, Set states, Widget? child) { + return AnimatedContainer( + duration: const Duration(milliseconds: 500), + decoration: statesToDecoration(states), + child: child, + ); + }, + ).copyWith( + side: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.hovered)) { + return BorderSide(width: 3, color: color3); + } + return null; // defer to the default + }), + ), + child: const Text('TextButton #7'), + ), + verticalSpacer, + + // Override the backgroundBuilder to add a grass image background. + // + // The image is clipped to the button's shape. We've included an Ink widget + // because the background image is opaque and would otherwise obscure the splash + // and highlight overlays that are painted on the button's Material widget + // by default. They're drawn on the Ink widget instead. The foreground color + // was overridden as well because white shows up a little better on the mottled + // green background. + TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + foregroundColor: Colors.white, + backgroundBuilder: (BuildContext context, Set states, Widget? child) { + return Ink( + decoration: const BoxDecoration( + image: DecorationImage( + image: NetworkImage(grassUrl), + fit: BoxFit.cover, + ), + ), + child: child, + ); + }, + ), + child: const Text('TextButton #8'), + ), + verticalSpacer, + + // Override the foregroundBuilder to specify images for the button's pressed + // hovered and inactive states. + // + // This is an example of completely changing the default appearance of a button + // by specifying images for each state and by turning off the overlays by + // overlayColor: Colors.transparent. AnimatedContainer takes care of the + // fade in and out segues between images. + // + // This foregroundBuilder function ignores its child parameter. Unfortunately + // TextButton's child parameter is required, so we still have + // to provide one. + TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + overlayColor: Colors.transparent, + foregroundBuilder: (BuildContext context, Set states, Widget? child) { + String url = states.contains(MaterialState.hovered) ? smiley3Url : smiley1Url; + if (states.contains(MaterialState.pressed)) { + url = smiley2Url; + } + return AnimatedContainer( + width: 64, + height: 64, + duration: const Duration(milliseconds: 300), + curve: Curves.fastOutSlowIn, + decoration: BoxDecoration( + image: DecorationImage( + image: NetworkImage(url), + fit: BoxFit.contain, + ), + ), + ); + }, + ), + child: const Text('This child is not used'), + ), + ]; + + return Row( + children: [ + // The dark/light and LTR/RTL switches. We use the updateDarkMode function + // provided by the parent TextButtonExampleApp to rebuild the MaterialApp + // in the appropriate dark/light ThemeMdoe. The directionality of the rest + // of the UI is controlled by the Directionality widget below, and the + // textDirection local state variable. + TextButtonExampleSwitches( + darkMode: widget.darkMode, + updateDarkMode: widget.updateDarkMode, + textDirection: textDirection, + updateRTL: (bool value) { + setState(() { + textDirection = value ? TextDirection.rtl : TextDirection.ltr; + }); + }, + ), + horizontalSpacer, + + // All of the button examples appear below. They're arranged in two columns. + + Expanded( + child: Scrollbar( + controller: scrollController, + thumbVisibility: true, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: scrollController, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisSize: MainAxisSize.min, + children: [ + Directionality( + textDirection: textDirection, + child: Column( + children: columnOneButtons, ), ), - ), - TextButton( - style: TextButton.styleFrom( - foregroundColor: Colors.white, - padding: const EdgeInsets.all(16.0), - textStyle: const TextStyle(fontSize: 20), + horizontalSpacer, + + Directionality( + textDirection: textDirection, + child: Column( + children: columnTwoButtons + ), ), - onPressed: () {}, - child: const Text('Gradient'), - ), - ], + horizontalSpacer, + ], + ), ), ), - ], + ), + ], + ); + } +} + +class TextButtonExampleSwitches extends StatelessWidget { + const TextButtonExampleSwitches({ + super.key, + required this.darkMode, + required this.updateDarkMode, + required this.textDirection, + required this.updateRTL + }); + + final bool darkMode; + final ValueChanged updateDarkMode; + final TextDirection textDirection; + final ValueChanged updateRTL; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: IntrinsicWidth( + child: Column( + children: [ + Row( + children: [ + const Expanded(child: Text('Dark Mode')), + const SizedBox(width: 4), + Switch( + value: darkMode, + onChanged: updateDarkMode, + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + const Expanded(child: Text('RTL Text')), + const SizedBox(width: 4), + Switch( + value: textDirection == TextDirection.rtl, + onChanged: updateRTL, + ), + ], + ), + ], + ), + ), ), ); } } + +const String grassUrl = 'https://flutter.github.io/assets-for-api-docs/assets/material/text_button_grass.jpeg'; +const String smiley1Url = 'https://flutter.github.io/assets-for-api-docs/assets/material/text_button_smiley1.png'; +const String smiley2Url = 'https://flutter.github.io/assets-for-api-docs/assets/material/text_button_smiley2.png'; +const String smiley3Url = 'https://flutter.github.io/assets-for-api-docs/assets/material/text_button_smiley3.png'; diff --git a/examples/api/test/material/text_button/text_button.0_test.dart b/examples/api/test/material/text_button/text_button.0_test.dart new file mode 100644 index 00000000000..e6a13b9dc3a --- /dev/null +++ b/examples/api/test/material/text_button/text_button.0_test.dart @@ -0,0 +1,66 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/material/text_button/text_button.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + + // The app being tested loads images via HTTP which the test + // framework defeats by default. + setUpAll(() { + HttpOverrides.global = null; + }); + + testWidgets('TextButtonExample smoke test', (WidgetTester tester) async { + await tester.pumpWidget(const example.TextButtonExampleApp()); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'Enabled')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'Disabled')); + await tester.pumpAndSettle(); + + // TextButton.icon buttons are _TextButtonWithIcons rather than TextButtons. + // For the purposes of this test, just tapping in the right place is OK. + + await tester.tap(find.text('TextButton.icon #1')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('TextButton.icon #2')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'TextButton #3')); + await tester.pumpAndSettle(); + + + await tester.tap(find.widgetWithText(TextButton, 'TextButton #4')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'TextButton #5')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'TextButton #6')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'TextButton #7')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'TextButton #8')); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(TextButton).last); // Smiley image button + await tester.pumpAndSettle(); + + await tester.tap(find.byType(Switch).at(0)); // Dark Mode Switch + await tester.pumpAndSettle(); + + await tester.tap(find.byType(Switch).at(1)); // RTL Text Switch + await tester.pumpAndSettle(); + }); +} diff --git a/packages/flutter/lib/src/material/button_style.dart b/packages/flutter/lib/src/material/button_style.dart index 701071dff48..d2c7dffca00 100644 --- a/packages/flutter/lib/src/material/button_style.dart +++ b/packages/flutter/lib/src/material/button_style.dart @@ -16,6 +16,12 @@ import 'theme_data.dart'; // late BuildContext context; // typedef MyAppHome = Placeholder; +/// The type for [ButtonStyle.backgroundBuilder] and [ButtonStyle.foregroundBuilder]. +/// +/// The [states] parameter is the button's current pressed/hovered/etc state. The [child] is +/// typically a descendant of the returned widget. +typedef ButtonLayerBuilder = Widget Function(BuildContext context, Set states, Widget? child); + /// The visual properties that most buttons have in common. /// /// Buttons and their themes have a ButtonStyle property which defines the visual @@ -162,6 +168,8 @@ class ButtonStyle with Diagnosticable { this.enableFeedback, this.alignment, this.splashFactory, + this.backgroundBuilder, + this.foregroundBuilder, }); /// The style for a button's [Text] widget descendants. @@ -315,6 +323,42 @@ class ButtonStyle with Diagnosticable { /// ``` final InteractiveInkFeatureFactory? splashFactory; + /// Creates a widget that becomes the child of the button's [Material] + /// and whose child is the rest of the button, including the button's + /// `child` parameter. + /// + /// The widget created by [backgroundBuilder] is constrained to be + /// the same size as the overall button and will appear behind the + /// button's child. The widget created by [foregroundBuilder] is + /// constrained to be the same size as the button's child, i.e. it's + /// inset by [ButtonStyle.padding] and aligned by the button's + /// [ButtonStyle.alignment]. + /// + /// By default the returned widget is clipped to the Material's [ButtonStyle.shape]. + /// + /// See also: + /// + /// * [foregroundBuilder], to create a widget that's as big as the button's + /// child and is layered behind the child. + /// * [ButtonStyleButton.clipBehavior], for more information about + /// configuring clipping. + final ButtonLayerBuilder? backgroundBuilder; + + /// Creates a Widget that contains the button's child parameter which is used + /// instead of the button's child. + /// + /// The returned widget is clipped by the button's + /// [ButtonStyle.shape], inset by the button's [ButtonStyle.padding] + /// and aligned by the button's [ButtonStyle.alignment]. + /// + /// See also: + /// + /// * [backgroundBuilder], to create a widget that's as big as the button and + /// is layered behind the button's child. + /// * [ButtonStyleButton.clipBehavior], for more information about + /// configuring clipping. + final ButtonLayerBuilder? foregroundBuilder; + /// Returns a copy of this ButtonStyle with the given fields replaced with /// the new values. ButtonStyle copyWith({ @@ -340,6 +384,8 @@ class ButtonStyle with Diagnosticable { bool? enableFeedback, AlignmentGeometry? alignment, InteractiveInkFeatureFactory? splashFactory, + ButtonLayerBuilder? backgroundBuilder, + ButtonLayerBuilder? foregroundBuilder, }) { return ButtonStyle( textStyle: textStyle ?? this.textStyle, @@ -364,6 +410,8 @@ class ButtonStyle with Diagnosticable { enableFeedback: enableFeedback ?? this.enableFeedback, alignment: alignment ?? this.alignment, splashFactory: splashFactory ?? this.splashFactory, + backgroundBuilder: backgroundBuilder ?? this.backgroundBuilder, + foregroundBuilder: foregroundBuilder ?? this.foregroundBuilder, ); } @@ -399,6 +447,8 @@ class ButtonStyle with Diagnosticable { enableFeedback: enableFeedback ?? style.enableFeedback, alignment: alignment ?? style.alignment, splashFactory: splashFactory ?? style.splashFactory, + backgroundBuilder: backgroundBuilder ?? style.backgroundBuilder, + foregroundBuilder: foregroundBuilder ?? style.foregroundBuilder, ); } @@ -427,6 +477,8 @@ class ButtonStyle with Diagnosticable { enableFeedback, alignment, splashFactory, + backgroundBuilder, + foregroundBuilder, ]; return Object.hashAll(values); } @@ -461,7 +513,9 @@ class ButtonStyle with Diagnosticable { && other.animationDuration == animationDuration && other.enableFeedback == enableFeedback && other.alignment == alignment - && other.splashFactory == splashFactory; + && other.splashFactory == splashFactory + && other.backgroundBuilder == backgroundBuilder + && other.foregroundBuilder == foregroundBuilder; } @override @@ -488,6 +542,8 @@ class ButtonStyle with Diagnosticable { properties.add(DiagnosticsProperty('animationDuration', animationDuration, defaultValue: null)); properties.add(DiagnosticsProperty('enableFeedback', enableFeedback, defaultValue: null)); properties.add(DiagnosticsProperty('alignment', alignment, defaultValue: null)); + properties.add(DiagnosticsProperty('backgroundBuilder', backgroundBuilder, defaultValue: null)); + properties.add(DiagnosticsProperty('foregroundBuilder', foregroundBuilder, defaultValue: null)); } /// Linearly interpolate between two [ButtonStyle]s. @@ -518,6 +574,8 @@ class ButtonStyle with Diagnosticable { enableFeedback: t < 0.5 ? a?.enableFeedback : b?.enableFeedback, alignment: AlignmentGeometry.lerp(a?.alignment, b?.alignment, t), splashFactory: t < 0.5 ? a?.splashFactory : b?.splashFactory, + backgroundBuilder: t < 0.5 ? a?.backgroundBuilder : b?.backgroundBuilder, + foregroundBuilder: t < 0.5 ? a?.foregroundBuilder : b?.foregroundBuilder, ); } diff --git a/packages/flutter/lib/src/material/button_style_button.dart b/packages/flutter/lib/src/material/button_style_button.dart index e51d5a17172..5b35eb583cd 100644 --- a/packages/flutter/lib/src/material/button_style_button.dart +++ b/packages/flutter/lib/src/material/button_style_button.dart @@ -89,8 +89,10 @@ abstract class ButtonStyleButton extends StatefulWidget { /// {@macro flutter.material.Material.clipBehavior} /// - /// Defaults to [Clip.none]. - final Clip clipBehavior; + /// Defaults to [Clip.none] unless [ButtonStyle.backgroundBuilder] or + /// [ButtonStyle.foregroundBuilder] is specified. In those + /// cases the default is [Clip.antiAlias]. + final Clip? clipBehavior; /// {@macro flutter.widgets.Focus.focusNode} final FocusNode? focusNode; @@ -318,6 +320,11 @@ class _ButtonStyleState extends State with TickerProviderStat final AlignmentGeometry? resolvedAlignment = effectiveValue((ButtonStyle? style) => style?.alignment); final Offset densityAdjustment = resolvedVisualDensity!.baseSizeAdjustment; final InteractiveInkFeatureFactory? resolvedSplashFactory = effectiveValue((ButtonStyle? style) => style?.splashFactory); + final ButtonLayerBuilder? resolvedBackgroundBuilder = effectiveValue((ButtonStyle? style) => style?.backgroundBuilder); + final ButtonLayerBuilder? resolvedForegroundBuilder = effectiveValue((ButtonStyle? style) => style?.foregroundBuilder); + + final Clip effectiveClipBehavior = widget.clipBehavior + ?? ((resolvedBackgroundBuilder ?? resolvedForegroundBuilder) != null ? Clip.antiAlias : Clip.none); BoxConstraints effectiveConstraints = resolvedVisualDensity.effectiveConstraints( BoxConstraints( @@ -384,6 +391,21 @@ class _ButtonStyleState extends State with TickerProviderStat elevation = resolvedElevation; backgroundColor = resolvedBackgroundColor; + Widget effectiveChild = Padding( + padding: padding, + child: Align( + alignment: resolvedAlignment!, + widthFactor: 1.0, + heightFactor: 1.0, + child: resolvedForegroundBuilder != null + ? resolvedForegroundBuilder(context, statesController.value, widget.child) + : widget.child, + ), + ); + if (resolvedBackgroundBuilder != null) { + effectiveChild = resolvedBackgroundBuilder(context, statesController.value, effectiveChild); + } + final Widget result = ConstrainedBox( constraints: effectiveConstraints, child: Material( @@ -395,7 +417,7 @@ class _ButtonStyleState extends State with TickerProviderStat surfaceTintColor: resolvedSurfaceTintColor, type: resolvedBackgroundColor == null ? MaterialType.transparency : MaterialType.button, animationDuration: resolvedAnimationDuration, - clipBehavior: widget.clipBehavior, + clipBehavior: effectiveClipBehavior, child: InkWell( onTap: widget.onPressed, onLongPress: widget.onLongPress, @@ -413,15 +435,7 @@ class _ButtonStyleState extends State with TickerProviderStat statesController: statesController, child: IconTheme.merge( data: IconThemeData(color: resolvedIconColor ?? resolvedForegroundColor, size: resolvedIconSize), - child: Padding( - padding: padding, - child: Align( - alignment: resolvedAlignment!, - widthFactor: 1.0, - heightFactor: 1.0, - child: widget.child, - ), - ), + child: effectiveChild, ), ), ), diff --git a/packages/flutter/lib/src/material/elevated_button.dart b/packages/flutter/lib/src/material/elevated_button.dart index 1f83100ab0f..ae4828f687c 100644 --- a/packages/flutter/lib/src/material/elevated_button.dart +++ b/packages/flutter/lib/src/material/elevated_button.dart @@ -10,6 +10,7 @@ import 'package:flutter/widgets.dart'; import 'button_style.dart'; import 'button_style_button.dart'; import 'color_scheme.dart'; +import 'colors.dart'; import 'constants.dart'; import 'elevated_button_theme.dart'; import 'ink_ripple.dart'; @@ -70,7 +71,7 @@ class ElevatedButton extends ButtonStyleButton { super.style, super.focusNode, super.autofocus = false, - super.clipBehavior = Clip.none, + super.clipBehavior, super.statesController, required super.child, }); @@ -100,19 +101,26 @@ class ElevatedButton extends ButtonStyleButton { /// /// The [foregroundColor] and [disabledForegroundColor] colors are used /// to create a [MaterialStateProperty] [ButtonStyle.foregroundColor], and - /// a derived [ButtonStyle.overlayColor]. + /// a derived [ButtonStyle.overlayColor] if [overlayColor] isn't specified. + /// + /// If [overlayColor] is specified and its value is [Colors.transparent] + /// then the pressed/focused/hovered highlights are effectively defeated. + /// Otherwise a [MaterialStateProperty] with the same opacities as the + /// default is created. /// /// The [backgroundColor] and [disabledBackgroundColor] colors are /// used to create a [MaterialStateProperty] [ButtonStyle.backgroundColor]. /// + /// Similarly, the [enabledMouseCursor] and [disabledMouseCursor] + /// parameters are used to construct [ButtonStyle.mouseCursor] and + /// [iconColor], [disabledIconColor] are used to construct + /// [ButtonStyle.iconColor]. + /// /// The button's elevations are defined relative to the [elevation] /// parameter. The disabled elevation is the same as the parameter /// value, [elevation] + 2 is used when the button is hovered /// or focused, and elevation + 6 is used when the button is pressed. /// - /// Similarly, the [enabledMouseCursor] and [disabledMouseCursor] - /// parameters are used to construct [ButtonStyle].mouseCursor. - /// /// All of the other parameters are either used directly or used to /// create a [MaterialStateProperty] with a single value for all /// states. @@ -154,6 +162,9 @@ class ElevatedButton extends ButtonStyleButton { Color? disabledBackgroundColor, Color? shadowColor, Color? surfaceTintColor, + Color? iconColor, + Color? disabledIconColor, + Color? overlayColor, double? elevation, TextStyle? textStyle, EdgeInsetsGeometry? padding, @@ -170,32 +181,40 @@ class ElevatedButton extends ButtonStyleButton { bool? enableFeedback, AlignmentGeometry? alignment, InteractiveInkFeatureFactory? splashFactory, + ButtonLayerBuilder? backgroundBuilder, + ButtonLayerBuilder? foregroundBuilder, }) { - final Color? background = backgroundColor; - final Color? disabledBackground = disabledBackgroundColor; - final MaterialStateProperty? backgroundColorProp = (background == null && disabledBackground == null) - ? null - : _ElevatedButtonDefaultColor(background, disabledBackground); - final Color? foreground = foregroundColor; - final Color? disabledForeground = disabledForegroundColor; - final MaterialStateProperty? foregroundColorProp = (foreground == null && disabledForeground == null) - ? null - : _ElevatedButtonDefaultColor(foreground, disabledForeground); - final MaterialStateProperty? overlayColor = (foreground == null) - ? null - : _ElevatedButtonDefaultOverlay(foreground); - final MaterialStateProperty? elevationValue = (elevation == null) - ? null - : _ElevatedButtonDefaultElevation(elevation); + final MaterialStateProperty? foregroundColorProp = switch ((foregroundColor, disabledForegroundColor)) { + (null, null) => null, + (_, _) => _ElevatedButtonDefaultColor(foregroundColor, disabledForegroundColor), + }; + final MaterialStateProperty? backgroundColorProp = switch ((backgroundColor, disabledBackgroundColor)) { + (null, null) => null, + (_, _) => _ElevatedButtonDefaultColor(backgroundColor, disabledBackgroundColor), + }; + final MaterialStateProperty? iconColorProp = switch ((iconColor, disabledIconColor)) { + (null, null) => null, + (_, _) => _ElevatedButtonDefaultColor(iconColor, disabledIconColor), + }; + final MaterialStateProperty? overlayColorProp = switch ((foregroundColor, overlayColor)) { + (null, null) => null, + (_, final Color overlayColor) when overlayColor.value == 0 => const MaterialStatePropertyAll(Colors.transparent), + (_, _) => _ElevatedButtonDefaultOverlay((overlayColor ?? foregroundColor)!), + }; + final MaterialStateProperty? elevationValue = switch (elevation) { + null => null, + _ => _ElevatedButtonDefaultElevation(elevation), + }; final MaterialStateProperty mouseCursor = _ElevatedButtonDefaultMouseCursor(enabledMouseCursor, disabledMouseCursor); return ButtonStyle( textStyle: MaterialStatePropertyAll(textStyle), backgroundColor: backgroundColorProp, foregroundColor: foregroundColorProp, - overlayColor: overlayColor, + overlayColor: overlayColorProp, shadowColor: ButtonStyleButton.allOrNull(shadowColor), surfaceTintColor: ButtonStyleButton.allOrNull(surfaceTintColor), + iconColor: iconColorProp, elevation: elevationValue, padding: ButtonStyleButton.allOrNull(padding), minimumSize: ButtonStyleButton.allOrNull(minimumSize), @@ -210,6 +229,8 @@ class ElevatedButton extends ButtonStyleButton { enableFeedback: enableFeedback, alignment: alignment, splashFactory: splashFactory, + backgroundBuilder: backgroundBuilder, + foregroundBuilder: foregroundBuilder, ); } @@ -251,7 +272,7 @@ class ElevatedButton extends ButtonStyleButton { /// * others - Theme.colorScheme.onPrimary /// * `overlayColor` /// * hovered - Theme.colorScheme.onPrimary(0.08) - /// * focused or pressed - Theme.colorScheme.onPrimary(0.24) + /// * focused or pressed - Theme.colorScheme.onPrimary(0.12) /// * `shadowColor` - Theme.shadowColor /// * `elevation` /// * disabled - 0 @@ -475,13 +496,12 @@ class _ElevatedButtonWithIcon extends ElevatedButton { super.style, super.focusNode, bool? autofocus, - Clip? clipBehavior, + super.clipBehavior, super.statesController, required Widget icon, required Widget label, }) : super( autofocus: autofocus ?? false, - clipBehavior: clipBehavior ?? Clip.none, child: _ElevatedButtonWithIconChild(icon: icon, label: label, buttonStyle: style), ); diff --git a/packages/flutter/lib/src/material/filled_button.dart b/packages/flutter/lib/src/material/filled_button.dart index 09dc8d75750..651f9d42d83 100644 --- a/packages/flutter/lib/src/material/filled_button.dart +++ b/packages/flutter/lib/src/material/filled_button.dart @@ -153,19 +153,23 @@ class FilledButton extends ButtonStyleButton { /// A static convenience method that constructs a filled button /// [ButtonStyle] given simple values. /// - /// The [foregroundColor], and [disabledForegroundColor] colors are used to create a - /// [MaterialStateProperty] [ButtonStyle.foregroundColor] value. The - /// [backgroundColor] and [disabledBackgroundColor] are used to create a - /// [MaterialStateProperty] [ButtonStyle.backgroundColor] value. + /// The [foregroundColor] and [disabledForegroundColor] colors are used + /// to create a [MaterialStateProperty] [ButtonStyle.foregroundColor], and + /// a derived [ButtonStyle.overlayColor] if [overlayColor] isn't specified. + /// + /// If [overlayColor] is specified and its value is [Colors.transparent] + /// then the pressed/focused/hovered highlights are effectively defeated. + /// Otherwise a [MaterialStateProperty] with the same opacities as the + /// default is created. + /// + /// Similarly, the [enabledMouseCursor] and [disabledMouseCursor] + /// parameters are used to construct [ButtonStyle.mouseCursor]. /// /// The button's elevations are defined relative to the [elevation] /// parameter. The disabled elevation is the same as the parameter /// value, [elevation] + 2 is used when the button is hovered /// or focused, and elevation + 6 is used when the button is pressed. /// - /// Similarly, the [enabledMouseCursor] and [disabledMouseCursor] - /// parameters are used to construct [ButtonStyle.mouseCursor]. - /// /// All of the other parameters are either used directly or used to /// create a [MaterialStateProperty] with a single value for all /// states. @@ -201,6 +205,9 @@ class FilledButton extends ButtonStyleButton { Color? disabledBackgroundColor, Color? shadowColor, Color? surfaceTintColor, + Color? iconColor, + Color? disabledIconColor, + Color? overlayColor, double? elevation, TextStyle? textStyle, EdgeInsetsGeometry? padding, @@ -217,29 +224,36 @@ class FilledButton extends ButtonStyleButton { bool? enableFeedback, AlignmentGeometry? alignment, InteractiveInkFeatureFactory? splashFactory, + ButtonLayerBuilder? backgroundBuilder, + ButtonLayerBuilder? foregroundBuilder, }) { - final MaterialStateProperty? backgroundColorProp = - (backgroundColor == null && disabledBackgroundColor == null) - ? null - : _FilledButtonDefaultColor(backgroundColor, disabledBackgroundColor); - final Color? foreground = foregroundColor; - final Color? disabledForeground = disabledForegroundColor; - final MaterialStateProperty? foregroundColorProp = - (foreground == null && disabledForeground == null) - ? null - : _FilledButtonDefaultColor(foreground, disabledForeground); - final MaterialStateProperty? overlayColor = (foreground == null) - ? null - : _FilledButtonDefaultOverlay(foreground); + final MaterialStateProperty? foregroundColorProp = switch ((foregroundColor, disabledForegroundColor)) { + (null, null) => null, + (_, _) => _FilledButtonDefaultColor(foregroundColor, disabledForegroundColor), + }; + final MaterialStateProperty? backgroundColorProp = switch ((backgroundColor, disabledBackgroundColor)) { + (null, null) => null, + (_, _) => _FilledButtonDefaultColor(backgroundColor, disabledBackgroundColor), + }; + final MaterialStateProperty? iconColorProp = switch ((iconColor, disabledIconColor)) { + (null, null) => null, + (_, _) => _FilledButtonDefaultColor(iconColor, disabledIconColor), + }; + final MaterialStateProperty? overlayColorProp = switch ((foregroundColor, overlayColor)) { + (null, null) => null, + (_, final Color overlayColor) when overlayColor.value == 0 => const MaterialStatePropertyAll(Colors.transparent), + (_, _) => _FilledButtonDefaultOverlay((overlayColor ?? foregroundColor)!), + }; final MaterialStateProperty mouseCursor = _FilledButtonDefaultMouseCursor(enabledMouseCursor, disabledMouseCursor); return ButtonStyle( textStyle: MaterialStatePropertyAll(textStyle), backgroundColor: backgroundColorProp, foregroundColor: foregroundColorProp, - overlayColor: overlayColor, + overlayColor: overlayColorProp, shadowColor: ButtonStyleButton.allOrNull(shadowColor), surfaceTintColor: ButtonStyleButton.allOrNull(surfaceTintColor), + iconColor: iconColorProp, elevation: ButtonStyleButton.allOrNull(elevation), padding: ButtonStyleButton.allOrNull(padding), minimumSize: ButtonStyleButton.allOrNull(minimumSize), @@ -254,6 +268,8 @@ class FilledButton extends ButtonStyleButton { enableFeedback: enableFeedback, alignment: alignment, splashFactory: splashFactory, + backgroundBuilder: backgroundBuilder, + foregroundBuilder: foregroundBuilder, ); } @@ -468,14 +484,13 @@ class _FilledButtonWithIcon extends FilledButton { super.style, super.focusNode, bool? autofocus, - Clip? clipBehavior, + super.clipBehavior, super.statesController, required Widget icon, required Widget label, }) : super( autofocus: autofocus ?? false, - clipBehavior: clipBehavior ?? Clip.none, - child: _FilledButtonWithIconChild(icon: icon, label: label, buttonStyle: style), + child: _FilledButtonWithIconChild(icon: icon, label: label, buttonStyle: style) ); _FilledButtonWithIcon.tonal({ @@ -487,14 +502,13 @@ class _FilledButtonWithIcon extends FilledButton { super.style, super.focusNode, bool? autofocus, - Clip? clipBehavior, + super.clipBehavior, super.statesController, required Widget icon, required Widget label, }) : super.tonal( autofocus: autofocus ?? false, - clipBehavior: clipBehavior ?? Clip.none, - child: _FilledButtonWithIconChild(icon: icon, label: label, buttonStyle: style), + child: _FilledButtonWithIconChild(icon: icon, label: label, buttonStyle: style) ); @override diff --git a/packages/flutter/lib/src/material/material_state.dart b/packages/flutter/lib/src/material/material_state.dart index 59d5dfcb71e..c1d23d622c7 100644 --- a/packages/flutter/lib/src/material/material_state.dart +++ b/packages/flutter/lib/src/material/material_state.dart @@ -168,6 +168,9 @@ abstract class MaterialStateColor extends Color implements MaterialStateProperty /// specified state. @override Color resolve(Set states); + + /// A constant whose value is [Colors.transparent] for all states. + static const MaterialStateColor transparent = _MaterialStateColorTransparent(); } /// A [MaterialStateColor] created from a [MaterialPropertyResolver] @@ -189,6 +192,13 @@ class _MaterialStateColor extends MaterialStateColor { Color resolve(Set states) => _resolve(states); } +class _MaterialStateColorTransparent extends MaterialStateColor { + const _MaterialStateColorTransparent() : super(0x00000000); + + @override + Color resolve(Set states) => const Color(0x00000000); +} + /// Defines a [MouseCursor] whose value depends on a set of [MaterialState]s which /// represent the interactive state of a component. /// diff --git a/packages/flutter/lib/src/material/outlined_button.dart b/packages/flutter/lib/src/material/outlined_button.dart index e467e1b312b..2f805f8d70d 100644 --- a/packages/flutter/lib/src/material/outlined_button.dart +++ b/packages/flutter/lib/src/material/outlined_button.dart @@ -75,7 +75,7 @@ class OutlinedButton extends ButtonStyleButton { super.style, super.focusNode, super.autofocus = false, - super.clipBehavior = Clip.none, + super.clipBehavior, super.statesController, required super.child, }); @@ -101,16 +101,22 @@ class OutlinedButton extends ButtonStyleButton { /// A static convenience method that constructs an outlined button /// [ButtonStyle] given simple values. /// - /// /// The [foregroundColor] and [disabledForegroundColor] colors are used /// to create a [MaterialStateProperty] [ButtonStyle.foregroundColor], and - /// a derived [ButtonStyle.overlayColor]. + /// a derived [ButtonStyle.overlayColor] if [overlayColor] isn't specified. /// /// The [backgroundColor] and [disabledBackgroundColor] colors are /// used to create a [MaterialStateProperty] [ButtonStyle.backgroundColor]. /// /// Similarly, the [enabledMouseCursor] and [disabledMouseCursor] - /// parameters are used to construct [ButtonStyle.mouseCursor]. + /// parameters are used to construct [ButtonStyle.mouseCursor] and + /// [iconColor], [disabledIconColor] are used to construct + /// [ButtonStyle.iconColor]. + /// + /// If [overlayColor] is specified and its value is [Colors.transparent] + /// then the pressed/focused/hovered highlights are effectively defeated. + /// Otherwise a [MaterialStateProperty] with the same opacities as the + /// default is created. /// /// All of the other parameters are either used directly or used to /// create a [MaterialStateProperty] with a single value for all @@ -141,6 +147,9 @@ class OutlinedButton extends ButtonStyleButton { Color? disabledBackgroundColor, Color? shadowColor, Color? surfaceTintColor, + Color? iconColor, + Color? disabledIconColor, + Color? overlayColor, double? elevation, TextStyle? textStyle, EdgeInsetsGeometry? padding, @@ -157,29 +166,36 @@ class OutlinedButton extends ButtonStyleButton { bool? enableFeedback, AlignmentGeometry? alignment, InteractiveInkFeatureFactory? splashFactory, + ButtonLayerBuilder? backgroundBuilder, + ButtonLayerBuilder? foregroundBuilder, }) { - final Color? foreground = foregroundColor; - final Color? disabledForeground = disabledForegroundColor; - final MaterialStateProperty? foregroundColorProp = (foreground == null && disabledForeground == null) - ? null - : _OutlinedButtonDefaultColor(foreground, disabledForeground); - final MaterialStateProperty? backgroundColorProp = (backgroundColor == null && disabledBackgroundColor == null) - ? null - : disabledBackgroundColor == null - ? ButtonStyleButton.allOrNull(backgroundColor) - : _OutlinedButtonDefaultColor(backgroundColor, disabledBackgroundColor); - final MaterialStateProperty? overlayColor = (foreground == null) - ? null - : _OutlinedButtonDefaultOverlay(foreground); + final MaterialStateProperty? foregroundColorProp = switch ((foregroundColor, disabledForegroundColor)) { + (null, null) => null, + (_, _) => _OutlinedButtonDefaultColor(foregroundColor, disabledForegroundColor), + }; + final MaterialStateProperty? backgroundColorProp = switch ((backgroundColor, disabledBackgroundColor)) { + (null, null) => null, + (_, _) => _OutlinedButtonDefaultColor(backgroundColor, disabledBackgroundColor), + }; + final MaterialStateProperty? iconColorProp = switch ((iconColor, disabledIconColor)) { + (null, null) => null, + (_, _) => _OutlinedButtonDefaultColor(iconColor, disabledIconColor), + }; + final MaterialStateProperty? overlayColorProp = switch ((foregroundColor, overlayColor)) { + (null, null) => null, + (_, final Color overlayColor) when overlayColor.value == 0 => const MaterialStatePropertyAll(Colors.transparent), + (_, _) => _OutlinedButtonDefaultOverlay((overlayColor ?? foregroundColor)!), + }; final MaterialStateProperty mouseCursor = _OutlinedButtonDefaultMouseCursor(enabledMouseCursor, disabledMouseCursor); return ButtonStyle( textStyle: ButtonStyleButton.allOrNull(textStyle), foregroundColor: foregroundColorProp, backgroundColor: backgroundColorProp, - overlayColor: overlayColor, + overlayColor: overlayColorProp, shadowColor: ButtonStyleButton.allOrNull(shadowColor), surfaceTintColor: ButtonStyleButton.allOrNull(surfaceTintColor), + iconColor: iconColorProp, elevation: ButtonStyleButton.allOrNull(elevation), padding: ButtonStyleButton.allOrNull(padding), minimumSize: ButtonStyleButton.allOrNull(minimumSize), @@ -194,6 +210,8 @@ class OutlinedButton extends ButtonStyleButton { enableFeedback: enableFeedback, alignment: alignment, splashFactory: splashFactory, + backgroundBuilder: backgroundBuilder, + foregroundBuilder: foregroundBuilder, ); } @@ -228,7 +246,7 @@ class OutlinedButton extends ButtonStyleButton { /// * disabled - Theme.colorScheme.onSurface(0.38) /// * others - Theme.colorScheme.primary /// * `overlayColor` - /// * hovered - Theme.colorScheme.primary(0.04) + /// * hovered - Theme.colorScheme.primary(0.08) /// * focused or pressed - Theme.colorScheme.primary(0.12) /// * `shadowColor` - Theme.shadowColor /// * `elevation` - 0 @@ -311,9 +329,7 @@ class OutlinedButton extends ButtonStyleButton { padding: _scaledPadding(context), minimumSize: const Size(64, 36), maximumSize: Size.infinite, - side: BorderSide( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.12), - ), + side: BorderSide(color: colorScheme.onSurface.withOpacity(0.12)), shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4))), enabledMouseCursor: SystemMouseCursors.click, disabledMouseCursor: SystemMouseCursors.basic, @@ -406,13 +422,12 @@ class _OutlinedButtonWithIcon extends OutlinedButton { super.style, super.focusNode, bool? autofocus, - Clip? clipBehavior, + super.clipBehavior, super.statesController, required Widget icon, required Widget label, }) : super( autofocus: autofocus ?? false, - clipBehavior: clipBehavior ?? Clip.none, child: _OutlinedButtonWithIconChild(icon: icon, label: label, buttonStyle: style), ); diff --git a/packages/flutter/lib/src/material/text_button.dart b/packages/flutter/lib/src/material/text_button.dart index 3120989810d..229ca51335b 100644 --- a/packages/flutter/lib/src/material/text_button.dart +++ b/packages/flutter/lib/src/material/text_button.dart @@ -50,8 +50,9 @@ import 'theme_data.dart'; /// button will be disabled, it will not react to touch. /// /// {@tool dartpad} -/// This sample shows how to render a disabled TextButton, an enabled TextButton -/// and lastly a TextButton with gradient background. +/// This sample shows various ways to configure TextButtons, from the +/// simplest default appearance to versions that don't resemble +/// Material Design at all. /// /// ** See code in examples/api/lib/material/text_button/text_button.0.dart ** /// {@end-tool} @@ -82,7 +83,7 @@ class TextButton extends ButtonStyleButton { super.style, super.focusNode, super.autofocus = false, - super.clipBehavior = Clip.none, + super.clipBehavior, super.statesController, super.isSemanticButton, required Widget super.child, @@ -113,13 +114,20 @@ class TextButton extends ButtonStyleButton { /// /// The [foregroundColor] and [disabledForegroundColor] colors are used /// to create a [MaterialStateProperty] [ButtonStyle.foregroundColor], and - /// a derived [ButtonStyle.overlayColor]. + /// a derived [ButtonStyle.overlayColor] if [overlayColor] isn't specified. /// /// The [backgroundColor] and [disabledBackgroundColor] colors are /// used to create a [MaterialStateProperty] [ButtonStyle.backgroundColor]. /// /// Similarly, the [enabledMouseCursor] and [disabledMouseCursor] - /// parameters are used to construct [ButtonStyle.mouseCursor]. + /// parameters are used to construct [ButtonStyle.mouseCursor] and + /// [iconColor], [disabledIconColor] are used to construct + /// [ButtonStyle.iconColor]. + /// + /// If [overlayColor] is specified and its value is [Colors.transparent] + /// then the pressed/focused/hovered highlights are effectively defeated. + /// Otherwise a [MaterialStateProperty] with the same opacities as the + /// default is created. /// /// All of the other parameters are either used directly or used to /// create a [MaterialStateProperty] with a single value for all @@ -151,6 +159,7 @@ class TextButton extends ButtonStyleButton { Color? surfaceTintColor, Color? iconColor, Color? disabledIconColor, + Color? overlayColor, double? elevation, TextStyle? textStyle, EdgeInsetsGeometry? padding, @@ -167,32 +176,33 @@ class TextButton extends ButtonStyleButton { bool? enableFeedback, AlignmentGeometry? alignment, InteractiveInkFeatureFactory? splashFactory, + ButtonLayerBuilder? backgroundBuilder, + ButtonLayerBuilder? foregroundBuilder, }) { - final Color? foreground = foregroundColor; - final Color? disabledForeground = disabledForegroundColor; - final MaterialStateProperty? foregroundColorProp = (foreground == null && disabledForeground == null) - ? null - : _TextButtonDefaultColor(foreground, disabledForeground); - final MaterialStateProperty? backgroundColorProp = (backgroundColor == null && disabledBackgroundColor == null) - ? null - : disabledBackgroundColor == null - ? ButtonStyleButton.allOrNull(backgroundColor) - : _TextButtonDefaultColor(backgroundColor, disabledBackgroundColor); - final MaterialStateProperty? overlayColor = (foreground == null) - ? null - : _TextButtonDefaultOverlay(foreground); - final MaterialStateProperty? iconColorProp = (iconColor == null && disabledIconColor == null) - ? null - : disabledIconColor == null - ? ButtonStyleButton.allOrNull(iconColor) - : _TextButtonDefaultIconColor(iconColor, disabledIconColor); + final MaterialStateProperty? foregroundColorProp = switch ((foregroundColor, disabledForegroundColor)) { + (null, null) => null, + (_, _) => _TextButtonDefaultColor(foregroundColor, disabledForegroundColor), + }; + final MaterialStateProperty? backgroundColorProp = switch ((backgroundColor, disabledBackgroundColor)) { + (null, null) => null, + (_, _) => _TextButtonDefaultColor(backgroundColor, disabledBackgroundColor), + }; + final MaterialStateProperty? iconColorProp = switch ((iconColor, disabledIconColor)) { + (null, null) => null, + (_, _) => _TextButtonDefaultColor(iconColor, disabledIconColor), + }; + final MaterialStateProperty? overlayColorProp = switch ((foregroundColor, overlayColor)) { + (null, null) => null, + (_, final Color overlayColor) when overlayColor.value == 0 => const MaterialStatePropertyAll(Colors.transparent), + (_, _) => _TextButtonDefaultOverlay((overlayColor ?? foregroundColor)!), + }; final MaterialStateProperty mouseCursor = _TextButtonDefaultMouseCursor(enabledMouseCursor, disabledMouseCursor); return ButtonStyle( textStyle: ButtonStyleButton.allOrNull(textStyle), foregroundColor: foregroundColorProp, backgroundColor: backgroundColorProp, - overlayColor: overlayColor, + overlayColor: overlayColorProp, shadowColor: ButtonStyleButton.allOrNull(shadowColor), surfaceTintColor: ButtonStyleButton.allOrNull(surfaceTintColor), iconColor: iconColorProp, @@ -210,6 +220,8 @@ class TextButton extends ButtonStyleButton { enableFeedback: enableFeedback, alignment: alignment, splashFactory: splashFactory, + backgroundBuilder: backgroundBuilder, + foregroundBuilder: foregroundBuilder, ); } @@ -250,7 +262,7 @@ class TextButton extends ButtonStyleButton { /// * disabled - Theme.colorScheme.onSurface(0.38) /// * others - Theme.colorScheme.primary /// * `overlayColor` - /// * hovered - Theme.colorScheme.primary(0.04) + /// * hovered - Theme.colorScheme.primary(0.08) /// * focused or pressed - Theme.colorScheme.primary(0.12) /// * `shadowColor` - Theme.shadowColor /// * `elevation` - 0 @@ -424,27 +436,6 @@ class _TextButtonDefaultOverlay extends MaterialStateProperty { } } -@immutable -class _TextButtonDefaultIconColor extends MaterialStateProperty { - _TextButtonDefaultIconColor(this.iconColor, this.disabledIconColor); - - final Color? iconColor; - final Color? disabledIconColor; - - @override - Color? resolve(Set states) { - if (states.contains(MaterialState.disabled)) { - return disabledIconColor; - } - return iconColor; - } - - @override - String toString() { - return '{disabled: $disabledIconColor, color: $iconColor}'; - } -} - @immutable class _TextButtonDefaultMouseCursor extends MaterialStateProperty with Diagnosticable { _TextButtonDefaultMouseCursor(this.enabledCursor, this.disabledCursor); @@ -471,13 +462,12 @@ class _TextButtonWithIcon extends TextButton { super.style, super.focusNode, bool? autofocus, - Clip? clipBehavior, + super.clipBehavior, super.statesController, required Widget icon, required Widget label, }) : super( autofocus: autofocus ?? false, - clipBehavior: clipBehavior ?? Clip.none, child: _TextButtonWithIconChild(icon: icon, label: label, buttonStyle: style), ); diff --git a/packages/flutter/test/material/button_style_test.dart b/packages/flutter/test/material/button_style_test.dart index 2244c2940a7..de0c3558442 100644 --- a/packages/flutter/test/material/button_style_test.dart +++ b/packages/flutter/test/material/button_style_test.dart @@ -42,6 +42,8 @@ void main() { expect(style.tapTargetSize, null); expect(style.animationDuration, null); expect(style.enableFeedback, null); + expect(style.backgroundBuilder, null); + expect(style.foregroundBuilder, null); }); testWidgets('Default ButtonStyle debugFillProperties', (WidgetTester tester) async { @@ -107,6 +109,9 @@ void main() { }); testWidgets('ButtonStyle copyWith, merge', (WidgetTester tester) async { + Widget backgroundBuilder(BuildContext context, Set states, Widget? child) => child!; + Widget foregroundBuilder(BuildContext context, Set states, Widget? child) => child!; + const MaterialStateProperty textStyle = MaterialStatePropertyAll(TextStyle(fontSize: 10)); const MaterialStateProperty backgroundColor = MaterialStatePropertyAll(Color(0xfffffff1)); const MaterialStateProperty foregroundColor = MaterialStatePropertyAll(Color(0xfffffff2)); @@ -128,7 +133,7 @@ void main() { const Duration animationDuration = Duration(seconds: 1); const bool enableFeedback = true; - const ButtonStyle style = ButtonStyle( + final ButtonStyle style = ButtonStyle( textStyle: textStyle, backgroundColor: backgroundColor, foregroundColor: foregroundColor, @@ -149,6 +154,8 @@ void main() { tapTargetSize: tapTargetSize, animationDuration: animationDuration, enableFeedback: enableFeedback, + backgroundBuilder: backgroundBuilder, + foregroundBuilder: foregroundBuilder, ); expect( @@ -174,6 +181,8 @@ void main() { tapTargetSize: tapTargetSize, animationDuration: animationDuration, enableFeedback: enableFeedback, + backgroundBuilder: backgroundBuilder, + foregroundBuilder:foregroundBuilder, ), ); diff --git a/packages/flutter/test/material/elevated_button_test.dart b/packages/flutter/test/material/elevated_button_test.dart index 86ea05da5dd..44e3e1d50ec 100644 --- a/packages/flutter/test/material/elevated_button_test.dart +++ b/packages/flutter/test/material/elevated_button_test.dart @@ -1942,6 +1942,203 @@ void main() { expect(controller.value, {MaterialState.disabled}); expect(count, 1); }); + + testWidgets('ElevatedButton backgroundBuilder and foregroundBuilder', (WidgetTester tester) async { + const Color backgroundColor = Color(0xFF000011); + const Color foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundBuilder: (BuildContext context, Set states, Widget? child) { + return DecoratedBox( + decoration: const BoxDecoration( + color: backgroundColor, + ), + child: child, + ); + }, + foregroundBuilder: (BuildContext context, Set states, Widget? child) { + return DecoratedBox( + decoration: const BoxDecoration( + color: foregroundColor, + ), + child: child, + ); + }, + ), + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + BoxDecoration boxDecorationOf(Finder finder) { + return tester.widget(finder).decoration as BoxDecoration; + } + + final Finder decorations = find.descendant( + of: find.byType(ElevatedButton), + matching: find.byType(DecoratedBox), + ); + + expect(boxDecorationOf(decorations.at(0)).color, backgroundColor); + expect(boxDecorationOf(decorations.at(1)).color, foregroundColor); + + Text textChildOf(Finder finder) { + return tester.widget( + find.descendant( + of: finder, + matching: find.byType(Text), + ), + ); + } + + expect(textChildOf(decorations.at(0)).data, 'button'); + expect(textChildOf(decorations.at(1)).data, 'button'); + }); + + + testWidgets('ElevatedButton backgroundBuilder drops button child and foregroundBuilder return value', (WidgetTester tester) async { + const Color backgroundColor = Color(0xFF000011); + const Color foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundBuilder: (BuildContext context, Set states, Widget? child) { + return const DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + ), + ); + }, + foregroundBuilder: (BuildContext context, Set states, Widget? child) { + return const DecoratedBox( + decoration: BoxDecoration( + color: foregroundColor, + ), + ); + }, + ), + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + final Finder background = find.descendant( + of: find.byType(ElevatedButton), + matching: find.byType(DecoratedBox), + ); + + expect(background, findsOneWidget); + expect(find.text('button'), findsNothing); + }); + + testWidgets('ElevatedButton foregroundBuilder drops button child', (WidgetTester tester) async { + const Color foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + foregroundBuilder: (BuildContext context, Set states, Widget? child) { + return const DecoratedBox( + decoration: BoxDecoration( + color: foregroundColor, + ), + ); + }, + ), + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + final Finder foreground = find.descendant( + of: find.byType(ElevatedButton), + matching: find.byType(DecoratedBox), + ); + + expect(foreground, findsOneWidget); + expect(find.text('button'), findsNothing); + }); + + testWidgets('ElevatedButton foreground and background builders are applied to the correct states', (WidgetTester tester) async { + Set foregroundStates = {}; + Set backgroundStates = {}; + final FocusNode focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: ElevatedButton( + style: ButtonStyle( + backgroundBuilder: (BuildContext context, Set states, Widget? child) { + backgroundStates = states; + return child!; + }, + foregroundBuilder: (BuildContext context, Set states, Widget? child) { + foregroundStates = states; + return child!; + }, + ), + onPressed: () {}, + focusNode: focusNode, + child: const Text('button'), + ), + ), + ), + ), + ); + + // Default. + expect(backgroundStates.isEmpty, isTrue); + expect(foregroundStates.isEmpty, isTrue); + + const Set focusedStates = {MaterialState.focused}; + const Set focusedHoveredStates = {MaterialState.focused, MaterialState.hovered}; + const Set focusedHoveredPressedStates = {MaterialState.focused, MaterialState.hovered, MaterialState.pressed}; + + bool sameStates(Set expectedValue, Set actualValue) { + return expectedValue.difference(actualValue).isEmpty && actualValue.difference(expectedValue).isEmpty; + } + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(sameStates(focusedStates, backgroundStates), isTrue); + expect(sameStates(focusedStates, foregroundStates), isTrue); + + // Hovered. + final Offset center = tester.getCenter(find.byType(ElevatedButton)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(sameStates(focusedHoveredStates, backgroundStates), isTrue); + expect(sameStates(focusedHoveredStates, foregroundStates), isTrue); + + + // Highlighted (pressed). + await gesture.down(center); + await tester.pump(); // Start the splash and highlight animations. + await tester.pump(const Duration(milliseconds: 800)); // Wait for splash and highlight to be well under way. + expect(sameStates(focusedHoveredPressedStates, backgroundStates), isTrue); + expect(sameStates(focusedHoveredPressedStates, foregroundStates), isTrue); + + focusNode.dispose(); + }); } TextStyle _iconStyle(WidgetTester tester, IconData icon) { diff --git a/packages/flutter/test/material/filled_button_test.dart b/packages/flutter/test/material/filled_button_test.dart index 0a136b4bde1..19b69e0d4e6 100644 --- a/packages/flutter/test/material/filled_button_test.dart +++ b/packages/flutter/test/material/filled_button_test.dart @@ -2014,6 +2014,202 @@ void main() { expect(count, 1); }); + testWidgets('FilledButton backgroundBuilder and foregroundBuilder', (WidgetTester tester) async { + const Color backgroundColor = Color(0xFF000011); + const Color foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundBuilder: (BuildContext context, Set states, Widget? child) { + return DecoratedBox( + decoration: const BoxDecoration( + color: backgroundColor, + ), + child: child, + ); + }, + foregroundBuilder: (BuildContext context, Set states, Widget? child) { + return DecoratedBox( + decoration: const BoxDecoration( + color: foregroundColor, + ), + child: child, + ); + }, + ), + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + BoxDecoration boxDecorationOf(Finder finder) { + return tester.widget(finder).decoration as BoxDecoration; + } + + final Finder decorations = find.descendant( + of: find.byType(FilledButton), + matching: find.byType(DecoratedBox), + ); + + expect(boxDecorationOf(decorations.at(0)).color, backgroundColor); + expect(boxDecorationOf(decorations.at(1)).color, foregroundColor); + + Text textChildOf(Finder finder) { + return tester.widget( + find.descendant( + of: finder, + matching: find.byType(Text), + ), + ); + } + + expect(textChildOf(decorations.at(0)).data, 'button'); + expect(textChildOf(decorations.at(1)).data, 'button'); + }); + + + testWidgets('FilledButton backgroundBuilder drops button child and foregroundBuilder return value', (WidgetTester tester) async { + const Color backgroundColor = Color(0xFF000011); + const Color foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundBuilder: (BuildContext context, Set states, Widget? child) { + return const DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + ), + ); + }, + foregroundBuilder: (BuildContext context, Set states, Widget? child) { + return const DecoratedBox( + decoration: BoxDecoration( + color: foregroundColor, + ), + ); + }, + ), + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + final Finder background = find.descendant( + of: find.byType(FilledButton), + matching: find.byType(DecoratedBox), + ); + + expect(background, findsOneWidget); + expect(find.text('button'), findsNothing); + }); + + testWidgets('FilledButton foregroundBuilder drops button child', (WidgetTester tester) async { + const Color foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FilledButton( + style: FilledButton.styleFrom( + foregroundBuilder: (BuildContext context, Set states, Widget? child) { + return const DecoratedBox( + decoration: BoxDecoration( + color: foregroundColor, + ), + ); + }, + ), + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + final Finder foreground = find.descendant( + of: find.byType(FilledButton), + matching: find.byType(DecoratedBox), + ); + + expect(foreground, findsOneWidget); + expect(find.text('button'), findsNothing); + }); + + testWidgets('FilledButton foreground and background builders are applied to the correct states', (WidgetTester tester) async { + Set foregroundStates = {}; + Set backgroundStates = {}; + final FocusNode focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: FilledButton( + style: ButtonStyle( + backgroundBuilder: (BuildContext context, Set states, Widget? child) { + backgroundStates = states; + return child!; + }, + foregroundBuilder: (BuildContext context, Set states, Widget? child) { + foregroundStates = states; + return child!; + }, + ), + onPressed: () {}, + focusNode: focusNode, + child: const Text('button'), + ), + ), + ), + ), + ); + + // Default. + expect(backgroundStates.isEmpty, isTrue); + expect(foregroundStates.isEmpty, isTrue); + + const Set focusedStates = {MaterialState.focused}; + const Set focusedHoveredStates = {MaterialState.focused, MaterialState.hovered}; + const Set focusedHoveredPressedStates = {MaterialState.focused, MaterialState.hovered, MaterialState.pressed}; + + bool sameStates(Set expectedValue, Set actualValue) { + return expectedValue.difference(actualValue).isEmpty && actualValue.difference(expectedValue).isEmpty; + } + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(sameStates(focusedStates, backgroundStates), isTrue); + expect(sameStates(focusedStates, foregroundStates), isTrue); + + // Hovered. + final Offset center = tester.getCenter(find.byType(FilledButton)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(sameStates(focusedHoveredStates, backgroundStates), isTrue); + expect(sameStates(focusedHoveredStates, foregroundStates), isTrue); + + + // Highlighted (pressed). + await gesture.down(center); + await tester.pump(); // Start the splash and highlight animations. + await tester.pump(const Duration(milliseconds: 800)); // Wait for splash and highlight to be well under way. + expect(sameStates(focusedHoveredPressedStates, backgroundStates), isTrue); + expect(sameStates(focusedHoveredPressedStates, foregroundStates), isTrue); + + focusNode.dispose(); + }); } TextStyle _iconStyle(WidgetTester tester, IconData icon) { diff --git a/packages/flutter/test/material/outlined_button_test.dart b/packages/flutter/test/material/outlined_button_test.dart index 588b09bd4b1..301a0d3aa7a 100644 --- a/packages/flutter/test/material/outlined_button_test.dart +++ b/packages/flutter/test/material/outlined_button_test.dart @@ -2092,6 +2092,203 @@ void main() { expect(tester.takeException(), isNull); }); + + testWidgets('OutlinedButton backgroundBuilder and foregroundBuilder', (WidgetTester tester) async { + const Color backgroundColor = Color(0xFF000011); + const Color foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + backgroundBuilder: (BuildContext context, Set states, Widget? child) { + return DecoratedBox( + decoration: const BoxDecoration( + color: backgroundColor, + ), + child: child, + ); + }, + foregroundBuilder: (BuildContext context, Set states, Widget? child) { + return DecoratedBox( + decoration: const BoxDecoration( + color: foregroundColor, + ), + child: child, + ); + }, + ), + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + BoxDecoration boxDecorationOf(Finder finder) { + return tester.widget(finder).decoration as BoxDecoration; + } + + final Finder decorations = find.descendant( + of: find.byType(OutlinedButton), + matching: find.byType(DecoratedBox), + ); + + expect(boxDecorationOf(decorations.at(0)).color, backgroundColor); + expect(boxDecorationOf(decorations.at(1)).color, foregroundColor); + + Text textChildOf(Finder finder) { + return tester.widget( + find.descendant( + of: finder, + matching: find.byType(Text), + ), + ); + } + + expect(textChildOf(decorations.at(0)).data, 'button'); + expect(textChildOf(decorations.at(1)).data, 'button'); + }); + + + testWidgets('OutlinedButton backgroundBuilder drops button child and foregroundBuilder return value', (WidgetTester tester) async { + const Color backgroundColor = Color(0xFF000011); + const Color foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + backgroundBuilder: (BuildContext context, Set states, Widget? child) { + return const DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + ), + ); + }, + foregroundBuilder: (BuildContext context, Set states, Widget? child) { + return const DecoratedBox( + decoration: BoxDecoration( + color: foregroundColor, + ), + ); + }, + ), + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + final Finder background = find.descendant( + of: find.byType(OutlinedButton), + matching: find.byType(DecoratedBox), + ); + + expect(background, findsOneWidget); + expect(find.text('button'), findsNothing); + }); + + testWidgets('OutlinedButton foregroundBuilder drops button child', (WidgetTester tester) async { + const Color foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundBuilder: (BuildContext context, Set states, Widget? child) { + return const DecoratedBox( + decoration: BoxDecoration( + color: foregroundColor, + ), + ); + }, + ), + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + final Finder foreground = find.descendant( + of: find.byType(OutlinedButton), + matching: find.byType(DecoratedBox), + ); + + expect(foreground, findsOneWidget); + expect(find.text('button'), findsNothing); + }); + + testWidgets('OutlinedButton foreground and background builders are applied to the correct states', (WidgetTester tester) async { + Set foregroundStates = {}; + Set backgroundStates = {}; + final FocusNode focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: OutlinedButton( + style: ButtonStyle( + backgroundBuilder: (BuildContext context, Set states, Widget? child) { + backgroundStates = states; + return child!; + }, + foregroundBuilder: (BuildContext context, Set states, Widget? child) { + foregroundStates = states; + return child!; + }, + ), + onPressed: () {}, + focusNode: focusNode, + child: const Text('button'), + ), + ), + ), + ), + ); + + // Default. + expect(backgroundStates.isEmpty, isTrue); + expect(foregroundStates.isEmpty, isTrue); + + const Set focusedStates = {MaterialState.focused}; + const Set focusedHoveredStates = {MaterialState.focused, MaterialState.hovered}; + const Set focusedHoveredPressedStates = {MaterialState.focused, MaterialState.hovered, MaterialState.pressed}; + + bool sameStates(Set expectedValue, Set actualValue) { + return expectedValue.difference(actualValue).isEmpty && actualValue.difference(expectedValue).isEmpty; + } + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(sameStates(focusedStates, backgroundStates), isTrue); + expect(sameStates(focusedStates, foregroundStates), isTrue); + + // Hovered. + final Offset center = tester.getCenter(find.byType(OutlinedButton)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(sameStates(focusedHoveredStates, backgroundStates), isTrue); + expect(sameStates(focusedHoveredStates, foregroundStates), isTrue); + + + // Highlighted (pressed). + await gesture.down(center); + await tester.pump(); // Start the splash and highlight animations. + await tester.pump(const Duration(milliseconds: 800)); // Wait for splash and highlight to be well under way. + expect(sameStates(focusedHoveredPressedStates, backgroundStates), isTrue); + expect(sameStates(focusedHoveredPressedStates, foregroundStates), isTrue); + + focusNode.dispose(); + }); } TextStyle _iconStyle(WidgetTester tester, IconData icon) { diff --git a/packages/flutter/test/material/text_button_test.dart b/packages/flutter/test/material/text_button_test.dart index d62a820920b..f197f68d2c4 100644 --- a/packages/flutter/test/material/text_button_test.dart +++ b/packages/flutter/test/material/text_button_test.dart @@ -1925,6 +1925,203 @@ void main() { expect(tester.takeException(), isNull); }); + + testWidgets('TextButton backgroundBuilder and foregroundBuilder', (WidgetTester tester) async { + const Color backgroundColor = Color(0xFF000011); + const Color foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TextButton( + style: TextButton.styleFrom( + backgroundBuilder: (BuildContext context, Set states, Widget? child) { + return DecoratedBox( + decoration: const BoxDecoration( + color: backgroundColor, + ), + child: child, + ); + }, + foregroundBuilder: (BuildContext context, Set states, Widget? child) { + return DecoratedBox( + decoration: const BoxDecoration( + color: foregroundColor, + ), + child: child, + ); + }, + ), + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + BoxDecoration boxDecorationOf(Finder finder) { + return tester.widget(finder).decoration as BoxDecoration; + } + + final Finder decorations = find.descendant( + of: find.byType(TextButton), + matching: find.byType(DecoratedBox), + ); + + expect(boxDecorationOf(decorations.at(0)).color, backgroundColor); + expect(boxDecorationOf(decorations.at(1)).color, foregroundColor); + + Text textChildOf(Finder finder) { + return tester.widget( + find.descendant( + of: finder, + matching: find.byType(Text), + ), + ); + } + + expect(textChildOf(decorations.at(0)).data, 'button'); + expect(textChildOf(decorations.at(1)).data, 'button'); + }); + + + testWidgets('TextButton backgroundBuilder drops button child and foregroundBuilder return value', (WidgetTester tester) async { + const Color backgroundColor = Color(0xFF000011); + const Color foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TextButton( + style: TextButton.styleFrom( + backgroundBuilder: (BuildContext context, Set states, Widget? child) { + return const DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + ), + ); + }, + foregroundBuilder: (BuildContext context, Set states, Widget? child) { + return const DecoratedBox( + decoration: BoxDecoration( + color: foregroundColor, + ), + ); + }, + ), + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + final Finder background = find.descendant( + of: find.byType(TextButton), + matching: find.byType(DecoratedBox), + ); + + expect(background, findsOneWidget); + expect(find.text('button'), findsNothing); + }); + + testWidgets('TextButton foregroundBuilder drops button child', (WidgetTester tester) async { + const Color foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TextButton( + style: TextButton.styleFrom( + foregroundBuilder: (BuildContext context, Set states, Widget? child) { + return const DecoratedBox( + decoration: BoxDecoration( + color: foregroundColor, + ), + ); + }, + ), + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + final Finder foreground = find.descendant( + of: find.byType(TextButton), + matching: find.byType(DecoratedBox), + ); + + expect(foreground, findsOneWidget); + expect(find.text('button'), findsNothing); + }); + + testWidgets('TextButton foreground and background builders are applied to the correct states', (WidgetTester tester) async { + Set foregroundStates = {}; + Set backgroundStates = {}; + final FocusNode focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: TextButton( + style: ButtonStyle( + backgroundBuilder: (BuildContext context, Set states, Widget? child) { + backgroundStates = states; + return child!; + }, + foregroundBuilder: (BuildContext context, Set states, Widget? child) { + foregroundStates = states; + return child!; + }, + ), + onPressed: () {}, + focusNode: focusNode, + child: const Text('button'), + ), + ), + ), + ), + ); + + // Default. + expect(backgroundStates.isEmpty, isTrue); + expect(foregroundStates.isEmpty, isTrue); + + const Set focusedStates = {MaterialState.focused}; + const Set focusedHoveredStates = {MaterialState.focused, MaterialState.hovered}; + const Set focusedHoveredPressedStates = {MaterialState.focused, MaterialState.hovered, MaterialState.pressed}; + + bool sameStates(Set expectedValue, Set actualValue) { + return expectedValue.difference(actualValue).isEmpty && actualValue.difference(expectedValue).isEmpty; + } + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(sameStates(focusedStates, backgroundStates), isTrue); + expect(sameStates(focusedStates, foregroundStates), isTrue); + + // Hovered. + final Offset center = tester.getCenter(find.byType(TextButton)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(sameStates(focusedHoveredStates, backgroundStates), isTrue); + expect(sameStates(focusedHoveredStates, foregroundStates), isTrue); + + + // Highlighted (pressed). + await gesture.down(center); + await tester.pump(); // Start the splash and highlight animations. + await tester.pump(const Duration(milliseconds: 800)); // Wait for splash and highlight to be well under way. + expect(sameStates(focusedHoveredPressedStates, backgroundStates), isTrue); + expect(sameStates(focusedHoveredPressedStates, foregroundStates), isTrue); + + focusNode.dispose(); + }); } TextStyle? _iconStyle(WidgetTester tester, IconData icon) { diff --git a/packages/flutter/test/material/text_button_theme_test.dart b/packages/flutter/test/material/text_button_theme_test.dart index 473e0a64473..c80ee7a82ae 100644 --- a/packages/flutter/test/material/text_button_theme_test.dart +++ b/packages/flutter/test/material/text_button_theme_test.dart @@ -104,6 +104,15 @@ void main() { const bool enableFeedback = false; const AlignmentGeometry alignment = Alignment.centerLeft; + final Key backgroundKey = UniqueKey(); + final Key foregroundKey = UniqueKey(); + Widget backgroundBuilder(BuildContext context, Set states, Widget? child) { + return KeyedSubtree(key: backgroundKey, child: child!); + } + Widget foregroundBuilder(BuildContext context, Set states, Widget? child) { + return KeyedSubtree(key: foregroundKey, child: child!); + } + final ButtonStyle style = TextButton.styleFrom( foregroundColor: foregroundColor, disabledForegroundColor: disabledColor, @@ -122,6 +131,8 @@ void main() { animationDuration: animationDuration, enableFeedback: enableFeedback, alignment: alignment, + backgroundBuilder: backgroundBuilder, + foregroundBuilder: foregroundBuilder, ); Widget buildFrame({ ButtonStyle? buttonStyle, ButtonStyle? themeStyle, ButtonStyle? overallStyle }) { @@ -185,6 +196,8 @@ void main() { expect(tester.getSize(find.byType(TextButton)), const Size(200, 200)); final Align align = tester.firstWidget(find.ancestor(of: find.text('button'), matching: find.byType(Align))); expect(align.alignment, alignment); + expect(find.descendant(of: findMaterial, matching: find.byKey(backgroundKey)), findsOneWidget); + expect(find.descendant(of: findInkWell, matching: find.byKey(foregroundKey)), findsOneWidget); } testWidgets('Button style overrides defaults', (WidgetTester tester) async {