Adjustments to FocusHighlightMode handling (#162417)

Fixes https://github.com/flutter/flutter/issues/158527

Adjustments:
* Using the mouse/trackpad does no longer change `FocusHighlightMode`s
(this matches observed behavior on Android)
* Changing focus via a11y on the web forces `FocusHighlightModes.touch`,
which hides the visual input focus indication from non-Textfields. The
reason here is in order to give something input focus on the web it also
has to have a11y focus, which is indicated separately.
This commit is contained in:
Michael Goderbauer 2025-02-06 13:26:08 -08:00 committed by GitHub
parent 58b7ee137e
commit 1e035cc693
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 122 additions and 23 deletions

View File

@ -75,6 +75,23 @@ mixin SemanticsBinding on BindingBase {
_semanticsEnabled.removeListener(listener); _semanticsEnabled.removeListener(listener);
} }
final ObserverList<ValueSetter<ui.SemanticsActionEvent>> _semanticsActionListeners =
ObserverList<ValueSetter<ui.SemanticsActionEvent>>();
/// Adds a listener that is called for every [SemanticsActionEvent] received.
///
/// The listeners are called before [performSemanticsAction] is invoked.
///
/// To remove the listener, call [removeSemanticsActionListener].
void addSemanticsActionListener(ValueSetter<ui.SemanticsActionEvent> listener) {
_semanticsActionListeners.add(listener);
}
/// Removes a listener previously added with [addSemanticsActionListener].
void removeSemanticsActionListener(ValueSetter<ui.SemanticsActionEvent> listener) {
_semanticsActionListeners.remove(listener);
}
/// The number of clients registered to listen for semantics. /// The number of clients registered to listen for semantics.
/// ///
/// The number is increased whenever [ensureSemantics] is called and decreased /// The number is increased whenever [ensureSemantics] is called and decreased
@ -125,6 +142,15 @@ mixin SemanticsBinding on BindingBase {
arguments is ByteData arguments is ByteData
? action.copyWith(arguments: const StandardMessageCodec().decodeMessage(arguments)) ? action.copyWith(arguments: const StandardMessageCodec().decodeMessage(arguments))
: action; : action;
// Listeners may get added/removed while the iteration is in progress. Since the list cannot
// be modified while iterating, we are creating a local copy for the iteration.
final List<ValueSetter<ui.SemanticsActionEvent>> localListeners = _semanticsActionListeners
.toList(growable: false);
for (final ValueSetter<ui.SemanticsActionEvent> listener in localListeners) {
if (_semanticsActionListeners.contains(listener)) {
listener(decodedAction);
}
}
performSemanticsAction(decodedAction); performSemanticsAction(decodedAction);
} }

View File

