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

Add a Focus node to the DropdownButton, and adds an activation action for it. (#42811)

This adds a Focus node to the DropdownButton widget, allowing it to receive keyboard focus, and to show a focus highlight. In addition, I added the ability to activate the dropdown using the "enter" key binding (which is bound to ActivateAction in the WidgetsApp).

Related Issues
Fixes #42646
Fixes #43008
Fixes #42511
parent 18ff9a24
...@@ -698,11 +698,15 @@ class DropdownButton<T> extends StatefulWidget { ...@@ -698,11 +698,15 @@ class DropdownButton<T> extends StatefulWidget {
this.isDense = false, this.isDense = false,
this.isExpanded = false, this.isExpanded = false,
this.itemHeight = kMinInteractiveDimension, this.itemHeight = kMinInteractiveDimension,
this.focusColor,
this.focusNode,
this.autofocus = false,
}) : assert(items == null || items.isEmpty || value == null || items.where((DropdownMenuItem<T> item) => item.value == value).length == 1), }) : assert(items == null || items.isEmpty || value == null || items.where((DropdownMenuItem<T> item) => item.value == value).length == 1),
assert(elevation != null), assert(elevation != null),
assert(iconSize != null), assert(iconSize != null),
assert(isDense != null), assert(isDense != null),
assert(isExpanded != null), assert(isExpanded != null),
assert(autofocus != null),
assert(itemHeight == null || itemHeight >= kMinInteractiveDimension), assert(itemHeight == null || itemHeight >= kMinInteractiveDimension),
super(key: key); super(key: key);
...@@ -900,6 +904,15 @@ class DropdownButton<T> extends StatefulWidget { ...@@ -900,6 +904,15 @@ class DropdownButton<T> extends StatefulWidget {
/// [kMinInteractiveDimension]. /// [kMinInteractiveDimension].
final double itemHeight; final double itemHeight;
/// The color for the button's [Material] when it has the input focus.
final Color focusColor;
/// {@macro flutter.widgets.Focus.focusNode}
final FocusNode focusNode;
/// {@macro flutter.widgets.Focus.autofocus}
final bool autofocus;
@override @override
_DropdownButtonState<T> createState() => _DropdownButtonState<T>(); _DropdownButtonState<T> createState() => _DropdownButtonState<T>();
} }
...@@ -908,17 +921,33 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi ...@@ -908,17 +921,33 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
int _selectedIndex; int _selectedIndex;
_DropdownRoute<T> _dropdownRoute; _DropdownRoute<T> _dropdownRoute;
Orientation _lastOrientation; Orientation _lastOrientation;
FocusNode _internalNode;
FocusNode get focusNode => widget.focusNode ?? _internalNode;
bool _hasPrimaryFocus = false;
Map<LocalKey, ActionFactory> _actionMap;
// Only used if needed to create _internalNode.
FocusNode _createFocusNode() {
return FocusNode(debugLabel: '${widget.runtimeType}');
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_updateSelectedIndex(); _updateSelectedIndex();
if (widget.focusNode == null) {
_internalNode ??= _createFocusNode();
}
_actionMap = <LocalKey, ActionFactory>{ ActivateAction.key: _createAction };
focusNode.addListener(_handleFocusChanged);
} }
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_removeDropdownRoute(); _removeDropdownRoute();
focusNode.removeListener(_handleFocusChanged);
_internalNode?.dispose();
super.dispose(); super.dispose();
} }
...@@ -928,9 +957,25 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi ...@@ -928,9 +957,25 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
_lastOrientation = null; _lastOrientation = null;
} }
void _handleFocusChanged() {
if (_hasPrimaryFocus != focusNode.hasPrimaryFocus) {
setState(() {
_hasPrimaryFocus = focusNode.hasPrimaryFocus;
});
}
}
@override @override
void didUpdateWidget(DropdownButton<T> oldWidget) { void didUpdateWidget(DropdownButton<T> oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (widget.focusNode != oldWidget.focusNode) {
oldWidget.focusNode?.removeListener(_handleFocusChanged);
if (widget.focusNode == null) {
_internalNode ??= _createFocusNode();
}
_hasPrimaryFocus = focusNode.hasPrimaryFocus;
focusNode.addListener(_handleFocusChanged);
}
_updateSelectedIndex(); _updateSelectedIndex();
} }
...@@ -992,6 +1037,15 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi ...@@ -992,6 +1037,15 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
}); });
} }
Action _createAction() {
return CallbackAction(
ActivateAction.key,
onInvoke: (FocusNode node, Intent intent) {
_handleTap();
},
);
}
// When isDense is true, reduce the height of this button from _kMenuItemHeight to // When isDense is true, reduce the height of this button from _kMenuItemHeight to
// _kDenseButtonHeight, but don't make it smaller than the text that it contains. // _kDenseButtonHeight, but don't make it smaller than the text that it contains.
// Similarly, we don't reduce the height of the button so much that its icon // Similarly, we don't reduce the height of the button so much that its icon
...@@ -1112,6 +1166,10 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi ...@@ -1112,6 +1166,10 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
Widget result = DefaultTextStyle( Widget result = DefaultTextStyle(
style: _textStyle, style: _textStyle,
child: Container( child: Container(
decoration: BoxDecoration(
color:_hasPrimaryFocus ? widget.focusColor ?? Theme.of(context).focusColor : null,
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
),
padding: padding.resolve(Directionality.of(context)), padding: padding.resolve(Directionality.of(context)),
height: widget.isDense ? _denseButtonHeight : null, height: widget.isDense ? _denseButtonHeight : null,
child: Row( child: Row(
...@@ -1161,10 +1219,18 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi ...@@ -1161,10 +1219,18 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
return Semantics( return Semantics(
button: true, button: true,
child: GestureDetector( child: Actions(
onTap: _enabled ? _handleTap : null, actions: _actionMap,
behavior: HitTestBehavior.opaque, child: Focus(
child: result, canRequestFocus: _enabled,
focusNode: focusNode,
autofocus: widget.autofocus,
child: GestureDetector(
onTap: _enabled ? _handleTap : null,
behavior: HitTestBehavior.opaque,
child: result,
),
),
), ),
); );
} }
......
...@@ -8,6 +8,7 @@ import 'dart:ui' show window; ...@@ -8,6 +8,7 @@ import 'dart:ui' show window;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import '../rendering/mock_canvas.dart'; import '../rendering/mock_canvas.dart';
import '../widgets/semantics_tester.dart'; import '../widgets/semantics_tester.dart';
...@@ -44,6 +45,9 @@ Widget buildFrame({ ...@@ -44,6 +45,9 @@ Widget buildFrame({
Alignment alignment = Alignment.center, Alignment alignment = Alignment.center,
TextDirection textDirection = TextDirection.ltr, TextDirection textDirection = TextDirection.ltr,
Size mediaSize, Size mediaSize,
FocusNode focusNode,
bool autofocus = false,
Color focusColor,
}) { }) {
return TestApp( return TestApp(
textDirection: textDirection, textDirection: textDirection,
...@@ -65,6 +69,9 @@ Widget buildFrame({ ...@@ -65,6 +69,9 @@ Widget buildFrame({
isDense: isDense, isDense: isDense,
isExpanded: isExpanded, isExpanded: isExpanded,
underline: underline, underline: underline,
focusNode: focusNode,
autofocus: autofocus,
focusColor: focusColor,
items: items == null ? null : items.map<DropdownMenuItem<String>>((String item) { items: items == null ? null : items.map<DropdownMenuItem<String>>((String item) {
return DropdownMenuItem<String>( return DropdownMenuItem<String>(
key: ValueKey<String>(item), key: ValueKey<String>(item),
...@@ -990,6 +997,7 @@ void main() { ...@@ -990,6 +997,7 @@ void main() {
isButton: true, isButton: true,
label: 'test', label: 'test',
hasTapAction: true, hasTapAction: true,
isFocusable: true,
)); ));
await tester.pumpWidget(buildFrame( await tester.pumpWidget(buildFrame(
...@@ -1005,6 +1013,7 @@ void main() { ...@@ -1005,6 +1013,7 @@ void main() {
isButton: true, isButton: true,
label: 'three', label: 'three',
hasTapAction: true, hasTapAction: true,
isFocusable: true,
)); ));
handle.dispose(); handle.dispose();
}); });
...@@ -1327,10 +1336,10 @@ void main() { ...@@ -1327,10 +1336,10 @@ void main() {
await tester.pumpWidget(buildFrame(buttonKey: buttonKey, underline: customUnderline, await tester.pumpWidget(buildFrame(buttonKey: buttonKey, underline: customUnderline,
value: 'two', onChanged: onChanged)); value: 'two', onChanged: onChanged));
expect(tester.widget<DecoratedBox>(decoratedBox).decoration, decoration); expect(tester.widgetList<DecoratedBox>(decoratedBox).last.decoration, decoration);
await tester.pumpWidget(buildFrame(buttonKey: buttonKey, value: 'two', onChanged: onChanged)); await tester.pumpWidget(buildFrame(buttonKey: buttonKey, value: 'two', onChanged: onChanged));
expect(tester.widget<DecoratedBox>(decoratedBox).decoration, defaultDecoration); expect(tester.widgetList<DecoratedBox>(decoratedBox).last.decoration, defaultDecoration);
}); });
testWidgets('DropdownButton selectedItemBuilder builds custom buttons', (WidgetTester tester) async { testWidgets('DropdownButton selectedItemBuilder builds custom buttons', (WidgetTester tester) async {
...@@ -1912,4 +1921,79 @@ void main() { ...@@ -1912,4 +1921,79 @@ void main() {
expect(tester.getTopLeft(find.text('-item0-')).dx, 8); expect(tester.getTopLeft(find.text('-item0-')).dx, 8);
}); });
testWidgets('DropdownButton can be focused, and has focusColor', (WidgetTester tester) async {
final UniqueKey buttonKey = UniqueKey();
final FocusNode focusNode = FocusNode(debugLabel: 'DropdownButton');
await tester.pumpWidget(buildFrame(buttonKey: buttonKey, onChanged: onChanged, focusNode: focusNode, autofocus: true));
await tester.pump(); // Pump a frame for autofocus to take effect.
expect(focusNode.hasPrimaryFocus, isTrue);
final Finder buttonFinder = find.byKey(buttonKey);
expect(buttonFinder, paints ..rrect(rrect: const RRect.fromLTRBXY(0.0, 0.0, 104.0, 48.0, 4.0, 4.0), color: const Color(0x1f000000)));
await tester.pumpWidget(buildFrame(buttonKey: buttonKey, onChanged: onChanged, focusNode: focusNode, focusColor: const Color(0xff00ff00)));
expect(buttonFinder, paints ..rrect(rrect: const RRect.fromLTRBXY(0.0, 0.0, 104.0, 48.0, 4.0, 4.0), color: const Color(0xff00ff00)));
});
testWidgets("DropdownButton won't be focused if not enabled", (WidgetTester tester) async {
final UniqueKey buttonKey = UniqueKey();
final FocusNode focusNode = FocusNode(debugLabel: 'DropdownButton');
await tester.pumpWidget(buildFrame(buttonKey: buttonKey, focusNode: focusNode, autofocus: true, focusColor: const Color(0xff00ff00)));
await tester.pump(); // Pump a frame for autofocus to take effect (although it shouldn't).
expect(focusNode.hasPrimaryFocus, isFalse);
expect(find.byKey(buttonKey), isNot(paints ..rrect(rrect: const RRect.fromLTRBXY(0.0, 0.0, 104.0, 48.0, 4.0, 4.0), color: const Color(0xff00ff00))));
});
testWidgets('DropdownButton is activated with the enter key', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode(debugLabel: 'DropdownButton');
String value = 'one';
void didChangeValue(String newValue) {
value = newValue;
}
Widget buildFrame() {
return MaterialApp(
home: Scaffold(
body: Center(
child: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return DropdownButton<String>(
focusNode: focusNode,
autofocus: true,
onChanged: didChangeValue,
value: value,
itemHeight: null,
items: menuItems.map<DropdownMenuItem<String>>((String item) {
return DropdownMenuItem<String>(
key: ValueKey<String>(item),
value: item,
child: Text(item, key: ValueKey<String>(item + 'Text')),
);
}).toList(),
);
},
),
),
),
);
}
await tester.pumpWidget(buildFrame());
await tester.pump(); // Pump a frame for autofocus to take effect.
expect(focusNode.hasPrimaryFocus, isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
await tester.pump(const Duration(seconds: 1)); // finish the menu animation
expect(value, equals('one'));
await tester.sendKeyEvent(LogicalKeyboardKey.tab); // Focus 'one'
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.tab); // Focus 'two'
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter); // Select 'two'
await tester.pump();
await tester.pump();
await tester.pump(const Duration(seconds: 1)); // finish the menu animation
expect(value, equals('two'));
});
} }
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