Unverified Commit a11d7314 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Automatic focus highlight mode for FocusManager (#37825)

This adds a FocusHighlightMode to the FocusManager that switches based on the type of input that has recently been received. The initial value is based on the platform, but is updated as soon as user input is received. There is also a FocusHighlightStrategy enum so that the developer can change the strategy to a fixed value if needed.

The default is to automatically detect the mode based on the last type of user input. If they use a mouse or keyboard, it shows the focus highlights. If they use a touch interface, then the highlights disappear. This is consistent with the way that Android and Chrome work. The controls still receive focus, only the display of the highlight changes.

Text fields show the focus highlight regardless of the focus highlight mode.
parent 1d03459f
......@@ -478,6 +478,7 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
_focusNode?.removeListener(_handleFocusUpdate);
_focusNode = Focus.of(context, nullOk: true);
_focusNode?.addListener(_handleFocusUpdate);
WidgetsBinding.instance.focusManager.addHighlightModeListener(_handleFocusHighlightModeChange);
}
@override
......@@ -491,6 +492,7 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
@override
void dispose() {
WidgetsBinding.instance.focusManager.removeHighlightModeListener(_handleFocusHighlightModeChange);
_focusNode?.removeListener(_handleFocusUpdate);
super.dispose();
}
......@@ -608,8 +610,25 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
return splash;
}
void _handleFocusHighlightModeChange(FocusHighlightMode mode) {
if (!mounted) {
return;
}
setState(() {
_handleFocusUpdate();
});
}
void _handleFocusUpdate() {
final bool showFocus = enabled && (Focus.of(context, nullOk: true)?.hasPrimaryFocus ?? false);
bool showFocus;
switch (WidgetsBinding.instance.focusManager.highlightMode) {
case FocusHighlightMode.touch:
showFocus = false;
break;
case FocusHighlightMode.traditional:
showFocus = enabled && (Focus.of(context, nullOk: true)?.hasPrimaryFocus ?? false);
break;
}
updateHighlight(_HighlightType.focus, value: showFocus);
}
......
......@@ -3,10 +3,12 @@
// found in the LICENSE file.
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'binding.dart';
......@@ -499,6 +501,9 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
/// This object notifies its listeners whenever this value changes.
bool get hasPrimaryFocus => _manager?.primaryFocus == this;
/// Returns the [FocusHighlightMode] that is currently in effect for this node.
FocusHighlightMode get highlightMode => WidgetsBinding.instance.focusManager.highlightMode;
/// Returns the nearest enclosing scope node above this node, including
/// this node, if it's a scope.
///
......@@ -936,6 +941,40 @@ class FocusScopeNode extends FocusNode {
}
}
/// An enum to describe which kind of focus highlight behavior to use when
/// displaying focus information.
enum FocusHighlightMode {
/// Touch interfaces will not show the focus highlight except for controls
/// which bring up the soft keyboard.
///
/// If a device that uses a traditional mouse and keyboard has a touch screen
/// attached, it can also enter `touch` mode if the user is using the touch
/// screen.
touch,
/// Traditional interfaces (keyboard and mouse) will show the currently
/// focused control via a focus highlight of some sort.
///
/// If a touch device (like a mobile phone) has a keyboard and/or mouse
/// attached, it also can enter `traditional` mode if the user is using these
/// input devices.
traditional,
}
/// An enum to describe how the current value of [FocusManager.highlightMode] is
/// determined. The strategy is set on [FocusManager.highlightStrategy].
enum FocusHighlightStrategy {
/// Automatic switches between the various highlight modes based on the last
/// kind of input that was received. This is the default.
automatic,
/// [FocusManager.highlightMode] always returns [FocusHighlightMode.touch].
alwaysTouch,
/// [FocusManager.highlightMode] always returns [FocusHighlightMode.traditional].
alwaysTraditional,
}
/// Manages the focus tree.
///
/// The focus tree keeps track of which [FocusNode] is the user's current
......@@ -974,6 +1013,129 @@ class FocusManager with DiagnosticableTreeMixin {
FocusManager() {
rootScope._manager = this;
RawKeyboard.instance.addListener(_handleRawKeyEvent);
RendererBinding.instance.pointerRouter.addGlobalRoute(_handlePointerEvent);
}
bool _lastInteractionWasTouch = true;
/// Sets the strategy by which [highlightMode] is determined.
///
/// If set to [FocusHighlightStrategy.automatic], then the highlight mode will
/// change depending upon the interaction mode used last. For instance, if the
/// last interaction was a touch interaction, then [highlightMode] will return
/// [FocusHighlightMode.touch], and focus highlights will only appear on
/// widgets that bring up a soft keyboard. If the last interaction was a
/// non-touch interaction (hardware keyboard press, mouse click, etc.), then
/// [highlightMode] will return [FocusHighlightMode.traditional], and focus
/// highlights will appear on all widgets.
///
/// If set to [FocusHighlightStrategy.alwaysTouch] or
/// [FocusHighlightStrategy.alwaysTraditional], then [highlightMode] will
/// always return [FocusHighlightMode.touch] or
/// [FocusHighlightMode.traditional], respectively, regardless of the last UI
/// interaction type.
///
/// The initial value of [highlightMode] depends upon the value of
/// [defaultTargetPlatform] and
/// [RendererBinding.instance.mouseTracker.mouseIsConnected], making a guess
/// about which interaction is most appropriate for the initial interaction
/// mode.
///
/// Defaults to [FocusHighlightStrategy.automatic].
FocusHighlightStrategy get highlightStrategy => _highlightStrategy;
FocusHighlightStrategy _highlightStrategy = FocusHighlightStrategy.automatic;
set highlightStrategy(FocusHighlightStrategy highlightStrategy) {
_highlightStrategy = highlightStrategy;
_updateHighlightMode();
}
/// Indicates the current interaction mode for focus highlights.
///
/// The value returned depends upon the [highlightStrategy] used, and possibly
/// (depending on the value of [highlightStrategy]) the most recent
/// interaction mode that they user used.
///
/// If [highlightMode] returns [FocusHighlightMode.touch], then widgets should
/// not draw their focus highlight unless they perform text entry.
///
/// If [highlightMode] returns [FocusHighlightMode.traditional], then widgets should
/// draw their focus highlight whenever they are focused.
FocusHighlightMode get highlightMode => _highlightMode;
FocusHighlightMode _highlightMode = FocusHighlightMode.touch;
// Update function to be called whenever the state relating to highlightMode
// changes.
void _updateHighlightMode() {
// Assume that if we're on one of these mobile platforms, or if there's no
// mouse connected, that the initial interaction will be touch-based, and
// that it's traditional mouse and keyboard on all others.
//
// This only affects the initial value: the ongoing value is updated as soon
// as any input events are received.
_lastInteractionWasTouch ??= Platform.isAndroid || Platform.isIOS || !WidgetsBinding.instance.mouseTracker.mouseIsConnected;
FocusHighlightMode newMode;
switch (highlightStrategy) {
case FocusHighlightStrategy.automatic:
if (_lastInteractionWasTouch) {
newMode = FocusHighlightMode.touch;
} else {
newMode = FocusHighlightMode.traditional;
}
break;
case FocusHighlightStrategy.alwaysTouch:
newMode = FocusHighlightMode.touch;
break;
case FocusHighlightStrategy.alwaysTraditional:
newMode = FocusHighlightMode.traditional;
break;
}
if (newMode != _highlightMode) {
_highlightMode = newMode;
_notifyHighlightModeListeners();
}
}
// The list of listeners for [highlightMode] state changes.
ObserverList<ValueChanged<FocusHighlightMode>> _listeners;
/// Register a closure to be called when the [FocusManager] notifies its listeners
/// that the value of [highlightMode] has changed.
void addHighlightModeListener(ValueChanged<FocusHighlightMode> listener) {
_listeners ??= ObserverList<ValueChanged<FocusHighlightMode>>();
_listeners.add(listener);
}
/// Remove a previously registered closure from the list of closures that the
/// [FocusManager] notifies.
void removeHighlightModeListener(ValueChanged<FocusHighlightMode> listener) {
_listeners?.remove(listener);
}
void _notifyHighlightModeListeners() {
if (_listeners != null) {
final List<ValueChanged<FocusHighlightMode>> localListeners = List<ValueChanged<FocusHighlightMode>>.from(_listeners);
for (ValueChanged<FocusHighlightMode> listener in localListeners) {
try {
if (_listeners.contains(listener)) {
listener(_highlightMode);
}
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'widgets library',
context: ErrorDescription('while dispatching notifications for $runtimeType'),
informationCollector: () sync* {
yield DiagnosticsProperty<FocusManager>(
'The $runtimeType sending notification was',
this,
style: DiagnosticsTreeStyle.errorProperty,
);
},
));
}
}
}
}
/// The root [FocusScopeNode] in the focus tree.
......@@ -982,7 +1144,33 @@ class FocusManager with DiagnosticableTreeMixin {
/// for a given [FocusNode], call [FocusNode.nearestScope].
final FocusScopeNode rootScope = FocusScopeNode(debugLabel: 'Root Focus Scope');
void _handlePointerEvent(PointerEvent event) {
bool newState;
switch (event.kind) {
case PointerDeviceKind.touch:
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
newState = true;
break;
case PointerDeviceKind.mouse:
case PointerDeviceKind.unknown:
newState = false;
break;
}
if (_lastInteractionWasTouch != newState) {
_lastInteractionWasTouch = newState;
_updateHighlightMode();
}
}
void _handleRawKeyEvent(RawKeyEvent event) {
// Update this first, since things responding to the keys might look at the
// highlight mode, and it should be accurate.
if (_lastInteractionWasTouch) {
_lastInteractionWasTouch = false;
_updateHighlightMode();
}
// Walk the current focus from the leaf to the root, calling each one's
// onKey on the way up, and if one responds that they handled it, stop.
if (_primaryFocus == null) {
......
......@@ -2176,7 +2176,14 @@ class BuildOwner {
/// the [FocusScopeNode] for a given [BuildContext].
///
/// See [FocusManager] for more details.
FocusManager focusManager = FocusManager();
FocusManager get focusManager {
_focusManager ??= FocusManager();
return _focusManager;
}
FocusManager _focusManager;
set focusManager(FocusManager focusManager) {
_focusManager = focusManager;
}
/// Adds an element to the dirty elements list so that it will be rebuilt
/// when [WidgetsBinding.drawFrame] calls [buildScope].
......
......@@ -406,6 +406,8 @@ void main() {
),
),
);
WidgetsBinding.instance.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
focusNode.requestFocus();
await tester.pumpAndSettle();
......@@ -497,6 +499,7 @@ void main() {
),
);
await tester.pumpAndSettle();
WidgetsBinding.instance.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
// Base elevation
Material material = tester.widget<Material>(rawButtonMaterial);
......
......@@ -121,6 +121,7 @@ void main() {
});
testWidgets('ink response changes color on focus', (WidgetTester tester) async {
WidgetsBinding.instance.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
final FocusNode focusNode = FocusNode(debugLabel: 'Ink Focus');
await tester.pumpWidget(Material(
child: Directionality(
......@@ -154,6 +155,40 @@ void main() {
..rect(rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), color: const Color(0xff0000ff)));
});
testWidgets("ink response doesn't change color on focus when on touch device", (WidgetTester tester) async {
WidgetsBinding.instance.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTouch;
final FocusNode focusNode = FocusNode(debugLabel: 'Ink Focus');
await tester.pumpWidget(Material(
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Focus(
focusNode: focusNode,
child: Container(
width: 100,
height: 100,
child: InkWell(
hoverColor: const Color(0xff00ff00),
splashColor: const Color(0xffff0000),
focusColor: const Color(0xff0000ff),
highlightColor: const Color(0xf00fffff),
onTap: () {},
onLongPress: () {},
onHover: (bool hover) {}
),
),
),
),
),
));
await tester.pumpAndSettle();
final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures');
expect(inkFeatures, paintsExactlyCountTimes(#rect, 0));
focusNode.requestFocus();
await tester.pumpAndSettle();
expect(inkFeatures, paintsExactlyCountTimes(#rect, 0));
});
group('feedback', () {
FeedbackTester feedback;
......
......@@ -215,6 +215,7 @@ void main() {
const Key key = Key('test');
const Color focusColor = Color(0xff00ff00);
WidgetsBinding.instance.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
await tester.pumpWidget(
MaterialApp(
home: Center(
......@@ -241,6 +242,7 @@ void main() {
const Key key = Key('test');
const Color hoverColor = Color(0xff00ff00);
WidgetsBinding.instance.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
await tester.pumpWidget(
MaterialApp(
home: Center(
......
......@@ -5,6 +5,7 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
......@@ -483,6 +484,55 @@ void main() {
// receive it.
expect(receivedAnEvent, isEmpty);
});
testWidgets('Events change focus highlight mode.', (WidgetTester tester) async {
await setupWidget(tester);
int callCount = 0;
FocusHighlightMode lastMode;
void handleModeChange(FocusHighlightMode mode) {
lastMode = mode;
callCount++;
}
final FocusManager focusManager = WidgetsBinding.instance.focusManager;
focusManager.addHighlightModeListener(handleModeChange);
addTearDown(() => focusManager.removeHighlightModeListener(handleModeChange));
expect(callCount, equals(0));
expect(lastMode, isNull);
focusManager.highlightStrategy = FocusHighlightStrategy.automatic;
expect(focusManager.highlightMode, equals(FocusHighlightMode.touch));
sendFakeKeyEvent(<String, dynamic>{
'type': 'keydown',
'keymap': 'fuchsia',
'hidUsage': 0x04,
'codePoint': 0x64,
'modifiers': RawKeyEventDataFuchsia.modifierLeftMeta,
});
expect(callCount, equals(1));
expect(lastMode, FocusHighlightMode.traditional);
expect(focusManager.highlightMode, equals(FocusHighlightMode.traditional));
await tester.tap(find.byType(Container));
expect(callCount, equals(2));
expect(lastMode, FocusHighlightMode.touch);
expect(focusManager.highlightMode, equals(FocusHighlightMode.touch));
final TestGesture gesture = await tester.startGesture(Offset.zero, kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.up();
expect(callCount, equals(3));
expect(lastMode, FocusHighlightMode.traditional);
expect(focusManager.highlightMode, equals(FocusHighlightMode.traditional));
await tester.tap(find.byType(Container));
expect(callCount, equals(4));
expect(lastMode, FocusHighlightMode.touch);
expect(focusManager.highlightMode, equals(FocusHighlightMode.touch));
focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
expect(callCount, equals(5));
expect(lastMode, FocusHighlightMode.traditional);
expect(focusManager.highlightMode, equals(FocusHighlightMode.traditional));
focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTouch;
expect(callCount, equals(6));
expect(lastMode, FocusHighlightMode.touch);
expect(focusManager.highlightMode, equals(FocusHighlightMode.touch));
});
testWidgets('implements debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
FocusScopeNode(
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment