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
This commit is contained in:
@@ -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,10 +1219,18 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
onTap: _enabled ? _handleTap : null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: result,
|
||||
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'));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user