From 0cc96d8bab8566a1c8cdd22dd3597a3d069b3ebc Mon Sep 17 00:00:00 2001 From: Frederik Feichtmeier Date: Thu, 27 Jul 2023 13:33:16 +0200 Subject: [PATCH] feat: add YaruSearchField and YaruSearchTitleField (#734) * feat: add YaruSearchField and YaruSearchFieldTitle * Separate button from field and add clear icon * add onchanged and docs * Add alignment to title and improve example * Fix issues found in review * rename kYaruTitleBarItemHeight * height and width wording * add radius parameter * Fix example * Limit clear button width and add style * default to filled * forward text, better clip, hide clear btn if empty * Provide focusnode and controller parameters Co-authored-by: J-P Nurmi * apply suggestions --------- Co-authored-by: J-P Nurmi --- example/lib/example_page_items.dart | 10 + example/lib/pages/search_field_page.dart | 92 ++++++ lib/constants.dart | 6 + lib/src/widgets/yaru_search_field.dart | 387 +++++++++++++++++++++++ lib/widgets.dart | 1 + 5 files changed, 496 insertions(+) create mode 100644 example/lib/pages/search_field_page.dart create mode 100644 lib/src/widgets/yaru_search_field.dart diff --git a/example/lib/example_page_items.dart b/example/lib/example_page_items.dart index 4a0434443..9fab13e86 100644 --- a/example/lib/example_page_items.dart +++ b/example/lib/example_page_items.dart @@ -21,6 +21,7 @@ import 'pages/page_indicator.dart'; import 'pages/popup_page.dart'; import 'pages/progress_indicator_page.dart'; import 'pages/radio_page.dart'; +import 'pages/search_field_page.dart'; import 'pages/section_page.dart'; import 'pages/selectable_container_page.dart'; import 'pages/switch_page.dart'; @@ -182,6 +183,15 @@ final examplePageItems = [ ? const Icon(YaruIcons.radiobox_checked_filled) : const Icon(YaruIcons.radiobox_checked), ), + PageItem( + title: 'YaruSearchField', + snippetUrl: + 'https://raw.githubusercontent.com/ubuntu/yaru_widgets.dart/main/example/lib/pages/search_field_page.dart', + iconBuilder: (context, selected) => selected + ? const Icon(YaruIcons.search_filled) + : const Icon(YaruIcons.search), + pageBuilder: (_) => const SearchFieldPage(), + ), PageItem( title: 'YaruSection', snippetUrl: diff --git a/example/lib/pages/search_field_page.dart b/example/lib/pages/search_field_page.dart new file mode 100644 index 000000000..dd872d42f --- /dev/null +++ b/example/lib/pages/search_field_page.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:yaru_widgets/yaru_widgets.dart'; + +class SearchFieldPage extends StatefulWidget { + const SearchFieldPage({super.key}); + + @override + State createState() => _SearchFieldPageState(); +} + +class _SearchFieldPageState extends State { + var _titleSearchActive = false; + var _fieldSearchActive = false; + + String _titleText = 'The text you submitted'; + String _fieldText = 'Or the things you changed'; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final light = theme.brightness == Brightness.light; + + return Center( + child: ListView( + children: [ + SimpleDialog( + shadowColor: light ? Colors.black : null, + titlePadding: EdgeInsets.zero, + title: YaruDialogTitleBar( + titleSpacing: 0, + centerTitle: true, + title: YaruSearchTitleField( + text: _titleText, + onClear: () => setState(() => _titleText = ''), + onSubmitted: (value) => + setState(() => _titleText = value ?? ''), + searchActive: _titleSearchActive, + onSearchActive: () => + setState(() => _titleSearchActive = !_titleSearchActive), + title: const Text( + 'Any Widget Here', + ), + ), + ), + children: [ + SizedBox( + height: 300, + width: 450, + child: Center( + child: Text( + _titleText, + ), + ), + ) + ], + ), + SimpleDialog( + shadowColor: light ? Colors.black : null, + titlePadding: EdgeInsets.zero, + title: YaruDialogTitleBar( + heroTag: 'bar2', + titleSpacing: 0, + centerTitle: true, + title: _fieldSearchActive + ? YaruSearchField( + onClear: () {}, + onChanged: (value) => setState( + () => _fieldText = value, + ), + ) + : const Text('Title'), + leading: YaruSearchButton( + searchActive: _fieldSearchActive, + onPressed: () => + setState(() => _fieldSearchActive = !_fieldSearchActive), + ), + ), + children: [ + SizedBox( + height: 300, + width: 450, + child: Center( + child: Text(_fieldText), + ), + ) + ], + ), + ], + ), + ); + } +} diff --git a/lib/constants.dart b/lib/constants.dart index e5aa76ed9..671780f1b 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -16,3 +16,9 @@ const kYaruButtonRadius = 6.0; /// The default breakpoint width [YaruMasterDetailPage] uses for switching /// between portrait and landscape modes. const kYaruMasterDetailBreakpoint = 620.0; + +/// The best height for any item inside a [YaruTitleBar] +const kYaruTitleBarItemHeight = 35.0; + +/// The default icon size +const kYaruIconSize = 16.0; diff --git a/lib/src/widgets/yaru_search_field.dart b/lib/src/widgets/yaru_search_field.dart new file mode 100644 index 000000000..0a00c4355 --- /dev/null +++ b/lib/src/widgets/yaru_search_field.dart @@ -0,0 +1,387 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:yaru/yaru.dart'; +import 'package:yaru_icons/yaru_icons.dart'; +import 'package:yaru_widgets/constants.dart'; +import 'package:yaru_widgets/src/widgets/yaru_icon_button.dart'; + +/// A [TextField] to with fully rounded corners, +/// ideally in a [YaruWindowTitleBar] or [YaruDialogTitleBar] +class YaruSearchField extends StatefulWidget { + const YaruSearchField({ + super.key, + this.text, + this.onSubmitted, + this.hintText, + this.height = kYaruTitleBarItemHeight, + this.contentPadding = const EdgeInsets.only( + bottom: 10, + top: 10, + right: 15, + left: 15, + ), + this.autofocus = true, + this.onClear, + this.onChanged, + this.radius = const Radius.circular(kYaruTitleBarItemHeight), + this.style = YaruSearchFieldStyle.filled, + this.borderColor, + this.fillColor, + this.controller, + this.focusNode, + }); + + /// Optional [String] forwarded to the internal [TextEditingController] + final String? text; + + /// Optional [String] used inside the internal [InputDecoration] + final String? hintText; + + /// The callback forwarded to the [TextField] used when the enter key is pressed + final void Function(String? value)? onSubmitted; + + /// The callback forwarded to the [TextField] used when input changes + final void Function(String value)? onChanged; + + /// Optional callback used to clear the [TextField]. If provided an [IconButton] will use it + /// as the suffix icon inside the [InputDecoration] + final void Function()? onClear; + + /// The height of the [TextField] that defaults to [kYaruTitleBarItemHeight] + final double height; + + /// The padding for the [InputDecoration] that defaults to `EdgeInsets.only(bottom: 10,top: 10, right: 15, left: 15)` + final EdgeInsets contentPadding; + + /// Defines if the [TextField] is autofocused on build + final bool autofocus; + + /// Defines the radius for the corners. + final Radius radius; + + final YaruSearchFieldStyle style; + + /// Optional [Color] for the border. If not provided + /// it will fall back to `ColorScheme.dividerColor`. + final Color? borderColor; + + /// Optional [Color] for the border. If not provided + /// it will fall back to `ColorScheme.dividerColor`. + final Color? fillColor; + + /// Optional controller for the internal [TextField] + final TextEditingController? controller; + + /// Optional [FocusNode] for the internal [KeyboardListener] + final FocusNode? focusNode; + + @override + State createState() => _YaruSearchFieldState(); +} + +class _YaruSearchFieldState extends State { + late TextEditingController _controller; + late FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _controller = widget.controller ?? TextEditingController(text: widget.text); + _focusNode = widget.focusNode ?? FocusNode(); + } + + @override + void didUpdateWidget(YaruSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.controller != oldWidget.controller) { + if (oldWidget.controller == null) _controller.dispose(); + _controller = + widget.controller ?? TextEditingController(text: widget.text); + } + if (widget.focusNode != oldWidget.focusNode) { + if (oldWidget.focusNode == null) _focusNode.dispose(); + _focusNode = widget.focusNode ?? FocusNode(); + } + } + + @override + void dispose() { + if (widget.controller == null) _controller.dispose(); + if (widget.focusNode == null) _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final light = theme.brightness == Brightness.light; + + final border = OutlineInputBorder( + borderSide: widget.style == YaruSearchFieldStyle.filled + ? BorderSide.none + : BorderSide( + color: widget.borderColor ?? + theme.colorScheme.outline + .scale(lightness: light ? -0.1 : 0.1), + width: 1, + ), + borderRadius: BorderRadius.all(widget.radius), + ); + + final suffixRadius = BorderRadius.only( + topRight: widget.radius, + bottomRight: widget.radius, + ); + + return KeyboardListener( + focusNode: _focusNode, + onKeyEvent: (value) { + if (value.logicalKey == LogicalKeyboardKey.escape) { + _clear(); + } + }, + child: SizedBox( + height: widget.height, + child: TextField( + autofocus: widget.autofocus, + style: theme.textTheme.bodyMedium, + strutStyle: const StrutStyle( + leading: 0.2, + ), + textAlignVertical: TextAlignVertical.center, + cursorWidth: 1, + onSubmitted: widget.onSubmitted, + onChanged: widget.onChanged, + controller: _controller, + decoration: InputDecoration( + filled: widget.style != YaruSearchFieldStyle.outlined, + border: border, + enabledBorder: border, + errorBorder: border, + focusedBorder: border, + contentPadding: widget.contentPadding, + hintText: widget.hintText, + fillColor: widget.fillColor ?? theme.dividerColor, + hoverColor: + (widget.fillColor ?? theme.dividerColor).scale(lightness: 0.1), + suffixIconConstraints: + const BoxConstraints(maxWidth: kYaruTitleBarItemHeight), + suffixIcon: + widget.onClear == null || _controller.text.isEmpty == true + ? null + : IconButton( + style: IconButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: suffixRadius, + ), + ), + onPressed: _clear, + icon: ClipRRect( + borderRadius: suffixRadius, + child: const Icon( + YaruIcons.edit_clear, + ), + ), + ), + ), + ), + ), + ); + } + + void _clear() { + widget.onClear?.call(); + _controller.clear(); + } +} + +/// Combines [YaruSearchField], [YaruSearchButton] and any title [Widget] in a [Stack] +class YaruSearchTitleField extends StatefulWidget { + const YaruSearchTitleField({ + super.key, + required this.searchActive, + required this.title, + this.width = 190, + this.titlePadding = const EdgeInsets.only(left: 45.0), + this.autoFocus = true, + this.text, + this.hintText, + this.onSubmitted, + this.onClear, + this.onSearchActive, + this.onChanged, + this.alignment = Alignment.centerLeft, + this.radius = const Radius.circular(kYaruTitleBarItemHeight), + this.style = YaruSearchFieldStyle.filled, + this.controller, + this.focusNode, + }); + + final bool searchActive; + final Widget title; + final double width; + final EdgeInsets titlePadding; + final bool autoFocus; + final String? text; + final String? hintText; + final void Function(String? value)? onSubmitted; + final void Function(String)? onChanged; + final void Function()? onClear; + final void Function()? onSearchActive; + final Alignment alignment; + final Radius radius; + final YaruSearchFieldStyle style; + + /// Optional controller for the internal [TextField] + final TextEditingController? controller; + + /// Optional [FocusNode] for the internal [KeyboardListener] + final FocusNode? focusNode; + + @override + State createState() => _YaruSearchTitleFieldState(); +} + +class _YaruSearchTitleFieldState extends State { + late bool _searchActive; + + @override + void initState() { + super.initState(); + _searchActive = widget.searchActive; + } + + @override + void didUpdateWidget(covariant YaruSearchTitleField oldWidget) { + super.didUpdateWidget(oldWidget); + _searchActive = widget.searchActive; + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: widget.width, + child: ClipRRect( + borderRadius: BorderRadius.all(widget.radius), + clipBehavior: Clip.antiAlias, + child: Stack( + alignment: Alignment.centerLeft, + children: [ + if (_searchActive) + Center( + child: SizedBox( + height: kYaruTitleBarItemHeight, + child: YaruSearchField( + focusNode: widget.focusNode, + controller: widget.controller, + text: widget.text, + style: widget.style, + radius: widget.radius, + height: widget.width, + hintText: widget.hintText, + onClear: widget.onClear, + autofocus: widget.autoFocus, + onSubmitted: widget.onSubmitted, + onChanged: widget.onChanged, + contentPadding: const EdgeInsets.only( + bottom: 10, + top: 10, + right: 15, + left: 45, + ), + ), + ), + ) + else + Padding( + padding: widget.titlePadding, + child: Align( + alignment: widget.alignment, + child: widget.title, + ), + ), + YaruSearchButton( + style: widget.style == YaruSearchFieldStyle.outlined + ? widget.style + : YaruSearchFieldStyle.filled, + radius: widget.radius, + searchActive: _searchActive, + onPressed: () => setState(() { + _searchActive = !_searchActive; + widget.onSearchActive?.call(); + }), + ), + ], + ), + ), + ); + } +} + +/// A pre-styled [YaruIconButton], ideally used in combination with [YaruSearchField] +class YaruSearchButton extends StatelessWidget { + const YaruSearchButton({ + super.key, + this.searchActive, + this.onPressed, + this.size = kYaruTitleBarItemHeight, + this.radius = const Radius.circular(kYaruTitleBarItemHeight), + this.style = YaruSearchFieldStyle.filled, + this.borderColor, + }); + + final bool? searchActive; + final void Function()? onPressed; + final double? size; + final Radius radius; + final YaruSearchFieldStyle style; + final Color? borderColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final light = theme.brightness == Brightness.light; + + return Center( + widthFactor: 1, + child: SizedBox( + height: kYaruTitleBarItemHeight, + width: kYaruTitleBarItemHeight, + child: YaruIconButton( + style: IconButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(radius), + side: style == YaruSearchFieldStyle.filled + ? BorderSide.none + : BorderSide( + color: borderColor ?? + theme.colorScheme.outline + .scale(lightness: light ? -0.1 : 0.1), + width: 1, + ), + ), + ), + isSelected: searchActive, + selectedIcon: Icon( + YaruIcons.search, + size: kYaruIconSize, + color: theme.colorScheme.onSurface, + ), + icon: Icon( + YaruIcons.search, + size: kYaruIconSize, + color: theme.colorScheme.onSurface, + ), + onPressed: onPressed, + ), + ), + ); + } +} + +enum YaruSearchFieldStyle { + outlined, + filled, + filledOutlined; +} diff --git a/lib/widgets.dart b/lib/widgets.dart index 76cf6f6df..739eb2eca 100644 --- a/lib/widgets.dart +++ b/lib/widgets.dart @@ -36,6 +36,7 @@ export 'src/widgets/yaru_radio.dart'; export 'src/widgets/yaru_radio_button.dart'; export 'src/widgets/yaru_radio_list_tile.dart'; export 'src/widgets/yaru_radio_theme.dart'; +export 'src/widgets/yaru_search_field.dart'; export 'src/widgets/yaru_section.dart'; export 'src/widgets/yaru_selectable_container.dart'; export 'src/widgets/yaru_switch.dart';