From ad49ff216ac0738a226382c39ab1e0feb26947da Mon Sep 17 00:00:00 2001 From: Feichtmeier Date: Mon, 7 Oct 2024 17:01:03 +0200 Subject: [PATCH 1/4] feat: add YaruSplitButton Fixes #912 --- example/lib/example_page_items.dart | 10 ++ example/lib/pages/split_button_page.dart | 53 +++++++ lib/src/widgets/yaru_split_button.dart | 171 +++++++++++++++++++++++ lib/widgets.dart | 1 + pubspec.yaml | 1 + 5 files changed, 236 insertions(+) create mode 100644 example/lib/pages/split_button_page.dart create mode 100644 lib/src/widgets/yaru_split_button.dart diff --git a/example/lib/example_page_items.dart b/example/lib/example_page_items.dart index f8856de0..dd439b19 100644 --- a/example/lib/example_page_items.dart +++ b/example/lib/example_page_items.dart @@ -30,6 +30,7 @@ import 'pages/radio_page.dart'; import 'pages/search_field_page.dart'; import 'pages/section_page.dart'; import 'pages/selectable_container_page.dart'; +import 'pages/split_button_page.dart'; import 'pages/switch_page.dart'; import 'pages/tab_bar_page.dart'; import 'pages/theme_page/theme_page.dart'; @@ -381,4 +382,13 @@ final examplePageItems = [ 'https://raw.githubusercontent.com/ubuntu/yaru.dart/main/example/lib/pages/border_container_page.dart', ), ), + PageItem( + title: 'YaruSplitButton', + floatingActionButtonBuilder: (_) => const CodeSnippedButton( + snippetUrl: + 'https://raw.githubusercontent.com/ubuntu/yaru.dart/main/example/lib/pages/split_button_page.dart', + ), + pageBuilder: (context) => const SplitButtonPage(), + iconBuilder: (context, selected) => const Icon(YaruIcons.pan_down), + ), ].sortedBy((page) => page.title); diff --git a/example/lib/pages/split_button_page.dart b/example/lib/pages/split_button_page.dart new file mode 100644 index 00000000..f0f20702 --- /dev/null +++ b/example/lib/pages/split_button_page.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; + +class SplitButtonPage extends StatelessWidget { + const SplitButtonPage({super.key}); + + @override + Widget build(BuildContext context) { + final items = List.generate( + 10, + (index) { + final text = + '${index.isEven ? 'Super long action name' : 'action'} ${index + 1}'; + return PopupMenuItem( + child: Text( + text, + overflow: TextOverflow.ellipsis, + ), + onTap: () => ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(text))), + ); + }, + ); + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + YaruSplitButton( + onPressed: () => ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Main Action'))), + items: items, + child: const Text('Main Action'), + ), + const SizedBox(height: 10), + YaruSplitButton.filled( + onPressed: () => ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Main Action'))), + items: items, + child: const Text('Main Action'), + ), + const SizedBox(height: 10), + YaruSplitButton.outlined( + onPressed: () => ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Main Action'))), + items: items, + child: const Text('Main Action'), + ), + ], + ), + ); + } +} diff --git a/lib/src/widgets/yaru_split_button.dart b/lib/src/widgets/yaru_split_button.dart new file mode 100644 index 00000000..0ef5e7a3 --- /dev/null +++ b/lib/src/widgets/yaru_split_button.dart @@ -0,0 +1,171 @@ +import 'package:assorted_layout_widgets/assorted_layout_widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; + +enum _YaruSplitButtonVariant { elevated, filled, outlined } + +class YaruSplitButton extends StatelessWidget { + const YaruSplitButton({ + super.key, + required this.items, + this.onPressed, + this.child, + this.onOptionsPressed, + this.icon, + this.radius, + this.menuWidth = menuDefaultWidth, + }) : _variant = _YaruSplitButtonVariant.elevated; + + const YaruSplitButton.filled({ + super.key, + required this.items, + this.onPressed, + this.child, + this.onOptionsPressed, + this.icon, + this.radius, + this.menuWidth = menuDefaultWidth, + }) : _variant = _YaruSplitButtonVariant.filled; + + const YaruSplitButton.outlined({ + super.key, + required this.items, + this.onPressed, + this.child, + this.onOptionsPressed, + this.icon, + this.radius, + this.menuWidth = menuDefaultWidth, + }) : _variant = _YaruSplitButtonVariant.outlined; + + final _YaruSplitButtonVariant _variant; + final void Function()? onPressed; + final void Function()? onOptionsPressed; + final Widget? child; + final Widget? icon; + final List> items; + final double? radius; + final double menuWidth; + + static const menuDefaultWidth = 148.0; + + @override + Widget build(BuildContext context) { + // TODO: fix common_themes to use a fixed size for buttons instead of fiddling around with padding + // then we can rely on this size here + const size = Size.square(36); + const dropdownPadding = EdgeInsets.only(top: 16, bottom: 16); + + final defaultRadius = Radius.circular(radius ?? kYaruButtonRadius); + + final mainActionShape = RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: defaultRadius, + bottomLeft: defaultRadius, + ), + ); + + final dropdownShape = switch (_variant) { + _YaruSplitButtonVariant.outlined => + const NonUniformRoundedRectangleBorder(hideLeftSide: true), + _ => RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: defaultRadius, + bottomRight: defaultRadius, + ), + ), + }; + + final onDropdownPressed = onPressed == null + ? null + : (onOptionsPressed ?? + () => showMenu( + context: context, + position: _menuPosition(context), + items: items, + menuPadding: EdgeInsets.symmetric(vertical: defaultRadius.x), + constraints: BoxConstraints( + minWidth: menuWidth, + maxWidth: menuWidth, + ), + )); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + switch (_variant) { + _YaruSplitButtonVariant.elevated => ElevatedButton( + style: ElevatedButton.styleFrom(shape: mainActionShape), + onPressed: onPressed, + child: child, + ), + _YaruSplitButtonVariant.filled => FilledButton( + style: FilledButton.styleFrom(shape: mainActionShape), + onPressed: onPressed, + child: child, + ), + _YaruSplitButtonVariant.outlined => OutlinedButton( + style: OutlinedButton.styleFrom(shape: mainActionShape), + onPressed: onPressed, + child: child, + ), + }, + switch (_variant) { + _YaruSplitButtonVariant.elevated => ElevatedButton( + style: ElevatedButton.styleFrom( + fixedSize: size, + minimumSize: size, + maximumSize: size, + padding: dropdownPadding, + shape: dropdownShape, + ), + onPressed: onDropdownPressed, + child: icon ?? const Icon(YaruIcons.pan_down), + ), + _YaruSplitButtonVariant.filled => FilledButton( + style: FilledButton.styleFrom( + fixedSize: size, + minimumSize: size, + maximumSize: size, + padding: dropdownPadding, + shape: dropdownShape, + ), + onPressed: onDropdownPressed, + child: icon ?? const Icon(YaruIcons.pan_down), + ), + _YaruSplitButtonVariant.outlined => OutlinedButton( + style: OutlinedButton.styleFrom( + fixedSize: size, + minimumSize: size, + maximumSize: size, + padding: dropdownPadding, + shape: dropdownShape, + ), + onPressed: onDropdownPressed, + child: icon ?? const Icon(YaruIcons.pan_down), + ), + }, + ], + ); + } + + RelativeRect _menuPosition(BuildContext context) { + final bar = context.findRenderObject() as RenderBox; + final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; + const offset = Offset.zero; + + return RelativeRect.fromRect( + Rect.fromPoints( + bar.localToGlobal( + bar.size.bottomCenter(offset), + ancestor: overlay, + ), + bar.localToGlobal( + bar.size.bottomLeft(offset), + ancestor: overlay, + ), + ), + offset & overlay.size, + ); + } +} diff --git a/lib/widgets.dart b/lib/widgets.dart index 27159420..f2861fb9 100644 --- a/lib/widgets.dart +++ b/lib/widgets.dart @@ -43,6 +43,7 @@ export 'src/widgets/yaru_search_field.dart'; export 'src/widgets/yaru_section.dart'; export 'src/widgets/yaru_segmented_entry.dart'; export 'src/widgets/yaru_selectable_container.dart'; +export 'src/widgets/yaru_split_button.dart'; export 'src/widgets/yaru_switch.dart'; export 'src/widgets/yaru_switch_button.dart'; export 'src/widgets/yaru_switch_list_tile.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 0aae8923..2b4e7dbb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,7 @@ environment: dependencies: animated_vector: ^0.2.0 animated_vector_annotations: ^0.2.0 + assorted_layout_widgets: ^9.0.2 collection: ^1.17.0 dbus: ^0.7.10 flutter: From 0ed06d359a04b145a4e678af4f84339e255f9a86 Mon Sep 17 00:00:00 2001 From: Feichtmeier Date: Mon, 7 Oct 2024 17:09:06 +0200 Subject: [PATCH 2/4] fix: assert --- lib/src/widgets/yaru_split_button.dart | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/lib/src/widgets/yaru_split_button.dart b/lib/src/widgets/yaru_split_button.dart index 0ef5e7a3..f1d5d330 100644 --- a/lib/src/widgets/yaru_split_button.dart +++ b/lib/src/widgets/yaru_split_button.dart @@ -18,7 +18,7 @@ class YaruSplitButton extends StatelessWidget { const YaruSplitButton.filled({ super.key, - required this.items, + this.items, this.onPressed, this.child, this.onOptionsPressed, @@ -43,7 +43,7 @@ class YaruSplitButton extends StatelessWidget { final void Function()? onOptionsPressed; final Widget? child; final Widget? icon; - final List> items; + final List>? items; final double? radius; final double menuWidth; @@ -51,6 +51,11 @@ class YaruSplitButton extends StatelessWidget { @override Widget build(BuildContext context) { + assert( + items?.isNotEmpty == true && onOptionsPressed == null || + items == null && onOptionsPressed != null, + ); + // TODO: fix common_themes to use a fixed size for buttons instead of fiddling around with padding // then we can rely on this size here const size = Size.square(36); @@ -76,19 +81,19 @@ class YaruSplitButton extends StatelessWidget { ), }; - final onDropdownPressed = onPressed == null - ? null - : (onOptionsPressed ?? - () => showMenu( + final onDropdownPressed = onOptionsPressed ?? + (items?.isNotEmpty == true + ? () => showMenu( context: context, position: _menuPosition(context), - items: items, + items: items!, menuPadding: EdgeInsets.symmetric(vertical: defaultRadius.x), constraints: BoxConstraints( minWidth: menuWidth, maxWidth: menuWidth, ), - )); + ) + : null); return Row( mainAxisSize: MainAxisSize.min, From 6ee266bd3fdfa8196d13816fd3493ecbdfb9ea98 Mon Sep 17 00:00:00 2001 From: Feichtmeier Date: Mon, 7 Oct 2024 17:14:43 +0200 Subject: [PATCH 3/4] fix: constructors --- lib/src/widgets/yaru_split_button.dart | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/src/widgets/yaru_split_button.dart b/lib/src/widgets/yaru_split_button.dart index f1d5d330..442e1dd0 100644 --- a/lib/src/widgets/yaru_split_button.dart +++ b/lib/src/widgets/yaru_split_button.dart @@ -7,7 +7,7 @@ enum _YaruSplitButtonVariant { elevated, filled, outlined } class YaruSplitButton extends StatelessWidget { const YaruSplitButton({ super.key, - required this.items, + this.items, this.onPressed, this.child, this.onOptionsPressed, @@ -29,7 +29,7 @@ class YaruSplitButton extends StatelessWidget { const YaruSplitButton.outlined({ super.key, - required this.items, + this.items, this.onPressed, this.child, this.onOptionsPressed, @@ -95,6 +95,8 @@ class YaruSplitButton extends StatelessWidget { ) : null); + final dropdownIcon = icon ?? const Icon(YaruIcons.pan_down); + return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -125,7 +127,7 @@ class YaruSplitButton extends StatelessWidget { shape: dropdownShape, ), onPressed: onDropdownPressed, - child: icon ?? const Icon(YaruIcons.pan_down), + child: dropdownIcon, ), _YaruSplitButtonVariant.filled => FilledButton( style: FilledButton.styleFrom( @@ -136,7 +138,7 @@ class YaruSplitButton extends StatelessWidget { shape: dropdownShape, ), onPressed: onDropdownPressed, - child: icon ?? const Icon(YaruIcons.pan_down), + child: dropdownIcon, ), _YaruSplitButtonVariant.outlined => OutlinedButton( style: OutlinedButton.styleFrom( @@ -147,7 +149,7 @@ class YaruSplitButton extends StatelessWidget { shape: dropdownShape, ), onPressed: onDropdownPressed, - child: icon ?? const Icon(YaruIcons.pan_down), + child: dropdownIcon, ), }, ], From 52cff19f9cd85036c9bdec911595fd02b6a68599 Mon Sep 17 00:00:00 2001 From: Feichtmeier Date: Tue, 8 Oct 2024 19:56:53 +0200 Subject: [PATCH 4/4] fix: right align, don't default height --- example/lib/common/space.dart | 20 ++++++ example/lib/pages/split_button_page.dart | 78 +++++++++++++++++------- lib/src/widgets/yaru_split_button.dart | 43 +++++++------ 3 files changed, 96 insertions(+), 45 deletions(-) create mode 100644 example/lib/common/space.dart diff --git a/example/lib/common/space.dart b/example/lib/common/space.dart new file mode 100644 index 00000000..58983e39 --- /dev/null +++ b/example/lib/common/space.dart @@ -0,0 +1,20 @@ +import 'package:flutter/widgets.dart'; + +List space({ + required Iterable children, + double? widthGap, + double? heightGap, + int skip = 1, +}) => + children + .expand( + (item) sync* { + yield SizedBox( + width: widthGap, + height: heightGap, + ); + yield item; + }, + ) + .skip(skip) + .toList(); diff --git a/example/lib/pages/split_button_page.dart b/example/lib/pages/split_button_page.dart index f0f20702..abb066f4 100644 --- a/example/lib/pages/split_button_page.dart +++ b/example/lib/pages/split_button_page.dart @@ -1,9 +1,18 @@ import 'package:flutter/material.dart'; import 'package:yaru/yaru.dart'; -class SplitButtonPage extends StatelessWidget { +import '../common/space.dart'; + +class SplitButtonPage extends StatefulWidget { const SplitButtonPage({super.key}); + @override + State createState() => _SplitButtonPageState(); +} + +class _SplitButtonPageState extends State { + double width = 200.0; + @override Widget build(BuildContext context) { final items = List.generate( @@ -25,28 +34,51 @@ class SplitButtonPage extends StatelessWidget { return Center( child: Column( mainAxisSize: MainAxisSize.min, - children: [ - YaruSplitButton( - onPressed: () => ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar(content: Text('Main Action'))), - items: items, - child: const Text('Main Action'), - ), - const SizedBox(height: 10), - YaruSplitButton.filled( - onPressed: () => ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar(content: Text('Main Action'))), - items: items, - child: const Text('Main Action'), - ), - const SizedBox(height: 10), - YaruSplitButton.outlined( - onPressed: () => ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar(content: Text('Main Action'))), - items: items, - child: const Text('Main Action'), - ), - ], + children: space( + heightGap: 10, + children: [ + YaruSplitButton( + onPressed: () => ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Main Action'))), + items: items, + child: const Text('Main Action'), + ), + YaruSplitButton.filled( + onPressed: () => ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Main Action'))), + items: items, + child: const Text('Main Action'), + ), + YaruSplitButton.outlined( + menuWidth: width, + onPressed: () => ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Main Action'))), + items: items, + child: const Text('Main Action'), + ), + SizedBox( + width: 300, + child: Slider( + min: 100, + max: 500, + value: width, + onChanged: (v) => setState(() => width = v), + ), + ), + Center( + child: Text('Menu width: ${width.toInt()}'), + ), + YaruSplitButton( + menuWidth: width, + items: items, + child: const Text('Main Action'), + ), + YaruSplitButton( + menuWidth: width, + child: const Text('Main Action'), + ), + ], + ), ), ); } diff --git a/lib/src/widgets/yaru_split_button.dart b/lib/src/widgets/yaru_split_button.dart index 442e1dd0..1d0cda99 100644 --- a/lib/src/widgets/yaru_split_button.dart +++ b/lib/src/widgets/yaru_split_button.dart @@ -13,7 +13,7 @@ class YaruSplitButton extends StatelessWidget { this.onOptionsPressed, this.icon, this.radius, - this.menuWidth = menuDefaultWidth, + this.menuWidth, }) : _variant = _YaruSplitButtonVariant.elevated; const YaruSplitButton.filled({ @@ -24,7 +24,7 @@ class YaruSplitButton extends StatelessWidget { this.onOptionsPressed, this.icon, this.radius, - this.menuWidth = menuDefaultWidth, + this.menuWidth, }) : _variant = _YaruSplitButtonVariant.filled; const YaruSplitButton.outlined({ @@ -35,7 +35,7 @@ class YaruSplitButton extends StatelessWidget { this.onOptionsPressed, this.icon, this.radius, - this.menuWidth = menuDefaultWidth, + this.menuWidth, }) : _variant = _YaruSplitButtonVariant.outlined; final _YaruSplitButtonVariant _variant; @@ -45,17 +45,10 @@ class YaruSplitButton extends StatelessWidget { final Widget? icon; final List>? items; final double? radius; - final double menuWidth; - - static const menuDefaultWidth = 148.0; + final double? menuWidth; @override Widget build(BuildContext context) { - assert( - items?.isNotEmpty == true && onOptionsPressed == null || - items == null && onOptionsPressed != null, - ); - // TODO: fix common_themes to use a fixed size for buttons instead of fiddling around with padding // then we can rely on this size here const size = Size.square(36); @@ -71,8 +64,12 @@ class YaruSplitButton extends StatelessWidget { ); final dropdownShape = switch (_variant) { - _YaruSplitButtonVariant.outlined => - const NonUniformRoundedRectangleBorder(hideLeftSide: true), + _YaruSplitButtonVariant.outlined => NonUniformRoundedRectangleBorder( + hideLeftSide: true, + borderRadius: BorderRadius.all( + defaultRadius, + ), + ), _ => RoundedRectangleBorder( borderRadius: BorderRadius.only( topRight: defaultRadius, @@ -88,10 +85,12 @@ class YaruSplitButton extends StatelessWidget { position: _menuPosition(context), items: items!, menuPadding: EdgeInsets.symmetric(vertical: defaultRadius.x), - constraints: BoxConstraints( - minWidth: menuWidth, - maxWidth: menuWidth, - ), + constraints: menuWidth == null + ? null + : BoxConstraints( + minWidth: menuWidth!, + maxWidth: menuWidth!, + ), ) : null); @@ -157,18 +156,18 @@ class YaruSplitButton extends StatelessWidget { } RelativeRect _menuPosition(BuildContext context) { - final bar = context.findRenderObject() as RenderBox; + final box = context.findRenderObject() as RenderBox; final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; const offset = Offset.zero; return RelativeRect.fromRect( Rect.fromPoints( - bar.localToGlobal( - bar.size.bottomCenter(offset), + box.localToGlobal( + box.size.bottomCenter(offset), ancestor: overlay, ), - bar.localToGlobal( - bar.size.bottomLeft(offset), + box.localToGlobal( + box.size.bottomRight(offset), ancestor: overlay, ), ),