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 {
this.isDense = false,
this.isExpanded = false,
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(elevation != null),
assert(iconSize != null),
assert(isDense != null),
assert(isExpanded != null),
assert(autofocus != null),
assert(itemHeight == null || itemHeight >= kMinInteractiveDimension),
super(key: key);
......@@ -900,6 +904,15 @@ class DropdownButton<T> extends StatefulWidget {
/// [kMinInteractiveDimension].
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
_DropdownButtonState<T> createState() => _DropdownButtonState<T>();
}
......@@ -908,17 +921,33 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
int _selectedIndex;
_DropdownRoute<T> _dropdownRoute;
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
void initState() {
super.initState();
_updateSelectedIndex();
if (widget.focusNode == null) {
_internalNode ??= _createFocusNode();
}
_actionMap = <LocalKey, ActionFactory>{ ActivateAction.key: _createAction };
focusNode.addListener(_handleFocusChanged);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_removeDropdownRoute();
focusNode.removeListener(_handleFocusChanged);
_internalNode?.dispose();
super.dispose();
}
......@@ -928,9 +957,25 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
_lastOrientation = null;
}
void _handleFocusChanged() {
if (_hasPrimaryFocus != focusNode.hasPrimaryFocus) {
setState(() {
_hasPrimaryFocus = focusNode.hasPrimaryFocus;
});
}
}
@override
void didUpdateWidget(DropdownButton<T> 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();
}
......@@ -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
// _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
......@@ -1112,6 +1166,10 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
Widget result = DefaultTextStyle(
style: _textStyle,
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)),
height: widget.isDense ? _denseButtonHeight : null,
child: Row(
......@@ -1161,11 +1219,19 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
return Semantics(
button: true,
child: Actions(
actions: _actionMap,
child: Focus(
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;
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import '../rendering/mock_canvas.dart';
import '../widgets/semantics_tester.dart';
......@@ -44,6 +45,9 @@ Widget buildFrame({
Alignment alignment = Alignment.center,
TextDirection textDirection = TextDirection.ltr,
Size mediaSize,
FocusNode focusNode,
bool autofocus = false,
Color focusColor,
}) {
return TestApp(
textDirection: textDirection,
......@@ -65,6 +69,9 @@ Widget buildFrame({
isDense: isDense,
isExpanded: isExpanded,
underline: underline,
focusNode: focusNode,
autofocus: autofocus,
focusColor: focusColor,
items: items == null ? null : items.map<DropdownMenuItem<String>>((String item) {
return DropdownMenuItem<String>(
key: ValueKey<String>(item),
......@@ -990,6 +997,7 @@ void main() {
isButton: true,
label: 'test',
hasTapAction: true,
isFocusable: true,
));
await tester.pumpWidget(buildFrame(
......@@ -1005,6 +1013,7 @@ void main() {
isButton: true,
label: 'three',
hasTapAction: true,
isFocusable: true,
));
handle.dispose();
});
......@@ -1327,10 +1336,10 @@ void main() {
await tester.pumpWidget(buildFrame(buttonKey: buttonKey, underline: customUnderline,
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));
expect(tester.widget<DecoratedBox>(decoratedBox).decoration, defaultDecoration);
expect(tester.widgetList<DecoratedBox>(decoratedBox).last.decoration, defaultDecoration);
});
testWidgets('DropdownButton selectedItemBuilder builds custom buttons', (WidgetTester tester) async {
......@@ -1912,4 +1921,79 @@ void main() {
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