From 868b26cfbd64a521d6726e3271421a052257ec06 Mon Sep 17 00:00:00 2001 From: mikecoomber <58986130+mikecoomber@users.noreply.github.com> Date: Wed, 12 Jun 2024 16:45:18 +0100 Subject: [PATCH] feat: Created dropdown list item (#101) feat: Created ZetaAnimationDuration tokens --- .../lib/pages/components/list_example.dart | 22 ++- .../pages/components/list_item_example.dart | 66 ++++--- example/lib/pages/theme/color_example.dart | 2 +- example/widgetbook/main.dart | 9 +- .../dropdown_list_item_widgetbook.dart | 39 +++++ lib/src/components/accordion/accordion.dart | 4 +- lib/src/components/checkbox/checkbox.dart | 4 +- lib/src/components/fabs/fab.dart | 2 +- .../list_item/dropdown_list_item.dart | 164 ++++++++++++++++++ lib/src/components/list_item/list_item.dart | 81 +++++---- lib/src/components/list_item/list_scope.dart | 39 +++++ lib/src/components/progress/progress.dart | 4 +- lib/src/components/progress/progress_bar.dart | 2 +- lib/src/components/radio/radio.dart | 2 +- lib/src/theme/tokens.dart | 18 ++ lib/zeta_flutter.dart | 1 + 16 files changed, 381 insertions(+), 78 deletions(-) create mode 100644 example/widgetbook/pages/components/dropdown_list_item_widgetbook.dart create mode 100644 lib/src/components/list_item/dropdown_list_item.dart create mode 100644 lib/src/components/list_item/list_scope.dart diff --git a/example/lib/pages/components/list_example.dart b/example/lib/pages/components/list_example.dart index 34133ea2..da9c6341 100644 --- a/example/lib/pages/components/list_example.dart +++ b/example/lib/pages/components/list_example.dart @@ -12,11 +12,29 @@ class ListExample extends StatelessWidget { return ExampleScaffold( name: 'List', child: ZetaList( - showDivider: false, + showDivider: true, items: [ ZetaListItem(primaryText: 'Item 1'), ZetaListItem(primaryText: 'Item 2'), - ZetaListItem(primaryText: 'Item 3', showDivider: true), + ZetaDropdownListItem( + primaryText: 'Item 3', + leading: Icon(ZetaIcons.star_round), + expanded: true, + items: [ + ZetaListItem.checkbox( + primaryText: 'Dropdown Item 1', + onChanged: (_) {}, + ), + ZetaListItem.checkbox( + primaryText: 'Dropdown Item 2', + onChanged: (_) {}, + ), + ZetaListItem.checkbox( + primaryText: 'Dropdown Item 3', + onChanged: (_) {}, + ), + ], + ), ZetaListItem(primaryText: 'Item 4', showDivider: true), ZetaListItem(primaryText: 'Item 5'), ZetaListItem(primaryText: 'Item 6'), diff --git a/example/lib/pages/components/list_item_example.dart b/example/lib/pages/components/list_item_example.dart index 72b2ebd9..14a48adf 100644 --- a/example/lib/pages/components/list_item_example.dart +++ b/example/lib/pages/components/list_item_example.dart @@ -66,31 +66,47 @@ class _ListItemExampleState extends State { }, )), _buildListItem( - 'Radio Right', - Column( - children: [ - ZetaListItem.radio( - primaryText: 'Radio option 1', - value: radioOption1, - groupValue: radioGroupValue, - onChanged: (value) { - setState(() { - radioGroupValue = value; - }); - }, - ), - ZetaListItem.radio( - primaryText: 'Radio option 2', - value: radioOption2, - groupValue: radioGroupValue, - onChanged: (value) { - setState(() { - radioGroupValue = value; - }); - }, - ), - ], - )), + 'Radio Right', + Column( + children: [ + ZetaListItem.radio( + primaryText: 'Radio option 1', + value: radioOption1, + groupValue: radioGroupValue, + onChanged: (value) { + setState(() { + radioGroupValue = value; + }); + }, + ), + ZetaListItem.radio( + primaryText: 'Radio option 2', + value: radioOption2, + groupValue: radioGroupValue, + onChanged: (value) { + setState(() { + radioGroupValue = value; + }); + }, + ), + ], + ), + ), + _buildListItem( + 'Dropdown list', + ZetaDropdownListItem( + items: [ + ZetaListItem(primaryText: 'List Item'), + ZetaListItem(primaryText: 'List Item'), + ZetaListItem(primaryText: 'List Item'), + ], + expanded: true, + primaryText: 'List Item', + leading: Icon( + ZetaIcons.star_round, + ), + ), + ), ].divide(const SizedBox(height: 16)).toList(), ), ), diff --git a/example/lib/pages/theme/color_example.dart b/example/lib/pages/theme/color_example.dart index fc423547..323f8642 100644 --- a/example/lib/pages/theme/color_example.dart +++ b/example/lib/pages/theme/color_example.dart @@ -254,7 +254,7 @@ class MyRow extends StatelessWidget { height: 160, width: 160, color: e.value, - duration: const Duration(milliseconds: 250), + duration: ZetaAnimationLength.fast, child: FittedBox( fit: BoxFit.scaleDown, child: Column( diff --git a/example/widgetbook/main.dart b/example/widgetbook/main.dart index 1bc81326..9c0ac3f1 100644 --- a/example/widgetbook/main.dart +++ b/example/widgetbook/main.dart @@ -7,6 +7,7 @@ import 'package:zeta_flutter/zeta_flutter.dart'; import 'pages/assets/icon_widgetbook.dart'; import 'pages/components/accordion_widgetbook.dart'; +import 'pages/components/dropdown_list_item_widgetbook.dart'; import 'pages/components/notification_list_item_widgetbook.dart'; import 'pages/components/text_input_widgetbook.dart'; import 'pages/components/top_app_bar_widgetbook.dart'; @@ -159,7 +160,13 @@ class _HotReloadState extends State { useCases: [ WidgetbookUseCase(name: 'List Item', builder: (context) => listItemUseCase(context)), WidgetbookUseCase( - name: 'Notification List Item', builder: (context) => notificationListItemUseCase(context)), + name: 'Dropdown List Item', + builder: (context) => dropdownListItemUseCase(context), + ), + WidgetbookUseCase( + name: 'Notification List Item', + builder: (context) => notificationListItemUseCase(context), + ), WidgetbookUseCase(name: 'Contact Item', builder: (context) => contactItemUseCase(context)), WidgetbookUseCase(name: 'Chat List Item', builder: (context) => chatItemWidgetBook(context)), ], diff --git a/example/widgetbook/pages/components/dropdown_list_item_widgetbook.dart b/example/widgetbook/pages/components/dropdown_list_item_widgetbook.dart new file mode 100644 index 00000000..9ca182cc --- /dev/null +++ b/example/widgetbook/pages/components/dropdown_list_item_widgetbook.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../test/test_components.dart'; +import '../../utils/utils.dart'; + +Widget dropdownListItemUseCase(BuildContext context) { + return WidgetbookTestWidget( + widget: StatefulBuilder( + builder: (context, setState) { + final primaryText = context.knobs.string(label: 'Primary text', initialValue: 'Label'); + + final secondaryText = context.knobs.string(label: 'Secondary text', initialValue: 'Descriptor'); + + final showIcon = context.knobs.boolean(label: 'Show icon'); + + final showDivider = context.knobs.boolean(label: 'Show divider'); + + final rounded = roundedKnob(context); + + final leading = showIcon ? Icon(ZetaIcons.star_round) : null; + + return ZetaDropdownListItem( + primaryText: primaryText, + items: [ + ZetaListItem(primaryText: 'List Item'), + ZetaListItem(primaryText: 'List Item'), + ZetaListItem(primaryText: 'List Item'), + ], + rounded: rounded, + secondaryText: secondaryText, + leading: leading, + showDivider: showDivider, + ); + }, + ), + ); +} diff --git a/lib/src/components/accordion/accordion.dart b/lib/src/components/accordion/accordion.dart index d4dd51c5..b0553d5e 100644 --- a/lib/src/components/accordion/accordion.dart +++ b/lib/src/components/accordion/accordion.dart @@ -60,8 +60,8 @@ class _ZetaAccordionState extends State with TickerProviderStateM void initState() { super.initState(); _controller = AnimationController( - duration: const Duration(milliseconds: 300), - reverseDuration: const Duration(milliseconds: 200), + duration: ZetaAnimationLength.normal, + reverseDuration: ZetaAnimationLength.fast, vsync: this, ); _animation = CurvedAnimation( diff --git a/lib/src/components/checkbox/checkbox.dart b/lib/src/components/checkbox/checkbox.dart index dcbca898..1d08d0da 100644 --- a/lib/src/components/checkbox/checkbox.dart +++ b/lib/src/components/checkbox/checkbox.dart @@ -151,7 +151,7 @@ class _CheckboxState extends State<_Checkbox> { onTap: !widget.disabled ? () => widget.onChanged.call(!_checked) : null, borderRadius: ZetaRadius.full, child: Padding( - padding: const EdgeInsets.all(ZetaSpacing.small), + padding: const EdgeInsets.all(ZetaSpacing.medium), child: Semantics( mixed: widget.useIndeterminate, enabled: !widget.disabled, @@ -194,7 +194,7 @@ class _CheckboxState extends State<_Checkbox> { mainAxisSize: MainAxisSize.min, children: [ AnimatedContainer( - duration: const Duration(milliseconds: 200), + duration: ZetaAnimationLength.fast, decoration: BoxDecoration( boxShadow: [ if (_isFocused && !widget.disabled) diff --git a/lib/src/components/fabs/fab.dart b/lib/src/components/fabs/fab.dart index e6bdb75d..9953eef8 100644 --- a/lib/src/components/fabs/fab.dart +++ b/lib/src/components/fabs/fab.dart @@ -154,7 +154,7 @@ class _ZetaFABState extends State { ), ), child: AnimatedContainer( - duration: const Duration(milliseconds: 300), + duration: ZetaAnimationLength.normal, child: Padding( padding: _isExpanded ? const EdgeInsets.symmetric(horizontal: ZetaSpacingBase.x3_5, vertical: ZetaSpacing.medium) diff --git a/lib/src/components/list_item/dropdown_list_item.dart b/lib/src/components/list_item/dropdown_list_item.dart new file mode 100644 index 00000000..e6b0b80b --- /dev/null +++ b/lib/src/components/list_item/dropdown_list_item.dart @@ -0,0 +1,164 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../assets/icons.dart'; +import '../../theme/tokens.dart'; +import '../../zeta.dart'; +import 'list_item.dart'; +import 'list_scope.dart'; + +/// An expandable list item containing other [ZetaListItem]s within it. +class ZetaDropdownListItem extends StatefulWidget { + /// Creates a new [ZetaDropdownListItem] + const ZetaDropdownListItem({ + required this.primaryText, + required this.items, + this.secondaryText, + this.expanded = false, + this.leading, + this.rounded = true, + this.showDivider, + super.key, + }); + + /// The list of [ZetaListItem]s contained within the dropdown. + final List items; + + /// {@macro list-item-primary-text} + final String primaryText; + + /// {@macro list-item-secondary-text} + final String? secondaryText; + + /// {@macro list-item-leading} + final Widget? leading; + + /// Expands the list item if set to true. + /// Defaults to false. + final bool expanded; + + /// {@macro zeta-component-rounded} + final bool rounded; + + /// {@macro list-item-show-divider} + final bool? showDivider; + + @override + State createState() => _ZetaDropdownListItemState(); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(IterableProperty('items', items)) + ..add(StringProperty('primaryText', primaryText)) + ..add(StringProperty('secondaryText', secondaryText)) + ..add(DiagnosticsProperty('expanded', expanded)) + ..add(DiagnosticsProperty('rounded', rounded)) + ..add(DiagnosticsProperty('showDivider', showDivider)); + } +} + +class _ZetaDropdownListItemState extends State with SingleTickerProviderStateMixin { + late AnimationController _expandController; + late Animation _animation; + + late bool _expanded; + + IconData get _icon { + return widget.rounded ? ZetaIcons.expand_more_round : ZetaIcons.expand_more_sharp; + } + + @override + void initState() { + _expanded = widget.expanded; + _expandController = AnimationController( + vsync: this, + duration: ZetaAnimationLength.fast, + ); + _animation = CurvedAnimation( + parent: _expandController, + curve: Curves.easeInOut, + ); + if (_expanded) { + _expandController.value = 1; + } + super.initState(); + } + + @override + void didUpdateWidget(covariant ZetaDropdownListItem oldWidget) { + if (oldWidget.expanded != widget.expanded) { + _setExpanded(widget.expanded); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + _expandController.dispose(); + super.dispose(); + } + + void _setExpanded(bool value) { + setState(() { + _expanded = value; + }); + if (_expanded) { + _expandController.forward(); + } else { + _expandController.reverse(); + } + } + + void _onTap() => _setExpanded(!_expanded); + + @override + Widget build(BuildContext context) { + final divide = widget.showDivider ?? ListScope.of(context)?.showDivider ?? false; + final colors = Zeta.of(context).colors; + + // DecoratedBox does not correctly animated the border when the widget expands. + // ignore: use_decorated_box + return Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: divide ? colors.borderDefault : Colors.transparent, + ), + ), + ), + child: Column( + children: [ + ZetaListItem( + primaryText: widget.primaryText, + secondaryText: widget.secondaryText, + leading: widget.leading, + onTap: _onTap, + showDivider: false, + trailing: IconButton( + icon: AnimatedRotation( + turns: _expanded ? 0.5 : 0, + duration: ZetaAnimationLength.fast, + child: Icon( + _icon, + color: colors.iconSubtle, + ), + ), + onPressed: _onTap, + ), + ), + ListScope( + showDivider: false, + indentItems: true, + child: SizeTransition( + sizeFactor: _animation, + child: Column( + children: widget.items, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/components/list_item/list_item.dart b/lib/src/components/list_item/list_item.dart index f47e7975..7b47c342 100644 --- a/lib/src/components/list_item/list_item.dart +++ b/lib/src/components/list_item/list_item.dart @@ -1,24 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../../zeta_flutter.dart'; - -class _DivderScope extends InheritedWidget { - const _DivderScope({required super.child, required this.showDivider}); - - final bool showDivider; - - static _DivderScope? of(BuildContext context) { - return context.dependOnInheritedWidgetOfExactType<_DivderScope>(); - } - - @override - bool updateShouldNotify(_DivderScope oldWidget) => oldWidget.showDivider != showDivider; - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('showDivider', showDivider)); - } -} +import 'list_scope.dart'; /// Used to apply dividers to a group of [ZetaListItem]s. /// @@ -33,8 +16,8 @@ class ZetaList extends StatelessWidget { super.key, }); - /// The list of [ZetaListItem]s to be shown in the list. - final List items; + /// The list of widgets to be shown in the list. + final List items; /// Shows/hides the divider between lists. /// Defaults to true. @@ -42,7 +25,7 @@ class ZetaList extends StatelessWidget { @override Widget build(BuildContext context) { - return _DivderScope( + return ListScope( showDivider: showDivider, child: ListView.builder( itemBuilder: (context, i) => items[i], @@ -125,23 +108,32 @@ class ZetaListItem extends StatelessWidget { ), onTap = (() => onChanged?.call(value)); - /// A Widget to display before the title; + /// {@template list-item-leading} + /// A widget to display before the title; + /// {@endtemplate} final Widget? leading; /// Called when user taps on the [ZetaListItem]. final VoidCallback? onTap; + /// {@template list-item-primary-text} /// The primary text of the [ZetaListItem]. + /// {@endtemplate} final String primaryText; - /// The primary text of the [ZetaListItem]. + /// {@template list-item-secondary-text} + /// The secondary text of the [ZetaListItem]. + /// {@endtemplate} final String? secondaryText; - /// A widget to display after the title. + /// A widget to display after the primary text. + /// If this is a checkbox, radio button, or switch, use the relevant named constructor. final Widget? trailing; + /// {@template list-item-show-divider} /// Adds a border to the bottom of the list item. /// If this isn't provided and the item is used in a [ZetaList], the value is fetched from the [showDivider] prop on the [ZetaList]. + /// {@endtemplate} final bool? showDivider; @override @@ -159,35 +151,42 @@ class ZetaListItem extends StatelessWidget { @override Widget build(BuildContext context) { final colors = Zeta.of(context).colors; - final divide = showDivider ?? _DivderScope.of(context)?.showDivider ?? false; - - return Container( - constraints: const BoxConstraints(minHeight: ZetaSpacing.xl_9), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: divide ? colors.borderDefault : Colors.transparent, + final listScope = ListScope.of(context); + + final divide = showDivider ?? listScope?.showDivider ?? false; + final Widget? leadingWidget = + leading ?? ((listScope?.indentItems ?? false) ? const SizedBox(width: ZetaSpacing.xl_2) : null); + + return Material( + color: Zeta.of(context).colors.surfaceDefault, + child: InkWell( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: ZetaSpacing.xl_9), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: divide ? colors.borderDefault : Colors.transparent, + ), + ), ), - ), - ), - child: Material( - color: Zeta.of(context).colors.surfaceDefault, - child: InkWell( - onTap: onTap, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: ZetaSpacing.large), + padding: const EdgeInsets.only( + left: ZetaSpacing.large, + right: ZetaSpacing.small, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Row( children: [ - if (leading != null) + if (leadingWidget != null) Padding( padding: const EdgeInsets.only( right: ZetaSpacing.small, ), - child: leading, + child: leadingWidget, ), Flexible( child: Column( diff --git a/lib/src/components/list_item/list_scope.dart b/lib/src/components/list_item/list_scope.dart new file mode 100644 index 00000000..d0dab8df --- /dev/null +++ b/lib/src/components/list_item/list_scope.dart @@ -0,0 +1,39 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../zeta_flutter.dart'; + +/// Not to be used externally. +/// Do not export from zeta.dart. +class ListScope extends InheritedWidget { + /// Creates a new [ListScope] + const ListScope({ + required super.child, + required this.showDivider, + this.indentItems = false, + super.key, + }); + + /// Adds a divider between [ZetaListItem]s and [ZetaDropdownListItem]s in the scope. + final bool showDivider; + + /// Indents all items in the scope without a leading widget. + final bool indentItems; + + /// Fetches the closest [ListScope] ancestor in the widget tree. + static ListScope? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + @override + bool updateShouldNotify(ListScope oldWidget) => + oldWidget.showDivider != showDivider || oldWidget.indentItems != indentItems; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('showDivider', showDivider)) + ..add(DiagnosticsProperty('indentItems', indentItems)); + } +} diff --git a/lib/src/components/progress/progress.dart b/lib/src/components/progress/progress.dart index 3ad63440..c35f068e 100644 --- a/lib/src/components/progress/progress.dart +++ b/lib/src/components/progress/progress.dart @@ -1,6 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import '../../theme/tokens.dart'; + /// Super class for [ZetaProgress] widgets. /// Handles state for progress of [ZetaProgress] widgets. abstract class ZetaProgress extends StatefulWidget { @@ -35,7 +37,7 @@ abstract class ZetaProgressState extends State with T progress = widget.progress; controller = AnimationController( vsync: this, - duration: const Duration(milliseconds: 200), + duration: ZetaAnimationLength.fast, ); animation = Tween( begin: widget.progress, // Start value diff --git a/lib/src/components/progress/progress_bar.dart b/lib/src/components/progress/progress_bar.dart index 98bfe79d..ff1408ea 100644 --- a/lib/src/components/progress/progress_bar.dart +++ b/lib/src/components/progress/progress_bar.dart @@ -110,7 +110,7 @@ class _ZetaProgressBarState extends ZetaProgressState { children: [ Expanded( child: AnimatedContainer( - duration: const Duration(milliseconds: 500), + duration: ZetaAnimationLength.verySlow, height: _weight, child: LinearProgressIndicator( borderRadius: _border, diff --git a/lib/src/components/radio/radio.dart b/lib/src/components/radio/radio.dart index a0458337..81961506 100644 --- a/lib/src/components/radio/radio.dart +++ b/lib/src/components/radio/radio.dart @@ -70,7 +70,7 @@ class _ZetaRadioState extends State> with TickerProviderStateMix mainAxisSize: MainAxisSize.min, children: [ buildToggleable( - size: const Size(ZetaSpacing.xl_5, ZetaSpacing.xl_5), + size: const Size(ZetaSpacing.xl_6, ZetaSpacing.xl_6), painter: _painter! ..position = position ..reaction = reaction diff --git a/lib/src/theme/tokens.dart b/lib/src/theme/tokens.dart index f9a93306..e20b78ce 100644 --- a/lib/src/theme/tokens.dart +++ b/lib/src/theme/tokens.dart @@ -307,3 +307,21 @@ class ZetaRadiusBase { /// 360px radius static const BorderRadius x4 = BorderRadius.all(Radius.circular(360)); } + +/// Tokenised durations used for animations +class ZetaAnimationLength { + /// 100ms + static const veryFast = Duration(milliseconds: 100); + + /// 200ms + static const fast = Duration(milliseconds: 200); + + /// 300ms + static const normal = Duration(milliseconds: 300); + + /// 400ms + static const slow = Duration(milliseconds: 400); + + /// 500ms + static const verySlow = Duration(milliseconds: 500); +} diff --git a/lib/zeta_flutter.dart b/lib/zeta_flutter.dart index 2e6f3964..558d3477 100644 --- a/lib/zeta_flutter.dart +++ b/lib/zeta_flutter.dart @@ -30,6 +30,7 @@ export 'src/components/filter_selection/filter_selection.dart'; export 'src/components/global_header/global_header.dart'; export 'src/components/global_header/header_tab_item.dart'; export 'src/components/in_page_banner/in_page_banner.dart'; +export 'src/components/list_item/dropdown_list_item.dart'; export 'src/components/list_item/list_item.dart'; export 'src/components/list_item/notification_list_item.dart'; export 'src/components/navigation bar/navigation_bar.dart';