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/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..abb066f4 --- /dev/null +++ b/example/lib/pages/split_button_page.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; + +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( + 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: 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 new file mode 100644 index 00000000..1d0cda99 --- /dev/null +++ b/lib/src/widgets/yaru_split_button.dart @@ -0,0 +1,177 @@ +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, + this.items, + this.onPressed, + this.child, + this.onOptionsPressed, + this.icon, + this.radius, + this.menuWidth, + }) : _variant = _YaruSplitButtonVariant.elevated; + + const YaruSplitButton.filled({ + super.key, + this.items, + this.onPressed, + this.child, + this.onOptionsPressed, + this.icon, + this.radius, + this.menuWidth, + }) : _variant = _YaruSplitButtonVariant.filled; + + const YaruSplitButton.outlined({ + super.key, + this.items, + this.onPressed, + this.child, + this.onOptionsPressed, + this.icon, + this.radius, + this.menuWidth, + }) : _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; + + @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 => NonUniformRoundedRectangleBorder( + hideLeftSide: true, + borderRadius: BorderRadius.all( + defaultRadius, + ), + ), + _ => RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: defaultRadius, + bottomRight: defaultRadius, + ), + ), + }; + + final onDropdownPressed = onOptionsPressed ?? + (items?.isNotEmpty == true + ? () => showMenu( + context: context, + position: _menuPosition(context), + items: items!, + menuPadding: EdgeInsets.symmetric(vertical: defaultRadius.x), + constraints: menuWidth == null + ? null + : BoxConstraints( + minWidth: menuWidth!, + maxWidth: menuWidth!, + ), + ) + : null); + + final dropdownIcon = icon ?? const Icon(YaruIcons.pan_down); + + 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: dropdownIcon, + ), + _YaruSplitButtonVariant.filled => FilledButton( + style: FilledButton.styleFrom( + fixedSize: size, + minimumSize: size, + maximumSize: size, + padding: dropdownPadding, + shape: dropdownShape, + ), + onPressed: onDropdownPressed, + child: dropdownIcon, + ), + _YaruSplitButtonVariant.outlined => OutlinedButton( + style: OutlinedButton.styleFrom( + fixedSize: size, + minimumSize: size, + maximumSize: size, + padding: dropdownPadding, + shape: dropdownShape, + ), + onPressed: onDropdownPressed, + child: dropdownIcon, + ), + }, + ], + ); + } + + RelativeRect _menuPosition(BuildContext context) { + final box = context.findRenderObject() as RenderBox; + final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; + const offset = Offset.zero; + + return RelativeRect.fromRect( + Rect.fromPoints( + box.localToGlobal( + box.size.bottomCenter(offset), + ancestor: overlay, + ), + box.localToGlobal( + box.size.bottomRight(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: