diff --git a/CHANGELOG.md b/CHANGELOG.md index ac42eca55..785851875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,14 @@ Date format: DD/MM/YYYY - Updated selected tab paint - Added `TabView.tabWidthBehavior`. Defaults to `TabWidthBehavior.equal` - Added `TabView.header` and `TabView.footer` +- `Slider`'s mouse cursor is now [MouseCursor.defer] +- Added `SmallIconButton`, which makes an [IconButton] small if wrapped. It's used by `TextBox` +- Added `ButtonStyle.iconSize` +- **BREAKING** `AutoSuggestBox` updates: + - Added `FluentLocalizations.noResultsFoundLabel`. "No results found" is the default text + - Removed `itemBuilder`, `sorter`, `noResultsFound`, `textBoxBuilder`, `defaultNoResultsFound` and `defaultTextBoxBuilder` + - Added `onChanged`, `trailingIcon`, `clearButtonEnabled` and `placeholder` + - `controller` is now nullable. If null, an internal controller is creted ## [3.5.2] - [17/12/2021] diff --git a/example/lib/screens/forms.dart b/example/lib/screens/forms.dart index ba0f0dc3f..5c1f28e3c 100644 --- a/example/lib/screens/forms.dart +++ b/example/lib/screens/forms.dart @@ -10,12 +10,25 @@ class Forms extends StatefulWidget { } class _FormsState extends State { - final autoSuggestBox = TextEditingController(); - final _clearController = TextEditingController(); bool _showPassword = false; - final values = ['Blue', 'Green', 'Yellow', 'Red']; + static const values = [ + 'Red', + 'Yellow', + 'Green', + 'Cyan', + 'Blue', + 'Magenta', + 'Orange', + 'Violet', + 'Pink', + 'Brown', + 'Purple', + 'Gray', + 'Black', + 'White', + ]; String? comboBoxValue; DateTime date = DateTime.now(); @@ -53,29 +66,17 @@ class _FormsState extends State { const SizedBox(width: 10), Expanded( child: AutoSuggestBox( - controller: autoSuggestBox, items: values, + placeholder: 'Pick a color', + trailingIcon: IconButton( + icon: const Icon(FluentIcons.search), + onPressed: () { + debugPrint('trailing button pressed'); + }, + ), onSelected: (text) { print(text); }, - textBoxBuilder: (context, controller, focusNode, key) { - return TextBox( - key: key, - controller: controller, - focusNode: focusNode, - suffixMode: OverlayVisibilityMode.editing, - suffix: IconButton( - icon: const Icon(FluentIcons.close), - onPressed: () { - controller.clear(); - focusNode.unfocus(); - }, - ), - placeholder: 'Type a color', - clipBehavior: - focusNode.hasFocus ? Clip.none : Clip.antiAlias, - ); - }, ), ), ]), diff --git a/lib/src/controls/form/auto_suggest_box.dart b/lib/src/controls/form/auto_suggest_box.dart index 6de0c5681..a76d15c84 100644 --- a/lib/src/controls/form/auto_suggest_box.dart +++ b/lib/src/controls/form/auto_suggest_box.dart @@ -1,65 +1,67 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; -// TODO: Navigate through items using keyboard (https://github.com/bdlukaa/fluent_ui/issues/19) +enum TextChangedReason { + /// Whether the text in an [AutoSuggestBox] was changed by user input + userInput, + + /// Whether the text in an [AutoSuggestBox] was changed because the user + /// chose the suggestion + suggestionChosen, +} -typedef AutoSuggestBoxItemBuilder = Widget Function(BuildContext, T); -typedef AutoSuggestBoxItemSorter = List Function(String, List); -typedef AutoSuggestBoxTextBoxBuilder = Widget Function( - BuildContext context, - TextEditingController controller, - FocusNode focusNode, - GlobalKey key, -); +// TODO: Navigate through items using keyboard (https://github.com/bdlukaa/fluent_ui/issues/19) /// An AutoSuggestBox provides a list of suggestions for a user to select /// from as they type. /// -/// ![AutoSuggestBox Preview](https://docs.microsoft.com/en-us/windows/uwp/design/controls-and-patterns/images/controls_autosuggest_expanded01.png) +/// ![AutoSuggestBox Preview](https://docs.microsoft.com/en-us/windows/apps/design/controls/images/controls-autosuggest-expanded-01.png) /// /// See also: -/// - [TextBox] -/// - [ComboBox] +/// +/// * +/// * [TextBox], which is used by this widget to enter user text input +/// * [Overlay], which is used to show the popup class AutoSuggestBox extends StatefulWidget { /// Creates a fluent-styled auto suggest box. const AutoSuggestBox({ Key? key, - required this.controller, required this.items, - this.itemBuilder, - this.sorter = defaultItemSorter, - this.noResultsFound = defaultNoResultsFound, - this.textBoxBuilder = defaultTextBoxBuilder, + this.controller, + this.onChanged, this.onSelected, + this.trailingIcon, + this.clearButtonEnabled = true, + this.placeholder, }) : super(key: key); + /// The list of items to display to the user to pick + final List items; + /// The controller used to have control over what to show on /// the [TextBox]. - final TextEditingController controller; - - /// The list of items to display to the user to pick. If empty, - /// [noResultsFound] is used. - final List items; + final TextEditingController? controller; - /// The item builder to build [items]. If null, uses a default - /// internal builder - final AutoSuggestBoxItemBuilder? itemBuilder; + /// Called when the text is updated + final void Function(String text, TextChangedReason reason)? onChanged; - /// Sort the items to show. [defaultItemSorter] is used by default - final AutoSuggestBoxItemSorter sorter; + /// Called when the user selected a value. + final ValueChanged? onSelected; - /// Build the text box. [defaultTextBoxBuilder] is used by default - final AutoSuggestBoxTextBoxBuilder textBoxBuilder; + /// A widget displayed in the end of the [TextBox] + /// + /// Usually an [IconButton] or [Icon] + final Widget? trailingIcon; - /// The widget to show when the text the user typed doesn't match with - /// [items]s. [defaultNoResultsFound] is used by default. + /// Whether the close button is enabled /// - /// ![No results found Preview](https://docs.microsoft.com/en-us/windows/uwp/design/controls-and-patterns/images/controls_autosuggest_noresults.png) - final WidgetBuilder noResultsFound; + /// Defauls to true + final bool clearButtonEnabled; - /// Called when the user selected a value. - final ValueChanged? onSelected; + /// The placeholder + final String? placeholder; @override _AutoSuggestBoxState createState() => _AutoSuggestBoxState(); @@ -80,36 +82,6 @@ class AutoSuggestBox extends StatefulWidget { return element.toString().toLowerCase().contains(text.toLowerCase()); }).toList(); } - - /// Creates a 'No results found' tile. - /// - /// ![No results found Preview](https://docs.microsoft.com/en-us/windows/uwp/design/controls-and-patterns/images/controls_autosuggest_noresults.png) - static Widget defaultNoResultsFound(context) { - return const ListTile( - title: DefaultTextStyle( - style: TextStyle(fontWeight: FontWeight.normal), - child: Text('No results found'), - ), - ); - } - - static Widget defaultTextBoxBuilder( - BuildContext context, - TextEditingController controller, - FocusNode focusNode, - GlobalKey key, - ) { - assert(debugCheckHasFluentLocalizations(context)); - final FluentLocalizations localizations = FluentLocalizations.of(context); - return TextBox( - key: key, - controller: controller, - focusNode: focusNode, - placeholder: localizations.searchLabel, - clipBehavior: - focusNode.hasFocus ? Clip.none : Clip.antiAliasWithSaveLayer, - ); - } } class _AutoSuggestBoxState extends State> { @@ -118,47 +90,37 @@ class _AutoSuggestBoxState extends State> { final LayerLink _layerLink = LayerLink(); final GlobalKey _textBoxKey = GlobalKey(); + late TextEditingController controller; + @override void initState() { super.initState(); + controller = widget.controller ?? TextEditingController(); + controller.addListener(() { + if (!mounted) return; + if (controller.text.length < 2) setState(() {}); + }); focusNode.addListener(_handleFocusChanged); } @override void dispose() { focusNode.removeListener(_handleFocusChanged); + if (widget.controller == null) { + controller.dispose(); + } super.dispose(); } void _handleFocusChanged() { final hasFocus = focusNode.hasFocus; - if (hasFocus) { - if (_entry == null && !(_entry?.mounted ?? false)) { - _insertOverlay(); - } - } else { + if (!hasFocus) { _dismissOverlay(); } setState(() {}); } - AutoSuggestBoxItemBuilder get itemBuilder => - widget.itemBuilder ?? _defaultItemBuilder; - - Widget _defaultItemBuilder(BuildContext context, T value) { - return TappableListTile( - onTap: () { - widget.controller.text = '$value'; - widget.onSelected?.call(value); - focusNode.unfocus(); - }, - title: - Text('$value', style: FluentTheme.maybeOf(context)?.typography.body), - ); - } - void _insertOverlay() { - final acrylicDisabled = DisableAcrylic.of(context) != null; _entry = OverlayEntry(builder: (context) { final context = _textBoxKey.currentContext; if (context == null) return const SizedBox.shrink(); @@ -171,62 +133,25 @@ class _AutoSuggestBoxState extends State> { offset: Offset(0, box.size.height + 0.8), child: SizedBox( width: box.size.width, - child: Acrylic( - shape: RoundedRectangleBorder( - borderRadius: const BorderRadius.vertical( - bottom: Radius.circular(4.0), - ), - side: BorderSide( - color: FluentTheme.of(context).scaffoldBackgroundColor, - width: 0.8, - ), - ), - child: ValueListenableBuilder( - valueListenable: widget.controller, - builder: (context, value, _) { - final items = widget.sorter(value.text, widget.items); - late Widget result; - if (items.isEmpty) { - result = widget.noResultsFound(context); - } else { - result = ListView( - shrinkWrap: true, - children: List.generate(items.length, (index) { - final item = items[index]; - return itemBuilder(context, item); - }), - ); - } - return AnimatedSwitcher( - duration: FluentTheme.of(context).fastAnimationDuration, - switchInCurve: FluentTheme.of(context).animationCurve, - transitionBuilder: (child, animation) { - if (child is ListView) { - return child; - } - return EntrancePageTransition( - child: child, - animation: animation, - vertical: true, - ); - }, - layoutBuilder: (child, children) => - child ?? const SizedBox(), - child: result, - ); - }, - ), + child: _AutoSuggestBoxOverlay( + controller: controller, + items: widget.items, + onSelected: (item) { + widget.onSelected?.call(item); + controller.text = item; + widget.onChanged?.call(item, TextChangedReason.userInput); + }, ), ), ), ); - if (acrylicDisabled) return DisableAcrylic(child: child); return child; }); if (_textBoxKey.currentContext != null) { Overlay.of(context)?.insert(_entry!); + if (mounted) setState(() {}); } } @@ -238,13 +163,195 @@ class _AutoSuggestBoxState extends State> { @override Widget build(BuildContext context) { assert(debugCheckHasFluentTheme(context)); + assert(debugCheckHasFluentLocalizations(context)); + return CompositedTransformTarget( link: _layerLink, - child: widget.textBoxBuilder( - context, - widget.controller, - focusNode, - _textBoxKey, + child: TextBox( + key: _textBoxKey, + controller: controller, + focusNode: focusNode, + placeholder: widget.placeholder, + clipBehavior: _entry != null ? Clip.none : Clip.antiAliasWithSaveLayer, + suffix: Row(children: [ + if (widget.trailingIcon != null) widget.trailingIcon!, + if (widget.clearButtonEnabled && controller.text.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 2.0), + child: IconButton( + icon: const Icon(FluentIcons.close), + onPressed: () { + controller.clear(); + focusNode.unfocus(); + }, + ), + ), + ]), + suffixMode: OverlayVisibilityMode.always, + onChanged: (text) { + widget.onChanged?.call(text, TextChangedReason.userInput); + if (_entry == null && !(_entry?.mounted ?? false)) { + _insertOverlay(); + } + }, + ), + ); + } +} + +class _AutoSuggestBoxOverlay extends StatelessWidget { + const _AutoSuggestBoxOverlay({ + Key? key, + required this.items, + required this.controller, + required this.onSelected, + }) : super(key: key); + + final List items; + final TextEditingController controller; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final theme = FluentTheme.of(context); + final localizations = FluentLocalizations.of(context); + return FocusScope( + autofocus: true, + child: Container( + constraints: const BoxConstraints( + maxHeight: 385, + ), + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(4.0), + ), + side: BorderSide( + color: theme.scaffoldBackgroundColor, + width: 0.8, + ), + ), + color: theme.micaBackgroundColor, + ), + child: ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) { + final items = + AutoSuggestBox.defaultItemSorter(value.text, this.items); + late Widget result; + if (items.isEmpty) { + result = Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: _AutoSuggestBoxOverlayTile( + text: localizations.noResultsFoundLabel), + ); + } else { + result = ListView( + key: ValueKey(items.length), + shrinkWrap: true, + padding: const EdgeInsets.only(bottom: 4.0), + children: List.generate(items.length, (index) { + final item = items[index]; + return _AutoSuggestBoxOverlayTile( + text: '$item', + onSelected: () { + onSelected(item); + }, + ); + }), + ); + } + return result; + }, + ), + ), + ); + } +} + +class _AutoSuggestBoxOverlayTile extends StatefulWidget { + const _AutoSuggestBoxOverlayTile({ + Key? key, + required this.text, + this.onSelected, + }) : super(key: key); + + final String text; + final VoidCallback? onSelected; + + @override + __AutoSuggestBoxOverlayTileState createState() => + __AutoSuggestBoxOverlayTileState(); +} + +class __AutoSuggestBoxOverlayTileState extends State<_AutoSuggestBoxOverlayTile> + with SingleTickerProviderStateMixin { + late AnimationController controller; + + @override + void initState() { + super.initState(); + controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 125), + ); + controller.forward(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = FluentTheme.of(context); + return HoverButton( + onPressed: widget.onSelected, + margin: const EdgeInsets.only(top: 4.0, left: 4.0, right: 4.0), + builder: (context, states) => Stack( + children: [ + Container( + height: 40.0, + padding: const EdgeInsets.symmetric(horizontal: 10.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6.0), + color: ButtonThemeData.uncheckedInputColor( + theme, + states.isDisabled ? {ButtonStates.none} : states, + ), + ), + alignment: Alignment.centerLeft, + child: EntrancePageTransition( + child: Text( + widget.text, + style: theme.typography.body, + ), + animation: Tween( + begin: 0.75, + end: 1.0, + ).animate(CurvedAnimation( + parent: controller, + curve: Curves.easeOut, + )), + vertical: true, + ), + ), + if (states.isFocused) + Positioned( + top: 11.0, + bottom: 11.0, + left: 0.0, + child: Container( + width: 3.0, + decoration: BoxDecoration( + color: theme.accentColor, + borderRadius: BorderRadius.circular(4.0), + ), + ), + ), + ], ), ); } diff --git a/lib/src/controls/form/text_box.dart b/lib/src/controls/form/text_box.dart index bc0691174..f815f52cb 100644 --- a/lib/src/controls/form/text_box.dart +++ b/lib/src/controls/form/text_box.dart @@ -762,16 +762,18 @@ class _TextBoxState extends State data: widget.iconButtonThemeData ?? const ButtonThemeData(), child: IconTheme.merge( data: const IconThemeData(size: 14), - child: () { - if (widget.header != null) { - return InfoLabel( - child: listener, - label: widget.header!, - labelStyle: widget.headerStyle, - ); - } - return listener; - }(), + child: SmallIconButton( + child: () { + if (widget.header != null) { + return InfoLabel( + child: listener, + label: widget.header!, + labelStyle: widget.headerStyle, + ); + } + return listener; + }(), + ), ), ); } diff --git a/lib/src/controls/inputs/buttons/base.dart b/lib/src/controls/inputs/buttons/base.dart index f703d0460..c5e5dac27 100644 --- a/lib/src/controls/inputs/buttons/base.dart +++ b/lib/src/controls/inputs/buttons/base.dart @@ -91,10 +91,10 @@ class _BaseButtonState extends State { } final Widget result = HoverButton( - onLongPress: widget.onLongPress, autofocus: widget.autofocus, focusNode: widget.focusNode, onPressed: widget.onPressed, + onLongPress: widget.onLongPress, builder: (context, states) { T? resolve( ButtonState? Function(ButtonStyle? style) getProperty) { @@ -128,6 +128,7 @@ class _BaseButtonState extends State { vertical: theme.visualDensity.vertical, )) .clamp(EdgeInsets.zero, EdgeInsetsGeometry.infinity); + final double? iconSize = resolve((style) => style?.iconSize); Widget result = PhysicalModel( color: Colors.transparent, shadowColor: resolvedShadowColor ?? Colors.black, @@ -146,7 +147,10 @@ class _BaseButtonState extends State { ), padding: padding, child: IconTheme.merge( - data: IconThemeData(color: resolvedForegroundColor, size: 14.0), + data: IconThemeData( + color: resolvedForegroundColor, + size: iconSize ?? 14.0, + ), child: DefaultTextStyle( style: (resolvedTextStyle ?? const TextStyle(inherit: true)) .copyWith(color: resolvedForegroundColor), diff --git a/lib/src/controls/inputs/buttons/icon_button.dart b/lib/src/controls/inputs/buttons/icon_button.dart index bfdd208bb..8f2db895b 100644 --- a/lib/src/controls/inputs/buttons/icon_button.dart +++ b/lib/src/controls/inputs/buttons/icon_button.dart @@ -23,8 +23,12 @@ class IconButton extends BaseButton { ButtonStyle defaultStyleOf(BuildContext context) { assert(debugCheckHasFluentTheme(context)); final theme = FluentTheme.of(context); + final isSmall = SmallIconButton.of(context) != null; return ButtonStyle( - padding: ButtonState.all(const EdgeInsets.all(10.0)), + iconSize: ButtonState.all(isSmall ? 12.0 : 0.0), + padding: ButtonState.all(isSmall + ? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0) + : const EdgeInsets.all(8.0)), backgroundColor: ButtonState.resolveWith((states) { return states.isDisabled ? ButtonThemeData.buttonColor(theme.brightness, states) @@ -46,3 +50,19 @@ class IconButton extends BaseButton { return ButtonTheme.of(context).iconButtonStyle; } } + +class SmallIconButton extends InheritedWidget { + const SmallIconButton({ + Key? key, + required Widget child, + }) : super(key: key, child: child); + + static SmallIconButton? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + @override + bool updateShouldNotify(SmallIconButton oldWidget) { + return true; + } +} diff --git a/lib/src/controls/inputs/buttons/theme.dart b/lib/src/controls/inputs/buttons/theme.dart index 312aaec1e..648b73d5b 100644 --- a/lib/src/controls/inputs/buttons/theme.dart +++ b/lib/src/controls/inputs/buttons/theme.dart @@ -14,6 +14,7 @@ class ButtonStyle with Diagnosticable { this.padding, this.border, this.shape, + this.iconSize, }); final ButtonState? textStyle; @@ -32,6 +33,8 @@ class ButtonStyle with Diagnosticable { final ButtonState? shape; + final ButtonState? iconSize; + ButtonStyle? merge(ButtonStyle? other) { if (other == null) return this; return ButtonStyle( @@ -43,6 +46,7 @@ class ButtonStyle with Diagnosticable { padding: other.padding ?? padding, border: other.border ?? border, shape: other.shape ?? shape, + iconSize: other.iconSize ?? iconSize, ); } @@ -68,6 +72,12 @@ class ButtonStyle with Diagnosticable { shape: ButtonState.lerp(a?.shape, b?.shape, t, (a, b, t) { return ShapeBorder.lerp(a, b, t) as OutlinedBorder; }), + iconSize: ButtonState.lerp( + a?.iconSize, + b?.iconSize, + t, + lerpDouble, + ), ); } } diff --git a/lib/src/controls/inputs/slider.dart b/lib/src/controls/inputs/slider.dart index 11aa54499..3b5c6c1b9 100644 --- a/lib/src/controls/inputs/slider.dart +++ b/lib/src/controls/inputs/slider.dart @@ -192,6 +192,7 @@ class _SliderState extends m.State { label: widget.label, focusNode: _focusNode, autofocus: widget.autofocus, + mouseCursor: MouseCursor.defer, ), ), ), diff --git a/lib/src/controls/navigation/tab_view.dart b/lib/src/controls/navigation/tab_view.dart index b05debd61..e56fbacf9 100644 --- a/lib/src/controls/navigation/tab_view.dart +++ b/lib/src/controls/navigation/tab_view.dart @@ -413,7 +413,7 @@ class _TabViewState extends State { scrollController.backward(); } : null, - localizations.scrollTabBackward, + localizations.scrollTabBackwardLabel, ), if (scrollable) Expanded(child: listView) @@ -430,7 +430,7 @@ class _TabViewState extends State { scrollController.forward(); } : null, - localizations.scrollTabForward, + localizations.scrollTabForwardLabel, ), if (widget.showNewButton) _buttonTabBuilder( diff --git a/lib/src/localization.dart b/lib/src/localization.dart index 6441f8cd5..959f26ea1 100644 --- a/lib/src/localization.dart +++ b/lib/src/localization.dart @@ -65,10 +65,13 @@ abstract class FluentLocalizations { String get closeTabLabel; /// The label used by [TabView]'s scroll backward button - String get scrollTabBackward; + String get scrollTabBackwardLabel; /// The label used by [TabView]'s scroll forward button - String get scrollTabForward; + String get scrollTabForwardLabel; + + /// The label used by [AutoSuggestBox] when the results can't be found + String get noResultsFoundLabel; /// The `FluentLocalizations` from the closest [Localizations] instance /// that encloses the given context. @@ -152,10 +155,13 @@ class DefaultFluentLocalizations implements FluentLocalizations { String get closeTabLabel => 'Close tab (Ctrl+F4)'; @override - String get scrollTabBackward => 'Scroll tab list backward'; + String get scrollTabBackwardLabel => 'Scroll tab list backward'; + + @override + String get scrollTabForwardLabel => 'Scroll tab list forward'; @override - String get scrollTabForward => 'Scroll tab list forward'; + String get noResultsFoundLabel => 'No results found'; /// Creates an object that provides US English resource values for the material /// library widgets.