diff --git a/packages/flutter/lib/src/material/ink_well.dart b/packages/flutter/lib/src/material/ink_well.dart index 61880fcb8c2..0940cfa9ae2 100644 --- a/packages/flutter/lib/src/material/ink_well.dart +++ b/packages/flutter/lib/src/material/ink_well.dart @@ -773,7 +773,11 @@ class _InkResponseState extends State<_InkResponseStateWidget> void didUpdateWidget(_InkResponseStateWidget oldWidget) { super.didUpdateWidget(oldWidget); if (_isWidgetEnabled(widget) != _isWidgetEnabled(oldWidget)) { - _handleHoverChange(_hovering); + if (enabled) { + // Don't call wigdet.onHover because many wigets, including the button + // widgets, apply setState to an ancestor context from onHover. + updateHighlight(_HighlightType.hover, value: _hovering, callOnHover: false); + } _updateFocusHighlights(); } } @@ -818,7 +822,7 @@ class _InkResponseState extends State<_InkResponseStateWidget> return null; } - void updateHighlight(_HighlightType type, {@required bool value}) { + void updateHighlight(_HighlightType type, { @required bool value, bool callOnHover = true }) { final InkHighlight highlight = _highlights[type]; void handleInkRemoval() { assert(_highlights[type] != null); @@ -862,7 +866,7 @@ class _InkResponseState extends State<_InkResponseStateWidget> widget.onHighlightChanged(value); break; case _HighlightType.hover: - if (widget.onHover != null) + if (callOnHover && widget.onHover != null) widget.onHover(value); break; case _HighlightType.focus: @@ -1040,15 +1044,24 @@ class _InkResponseState extends State<_InkResponseStateWidget> bool get enabled => _isWidgetEnabled(widget); - void _handleMouseEnter(PointerEnterEvent event) => _handleHoverChange(true); - void _handleMouseExit(PointerExitEvent event) => _handleHoverChange(false); - void _handleHoverChange(bool hovering) { - if (_hovering != hovering) { - _hovering = hovering; - updateHighlight(_HighlightType.hover, value: enabled && _hovering); + void _handleMouseEnter(PointerEnterEvent event) { + _hovering = true; + if (enabled) { + _handleHoverChange(); } } + void _handleMouseExit(PointerExitEvent event) { + _hovering = false; + // If the exit occurs after we've been disabled, we still + // want to take down the highlights and run widget.onHover. + _handleHoverChange(); + } + + void _handleHoverChange() { + updateHighlight(_HighlightType.hover, value: _hovering); + } + bool get _canRequestFocus { final NavigationMode mode = MediaQuery.of(context, nullOk: true)?.navigationMode ?? NavigationMode.traditional; switch (mode) { @@ -1076,7 +1089,7 @@ class _InkResponseState extends State<_InkResponseStateWidget> widget.mouseCursor ?? MaterialStateMouseCursor.clickable, { if (!enabled) MaterialState.disabled, - if (_hovering) MaterialState.hovered, + if (_hovering && enabled) MaterialState.hovered, if (_hasFocus) MaterialState.focused, }, ); @@ -1091,8 +1104,8 @@ class _InkResponseState extends State<_InkResponseStateWidget> autofocus: widget.autofocus, child: MouseRegion( cursor: effectiveMouseCursor, - onEnter: enabled ? _handleMouseEnter : null, - onExit: enabled ? _handleMouseExit : null, + onEnter: _handleMouseEnter, + onExit: _handleMouseExit, child: GestureDetector( onTapDown: enabled ? _handleTapDown : null, onTap: enabled ? () => _handleTap(context) : null, diff --git a/packages/flutter/test/material/ink_well_test.dart b/packages/flutter/test/material/ink_well_test.dart index 2da044eddc4..24800e045f9 100644 --- a/packages/flutter/test/material/ink_well_test.dart +++ b/packages/flutter/test/material/ink_well_test.dart @@ -1191,4 +1191,103 @@ void main() { await tester.pump(const Duration(milliseconds: 200)); expect(material, paintsExactlyCountTimes(#drawCircle, 1)); }); + + testWidgets('disabled and hovered inkwell responds to mouse-exit', (WidgetTester tester) async { + int onHoverCount = 0; + bool hover; + + Widget buildFrame({ bool enabled }) { + return Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 100, + height: 100, + child: InkWell( + onTap: enabled ? () { } : null, + onHover: (bool value) { + onHoverCount += 1; + hover = value; + }, + ), + ), + ), + ), + ); + } + + 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(InkWell))); + 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 InkWell 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(InkWell))); + await tester.pumpAndSettle(); + // We no longer see hover events because the InkWell 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 InkWell 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(InkWell)) - const Offset(1, 1)); + await tester.pumpAndSettle(); + // Moving the mouse a little within the InkWell doesn't change anything. + expect(onHoverCount, 2); + expect(hover, false); + }); + + testWidgets('Changing InkWell.enabled should not trigger TextButton setState()', (WidgetTester tester) async { + Widget buildFrame({ bool enabled }) { + return Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: TextButton( + onPressed: enabled ? () { } : null, + child: const Text('button'), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(enabled: false)); + + 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(); + + // Rebuilding the button with enabled:true causes InkWell.didUpdateWidget() + // to be called per the change in its enabled flag. If onHover() was called, + // this test would crash. + await tester.pumpWidget(buildFrame(enabled: true)); + await tester.pumpAndSettle(); + + // Rebuild again, with enabled:false + await gesture.moveBy(const Offset(1, 1)); + await tester.pumpWidget(buildFrame(enabled: false)); + await tester.pumpAndSettle(); + }); }