Skip to content

Commit 5881b13

Browse files
authored
Fix DropdownButtonFormField overlay colors management (flutter#159472)
## Description This PR fixes some `DropdownButtonFormField` issues where the overlay color overflows. Before this PR, `DropdownButtonFormField` was relying on an `InkWell` to display overlay colors. This resulted in several issues related to the `InkWell` overflowing because it is not aware of the inner container inside `InputDecorator`, for instance see flutter#106659. With this PR, `DropdownButtonFormField` does not use an `InkWell` but rely on `InputDecorator` to paint overlay colors. `InputDecorator` paints overlay colors only on its internal container, this fixes the color overflowing when using `InkWell`. With this change users can opt-in for overlay colors to be painted by setting InputDecorator.filled to true (similarly to TextField and accordingly to [the Material specification](https://m2.material.io/components/menus#dropdown-menu)). Code sample from flutter#106659 with InputDecoration.filled set to true: <details><summary>Code sample with InputDecoration.filled set to true</summary> ```dart import 'package:flutter/material.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { MyApp({Key? key}) : super(key: key); static const String _title = 'Flutter Code Sample'; final _formKey = GlobalKey<FormState>(); @OverRide Widget build(BuildContext context) { var items = [ 'Ayo', 'This', 'Don', 'Look', 'Right', ].map((String val) { return DropdownMenuItem( value: val, child: Text( val, ), ); }).toList(); return MaterialApp( title: _title, theme: ThemeData( inputDecorationTheme: const InputDecorationTheme( border: OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(32)), borderSide: BorderSide(color: Colors.blue, width: 2), ), ), ), home: Scaffold( body: Center( child: SizedBox( width: 500, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Form( key: _formKey, child: DropdownButtonFormField( onTap: () { _formKey.currentState!.validate(); }, validator: (String? v) => 'Required', onChanged: (String? value) {}, items: items, // Set InputDecoration.filled to true if overlays should be visible. // See Material specification for filled vs outlined dropdown button: // https://m2.material.io/components/menus#dropdown-menu. decoration: const InputDecoration(filled: true), ), ), ], ), ), ), ), ); } } ``` </details> Before: ![image](https://github.com/user-attachments/assets/3c22975f-9b8c-4184-8ffe-67f2191bf563) After: ![image](https://github.com/user-attachments/assets/47ac35c3-b516-454f-bd47-2d35d85f172f) After (when filled is not set to true): ![image](https://github.com/user-attachments/assets/faf46e40-5817-4d64-9158-7a57d94a9776) ## Related Issue Fixes [DropdownButtonFormField InkWell spreads to error message](flutter#106659). Fixes [DropdownButtonFormField input decorator focus/hover is not clipped and appears behind fill color.](flutter#147069) First step for [DropDownButtonFormField hoverColor has no effect in web and desktop platforms](flutter#151460) ## Tests Adds 4 tests. Updates 2 tests (remove checks specific to InkWell usage and use filled: true when checking for hover/focus colors). Removes 1 test (test specific to InkWell usage, because this PR removes the InkWell the test is obsolete).
1 parent c9477b4 commit 5881b13

File tree

4 files changed

+252
-159
lines changed

4 files changed

+252
-159
lines changed

packages/flutter/lib/src/material/dropdown.dart

Lines changed: 79 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import 'constants.dart';
2323
import 'debug.dart';
2424
import 'icons.dart';
2525
import 'ink_well.dart';
26-
import 'input_border.dart';
2726
import 'input_decorator.dart';
2827
import 'material.dart';
2928
import 'material_localizations.dart';
@@ -1010,8 +1009,7 @@ class DropdownButton<T> extends StatefulWidget {
10101009
),
10111010
assert(itemHeight == null || itemHeight >= kMinInteractiveDimension),
10121011
_inputDecoration = null,
1013-
_isEmpty = false,
1014-
_isFocused = false;
1012+
_isEmpty = false;
10151013

10161014
DropdownButton._formField({
10171015
super.key,
@@ -1044,7 +1042,6 @@ class DropdownButton<T> extends StatefulWidget {
10441042
this.padding,
10451043
required InputDecoration inputDecoration,
10461044
required bool isEmpty,
1047-
required bool isFocused,
10481045
}) : assert(items == null || items.isEmpty || value == null ||
10491046
items.where((DropdownMenuItem<T> item) {
10501047
return item.value == value;
@@ -1056,8 +1053,7 @@ class DropdownButton<T> extends StatefulWidget {
10561053
),
10571054
assert(itemHeight == null || itemHeight >= kMinInteractiveDimension),
10581055
_inputDecoration = inputDecoration,
1059-
_isEmpty = isEmpty,
1060-
_isFocused = isFocused;
1056+
_isEmpty = isEmpty;
10611057

10621058
/// The list of items the user can select.
10631059
///
@@ -1287,8 +1283,6 @@ class DropdownButton<T> extends StatefulWidget {
12871283

12881284
final bool _isEmpty;
12891285

1290-
final bool _isFocused;
1291-
12921286
@override
12931287
State<DropdownButton<T>> createState() => _DropdownButtonState<T>();
12941288
}
@@ -1300,6 +1294,8 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
13001294
FocusNode? _internalNode;
13011295
FocusNode? get focusNode => widget.focusNode ?? _internalNode;
13021296
late Map<Type, Action<Intent>> _actionMap;
1297+
bool _isHovering = false;
1298+
bool _hasPrimaryFocus = false;
13031299

13041300
// Only used if needed to create _internalNode.
13051301
FocusNode _createFocusNode() {
@@ -1321,16 +1317,26 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
13211317
onInvoke: (ButtonActivateIntent intent) => _handleTap(),
13221318
),
13231319
};
1320+
focusNode?.addListener(_handleFocusChanged);
13241321
}
13251322

13261323
@override
13271324
void dispose() {
13281325
WidgetsBinding.instance.removeObserver(this);
13291326
_removeDropdownRoute();
1327+
focusNode?.removeListener(_handleFocusChanged);
13301328
_internalNode?.dispose();
13311329
super.dispose();
13321330
}
13331331

1332+
void _handleFocusChanged() {
1333+
if (_hasPrimaryFocus != focusNode!.hasPrimaryFocus) {
1334+
setState(() {
1335+
_hasPrimaryFocus = focusNode!.hasPrimaryFocus;
1336+
});
1337+
}
1338+
}
1339+
13341340
void _removeDropdownRoute() {
13351341
_dropdownRoute?._dismiss();
13361342
_dropdownRoute = null;
@@ -1586,30 +1592,76 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
15861592
},
15871593
);
15881594

1595+
// When an InputDecoration is provided, use it instead of using an InkWell
1596+
// that overflows in some cases (such as showing an errorText) and requires
1597+
// additional logic to manage clipping properly.
1598+
// A filled InputDecoration is able to fill the InputDecorator container
1599+
// without overflowing. It also supports blending the hovered color.
1600+
// According to the Material specification, the overlay colors should be
1601+
// visible only for filled dropdown button, see:
1602+
// https://m2.material.io/components/menus#dropdown-menu
15891603
if (widget._inputDecoration != null) {
1590-
result = InputDecorator(
1591-
decoration: widget._inputDecoration!,
1592-
isEmpty: widget._isEmpty,
1593-
isFocused: widget._isFocused,
1594-
child: result,
1604+
InputDecoration effectiveDecoration = widget._inputDecoration!;
1605+
if (_hasPrimaryFocus) {
1606+
final Color? focusColor = widget.focusColor ?? effectiveDecoration.focusColor;
1607+
// For compatibility, override the fill color when focusColor is set.
1608+
if (focusColor != null) {
1609+
effectiveDecoration = effectiveDecoration.copyWith(fillColor: focusColor);
1610+
}
1611+
}
1612+
result = Focus(
1613+
canRequestFocus: _enabled,
1614+
focusNode: focusNode,
1615+
autofocus: widget.autofocus,
1616+
child: MouseRegion(
1617+
onEnter: (PointerEnterEvent event) {
1618+
if (!_isHovering) {
1619+
setState(() {
1620+
_isHovering = true;
1621+
});
1622+
}
1623+
},
1624+
onExit: (PointerExitEvent event) {
1625+
if (_isHovering) {
1626+
setState(() {
1627+
_isHovering = false;
1628+
});
1629+
}
1630+
},
1631+
cursor: effectiveMouseCursor,
1632+
child: GestureDetector(
1633+
onTap: _enabled ? _handleTap : null,
1634+
behavior: HitTestBehavior.opaque,
1635+
child: InputDecorator(
1636+
decoration: effectiveDecoration,
1637+
isEmpty: widget._isEmpty,
1638+
isFocused: _hasPrimaryFocus,
1639+
isHovering: _isHovering,
1640+
child: widget.padding == null ? result : Padding(padding: widget.padding!, child: result),
1641+
),
1642+
),
1643+
),
1644+
);
1645+
} else {
1646+
result = InkWell(
1647+
mouseCursor: effectiveMouseCursor,
1648+
onTap: _enabled ? _handleTap : null,
1649+
canRequestFocus: _enabled,
1650+
borderRadius: widget.borderRadius,
1651+
focusNode: focusNode,
1652+
autofocus: widget.autofocus,
1653+
focusColor: widget.focusColor ?? Theme.of(context).focusColor,
1654+
enableFeedback: false,
1655+
child: widget.padding == null ? result : Padding(padding: widget.padding!, child: result),
15951656
);
15961657
}
1658+
15971659
final bool childHasButtonSemantic = hintIndex != null || (_selectedIndex != null && widget.selectedItemBuilder == null);
15981660
return Semantics(
15991661
button: !childHasButtonSemantic,
16001662
child: Actions(
16011663
actions: _actionMap,
1602-
child: InkWell(
1603-
mouseCursor: effectiveMouseCursor,
1604-
onTap: _enabled ? _handleTap : null,
1605-
canRequestFocus: _enabled,
1606-
borderRadius: widget.borderRadius,
1607-
focusNode: focusNode,
1608-
autofocus: widget.autofocus,
1609-
focusColor: widget.focusColor ?? Theme.of(context).focusColor,
1610-
enableFeedback: false,
1611-
child: widget.padding == null ? result : Padding(padding: widget.padding!, child: result),
1612-
),
1664+
child: result,
16131665
),
16141666
);
16151667
}
@@ -1679,13 +1731,13 @@ class DropdownButtonFormField<T> extends FormField<T> {
16791731
'with the same value',
16801732
),
16811733
assert(itemHeight == null || itemHeight >= kMinInteractiveDimension),
1682-
decoration = decoration ?? InputDecoration(focusColor: focusColor),
1734+
decoration = decoration ?? const InputDecoration(),
16831735
super(
16841736
initialValue: value,
16851737
autovalidateMode: autovalidateMode ?? AutovalidateMode.disabled,
16861738
builder: (FormFieldState<T> field) {
16871739
final _DropdownButtonFormFieldState<T> state = field as _DropdownButtonFormFieldState<T>;
1688-
final InputDecoration decorationArg = decoration ?? InputDecoration(focusColor: focusColor);
1740+
final InputDecoration decorationArg = decoration ?? const InputDecoration();
16891741
final InputDecoration effectiveDecoration = decorationArg.applyDefaults(
16901742
Theme.of(field.context).inputDecorationTheme,
16911743
);
@@ -1707,19 +1759,6 @@ class DropdownButtonFormField<T> extends FormField<T> {
17071759
canRequestFocus: false,
17081760
skipTraversal: true,
17091761
child: Builder(builder: (BuildContext context) {
1710-
final bool isFocused = Focus.of(context).hasFocus;
1711-
late final InputBorder? resolvedBorder = switch ((
1712-
enabled: effectiveDecoration.enabled,
1713-
focused: isFocused,
1714-
error: effectiveDecoration.errorText != null,
1715-
)) {
1716-
(enabled: _, focused: true, error: true) => effectiveDecoration.focusedErrorBorder,
1717-
(enabled: _, focused: _, error: true) => effectiveDecoration.errorBorder,
1718-
(enabled: _, focused: true, error: _) => effectiveDecoration.focusedBorder,
1719-
(enabled: true, focused: _, error: _) => effectiveDecoration.enabledBorder,
1720-
(enabled: false, focused: _, error: _) => effectiveDecoration.border,
1721-
};
1722-
17231762
return DropdownButtonHideUnderline(
17241763
child: DropdownButton<T>._formField(
17251764
items: items,
@@ -1745,18 +1784,13 @@ class DropdownButtonFormField<T> extends FormField<T> {
17451784
menuMaxHeight: menuMaxHeight,
17461785
enableFeedback: enableFeedback,
17471786
alignment: alignment,
1748-
borderRadius: borderRadius ?? switch (resolvedBorder) {
1749-
final OutlineInputBorder border => border.borderRadius,
1750-
final UnderlineInputBorder border => border.borderRadius,
1751-
_ => null,
1752-
},
1787+
borderRadius: borderRadius,
17531788
// Clear the decoration hintText because DropdownButton has its own hint logic.
17541789
inputDecoration: effectiveDecoration.copyWith(
17551790
errorText: field.errorText,
17561791
hintText: effectiveDecoration.hintText != null ? '' : null,
17571792
),
17581793
isEmpty: isEmpty,
1759-
isFocused: isFocused,
17601794
padding: padding,
17611795
),
17621796
);

packages/flutter/lib/src/material/input_decorator.dart

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2079,7 +2079,6 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat
20792079
if (_hasError) MaterialState.error,
20802080
};
20812081

2082-
20832082
InputBorder _getDefaultBorder(ThemeData themeData, InputDecorationTheme defaults) {
20842083
final InputBorder border = MaterialStateProperty.resolveAs(decoration.border, materialState)
20852084
?? const UnderlineInputBorder();

packages/flutter/test/material/dropdown_form_field_test.dart

Lines changed: 37 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
import 'dart:math' as math;
66

7-
import 'package:flutter/gestures.dart';
87
import 'package:flutter/material.dart';
98
import 'package:flutter_test/flutter_test.dart';
109

@@ -1130,103 +1129,6 @@ void main() {
11301129
);
11311130
});
11321131

1133-
testWidgets('InputDecoration borders are used for clipping', (WidgetTester tester) async {
1134-
const BorderRadius errorBorderRadius = BorderRadius.all(Radius.circular(5.0));
1135-
const BorderRadius focusedErrorBorderRadius = BorderRadius.all(Radius.circular(6.0));
1136-
const BorderRadius focusedBorder = BorderRadius.all(Radius.circular(7.0));
1137-
const BorderRadius enabledBorder = BorderRadius.all(Radius.circular(9.0));
1138-
1139-
final FocusNode focusNode = FocusNode();
1140-
addTearDown(focusNode.dispose);
1141-
1142-
const String errorText = 'This is an error';
1143-
bool showError = false;
1144-
1145-
await tester.pumpWidget(
1146-
MaterialApp(
1147-
theme: ThemeData(
1148-
inputDecorationTheme: const InputDecorationTheme(
1149-
errorBorder: OutlineInputBorder(
1150-
borderRadius: errorBorderRadius,
1151-
),
1152-
focusedErrorBorder: OutlineInputBorder(
1153-
borderRadius: focusedErrorBorderRadius,
1154-
),
1155-
focusedBorder: OutlineInputBorder(
1156-
borderRadius: focusedBorder,
1157-
),
1158-
enabledBorder: OutlineInputBorder(
1159-
borderRadius: enabledBorder,
1160-
),
1161-
),
1162-
),
1163-
home: Material(
1164-
child: Center(
1165-
child: StatefulBuilder(
1166-
builder: (BuildContext context, StateSetter setState) {
1167-
return DropdownButtonFormField<String>(
1168-
value: 'two',
1169-
onChanged:(String? value) {
1170-
setState(() {
1171-
if (value == 'three') {
1172-
showError = true;
1173-
} else {
1174-
showError = false;
1175-
}
1176-
});
1177-
},
1178-
decoration: InputDecoration(
1179-
errorText: showError ? errorText : null,
1180-
),
1181-
focusNode: focusNode,
1182-
items: menuItems.map<DropdownMenuItem<String>>((String item) {
1183-
return DropdownMenuItem<String>(
1184-
key: ValueKey<String>(item),
1185-
value: item,
1186-
child: Text(item, key: ValueKey<String>('${item}Text')),
1187-
);
1188-
}).toList(),
1189-
);
1190-
}
1191-
),
1192-
),
1193-
),
1194-
),
1195-
);
1196-
1197-
// Test enabled border.
1198-
InkWell inkWell = tester.widget<InkWell>(find.byType(InkWell));
1199-
expect(inkWell.borderRadius, enabledBorder);
1200-
1201-
// Test focused border.
1202-
focusNode.requestFocus();
1203-
await tester.pump();
1204-
1205-
inkWell = tester.widget<InkWell>(find.byType(InkWell));
1206-
expect(inkWell.borderRadius, focusedBorder);
1207-
1208-
// Test focused error border.
1209-
await tester.tap(find.text('two'), warnIfMissed: false);
1210-
await tester.pumpAndSettle();
1211-
await tester.tap(find.text('three').last);
1212-
await tester.pumpAndSettle();
1213-
1214-
inkWell = tester.widget<InkWell>(find.byType(InkWell));
1215-
expect(inkWell.borderRadius, focusedErrorBorderRadius);
1216-
1217-
// Test error border with no focus.
1218-
focusNode.unfocus();
1219-
await tester.pump();
1220-
1221-
// Hovering over the widget should show the error border.
1222-
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
1223-
await gesture.moveTo(tester.getCenter(find.text('three').last));
1224-
await tester.pumpAndSettle();
1225-
1226-
inkWell = tester.widget<InkWell>(find.byType(InkWell));
1227-
expect(inkWell.borderRadius, errorBorderRadius);
1228-
});
1229-
12301132
testWidgets('DropdownButtonFormField onChanged is called when the form is reset', (WidgetTester tester) async {
12311133
// Regression test for https://github.com/flutter/flutter/issues/123009.
12321134
final GlobalKey<FormFieldState<String>> stateKey = GlobalKey<FormFieldState<String>>();
@@ -1307,4 +1209,41 @@ void main() {
13071209

13081210
expect(tester.takeException(), isNull);
13091211
});
1212+
1213+
// Regression test for https://github.com/flutter/flutter/issues/106659.
1214+
testWidgets('Error visual logic is delegated to InputDecorator', (WidgetTester tester) async {
1215+
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
1216+
1217+
await tester.pumpWidget(
1218+
MaterialApp(
1219+
home: Material(
1220+
child: Form(
1221+
key: formKey,
1222+
child: DropdownButtonFormField<String>(
1223+
items: menuItems.map((String value) {
1224+
return DropdownMenuItem<String>(
1225+
value: value,
1226+
child: Text(value),
1227+
);
1228+
}).toList(),
1229+
onChanged: onChanged,
1230+
validator: (String? v) => 'Required',
1231+
onTap: () {
1232+
formKey.currentState!.validate();
1233+
},
1234+
),
1235+
),
1236+
),
1237+
),
1238+
);
1239+
1240+
await tester.tap(find.byType(InputDecorator));
1241+
await tester.pump();
1242+
1243+
// Check InputDecorator state because DropdownButtonFormField delegates
1244+
// visual logic to the InputDecorator.
1245+
final InputDecorator inputDecorator = tester.widget(find.byType(InputDecorator));
1246+
expect(inputDecorator.isFocused, true);
1247+
expect(inputDecorator.decoration.errorText, 'Required');
1248+
});
13101249
}

0 commit comments

Comments
 (0)