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);
}
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 is increased whenever [ensureSemantics] is called and decreased
@ -125,6 +142,15 @@ mixin SemanticsBinding on BindingBase {
arguments is ByteData
? action.copyWith(arguments: const StandardMessageCodec().decodeMessage(arguments))
: 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);
}

View File

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

View File

@ -9,6 +9,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../widgets/feedback_tester.dart';
import '../widgets/semantics_tester.dart';
@ -3317,6 +3318,62 @@ void main() {
await tester.pump();
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({

View File

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

View File

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

View File

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