Add MediaQueryData.navigationMode and allow controls to be focused when disabled. (#54919)

This adds a new navigationMode to the MediaQueryData that indicates how focusable controls should behave under different navigation modes, currently with two modes: NavigationMode.traditional, and NavigationMode.directional.

It may seem like focusing a disabled control is not desirable, but this is useful for user interfaces that use DPAD navigation because if a control gets disabled, losing focus is disruptive to the user, and it is difficult to control where the focus will end up unless it is done explicitly.
This commit is contained in:
Greg Spencer 2020-05-27 15:30:19 -07:00 committed by GitHub
parent bd06749edc
commit 8ef2915453
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 581 additions and 159 deletions

View File

@ -372,10 +372,10 @@ class IconButton extends StatelessWidget {
onTap: onPressed,
enableFeedback: enableFeedback,
child: result,
focusColor: focusColor ?? Theme.of(context).focusColor,
hoverColor: hoverColor ?? Theme.of(context).hoverColor,
highlightColor: highlightColor ?? Theme.of(context).highlightColor,
splashColor: splashColor ?? Theme.of(context).splashColor,
focusColor: focusColor ?? theme.focusColor,
hoverColor: hoverColor ?? theme.hoverColor,
highlightColor: highlightColor ?? theme.highlightColor,
splashColor: splashColor ?? theme.splashColor,
radius: splashRadius ?? math.max(
Material.defaultSplashRadius,
(iconSize + math.min(padding.horizontal, padding.vertical)) * 0.7,

View File

@ -871,6 +871,18 @@ class _InkResponseState extends State<_InnerInkResponse>
});
}
bool get _shouldShowFocus {
final NavigationMode mode = MediaQuery.of(context, nullOk: true)?.navigationMode ?? NavigationMode.traditional;
switch (mode) {
case NavigationMode.traditional:
return enabled && _hasFocus;
case NavigationMode.directional:
return _hasFocus;
}
assert(false, 'Navigation mode $mode not handled');
return null;
}
void _updateFocusHighlights() {
bool showFocus;
switch (FocusManager.instance.highlightMode) {
@ -878,7 +890,7 @@ class _InkResponseState extends State<_InnerInkResponse>
showFocus = false;
break;
case FocusHighlightMode.traditional:
showFocus = enabled && _hasFocus;
showFocus = _shouldShowFocus;
break;
}
updateHighlight(_HighlightType.focus, value: showFocus);
@ -991,6 +1003,18 @@ class _InkResponseState extends State<_InnerInkResponse>
}
}
bool get _canRequestFocus {
final NavigationMode mode = MediaQuery.of(context, nullOk: true)?.navigationMode ?? NavigationMode.traditional;
switch (mode) {
case NavigationMode.traditional:
return enabled && widget.canRequestFocus;
case NavigationMode.directional:
return true;
}
assert(false, 'NavigationMode $mode not handled.');
return null;
}
@override
Widget build(BuildContext context) {
assert(widget.debugCheckContext(context));
@ -999,14 +1023,13 @@ class _InkResponseState extends State<_InnerInkResponse>
_highlights[type]?.color = getHighlightColorForType(type);
}
_currentSplash?.color = widget.splashColor ?? Theme.of(context).splashColor;
final bool canRequestFocus = enabled && widget.canRequestFocus;
return _ParentInkResponseProvider(
state: this,
child: Actions(
actions: _actionMap,
child: Focus(
focusNode: widget.focusNode,
canRequestFocus: canRequestFocus,
canRequestFocus: _canRequestFocus,
onFocusChange: _handleFocusUpdate,
autofocus: widget.autofocus,
child: MouseRegion(

View File

@ -1772,7 +1772,7 @@ class InputDecorator extends StatefulWidget {
/// be null.
const InputDecorator({
Key key,
this.decoration,
@required this.decoration,
this.baseStyle,
this.textAlign,
this.textAlignVertical,
@ -1781,7 +1781,8 @@ class InputDecorator extends StatefulWidget {
this.expands = false,
this.isEmpty = false,
this.child,
}) : assert(isFocused != null),
}) : assert(decoration != null),
assert(isFocused != null),
assert(isHovering != null),
assert(expands != null),
assert(isEmpty != null),
@ -1789,9 +1790,10 @@ class InputDecorator extends StatefulWidget {
/// The text and styles to use when decorating the child.
///
/// If null, `const InputDecoration()` is used. Null [InputDecoration]
/// properties are initialized with the corresponding values from
/// [ThemeData.inputDecorationTheme].
/// Null [InputDecoration] properties are initialized with the corresponding
/// values from [ThemeData.inputDecorationTheme].
///
/// Must not be null.
final InputDecoration decoration;
/// The style on which to base the label, hint, counter, and error styles
@ -1880,7 +1882,9 @@ class InputDecorator extends StatefulWidget {
/// Whether the label needs to get out of the way of the input, either by
/// floating or disappearing.
bool get _labelShouldWithdraw => !isEmpty || isFocused;
///
/// Will withdraw when not empty, or when focused while enabled.
bool get _labelShouldWithdraw => !isEmpty || (isFocused && decoration.enabled);
@override
_InputDecoratorState createState() => _InputDecoratorState();
@ -1964,7 +1968,7 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat
}
TextAlign get textAlign => widget.textAlign;
bool get isFocused => widget.isFocused && decoration.enabled;
bool get isFocused => widget.isFocused;
bool get isHovering => widget.isHovering && decoration.enabled;
bool get isEmpty => widget.isEmpty;
bool get _floatingLabelEnabled {
@ -2061,7 +2065,7 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat
}
Color _getDefaultIconColor(ThemeData themeData) {
if (!decoration.enabled)
if (!decoration.enabled && !isFocused)
return themeData.disabledColor;
switch (themeData.brightness) {
@ -2123,7 +2127,7 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat
}
Color borderColor;
if (decoration.enabled) {
if (decoration.enabled || isFocused) {
borderColor = decoration.errorText == null
? _getDefaultBorderColor(themeData)
: themeData.errorColor;

View File

@ -1108,6 +1108,18 @@ class PopupMenuButtonState<T> extends State<PopupMenuButton<T>> {
return null;
}
bool get _canRequestFocus {
final NavigationMode mode = MediaQuery.of(context, nullOk: true)?.navigationMode ?? NavigationMode.traditional;
switch (mode) {
case NavigationMode.traditional:
return widget.enabled;
case NavigationMode.directional:
return true;
}
assert(false, 'Navigation mode $mode not handled');
return null;
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMaterialLocalizations(context));
@ -1117,7 +1129,7 @@ class PopupMenuButtonState<T> extends State<PopupMenuButton<T>> {
message: widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip,
child: InkWell(
onTap: widget.enabled ? showButtonMenu : null,
canRequestFocus: widget.enabled,
canRequestFocus: _canRequestFocus,
child: widget.child,
),
);

View File

@ -891,6 +891,24 @@ class _TextFieldState extends State<TextField> implements TextSelectionGestureDe
_effectiveFocusNode.canRequestFocus = _isEnabled;
}
bool get _canRequestFocus {
final NavigationMode mode = MediaQuery.of(context, nullOk: true)?.navigationMode ?? NavigationMode.traditional;
switch (mode) {
case NavigationMode.traditional:
return _isEnabled;
case NavigationMode.directional:
return true;
}
assert(false, 'Navigation mode $mode not handled');
return null;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_effectiveFocusNode.canRequestFocus = _canRequestFocus;
}
@override
void didUpdateWidget(TextField oldWidget) {
super.didUpdateWidget(oldWidget);
@ -898,8 +916,8 @@ class _TextFieldState extends State<TextField> implements TextSelectionGestureDe
_controller = TextEditingController.fromValue(oldWidget.controller.value);
else if (widget.controller != null && oldWidget.controller == null)
_controller = null;
_effectiveFocusNode.canRequestFocus = _isEnabled;
if (_effectiveFocusNode.hasFocus && widget.readOnly != oldWidget.readOnly) {
_effectiveFocusNode.canRequestFocus = _canRequestFocus;
if (_effectiveFocusNode.hasFocus && widget.readOnly != oldWidget.readOnly && _isEnabled) {
if(_effectiveController.selection.isCollapsed) {
_showSelectionHandles = !widget.readOnly;
}
@ -930,6 +948,9 @@ class _TextFieldState extends State<TextField> implements TextSelectionGestureDe
if (widget.readOnly && _effectiveController.selection.isCollapsed)
return false;
if (!_isEnabled)
return false;
if (cause == SelectionChangedCause.longPress)
return true;
@ -1034,7 +1055,7 @@ class _TextFieldState extends State<TextField> implements TextSelectionGestureDe
Widget child = RepaintBoundary(
child: EditableText(
key: editableTextKey,
readOnly: widget.readOnly,
readOnly: widget.readOnly || !_isEnabled,
toolbarOptions: widget.toolbarOptions,
showCursor: widget.showCursor,
showSelectionHandles: _showSelectionHandles,

View File

@ -10,6 +10,7 @@ import 'basic.dart';
import 'focus_manager.dart';
import 'focus_scope.dart';
import 'framework.dart';
import 'media_query.dart';
import 'shortcuts.dart';
// BuildContext/Element doesn't have a parent accessor, but it can be
@ -1012,8 +1013,20 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> {
return _hovering && target.enabled && _canShowHighlight;
}
bool canRequestFocus(FocusableActionDetector target) {
final NavigationMode mode = MediaQuery.of(context, nullOk: true)?.navigationMode ?? NavigationMode.traditional;
switch (mode) {
case NavigationMode.traditional:
return target.enabled;
case NavigationMode.directional:
return true;
}
assert(false, 'Navigation mode $mode not handled');
return null;
}
bool shouldShowFocusHighlight(FocusableActionDetector target) {
return _focused && target.enabled && _canShowHighlight;
return _focused && _canShowHighlight && canRequestFocus(target);
}
assert(SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks);
@ -1043,6 +1056,18 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> {
}
}
bool get _canRequestFocus {
final NavigationMode mode = MediaQuery.of(context, nullOk: true)?.navigationMode ?? NavigationMode.traditional;
switch (mode) {
case NavigationMode.traditional:
return widget.enabled;
case NavigationMode.directional:
return true;
}
assert(false, 'NavigationMode $mode not handled.');
return null;
}
@override
Widget build(BuildContext context) {
Widget child = MouseRegion(
@ -1051,7 +1076,7 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> {
child: Focus(
focusNode: widget.focusNode,
autofocus: widget.autofocus,
canRequestFocus: widget.enabled,
canRequestFocus: _canRequestFocus,
onFocusChange: _handleFocusChange,
child: widget.child,
),

View File

@ -102,6 +102,7 @@ class MediaQueryData {
this.highContrast = false,
this.disableAnimations = false,
this.boldText = false,
this.navigationMode = NavigationMode.traditional,
}) : assert(size != null),
assert(devicePixelRatio != null),
assert(textScaleFactor != null),
@ -116,7 +117,8 @@ class MediaQueryData {
assert(invertColors != null),
assert(highContrast != null),
assert(disableAnimations != null),
assert(boldText != null);
assert(boldText != null),
assert(navigationMode != null);
/// Creates data for a media query based on the given window.
///
@ -139,7 +141,8 @@ class MediaQueryData {
disableAnimations = window.accessibilityFeatures.disableAnimations,
boldText = window.accessibilityFeatures.boldText,
highContrast = window.accessibilityFeatures.highContrast,
alwaysUse24HourFormat = window.alwaysUse24HourFormat;
alwaysUse24HourFormat = window.alwaysUse24HourFormat,
navigationMode = NavigationMode.traditional;
/// The size of the media in logical pixels (e.g, the size of the screen).
///
@ -355,6 +358,23 @@ class MediaQueryData {
/// * [Window.AccessibilityFeatures], where the setting originates.
final bool boldText;
/// Describes the navigation mode requested by the platform.
///
/// Some user interfaces are better navigated using a directional pad (DPAD)
/// or arrow keys, and for those interfaces, some widgets need to handle these
/// directional events differently. In order to know when to do that, these
/// widgets will look for the navigation mode in effect for their context.
///
/// For instance, in a television interface, [NavigationMode.directional]
/// should be set, so that directional navigation is used to navigate away
/// from a text field using the DPAD. In contrast, on a regular desktop
/// application with the `navigationMode` set to [NavigationMode.traditional],
/// the arrow keys are used to move the cursor instead of navigating away.
///
/// The [NavigationMode] values indicate the type of navigation to be used in
/// a widget subtree for those widgets sensitive to it.
final NavigationMode navigationMode;
/// The orientation of the media (e.g., whether the device is in landscape or
/// portrait mode).
Orientation get orientation {
@ -379,6 +399,7 @@ class MediaQueryData {
bool invertColors,
bool accessibleNavigation,
bool boldText,
NavigationMode navigationMode,
}) {
return MediaQueryData(
size: size ?? this.size,
@ -396,6 +417,7 @@ class MediaQueryData {
disableAnimations: disableAnimations ?? this.disableAnimations,
accessibleNavigation: accessibleNavigation ?? this.accessibleNavigation,
boldText: boldText ?? this.boldText,
navigationMode: navigationMode ?? this.navigationMode,
);
}
@ -563,7 +585,8 @@ class MediaQueryData {
&& other.disableAnimations == disableAnimations
&& other.invertColors == invertColors
&& other.accessibleNavigation == accessibleNavigation
&& other.boldText == boldText;
&& other.boldText == boldText
&& other.navigationMode == navigationMode;
}
@override
@ -583,6 +606,7 @@ class MediaQueryData {
invertColors,
accessibleNavigation,
boldText,
navigationMode,
);
}
@ -603,6 +627,7 @@ class MediaQueryData {
'disableAnimations: $disableAnimations',
'invertColors: $invertColors',
'boldText: $boldText',
'navigationMode: ${describeEnum(navigationMode)}',
];
return '${objectRuntimeType(this, 'MediaQueryData')}(${properties.join(', ')})';
}
@ -852,3 +877,31 @@ class MediaQuery extends InheritedWidget {
properties.add(DiagnosticsProperty<MediaQueryData>('data', data, showName: false));
}
}
/// Describes the navigation mode to be set by a [MediaQuery] widget
///
/// The different modes indicate the type of navigation to be used in a widget
/// subtree for those widgets sensitive to it.
///
/// Use `MediaQuery.of(context).navigationMode` to determine the navigation mode
/// in effect for the given context. Use a [MediaQuery] widget to set the
/// navigation mode for its descendant widgets.
enum NavigationMode {
/// This indicates a traditional keyboard-and-mouse navigation modality.
///
/// This navigation mode is where the arrow keys can be used for secondary
/// modification operations, like moving sliders or cursors, and disabled
/// controls will lose focus and not be traversable.
traditional,
/// This indicates a directional-based navigation mode.
///
/// This navigation mode indicates that arrow keys should be reserved for
/// navigation operations, and secondary modifications operations, like moving
/// sliders or cursors, will use alternative bindings or be disabled.
///
/// Some behaviors are also affected by this mode. For instance, disabled
/// controls will retain focus when disabled, and will be able to receive
/// focus (although they remain disabled) when traversed.
directional,
}

View File

@ -459,6 +459,46 @@ void main() {
expect(focusNode.hasPrimaryFocus, isFalse);
});
testWidgets('IconButton keeps focus when disabled in directional navigation mode.', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode(debugLabel: 'IconButton');
await tester.pumpWidget(
wrap(
child: MediaQuery(
data: const MediaQueryData(
navigationMode: NavigationMode.directional,
),
child: IconButton(
focusNode: focusNode,
autofocus: true,
onPressed: () {},
icon: const Icon(Icons.link),
),
),
),
);
await tester.pump();
expect(focusNode.hasPrimaryFocus, isTrue);
await tester.pumpWidget(
wrap(
child: MediaQuery(
data: const MediaQueryData(
navigationMode: NavigationMode.directional,
),
child: IconButton(
focusNode: focusNode,
autofocus: true,
onPressed: null,
icon: const Icon(Icons.link),
),
),
),
);
await tester.pump();
expect(focusNode.hasPrimaryFocus, isTrue);
});
testWidgets("Disabled IconButton can't be traversed to when disabled.", (WidgetTester tester) async {
final FocusNode focusNode1 = FocusNode(debugLabel: 'IconButton 1');
final FocusNode focusNode2 = FocusNode(debugLabel: 'IconButton 2');

View File

@ -322,6 +322,7 @@ void main() {
semantics.dispose();
});
testWidgets("ink response doesn't focus when disabled", (WidgetTester tester) async {
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch;
final FocusNode focusNode = FocusNode(debugLabel: 'Ink Focus');
@ -358,6 +359,52 @@ void main() {
expect(focusNode.hasPrimaryFocus, isFalse);
});
testWidgets('ink response accepts focus when disabled in directional navigation mode', (WidgetTester tester) async {
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch;
final FocusNode focusNode = FocusNode(debugLabel: 'Ink Focus');
final GlobalKey childKey = GlobalKey();
await tester.pumpWidget(
Material(
child: MediaQuery(
data: const MediaQueryData(
navigationMode: NavigationMode.directional,
),
child: Directionality(
textDirection: TextDirection.ltr,
child: InkWell(
autofocus: true,
onTap: () {},
onLongPress: () {},
onHover: (bool hover) {},
focusNode: focusNode,
child: Container(key: childKey),
),
),
),
),
);
await tester.pumpAndSettle();
expect(focusNode.hasPrimaryFocus, isTrue);
await tester.pumpWidget(
Material(
child: MediaQuery(
data: const MediaQueryData(
navigationMode: NavigationMode.directional,
),
child: Directionality(
textDirection: TextDirection.ltr,
child: InkWell(
focusNode: focusNode,
child: Container(key: childKey),
),
),
),
),
);
await tester.pumpAndSettle();
expect(focusNode.hasPrimaryFocus, isTrue);
});
testWidgets("ink response doesn't hover when disabled", (WidgetTester tester) async {
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch;
final FocusNode focusNode = FocusNode(debugLabel: 'Ink Focus');

View File

@ -72,6 +72,17 @@ double getBorderBottom(WidgetTester tester) {
return box.size.height;
}
Finder findLabel() {
return find.descendant(
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_Shaker'),
matching: find.byWidgetPredicate((Widget w) => w is Text),
);
}
Rect getLabelRect(WidgetTester tester) {
return tester.getRect(findLabel());
}
InputBorder getBorder(WidgetTester tester) {
if (!tester.any(findBorderPainter()))
return null;
@ -3455,6 +3466,66 @@ void main() {
expect(getBorderColor(tester), equals(disabledColor));
});
testWidgets('InputDecorator withdraws label when not empty or focused', (WidgetTester tester) async {
Future<void> pumpDecorator({bool focused, bool enabled = true, bool filled = false, bool empty = true, bool directional = false}) async {
return await tester.pumpWidget(
buildInputDecorator(
isEmpty: empty,
isFocused: focused,
decoration: InputDecoration(
labelText: 'Label',
enabled: enabled,
filled: filled,
focusedBorder: const OutlineInputBorder(),
disabledBorder: const OutlineInputBorder(),
border: const OutlineInputBorder(),
),
),
);
}
await pumpDecorator(focused: false, empty: true);
await tester.pumpAndSettle();
expect(getLabelRect(tester).topLeft, equals(const Offset(12, 20)));
expect(getLabelRect(tester).size, equals(const Size(80, 16)));
await pumpDecorator(focused: false, empty: false);
await tester.pumpAndSettle();
expect(getLabelRect(tester).topLeft, equals(const Offset(12, -4)));
expect(getLabelRect(tester).size, equals(const Size(80, 16)));
await pumpDecorator(focused: true, empty: true);
await tester.pumpAndSettle();
expect(getLabelRect(tester).topLeft, equals(const Offset(12, -4)));
expect(getLabelRect(tester).size, equals(const Size(80, 16)));
await pumpDecorator(focused: true, empty: false);
await tester.pumpAndSettle();
expect(getLabelRect(tester).topLeft, equals(const Offset(12, -4)));
expect(getLabelRect(tester).size, equals(const Size(80, 16)));
await pumpDecorator(focused: false, empty: true, enabled: false);
await tester.pumpAndSettle();
expect(getLabelRect(tester).topLeft, equals(const Offset(12, 20)));
expect(getLabelRect(tester).size, equals(const Size(80, 16)));
await pumpDecorator(focused: false, empty: false, enabled: false);
await tester.pumpAndSettle();
expect(getLabelRect(tester).topLeft, equals(const Offset(12, -4)));
expect(getLabelRect(tester).size, equals(const Size(80, 16)));
// Focused and disabled happens with NavigationMode.directional.
await pumpDecorator(focused: true, empty: true, enabled: false);
await tester.pumpAndSettle();
expect(getLabelRect(tester).topLeft, equals(const Offset(12, 20)));
expect(getLabelRect(tester).size, equals(const Size(80, 16)));
await pumpDecorator(focused: true, empty: false, enabled: false);
await tester.pumpAndSettle();
expect(getLabelRect(tester).topLeft, equals(const Offset(12, -4)));
expect(getLabelRect(tester).size, equals(const Size(80, 16)));
});
testWidgets('InputDecorationTheme.toString()', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/19305
expect(

View File

@ -127,36 +127,45 @@ void main() {
});
testWidgets('disabled PopupMenuButton will not call itemBuilder, onSelected or onCanceled', (WidgetTester tester) async {
final Key popupButtonKey = UniqueKey();
final GlobalKey popupButtonKey = GlobalKey();
bool itemBuilderCalled = false;
bool onSelectedCalled = false;
bool onCanceledCalled = false;
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Column(
children: <Widget>[
PopupMenuButton<int>(
key: popupButtonKey,
enabled: false,
itemBuilder: (BuildContext context) {
itemBuilderCalled = true;
return <PopupMenuEntry<int>>[
const PopupMenuItem<int>(
value: 1,
child: Text('Tap me please!'),
),
];
},
onSelected: (int selected) => onSelectedCalled = true,
onCanceled: () => onCanceledCalled = true,
Widget buildApp({bool directional = false}) {
return MaterialApp(
home: Builder(builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
navigationMode: NavigationMode.directional,
),
child: Material(
child: Column(
children: <Widget>[
PopupMenuButton<int>(
child: Text('Tap Me', key: popupButtonKey),
enabled: false,
itemBuilder: (BuildContext context) {
itemBuilderCalled = true;
return <PopupMenuEntry<int>>[
const PopupMenuItem<int>(
value: 1,
child: Text('Tap me please!'),
),
];
},
onSelected: (int selected) => onSelectedCalled = true,
onCanceled: () => onCanceledCalled = true,
),
],
),
],
),
),
),
);
),
);
}),
);
}
await tester.pumpWidget(buildApp());
// Try to bring up the popup menu and select the first item from it
await tester.tap(find.byKey(popupButtonKey));
@ -173,6 +182,27 @@ void main() {
await tester.pumpAndSettle();
expect(itemBuilderCalled, isFalse);
expect(onCanceledCalled, isFalse);
// Test again, with directional navigation mode and after focusing the button.
await tester.pumpWidget(buildApp(directional: true));
// Try to bring up the popup menu and select the first item from it
Focus.of(popupButtonKey.currentContext).requestFocus();
await tester.pumpAndSettle();
await tester.tap(find.byKey(popupButtonKey));
await tester.pumpAndSettle();
await tester.tap(find.byKey(popupButtonKey));
await tester.pumpAndSettle();
expect(itemBuilderCalled, isFalse);
expect(onSelectedCalled, isFalse);
// Try to bring up the popup menu and tap outside it to cancel the menu
await tester.tap(find.byKey(popupButtonKey));
await tester.pumpAndSettle();
await tester.tapAt(const Offset(0.0, 0.0));
await tester.pumpAndSettle();
expect(itemBuilderCalled, isFalse);
expect(onCanceledCalled, isFalse);
});
testWidgets('disabled PopupMenuButton is not focusable', (WidgetTester tester) async {
@ -214,6 +244,47 @@ void main() {
expect(onSelectedCalled, isFalse);
});
testWidgets('disabled PopupMenuButton is focusable with directional navigation', (WidgetTester tester) async {
final Key popupButtonKey = UniqueKey();
final GlobalKey childKey = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Builder(builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
navigationMode: NavigationMode.directional,
),
child: Material(
child: Column(
children: <Widget>[
PopupMenuButton<int>(
key: popupButtonKey,
child: Container(key: childKey),
enabled: false,
itemBuilder: (BuildContext context) {
return <PopupMenuEntry<int>>[
const PopupMenuItem<int>(
value: 1,
child: Text('Tap me please!'),
),
];
},
onSelected: (int selected) {},
),
],
),
),
);
}),
),
);
Focus.of(childKey.currentContext, nullOk: true).requestFocus();
await tester.pump();
expect(Focus.of(childKey.currentContext, nullOk: true).hasPrimaryFocus, isTrue);
});
testWidgets('PopupMenuItem is only focusable when enabled', (WidgetTester tester) async {
final Key popupButtonKey = UniqueKey();
final GlobalKey childKey = GlobalKey();

View File

@ -3713,45 +3713,6 @@ void main() {
semantics.dispose();
});
testWidgets('TextField loses focus when disabled', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode(debugLabel: 'TextField');
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
focusNode: focusNode,
autofocus: true,
maxLength: 10,
enabled: true,
),
),
),
),
);
await tester.pump();
expect(focusNode.hasPrimaryFocus, isTrue);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
focusNode: focusNode,
autofocus: true,
maxLength: 10,
enabled: false,
),
),
),
),
);
await tester.pump();
expect(focusNode.hasPrimaryFocus, isFalse);
});
testWidgets("Disabled TextField can't be traversed to when disabled.", (WidgetTester tester) async {
final FocusNode focusNode1 = FocusNode(debugLabel: 'TextField 1');
final FocusNode focusNode2 = FocusNode(debugLabel: 'TextField 2');
@ -5147,6 +5108,47 @@ void main() {
),
);
expect(focusNode.hasFocus, isFalse);
await tester.pumpWidget(
boilerplate(
child: Builder(builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
navigationMode: NavigationMode.directional,
),
child: TextField(
focusNode: focusNode,
autofocus: true,
enabled: true,
),
);
}),
),
);
focusNode.requestFocus();
await tester.pump();
expect(focusNode.hasFocus, isTrue);
await tester.pumpWidget(
boilerplate(
child: Builder(builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
navigationMode: NavigationMode.directional,
),
child: TextField(
focusNode: focusNode,
autofocus: true,
enabled: false,
),
);
}),
),
);
await tester.pump();
expect(focusNode.hasFocus, isTrue);
});
testWidgets('TextField displays text with text direction', (WidgetTester tester) async {

View File

@ -317,76 +317,6 @@ void main() {
expect(() => Actions.find<DoNothingIntent>(containerKey.currentContext), throwsAssertionError);
expect(Actions.find<DoNothingIntent>(containerKey.currentContext, nullOk: true), isNull);
});
testWidgets('FocusableActionDetector keeps track of focus and hover even when disabled.', (WidgetTester tester) async {
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
final GlobalKey containerKey = GlobalKey();
bool invoked = false;
const Intent intent = TestIntent();
final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
final Action<Intent> testAction = TestAction(
onInvoke: (Intent intent) {
invoked = true;
return invoked;
},
);
bool hovering = false;
bool focusing = false;
Future<void> buildTest(bool enabled) async {
await tester.pumpWidget(
Center(
child: Actions(
dispatcher: TestDispatcher1(postInvoke: collect),
actions: const <Type, Action<Intent>>{},
child: FocusableActionDetector(
enabled: enabled,
focusNode: focusNode,
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.enter): intent,
},
actions: <Type, Action<Intent>>{
TestIntent: testAction,
},
onShowHoverHighlight: (bool value) => hovering = value,
onShowFocusHighlight: (bool value) => focusing = value,
child: Container(width: 100, height: 100, key: containerKey),
),
),
),
);
return tester.pump();
}
await buildTest(true);
focusNode.requestFocus();
await tester.pump();
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.byKey(containerKey)));
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
expect(hovering, isTrue);
expect(focusing, isTrue);
expect(invoked, isTrue);
invoked = false;
await buildTest(false);
expect(hovering, isFalse);
expect(focusing, isFalse);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(invoked, isFalse);
await buildTest(true);
expect(focusing, isFalse);
expect(hovering, isTrue);
await buildTest(false);
expect(focusing, isFalse);
expect(hovering, isFalse);
await gesture.moveTo(Offset.zero);
await buildTest(true);
expect(hovering, isFalse);
expect(focusing, isFalse);
});
});
group('Listening', () {
@ -554,6 +484,129 @@ void main() {
});
});
group(FocusableActionDetector, () {
const Intent intent = TestIntent();
bool invoked;
bool hovering;
bool focusing;
FocusNode focusNode;
Action<Intent> testAction;
Future<void> pumpTest(
WidgetTester tester, {
bool enabled = true,
bool directional = false,
@required Key key,
}) async {
await tester.pumpWidget(
MediaQuery(
data: MediaQueryData(
navigationMode: directional ? NavigationMode.directional : NavigationMode.traditional,
),
child: Center(
child: Actions(
dispatcher: const TestDispatcher1(),
actions: const <Type, Action<Intent>>{},
child: FocusableActionDetector(
enabled: enabled,
focusNode: focusNode,
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.enter): intent,
},
actions: <Type, Action<Intent>>{
TestIntent: testAction,
},
onShowHoverHighlight: (bool value) => hovering = value,
onShowFocusHighlight: (bool value) => focusing = value,
child: Container(width: 100, height: 100, key: key),
),
),
),
),
);
return tester.pump();
}
setUp(() async {
invoked = false;
hovering = false;
focusing = false;
focusNode = FocusNode(debugLabel: 'Test Node');
testAction = TestAction(
onInvoke: (Intent intent) {
invoked = true;
return invoked;
},
);
});
testWidgets('FocusableActionDetector keeps track of focus and hover even when disabled.', (WidgetTester tester) async {
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
final GlobalKey containerKey = GlobalKey();
await pumpTest(tester, enabled: true, key: containerKey);
focusNode.requestFocus();
await tester.pump();
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.byKey(containerKey)));
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
expect(hovering, isTrue);
expect(focusing, isTrue);
expect(invoked, isTrue);
invoked = false;
await pumpTest(tester, enabled: false, key: containerKey);
expect(hovering, isFalse);
expect(focusing, isFalse);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(invoked, isFalse);
await pumpTest(tester, enabled: true, key: containerKey);
expect(focusing, isFalse);
expect(hovering, isTrue);
await pumpTest(tester, enabled: false, key: containerKey);
expect(focusing, isFalse);
expect(hovering, isFalse);
await gesture.moveTo(Offset.zero);
await pumpTest(tester, enabled: true, key: containerKey);
expect(hovering, isFalse);
expect(focusing, isFalse);
});
testWidgets('FocusableActionDetector shows focus highlight appropriately when focused and disabled', (WidgetTester tester) async {
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
final GlobalKey containerKey = GlobalKey();
await pumpTest(tester, enabled: true, key: containerKey);
await tester.pump();
expect(focusing, isFalse);
await pumpTest(tester, enabled: true, key: containerKey);
focusNode.requestFocus();
await tester.pump();
expect(focusing, isTrue);
focusing = false;
await pumpTest(tester, enabled: false, key: containerKey);
focusNode.requestFocus();
await tester.pump();
expect(focusing, isFalse);
await pumpTest(tester, enabled: false, key: containerKey);
focusNode.requestFocus();
await tester.pump();
expect(focusing, isFalse);
// In directional navigation, focus should show, even if disabled.
await pumpTest(tester, enabled: false, key: containerKey, directional: true);
focusNode.requestFocus();
await tester.pump();
expect(focusing, isTrue);
});
});
group('Diagnostics', () {
testWidgets('default Intent debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();