@ -13,6 +13,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'binding.dart'; import 'binding.dart';
@ -2064,9 +2065,9 @@ class _HighlightModeManager {
} }
} }
// If set, indicates if the last interaction detected was touch or not. If // If null, no interactions have occurred yet and the default highlight mode for the current
// null, no interactions have occurred yet. // platform applies.
bool? _lastInteractionWasTouch; bool? _lastInteractionRequiresTraditionalHighlights;
FocusHighlightMode get highlightMode => _highlightMode ?? _defaultModeForPlatform; FocusHighlightMode get highlightMode => _highlightMode ?? _defaultModeForPlatform;
FocusHighlightMode? _highlightMode; FocusHighlightMode? _highlightMode;
@ -2122,6 +2123,7 @@ class _HighlightModeManager {
// HardwareKeyboard. // HardwareKeyboard.
ServicesBinding.instance.keyEventManager.keyMessageHandler = handleKeyMessage; ServicesBinding.instance.keyEventManager.keyMessageHandler = handleKeyMessage;
GestureBinding.instance.pointerRouter.addGlobalRoute(handlePointerEvent); GestureBinding.instance.pointerRouter.addGlobalRoute(handlePointerEvent);
SemanticsBinding.instance.addSemanticsActionListener(handleSemanticsAction);
} }
@mustCallSuper @mustCallSuper
@ -2132,6 +2134,7 @@ class _HighlightModeManager {
if (ServicesBinding.instance.keyEventManager.keyMessageHandler == handleKeyMessage) { if (ServicesBinding.instance.keyEventManager.keyMessageHandler == handleKeyMessage) {
GestureBinding.instance.pointerRouter.removeGlobalRoute(handlePointerEvent); GestureBinding.instance.pointerRouter.removeGlobalRoute(handlePointerEvent);
ServicesBinding.instance.keyEventManager.keyMessageHandler = null; ServicesBinding.instance.keyEventManager.keyMessageHandler = null;
SemanticsBinding.instance.removeSemanticsActionListener(handleSemanticsAction);
} }
_listeners = HashedObserverList<ValueChanged<FocusHighlightMode>>(); _listeners = HashedObserverList<ValueChanged<FocusHighlightMode>>();
} }
@ -2175,29 +2178,28 @@ class _HighlightModeManager {
} }
void handlePointerEvent(PointerEvent event) { void handlePointerEvent(PointerEvent event) {
final FocusHighlightMode expectedMode;
switch (event.kind) { switch (event.kind) {
case PointerDeviceKind.touch: case PointerDeviceKind.touch:
case PointerDeviceKind.stylus: case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus: case PointerDeviceKind.invertedStylus:
_lastInteractionWasTouch = true; if (_lastInteractionRequiresTraditionalHighlights != true) {
expectedMode = FocusHighlightMode.touch; _lastInteractionRequiresTraditionalHighlights = true;
updateMode();
}
case PointerDeviceKind.mouse: case PointerDeviceKind.mouse:
case PointerDeviceKind.trackpad: case PointerDeviceKind.trackpad:
case PointerDeviceKind.unknown: case PointerDeviceKind.unknown:
_lastInteractionWasTouch = false;
expectedMode = FocusHighlightMode.traditional;
}
if (expectedMode != highlightMode) {
updateMode();
} }
} }
bool handleKeyMessage(KeyMessage message) { bool handleKeyMessage(KeyMessage message) {
// Update highlightMode first, since things responding to the keys might // ignore: use_if_null_to_convert_nulls_to_bools
// look at the highlight mode, and it should be accurate. if (_lastInteractionRequiresTraditionalHighlights != false) {
_lastInteractionWasTouch = false; // Update highlightMode first, since things responding to the keys might
updateMode(); // look at the highlight mode, and it should be accurate.
_lastInteractionRequiresTraditionalHighlights = false;
updateMode();
}
assert(_focusDebug(() => 'Received key event $message')); assert(_focusDebug(() => 'Received key event $message'));
if (FocusManager.instance.primaryFocus == null) { if (FocusManager.instance.primaryFocus == null) {
@ -2290,20 +2292,29 @@ class _HighlightModeManager {
return handled; return handled;
} }
void handleSemanticsAction(SemanticsActionEvent semanticsActionEvent) {
if (kIsWeb &&
semanticsActionEvent.type == SemanticsAction.focus &&
_lastInteractionRequiresTraditionalHighlights != true) {
_lastInteractionRequiresTraditionalHighlights = true;
updateMode();
}
}
// Update function to be called whenever the state relating to highlightMode // Update function to be called whenever the state relating to highlightMode
// changes. // changes.
void updateMode() { void updateMode() {
final FocusHighlightMode newMode; final FocusHighlightMode newMode;
switch (strategy) { switch (strategy) {
case FocusHighlightStrategy.automatic: case FocusHighlightStrategy.automatic:
if (_lastInteractionWasTouch == null) { if (_lastInteractionRequiresTraditionalHighlights == null) {
// If we don't have any information about the last interaction yet, // If we don't have any information about the last interaction yet,
// then just rely on the default value for the platform, which will be // then just rely on the default value for the platform, which will be
// determined based on the target platform if _highlightMode is not // determined based on the target platform if _highlightMode is not
// set. // set.
return; return;
} }
if (_lastInteractionWasTouch!) { if (_lastInteractionRequiresTraditionalHighlights!) {
newMode = FocusHighlightMode.touch; newMode = FocusHighlightMode.touch;
} else { } else {
newMode = FocusHighlightMode.traditional; newMode = FocusHighlightMode.traditional;

View File

@ -9,6 +9,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../widgets/feedback_tester.dart'; import '../widgets/feedback_tester.dart';
import '../widgets/semantics_tester.dart'; import '../widgets/semantics_tester.dart';
@ -3317,6 +3318,62 @@ void main() {
await tester.pump(); await tester.pump();
expect(onLongPressed, false); expect(onLongPressed, false);
}); });
testWidgets('does not draw focus color when focused by semantics on the web', (
WidgetTester tester,
) async {
// Regression test for https://github.com/flutter/flutter/issues/158527.
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
const Color focusColor = Colors.orange;
await tester.pumpWidget(
MaterialApp(
home: Center(
child: IconButton(
focusColor: focusColor,
focusNode: focusNode,
icon: const Icon(Icons.headphones),
onPressed: () {},
),
),
),
);
// Make sure we are in "traditional mode" where the button could potentially draw focus highlights.
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(FocusManager.instance.highlightMode, equals(FocusHighlightMode.traditional));
expect(focusNode.hasFocus, isFalse);
// Focus on it with semantics.
tester.platformDispatcher.onSemanticsActionEvent!(
SemanticsActionEvent(
type: SemanticsAction.focus,
viewId: tester.view.viewId,
nodeId: tester.semantics.find(find.byIcon(Icons.headphones)).id,
),
);
await tester.pumpAndSettle();
// Make sure no focus highlight was drawn.
final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) {
return object.runtimeType.toString() == '_RenderInkFeatures';
});
expect(focusNode.hasFocus, isTrue);
expect(FocusManager.instance.highlightMode, equals(FocusHighlightMode.touch));
expect(inkFeatures, isNot(paints..rect(color: focusColor)));
// Check that focus highlight is drawn in traditional mode.
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(focusNode.hasFocus, isTrue);
expect(FocusManager.instance.highlightMode, equals(FocusHighlightMode.traditional));
expect(inkFeatures, paints..rect(color: focusColor));
}, skip: !isBrowser); // [intended] tests web-specific behavior.
} }
Widget buildAllVariants({ Widget buildAllVariants({

View File

@ -12,6 +12,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../widgets/semantics_tester.dart'; import '../widgets/semantics_tester.dart';
const double _defaultBorderWidth = 1.0; const double _defaultBorderWidth = 1.0;
@ -692,6 +693,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// focusColor // focusColor
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
focusNode.requestFocus(); focusNode.requestFocus();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) {
@ -748,6 +750,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// focusColor // focusColor
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
focusNode.requestFocus(); focusNode.requestFocus();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) {
@ -811,6 +814,7 @@ void main() {
await hoverGesture.moveTo(Offset.zero); await hoverGesture.moveTo(Offset.zero);
// focusColor // focusColor
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
focusNode.requestFocus(); focusNode.requestFocus();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) {

View File

@ -482,6 +482,7 @@ void main() {
await hoverGesture.moveTo(Offset.zero); await hoverGesture.moveTo(Offset.zero);
// focusColor // focusColor
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
focusNode.requestFocus(); focusNode.requestFocus();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) {

View File

@ -1519,19 +1519,19 @@ void main() {
kind: PointerDeviceKind.mouse, kind: PointerDeviceKind.mouse,
); );
await gesture.up(); await gesture.up();
expect(callCount, equals(3)); expect(callCount, equals(2));
expect(lastMode, FocusHighlightMode.traditional); expect(lastMode, FocusHighlightMode.touch);
expect(FocusManager.instance.highlightMode, equals(FocusHighlightMode.traditional)); expect(FocusManager.instance.highlightMode, equals(FocusHighlightMode.touch));
await tester.tap(find.byType(Container), warnIfMissed: false); await tester.tap(find.byType(Container), warnIfMissed: false);
expect(callCount, equals(4)); expect(callCount, equals(2));
expect(lastMode, FocusHighlightMode.touch); expect(lastMode, FocusHighlightMode.touch);
expect(FocusManager.instance.highlightMode, equals(FocusHighlightMode.touch)); expect(FocusManager.instance.highlightMode, equals(FocusHighlightMode.touch));
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
expect(callCount, equals(5)); expect(callCount, equals(3));
expect(lastMode, FocusHighlightMode.traditional); expect(lastMode, FocusHighlightMode.traditional);
expect(FocusManager.instance.highlightMode, equals(FocusHighlightMode.traditional)); expect(FocusManager.instance.highlightMode, equals(FocusHighlightMode.traditional));
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch;
expect(callCount, equals(6)); expect(callCount, equals(4));
expect(lastMode, FocusHighlightMode.touch); expect(lastMode, FocusHighlightMode.touch);
expect(FocusManager.instance.highlightMode, equals(FocusHighlightMode.touch)); expect(FocusManager.instance.highlightMode, equals(FocusHighlightMode.touch));
}); });