diff --git a/packages/flutter/lib/src/material/ink_well.dart b/packages/flutter/lib/src/material/ink_well.dart index 91e2db5171..5c261d338b 100644 --- a/packages/flutter/lib/src/material/ink_well.dart +++ b/packages/flutter/lib/src/material/ink_well.dart @@ -478,6 +478,7 @@ class _InkResponseState extends State 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 extends State with AutomaticKe @override void dispose() { + WidgetsBinding.instance.focusManager.removeHighlightModeListener(_handleFocusHighlightModeChange); _focusNode?.removeListener(_handleFocusUpdate); super.dispose(); } @@ -608,8 +610,25 @@ class _InkResponseState extends State 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); } diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart index 3447f63735..3653f1ef2c 100644 --- a/packages/flutter/lib/src/widgets/focus_manager.dart +++ b/packages/flutter/lib/src/widgets/focus_manager.dart @@ -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> _listeners; + + /// Register a closure to be called when the [FocusManager] notifies its listeners + /// that the value of [highlightMode] has changed. + void addHighlightModeListener(ValueChanged listener) { + _listeners ??= ObserverList>(); + _listeners.add(listener); + } + + /// Remove a previously registered closure from the list of closures that the + /// [FocusManager] notifies. + void removeHighlightModeListener(ValueChanged listener) { + _listeners?.remove(listener); + } + + void _notifyHighlightModeListeners() { + if (_listeners != null) { + final List> localListeners = List>.from(_listeners); + for (ValueChanged 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( + '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) { diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index e1b0c7bf2a..b75d3754c3 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -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]. diff --git a/packages/flutter/test/material/buttons_test.dart b/packages/flutter/test/material/buttons_test.dart index 5589e38395..99ae3b08a2 100644 --- a/packages/flutter/test/material/buttons_test.dart +++ b/packages/flutter/test/material/buttons_test.dart @@ -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(rawButtonMaterial); diff --git a/packages/flutter/test/material/ink_well_test.dart b/packages/flutter/test/material/ink_well_test.dart index b9418ff0e5..73a4543873 100644 --- a/packages/flutter/test/material/ink_well_test.dart +++ b/packages/flutter/test/material/ink_well_test.dart @@ -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; diff --git a/packages/flutter/test/material/raw_material_button_test.dart b/packages/flutter/test/material/raw_material_button_test.dart index da4a1df52a..8ba113911d 100644 --- a/packages/flutter/test/material/raw_material_button_test.dart +++ b/packages/flutter/test/material/raw_material_button_test.dart @@ -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( diff --git a/packages/flutter/test/widgets/focus_manager_test.dart b/packages/flutter/test/widgets/focus_manager_test.dart index 0db8db5fab..8e9b112a7c 100644 --- a/packages/flutter/test/widgets/focus_manager_test.dart +++ b/packages/flutter/test/widgets/focus_manager_test.dart @@ -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({ + '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(