From d2c8b623515588c3fa4a26d416ad07ffe9da284c Mon Sep 17 00:00:00 2001 From: yk3372 Date: Tue, 28 Sep 2021 12:51:14 -0500 Subject: [PATCH] make Elevated&Outlined&TextButton support onHover&onFocus callback (#90688) --- AUTHORS | 1 + .../lib/src/material/button_style_button.dart | 25 ++- .../lib/src/material/elevated_button.dart | 10 + .../lib/src/material/outlined_button.dart | 4 + .../flutter/lib/src/material/text_button.dart | 10 + .../test/material/elevated_button_test.dart | 176 ++++++++++++++++++ .../test/material/outlined_button_test.dart | 176 ++++++++++++++++++ .../test/material/text_button_test.dart | 176 ++++++++++++++++++ 8 files changed, 576 insertions(+), 2 deletions(-) diff --git a/AUTHORS b/AUTHORS index 7c6bbddba8..b66eb35d99 100644 --- a/AUTHORS +++ b/AUTHORS @@ -85,3 +85,4 @@ Callum Moffat Koutaro Mori Sergei Smitskoi Pradumna Saraf +Kai Yu diff --git a/packages/flutter/lib/src/material/button_style_button.dart b/packages/flutter/lib/src/material/button_style_button.dart index d49b34e306..bcbe421f2f 100644 --- a/packages/flutter/lib/src/material/button_style_button.dart +++ b/packages/flutter/lib/src/material/button_style_button.dart @@ -33,6 +33,8 @@ abstract class ButtonStyleButton extends StatefulWidget { Key? key, required this.onPressed, required this.onLongPress, + required this.onHover, + required this.onFocusChange, required this.style, required this.focusNode, required this.autofocus, @@ -60,6 +62,19 @@ abstract class ButtonStyleButton extends StatefulWidget { /// * [enabled], which is true if the button is enabled. final VoidCallback? onLongPress; + /// Called when a pointer enters or exits the button response area. + /// + /// The value passed to the callback is true if a pointer has entered this + /// part of the material and false if a pointer has exited this part of the + /// material. + final ValueChanged? onHover; + + /// Handler called when the focus changes. + /// + /// Called with true if this widget's node gains focus, and false if it loses + /// focus. + final ValueChanged? onFocusChange; + /// Customizes this button's appearance. /// /// Non-null properties of this style override the corresponding @@ -335,12 +350,18 @@ class _ButtonStyleState extends State with MaterialStateMixin onTap: widget.onPressed, onLongPress: widget.onLongPress, onHighlightChanged: updateMaterialState(MaterialState.pressed), - onHover: updateMaterialState(MaterialState.hovered), + onHover: updateMaterialState( + MaterialState.hovered, + onChanged: widget.onHover, + ), mouseCursor: resolvedMouseCursor, enableFeedback: resolvedEnableFeedback, focusNode: widget.focusNode, canRequestFocus: widget.enabled, - onFocusChange: updateMaterialState(MaterialState.focused), + onFocusChange: updateMaterialState( + MaterialState.focused, + onChanged: widget.onFocusChange, + ), autofocus: widget.autofocus, splashFactory: resolvedSplashFactory, overlayColor: overlayColor, diff --git a/packages/flutter/lib/src/material/elevated_button.dart b/packages/flutter/lib/src/material/elevated_button.dart index efa7cf01b5..51c5e1d7f3 100644 --- a/packages/flutter/lib/src/material/elevated_button.dart +++ b/packages/flutter/lib/src/material/elevated_button.dart @@ -65,6 +65,8 @@ class ElevatedButton extends ButtonStyleButton { Key? key, required VoidCallback? onPressed, VoidCallback? onLongPress, + ValueChanged? onHover, + ValueChanged? onFocusChange, ButtonStyle? style, FocusNode? focusNode, bool autofocus = false, @@ -74,6 +76,8 @@ class ElevatedButton extends ButtonStyleButton { key: key, onPressed: onPressed, onLongPress: onLongPress, + onHover: onHover, + onFocusChange: onFocusChange, style: style, focusNode: focusNode, autofocus: autofocus, @@ -92,6 +96,8 @@ class ElevatedButton extends ButtonStyleButton { Key? key, required VoidCallback? onPressed, VoidCallback? onLongPress, + ValueChanged? onHover, + ValueChanged? onFocusChange, ButtonStyle? style, FocusNode? focusNode, bool? autofocus, @@ -399,6 +405,8 @@ class _ElevatedButtonWithIcon extends ElevatedButton { Key? key, required VoidCallback? onPressed, VoidCallback? onLongPress, + ValueChanged? onHover, + ValueChanged? onFocusChange, ButtonStyle? style, FocusNode? focusNode, bool? autofocus, @@ -411,6 +419,8 @@ class _ElevatedButtonWithIcon extends ElevatedButton { key: key, onPressed: onPressed, onLongPress: onLongPress, + onHover: onHover, + onFocusChange: onFocusChange, style: style, focusNode: focusNode, autofocus: autofocus ?? false, diff --git a/packages/flutter/lib/src/material/outlined_button.dart b/packages/flutter/lib/src/material/outlined_button.dart index 8466ca8d72..d97f35f77d 100644 --- a/packages/flutter/lib/src/material/outlined_button.dart +++ b/packages/flutter/lib/src/material/outlined_button.dart @@ -70,6 +70,8 @@ class OutlinedButton extends ButtonStyleButton { Key? key, required VoidCallback? onPressed, VoidCallback? onLongPress, + ValueChanged? onHover, + ValueChanged? onFocusChange, ButtonStyle? style, FocusNode? focusNode, bool autofocus = false, @@ -79,6 +81,8 @@ class OutlinedButton extends ButtonStyleButton { key: key, onPressed: onPressed, onLongPress: onLongPress, + onHover: onHover, + onFocusChange: onFocusChange, style: style, focusNode: focusNode, autofocus: autofocus, diff --git a/packages/flutter/lib/src/material/text_button.dart b/packages/flutter/lib/src/material/text_button.dart index 2882127d27..7efd6b9580 100644 --- a/packages/flutter/lib/src/material/text_button.dart +++ b/packages/flutter/lib/src/material/text_button.dart @@ -70,6 +70,8 @@ class TextButton extends ButtonStyleButton { Key? key, required VoidCallback? onPressed, VoidCallback? onLongPress, + ValueChanged? onHover, + ValueChanged? onFocusChange, ButtonStyle? style, FocusNode? focusNode, bool autofocus = false, @@ -79,6 +81,8 @@ class TextButton extends ButtonStyleButton { key: key, onPressed: onPressed, onLongPress: onLongPress, + onHover: onHover, + onFocusChange: onFocusChange, style: style, focusNode: focusNode, autofocus: autofocus, @@ -97,6 +101,8 @@ class TextButton extends ButtonStyleButton { Key? key, required VoidCallback? onPressed, VoidCallback? onLongPress, + ValueChanged? onHover, + ValueChanged? onFocusChange, ButtonStyle? style, FocusNode? focusNode, bool? autofocus, @@ -362,6 +368,8 @@ class _TextButtonWithIcon extends TextButton { Key? key, required VoidCallback? onPressed, VoidCallback? onLongPress, + ValueChanged? onHover, + ValueChanged? onFocusChange, ButtonStyle? style, FocusNode? focusNode, bool? autofocus, @@ -374,6 +382,8 @@ class _TextButtonWithIcon extends TextButton { key: key, onPressed: onPressed, onLongPress: onLongPress, + onHover: onHover, + onFocusChange: onFocusChange, style: style, focusNode: focusNode, autofocus: autofocus ?? false, diff --git a/packages/flutter/test/material/elevated_button_test.dart b/packages/flutter/test/material/elevated_button_test.dart index 436d2743b5..b0a5627575 100644 --- a/packages/flutter/test/material/elevated_button_test.dart +++ b/packages/flutter/test/material/elevated_button_test.dart @@ -414,6 +414,182 @@ void main() { expect(didLongPressButton, isTrue); }); + testWidgets("ElevatedButton response doesn't hover when disabled", (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; + final FocusNode focusNode = FocusNode(debugLabel: 'ElevatedButton Focus'); + final GlobalKey childKey = GlobalKey(); + bool hovering = false; + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 100, + height: 100, + child: ElevatedButton( + autofocus: true, + onPressed: () {}, + onLongPress: () {}, + onHover: (bool value) { hovering = value; }, + focusNode: focusNode, + child: SizedBox(key: childKey), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byKey(childKey))); + await tester.pumpAndSettle(); + expect(hovering, isTrue); + + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 100, + height: 100, + child: ElevatedButton( + focusNode: focusNode, + onHover: (bool value) { hovering = value; }, + onPressed: null, + child: SizedBox(key: childKey), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + }); + + testWidgets('disabled and hovered ElevatedButton responds to mouse-exit', (WidgetTester tester) async { + int onHoverCount = 0; + late bool hover; + + Widget buildFrame({ required bool enabled }) { + return Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 100, + height: 100, + child: ElevatedButton( + onPressed: enabled ? () { } : null, + onHover: (bool value) { + onHoverCount += 1; + hover = value; + }, + child: const Text('ElevatedButton'), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(enabled: true)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + + await gesture.moveTo(tester.getCenter(find.byType(ElevatedButton))); + await tester.pumpAndSettle(); + expect(onHoverCount, 1); + expect(hover, true); + + await tester.pumpWidget(buildFrame(enabled: false)); + await tester.pumpAndSettle(); + await gesture.moveTo(Offset.zero); + // Even though the ElevatedButton has been disabled, the mouse-exit still + // causes onHover(false) to be called. + expect(onHoverCount, 2); + expect(hover, false); + + await gesture.moveTo(tester.getCenter(find.byType(ElevatedButton))); + await tester.pumpAndSettle(); + // We no longer see hover events because the ElevatedButton is disabled + // and it's no longer in the "hovering" state. + expect(onHoverCount, 2); + expect(hover, false); + + await tester.pumpWidget(buildFrame(enabled: true)); + await tester.pumpAndSettle(); + // The ElevatedButton was enabled while it contained the mouse, however + // we do not call onHover() because it may call setState(). + expect(onHoverCount, 2); + expect(hover, false); + + await gesture.moveTo(tester.getCenter(find.byType(ElevatedButton)) - const Offset(1, 1)); + await tester.pumpAndSettle(); + // Moving the mouse a little within the ElevatedButton doesn't change anything. + expect(onHoverCount, 2); + expect(hover, false); + }); + + testWidgets('Can set ElevatedButton focus and Can set unFocus.', (WidgetTester tester) async { + final FocusNode node = FocusNode(debugLabel: 'ElevatedButton Focus'); + bool gotFocus = false; + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: ElevatedButton( + focusNode: node, + onFocusChange: (bool focused) => gotFocus = focused, + onPressed: () { }, + child: const SizedBox(), + ), + ), + ), + ); + + node.requestFocus(); + + await tester.pump(); + + expect(gotFocus, isTrue); + expect(node.hasFocus, isTrue); + + node.unfocus(); + await tester.pump(); + + expect(gotFocus, isFalse); + expect(node.hasFocus, isFalse); + }); + + testWidgets('When ElevatedButton disable, Can not set ElevatedButton focus.', (WidgetTester tester) async { + final FocusNode node = FocusNode(debugLabel: 'ElevatedButton Focus'); + bool gotFocus = false; + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: ElevatedButton( + focusNode: node, + onFocusChange: (bool focused) => gotFocus = focused, + onPressed: null, + child: const SizedBox(), + ), + ), + ), + ); + + node.requestFocus(); + + await tester.pump(); + + expect(gotFocus, isFalse); + expect(node.hasFocus, isFalse); + }); + testWidgets('Does ElevatedButton work with hover', (WidgetTester tester) async { const Color hoverColor = Color(0xff001122); diff --git a/packages/flutter/test/material/outlined_button_test.dart b/packages/flutter/test/material/outlined_button_test.dart index b1e21aa058..bc1537bce8 100644 --- a/packages/flutter/test/material/outlined_button_test.dart +++ b/packages/flutter/test/material/outlined_button_test.dart @@ -613,6 +613,182 @@ void main() { expect(tester.widget(outlinedButton).enabled, false); }); + testWidgets("OutlinedButton response doesn't hover when disabled", (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; + final FocusNode focusNode = FocusNode(debugLabel: 'OutlinedButton Focus'); + final GlobalKey childKey = GlobalKey(); + bool hovering = false; + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 100, + height: 100, + child: OutlinedButton( + autofocus: true, + onPressed: () {}, + onLongPress: () {}, + onHover: (bool value) { hovering = value; }, + focusNode: focusNode, + child: SizedBox(key: childKey), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byKey(childKey))); + await tester.pumpAndSettle(); + expect(hovering, isTrue); + + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 100, + height: 100, + child: OutlinedButton( + focusNode: focusNode, + onHover: (bool value) { hovering = value; }, + onPressed: null, + child: SizedBox(key: childKey), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + }); + + testWidgets('disabled and hovered OutlinedButton responds to mouse-exit', (WidgetTester tester) async { + int onHoverCount = 0; + late bool hover; + + Widget buildFrame({ required bool enabled }) { + return Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 100, + height: 100, + child: OutlinedButton( + onPressed: enabled ? () { } : null, + onHover: (bool value) { + onHoverCount += 1; + hover = value; + }, + child: const Text('OutlinedButton'), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(enabled: true)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + + await gesture.moveTo(tester.getCenter(find.byType(OutlinedButton))); + await tester.pumpAndSettle(); + expect(onHoverCount, 1); + expect(hover, true); + + await tester.pumpWidget(buildFrame(enabled: false)); + await tester.pumpAndSettle(); + await gesture.moveTo(Offset.zero); + // Even though the OutlinedButton has been disabled, the mouse-exit still + // causes onHover(false) to be called. + expect(onHoverCount, 2); + expect(hover, false); + + await gesture.moveTo(tester.getCenter(find.byType(OutlinedButton))); + await tester.pumpAndSettle(); + // We no longer see hover events because the OutlinedButton is disabled + // and it's no longer in the "hovering" state. + expect(onHoverCount, 2); + expect(hover, false); + + await tester.pumpWidget(buildFrame(enabled: true)); + await tester.pumpAndSettle(); + // The OutlinedButton was enabled while it contained the mouse, however + // we do not call onHover() because it may call setState(). + expect(onHoverCount, 2); + expect(hover, false); + + await gesture.moveTo(tester.getCenter(find.byType(OutlinedButton)) - const Offset(1, 1)); + await tester.pumpAndSettle(); + // Moving the mouse a little within the OutlinedButton doesn't change anything. + expect(onHoverCount, 2); + expect(hover, false); + }); + + testWidgets('Can set OutlinedButton focus and Can set unFocus.', (WidgetTester tester) async { + final FocusNode node = FocusNode(debugLabel: 'OutlinedButton Focus'); + bool gotFocus = false; + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton( + focusNode: node, + onFocusChange: (bool focused) => gotFocus = focused, + onPressed: () { }, + child: const SizedBox(), + ), + ), + ), + ); + + node.requestFocus(); + + await tester.pump(); + + expect(gotFocus, isTrue); + expect(node.hasFocus, isTrue); + + node.unfocus(); + await tester.pump(); + + expect(gotFocus, isFalse); + expect(node.hasFocus, isFalse); + }); + + testWidgets('When OutlinedButton disable, Can not set OutlinedButton focus.', (WidgetTester tester) async { + final FocusNode node = FocusNode(debugLabel: 'OutlinedButton Focus'); + bool gotFocus = false; + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton( + focusNode: node, + onFocusChange: (bool focused) => gotFocus = focused, + onPressed: null, + child: const SizedBox(), + ), + ), + ), + ); + + node.requestFocus(); + + await tester.pump(); + + expect(gotFocus, isFalse); + expect(node.hasFocus, isFalse); + }); + testWidgets("Outline button doesn't crash if disabled during a gesture", (WidgetTester tester) async { Widget buildFrame(VoidCallback? onPressed) { return Directionality( diff --git a/packages/flutter/test/material/text_button_test.dart b/packages/flutter/test/material/text_button_test.dart index 7600449849..0da4d17f07 100644 --- a/packages/flutter/test/material/text_button_test.dart +++ b/packages/flutter/test/material/text_button_test.dart @@ -697,6 +697,182 @@ void main() { expect(didLongPressButton, isTrue); }); + testWidgets("TextButton response doesn't hover when disabled", (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; + final FocusNode focusNode = FocusNode(debugLabel: 'TextButton Focus'); + final GlobalKey childKey = GlobalKey(); + bool hovering = false; + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 100, + height: 100, + child: TextButton( + autofocus: true, + onPressed: () {}, + onLongPress: () {}, + onHover: (bool value) { hovering = value; }, + focusNode: focusNode, + child: SizedBox(key: childKey), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byKey(childKey))); + await tester.pumpAndSettle(); + expect(hovering, isTrue); + + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 100, + height: 100, + child: TextButton( + focusNode: focusNode, + onHover: (bool value) { hovering = value; }, + onPressed: null, + child: SizedBox(key: childKey), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + }); + + testWidgets('disabled and hovered TextButton responds to mouse-exit', (WidgetTester tester) async { + int onHoverCount = 0; + late bool hover; + + Widget buildFrame({ required bool enabled }) { + return Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 100, + height: 100, + child: TextButton( + onPressed: enabled ? () { } : null, + onHover: (bool value) { + onHoverCount += 1; + hover = value; + }, + child: const Text('TextButton'), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(enabled: true)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + + await gesture.moveTo(tester.getCenter(find.byType(TextButton))); + await tester.pumpAndSettle(); + expect(onHoverCount, 1); + expect(hover, true); + + await tester.pumpWidget(buildFrame(enabled: false)); + await tester.pumpAndSettle(); + await gesture.moveTo(Offset.zero); + // Even though the TextButton has been disabled, the mouse-exit still + // causes onHover(false) to be called. + expect(onHoverCount, 2); + expect(hover, false); + + await gesture.moveTo(tester.getCenter(find.byType(TextButton))); + await tester.pumpAndSettle(); + // We no longer see hover events because the TextButton is disabled + // and it's no longer in the "hovering" state. + expect(onHoverCount, 2); + expect(hover, false); + + await tester.pumpWidget(buildFrame(enabled: true)); + await tester.pumpAndSettle(); + // The TextButton was enabled while it contained the mouse, however + // we do not call onHover() because it may call setState(). + expect(onHoverCount, 2); + expect(hover, false); + + await gesture.moveTo(tester.getCenter(find.byType(TextButton)) - const Offset(1, 1)); + await tester.pumpAndSettle(); + // Moving the mouse a little within the TextButton doesn't change anything. + expect(onHoverCount, 2); + expect(hover, false); + }); + + testWidgets('Can set TextButton focus and Can set unFocus.', (WidgetTester tester) async { + final FocusNode node = FocusNode(debugLabel: 'TextButton Focus'); + bool gotFocus = false; + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: TextButton( + focusNode: node, + onFocusChange: (bool focused) => gotFocus = focused, + onPressed: () { }, + child: const SizedBox(), + ), + ), + ), + ); + + node.requestFocus(); + + await tester.pump(); + + expect(gotFocus, isTrue); + expect(node.hasFocus, isTrue); + + node.unfocus(); + await tester.pump(); + + expect(gotFocus, isFalse); + expect(node.hasFocus, isFalse); + }); + + testWidgets('When TextButton disable, Can not set TextButton focus.', (WidgetTester tester) async { + final FocusNode node = FocusNode(debugLabel: 'TextButton Focus'); + bool gotFocus = false; + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: TextButton( + focusNode: node, + onFocusChange: (bool focused) => gotFocus = focused, + onPressed: null, + child: const SizedBox(), + ), + ), + ), + ); + + node.requestFocus(); + + await tester.pump(); + + expect(gotFocus, isFalse); + expect(node.hasFocus, isFalse); + }); + testWidgets('TextButton responds to density changes.', (WidgetTester tester) async { const Key key = Key('test'); const Key childKey = Key('test child');