diff --git a/packages/flutter/lib/src/material/dropdown.dart b/packages/flutter/lib/src/material/dropdown.dart index 2ccd63cd74..62cd2c5c66 100644 --- a/packages/flutter/lib/src/material/dropdown.dart +++ b/packages/flutter/lib/src/material/dropdown.dart @@ -698,11 +698,15 @@ class DropdownButton 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 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 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 createState() => _DropdownButtonState(); } @@ -908,17 +921,33 @@ class _DropdownButtonState extends State> with WidgetsBindi int _selectedIndex; _DropdownRoute _dropdownRoute; Orientation _lastOrientation; + FocusNode _internalNode; + FocusNode get focusNode => widget.focusNode ?? _internalNode; + bool _hasPrimaryFocus = false; + Map _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 = { 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 extends State> with WidgetsBindi _lastOrientation = null; } + void _handleFocusChanged() { + if (_hasPrimaryFocus != focusNode.hasPrimaryFocus) { + setState(() { + _hasPrimaryFocus = focusNode.hasPrimaryFocus; + }); + } + } + @override void didUpdateWidget(DropdownButton 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 extends State> 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 extends State> 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 extends State> 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, + ), + ), ), ); } diff --git a/packages/flutter/test/material/dropdown_test.dart b/packages/flutter/test/material/dropdown_test.dart index 1fa94e75f0..99a403cc9b 100644 --- a/packages/flutter/test/material/dropdown_test.dart +++ b/packages/flutter/test/material/dropdown_test.dart @@ -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>((String item) { return DropdownMenuItem( key: ValueKey(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).decoration, decoration); + expect(tester.widgetList(decoratedBox).last.decoration, decoration); await tester.pumpWidget(buildFrame(buttonKey: buttonKey, value: 'two', onChanged: onChanged)); - expect(tester.widget(decoratedBox).decoration, defaultDecoration); + expect(tester.widgetList(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( + focusNode: focusNode, + autofocus: true, + onChanged: didChangeValue, + value: value, + itemHeight: null, + items: menuItems.map>((String item) { + return DropdownMenuItem( + key: ValueKey(item), + value: item, + child: Text(item, key: ValueKey(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')); + }); }