diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff3791cd..e19b61ffc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [next] +- refactor: Rework `ComboBox`. Rebuilt the popup using Flutter overlay infrastructure with viewport-aware positioning, configurable popup constraints, variable-height items, text scaling support, keyboard navigation, type-ahead search, and open/close callbacks. ([#1360](https://github.com/bdlukaa/fluent_ui/pull/1360), [#485](https://github.com/bdlukaa/fluent_ui/issues/485), [#1007](https://github.com/bdlukaa/fluent_ui/issues/1007), [#1230](https://github.com/bdlukaa/fluent_ui/issues/1230), and [#1256](https://github.com/bdlukaa/fluent_ui/issues/1256)) - refactor: Flutter 3.47.1 support ([#1353](https://github.com/bdlukaa/fluent_ui/pull/1353)) - migrate Material UI imports to the standalone `material_ui` package. [learn more](https://flutter.dev/blog/decoupling-material-cupertino) - fix: skip hidden `TitleBar` children when collecting semantics ([#1354](https://github.com/bdlukaa/fluent_ui/pull/1354)) diff --git a/example/lib/screens/forms/combo_box.dart b/example/lib/screens/forms/combo_box.dart index 8b4ac5d36..a4c5e5219 100644 --- a/example/lib/screens/forms/combo_box.dart +++ b/example/lib/screens/forms/combo_box.dart @@ -12,7 +12,7 @@ class ComboBoxPage extends StatefulWidget { } class _ComboBoxPageState extends State with PageMixin { - String? selectedColor = 'Green'; + String? selectedColor; String? selectedCat; double fontSize = 20; bool disabled = false; @@ -67,6 +67,7 @@ ComboBox( crossAxisAlignment: CrossAxisAlignment.start, children: [ ComboBox( + placeholder: const Text('Select a color'), popupColor: colors[selectedColor], value: selectedColor, items: colors.entries.map((final e) { @@ -131,6 +132,58 @@ ComboBox( ], ), ), + subtitle(content: const Text('Constrained and complex popup content')), + CodeSnippetCard( + codeSnippet: ''' +ComboBox( + popupConstraints: const BoxConstraints(maxHeight: 220), + items: options, + onChanged: onChanged, +),''', + child: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ComboBox( + value: 'Item 8', + popupConstraints: const BoxConstraints( + minWidth: 180, + maxHeight: 220, + ), + items: [ + for (var index = 0; index < 30; index++) + ComboBoxItem( + value: 'Item $index', + enabled: index != 4, + child: Text('Item $index'), + ), + ], + onChanged: (_) {}, + ), + MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(1.5)), + child: ComboBox( + placeholder: const Text('Complex item'), + items: const [ + ComboBoxItem( + value: 'Complex item', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Complex item'), + Text('A caption that can wrap'), + ], + ), + ), + ], + onChanged: (_) {}, + ), + ), + ], + ), + ), subtitle(content: const Text('An editable ComboBox')), description( content: const Text( diff --git a/lib/src/controls/form/combo_box.dart b/lib/src/controls/form/combo_box.dart index 95f26a526..542e31c9d 100644 --- a/lib/src/controls/form/combo_box.dart +++ b/lib/src/controls/form/combo_box.dart @@ -1,27 +1,39 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:fluent_ui/fluent_ui.dart'; import 'package:fluent_ui/src/controls/pickers/pickers.dart'; import 'package:flutter/foundation.dart'; + import 'package:flutter/services.dart'; part 'editable_combo_box.dart'; const Duration _kComboBoxMenuDuration = Duration(milliseconds: 300); +const Duration _kComboBoxSearchDuration = Duration(milliseconds: 500); const double _kMenuItemBottomPadding = 6; - -/// The default height of a combo box item. -const double kComboBoxItemHeight = kPickerHeight + _kMenuItemBottomPadding; const EdgeInsets _kMenuItemPadding = EdgeInsets.symmetric(horizontal: 12); const EdgeInsetsGeometry _kAlignedButtonPadding = EdgeInsetsDirectional.only( start: 11, end: 15, ); -const EdgeInsets _kAlignedMenuMargin = EdgeInsets.zero; const EdgeInsetsDirectional _kListPadding = EdgeInsetsDirectional.only( top: _kMenuItemBottomPadding, + bottom: _kMenuItemBottomPadding, ); +double _comboBoxEstimatedItemHeight(BuildContext context, TextStyle style) { + final painter = TextPainter( + text: TextSpan(text: 'M', style: style), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + return math.max(kComboBoxItemHeight, painter.height); +} + +/// The default height of a combo box item. +const double kComboBoxItemHeight = kPickerHeight + _kMenuItemBottomPadding; + /// The default corner radius for combo box elements. const kComboBoxRadius = Radius.circular(4); @@ -30,71 +42,8 @@ const kComboBoxRadius = Radius.circular(4); /// Used by [ComboBox.selectedItemBuilder]. typedef ComboBoxBuilder = List Function(BuildContext context); -class _ComboBoxMenuPainter extends CustomPainter { - _ComboBoxMenuPainter({ - required this.resize, - required this.getSelectedItemOffset, - required this.scaledItemHeight, - this.selectedIndex, - Color borderColor = Colors.black, - Color? backgroundColor, - int elevation = 0, - }) : _painter = BoxDecoration( - // If you add an image here, you must provide a real - // configuration in the paint() function and you must provide some sort - // of onChanged callback here. - // color: color, - borderRadius: const BorderRadius.all(kComboBoxRadius), - border: Border.all(color: borderColor), - boxShadow: kElevationToShadow[elevation], - color: backgroundColor, - ).createBoxPainter(), - super(repaint: resize); - - final int? selectedIndex; - final Animation resize; - final ValueGetter getSelectedItemOffset; - final double scaledItemHeight; - final BoxPainter _painter; - - @override - void paint(Canvas canvas, Size size) { - final selectedItemOffset = getSelectedItemOffset(); - final maxTopOffset = math.max(0, size.height - scaledItemHeight); - final minBottomOffset = math.min(scaledItemHeight, size.height); - final top = Tween( - begin: clampDouble(selectedItemOffset, 0, maxTopOffset), - end: 0, - ); - - final bottom = Tween( - begin: clampDouble( - top.begin! + scaledItemHeight, - minBottomOffset, - size.height, - ), - end: size.height, - ); - - final rect = Rect.fromLTRB( - 0, - top.evaluate(resize), - size.width, - bottom.evaluate(resize), - ); - - _painter.paint(canvas, rect.topLeft, ImageConfiguration(size: rect.size)); - } - - @override - bool shouldRepaint(_ComboBoxMenuPainter oldPainter) { - return oldPainter.selectedIndex != selectedIndex || - oldPainter.resize != resize; - } -} - // Do not use the platform-specific default scroll configuration. -// ComboBox menus should never overscroll or display an overscroll indicator. +// ComboBox menus should not overscroll or display an overscroll indicator. class _ComboBoxScrollBehavior extends FluentScrollBehavior { const _ComboBoxScrollBehavior(); @@ -106,872 +55,515 @@ class _ComboBoxScrollBehavior extends FluentScrollBehavior { const ClampingScrollPhysics(); } -// The widget that is the button wrapping the menu items. -class _ComboBoxItemButton extends StatefulWidget { - const _ComboBoxItemButton({ - required this.route, - required this.buttonRect, - required this.constraints, - required this.itemIndex, - super.key, - this.padding, - }); - - final _ComboBoxRoute route; - final EdgeInsets? padding; - final Rect buttonRect; - final BoxConstraints constraints; - final int itemIndex; - - @override - State<_ComboBoxItemButton> createState() => _ComboBoxItemButtonState(); -} - -class _ComboBoxItemButtonState extends State<_ComboBoxItemButton> { - void _handleFocusChange(bool focused) { - final bool inTraditionalMode; - switch (FocusManager.instance.highlightMode) { - case FocusHighlightMode.touch: - inTraditionalMode = false; - case FocusHighlightMode.traditional: - inTraditionalMode = true; - } - - final scrollable = widget.route.scrollController!.hasClients; - - if (focused && inTraditionalMode && scrollable) { - final menuLimits = widget.route.getMenuLimits( - widget.buttonRect, - widget.constraints.maxHeight, - widget.itemIndex, - ); - widget.route.scrollController!.animateTo( - menuLimits.scrollOffset, - curve: Curves.easeInOut, - duration: const Duration(milliseconds: 100), - ); - } - } - - ComboBoxItem get comboboxMenuItem => widget.route.items[widget.itemIndex]; - - void _handleOnTap() { - comboboxMenuItem.onTap?.call(); - - Navigator.pop(context, _ComboBoxRouteResult(comboboxMenuItem.value)); - } +class _ComboBoxItemContainer extends StatelessWidget { + const _ComboBoxItemContainer({required this.child, super.key}); - static final Map _webShortcuts = - { - LogicalKeySet(LogicalKeyboardKey.enter): const ActivateIntent(), - }; + final Widget child; @override Widget build(BuildContext context) { - assert(debugCheckHasFluentTheme(context)); - Widget child = HoverButton( - autofocus: widget.itemIndex == widget.route.selectedIndex, - builder: (context, states) { - final theme = FluentTheme.of(context); - return Padding( - padding: const EdgeInsetsDirectional.only( - end: 6, - start: 6, - // bottom: 4.0, - ), - child: Stack( - children: [ - Container( - decoration: BoxDecoration( - color: ButtonThemeData.uncheckedInputColor( - theme, - states.isFocused ? {WidgetState.hovered} : states, - transparentWhenNone: true, - ), - borderRadius: BorderRadius.circular(4), - ), - padding: widget.padding, - child: widget.route.items[widget.itemIndex], - ), - if (states.isFocused) - AnimatedPositionedDirectional( - duration: theme.fastAnimationDuration, - curve: theme.animationCurve, - top: states.isPressed ? 10.0 : 8.0, - bottom: states.isPressed ? 10.0 : 8.0, - child: Container( - width: 3, - decoration: BoxDecoration( - color: theme.accentColor.defaultBrushFor( - theme.brightness, - ), - borderRadius: BorderRadius.circular(50), - ), - ), - ), - ], - ), - ); - }, - onPressed: comboboxMenuItem.enabled ? _handleOnTap : null, - onFocusChange: _handleFocusChange, + final theme = FluentTheme.of(context); + final hasPadding = _ContainerWithoutPadding.of(context) == null; + final states = HoverButton.maybeOf(context)?.states ?? {}; + final foregroundColor = states.isDisabled + ? theme.resources.textFillColorDisabled + : states.isPressed + ? theme.resources.textFillColorTertiary + : states.isHovered + ? theme.resources.textFillColorSecondary + : theme.resources.textFillColorPrimary; + final densityAdjustment = theme.visualDensity.baseSizeAdjustment.dy; + final minimumHeight = math.max( + 0, + (hasPadding + ? kComboBoxItemHeight + : kComboBoxItemHeight - _kMenuItemBottomPadding) + + densityAdjustment, ); - if (kIsWeb) { - // On the web, enter doesn't select things, *except* in a