From 1b5288710c50d151b5b07d1c4f3f5cd5e890da54 Mon Sep 17 00:00:00 2001 From: Luke Walton Date: Thu, 16 May 2024 16:49:02 +0100 Subject: [PATCH] fix: UX-1090 - Add expansion option for TopAppBar (#73) --- .../pages/components/top_app_bar_example.dart | 248 ++++++--- example/widgetbook/main.dart | 1 + .../components/top_app_bar_widgetbook.dart | 264 ++++++---- example/widgetbook/test/test_components.dart | 4 +- example/widgetbook/utils/utils.dart | 9 +- .../screen_header_bar/screen_header_bar.dart | 1 - .../top_app_bar/extended_top_app_bar.dart | 75 +++ .../top_app_bar/search_top_app_bar.dart | 225 ++++++++ .../components/top_app_bar/top_app_bar.dart | 493 ++++++------------ 9 files changed, 809 insertions(+), 511 deletions(-) create mode 100644 lib/src/components/top_app_bar/extended_top_app_bar.dart create mode 100644 lib/src/components/top_app_bar/search_top_app_bar.dart diff --git a/example/lib/pages/components/top_app_bar_example.dart b/example/lib/pages/components/top_app_bar_example.dart index 8504c0a6..4976a820 100644 --- a/example/lib/pages/components/top_app_bar_example.dart +++ b/example/lib/pages/components/top_app_bar_example.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:zeta_example/widgets.dart'; import 'package:zeta_flutter/zeta_flutter.dart'; @@ -14,30 +15,47 @@ class TopAppBarExample extends StatefulWidget { } class _TopAppBarExampleState extends State { - late final _searchController = AppBarSearchController(); + final _searchControllerExtended = AppBarSearchController(); + final _searchControllerRegular = AppBarSearchController(); - void _showHideSearch() { - _searchController.isEnabled ? _searchController.closeSearch() : _searchController.startSearch(); + void _showHideSearchExtended() { + _searchControllerExtended.isEnabled + ? _searchControllerExtended.closeSearch() + : _searchControllerExtended.startSearch(); + } + + void _showHideSearchRegular() { + _searchControllerRegular.isEnabled + ? _searchControllerRegular.closeSearch() + : _searchControllerRegular.startSearch(); } @override Widget build(BuildContext context) { + final Widget image = CachedNetworkImage( + imageUrl: "https://i.ytimg.com/vi/KItsWUzFUOs/maxresdefault.jpg", + placeholder: (context, url) => Icon(ZetaIcons.user_round), + errorWidget: (context, url, error) => Icon(Icons.error), + fit: BoxFit.cover, + ); + final colors = Zeta.of(context).colors; + return ExampleScaffold( name: TopAppBarExample.name, - child: SingleChildScrollView( - child: Column( - children: [ - // Default - Padding( - padding: const EdgeInsets.only(top: ZetaSpacing.x4), - child: ZetaTopAppBar( + child: ColoredBox( + color: colors.surfaceSecondary, + child: SingleChildScrollView( + child: Column( + children: [ + Text('Default', style: ZetaTextStyles.titleLarge), + ZetaTopAppBar( leading: IconButton( onPressed: () {}, icon: Icon(Icons.menu_rounded), ), title: Row( children: [ - ZetaAvatar(size: ZetaAvatarSize.xs), + ZetaAvatar(size: ZetaAvatarSize.xs, image: image), Padding( padding: const EdgeInsets.only(left: ZetaSpacing.s), child: Text("Title"), @@ -59,12 +77,8 @@ class _TopAppBarExampleState extends State { ) ], ), - ), - - // Centered - Padding( - padding: const EdgeInsets.only(top: ZetaSpacing.x4), - child: ZetaTopAppBar( + Text('Centered', style: ZetaTextStyles.titleLarge), + ZetaTopAppBar( type: ZetaTopAppBarType.centeredTitle, leading: IconButton( onPressed: () {}, @@ -78,12 +92,8 @@ class _TopAppBarExampleState extends State { ), ], ), - ), - - // Contextual - Padding( - padding: const EdgeInsets.only(top: ZetaSpacing.x4), - child: ZetaTopAppBar( + Text('Contextual', style: ZetaTextStyles.titleLarge), + ZetaTopAppBar( leading: IconButton( onPressed: () {}, icon: Icon(ZetaIcons.close_round), @@ -108,75 +118,143 @@ class _TopAppBarExampleState extends State { ), ], ), - ), - - // Search - Padding( - padding: const EdgeInsets.only(top: ZetaSpacing.x4), - child: Column( - children: [ - ZetaTopAppBar( - type: ZetaTopAppBarType.centeredTitle, - leading: BackButton(), - title: Text("Title"), - actions: [ - IconButton( - onPressed: _showHideSearch, - icon: Icon(ZetaIcons.search_round), - ) - ], - searchController: _searchController, - onSearch: (text) => debugPrint('search text: $text'), - onSearchMicrophoneIconPressed: () async { - var sampleTexts = [ - 'This is a sample text', - 'Another sample', - 'Speech recognition text', - 'Example' - ]; - - var generatedText = sampleTexts[Random().nextInt(sampleTexts.length)]; - - _searchController.text = generatedText; - }, - ), - ZetaButton.primary( - label: "Show/Hide Search", - onPressed: _showHideSearch, - ) - ], - ), - ), - - // Extended - Padding( - padding: const EdgeInsets.only(top: ZetaSpacing.x4), - child: ZetaTopAppBar( - type: ZetaTopAppBarType.extendedTitle, - leading: IconButton( - onPressed: () {}, - icon: Icon(Icons.menu), - ), - title: Text("Large title"), + Text('Search', style: ZetaTextStyles.titleLarge), + ZetaTopAppBar( + type: ZetaTopAppBarType.centeredTitle, + leading: BackButton(), + title: Text("Title"), actions: [ IconButton( - onPressed: () {}, - icon: Icon(Icons.language), - ), - IconButton( - onPressed: () {}, - icon: Icon(Icons.favorite), - ), - IconButton( - onPressed: () {}, - icon: Icon(ZetaIcons.more_vertical_round), + onPressed: _showHideSearchRegular, + icon: Icon(ZetaIcons.search_round), ) ], + searchController: _searchControllerRegular, + onSearch: (text) => debugPrint('search text: $text'), + onSearchMicrophoneIconPressed: () async { + var sampleTexts = ['This is a sample text', 'Another sample', 'Speech recognition text', 'Example']; + + var generatedText = sampleTexts[Random().nextInt(sampleTexts.length)]; + + _searchControllerRegular.text = generatedText; + }, + ), + Text('Extended', style: ZetaTextStyles.titleLarge), + SizedBox( + width: 800, + height: 200, + child: CustomScrollView( + slivers: [ + ZetaTopAppBar.extended( + leading: IconButton( + onPressed: () {}, + icon: Icon(Icons.menu_rounded), + ), + title: Row( + children: [ + ZetaAvatar(size: ZetaAvatarSize.xs, image: image), + Padding( + padding: const EdgeInsets.only(left: ZetaSpacing.s), + child: Text("Title"), + ), + ], + ), + actions: [ + IconButton( + onPressed: () {}, + icon: Icon(Icons.language), + ), + IconButton( + onPressed: () {}, + icon: Icon(Icons.favorite), + ), + IconButton( + onPressed: () {}, + icon: Icon(ZetaIcons.more_vertical_round), + ) + ], + ), + SliverToBoxAdapter( + child: Container( + width: 800, + height: 800, + color: Zeta.of(context).colors.surfaceSecondary, + child: CustomPaint( + painter: Painter(colors: colors), + size: Size(800, 800), + ), + ), + ), + ], + ), + ), + Text('Extended Search', style: ZetaTextStyles.titleLarge), + SizedBox( + width: 800, + height: 200, + child: CustomScrollView( + slivers: [ + ZetaTopAppBar.extended( + leading: BackButton(), + title: Text("Title"), + actions: [ + IconButton( + onPressed: _showHideSearchExtended, + icon: Icon(ZetaIcons.search_round), + ) + ], + searchController: _searchControllerExtended, + onSearch: (text) => debugPrint('search text: $text'), + onSearchMicrophoneIconPressed: () async { + var sampleTexts = [ + 'This is a sample text', + 'Another sample', + 'Speech recognition text', + 'Example' + ]; + var generatedText = sampleTexts[Random().nextInt(sampleTexts.length)]; + _searchControllerExtended.text = generatedText; + }, + ), + SliverToBoxAdapter( + child: Container( + width: 800, + height: 800, + color: Zeta.of(context).colors.surfaceSecondary, + child: CustomPaint( + painter: Painter(colors: colors), + size: Size(800, 800), + ), + ), + ), + ], + ), ), - ), - ], + ].gap(20), + ), ), ), ); } } + +class Painter extends CustomPainter { + final ZetaColors colors; + + Painter({super.repaint, required this.colors}); + + @override + void paint(Canvas canvas, Size size) { + for (double i = -760; i < 760; i += 50) { + var p1 = Offset(i, -10); + var p2 = Offset(800 + i, 810); + var paint = Paint() + ..color = colors.primary + ..strokeWidth = ZetaSpacing.x1; + canvas.drawLine(p1, p2, paint); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => oldDelegate != this; +} diff --git a/example/widgetbook/main.dart b/example/widgetbook/main.dart index 90d3927b..6e074ccb 100644 --- a/example/widgetbook/main.dart +++ b/example/widgetbook/main.dart @@ -66,6 +66,7 @@ class HotReload extends StatelessWidget { useCases: [ WidgetbookUseCase(name: 'Default', builder: (context) => defaultTopAppBarUseCase(context)), WidgetbookUseCase(name: 'Search', builder: (context) => searchTopAppBarUseCase(context)), + WidgetbookUseCase(name: 'Extended', builder: (context) => extendedTopAppBarUseCase(context)), ], ), WidgetbookComponent( diff --git a/example/widgetbook/pages/components/top_app_bar_widgetbook.dart b/example/widgetbook/pages/components/top_app_bar_widgetbook.dart index b79cc865..b1153033 100644 --- a/example/widgetbook/pages/components/top_app_bar_widgetbook.dart +++ b/example/widgetbook/pages/components/top_app_bar_widgetbook.dart @@ -5,80 +5,65 @@ import 'package:widgetbook/widgetbook.dart'; import 'package:zeta_flutter/zeta_flutter.dart'; import '../../test/test_components.dart'; +import '../../utils/utils.dart'; Widget defaultTopAppBarUseCase(BuildContext context) { + final title = context.knobs.string(label: "Title", initialValue: "Title"); + final type = context.knobs.list( + label: "Title Alignment", + options: [ + ZetaTopAppBarType.defaultAppBar, + ZetaTopAppBarType.centeredTitle, + ], + initialOption: ZetaTopAppBarType.defaultAppBar, + labelBuilder: (option) { + return option == ZetaTopAppBarType.defaultAppBar ? 'Left' : 'Center'; + }, + ); + final enabledActions = context.knobs.boolean( + label: "Enabled actions", + initialValue: true, + ); + final leadingIcon = iconKnob(context, name: 'Leading Icon', initial: ZetaIcons.hamburger_menu_round); + return WidgetbookTestWidget( - widget: StatefulBuilder( - builder: (context, setState) { - final title = context.knobs.string(label: "Title", initialValue: "Title"); - final type = context.knobs.list( - label: "Type", - options: [ - ZetaTopAppBarType.defaultAppBar, - ZetaTopAppBarType.centeredTitle, - ZetaTopAppBarType.extendedTitle, - ], - initialOption: ZetaTopAppBarType.defaultAppBar, - labelBuilder: (type) => type.name, - ); - - final enabledActions = context.knobs.boolean( - label: "Enabled actions", - initialValue: true, - ); - - final leadingIcon = context.knobs.list( - label: "Leading Icon", - options: [ - Icon( - key: Key("Menu"), - Icons.menu_rounded, - ), - Icon( - key: Key("Close"), - ZetaIcons.close_round, - ), - Icon( - key: Key("Arrow back"), - ZetaIcons.arrow_back_round, + backgroundColor: Colors.green, + removeBody: true, + widget: Column( + children: [ + ZetaTopAppBar( + leading: IconButton( + onPressed: () {}, + icon: Icon(leadingIcon), ), - ], - initialOption: Icon(Icons.menu_rounded), - labelBuilder: (icon) => icon.key.toString(), - ); - - return ZetaTopAppBar( - leading: IconButton( - onPressed: () {}, - icon: leadingIcon, + type: type, + title: Text(title), + actions: enabledActions + ? [ + IconButton( + onPressed: () {}, + icon: Icon(Icons.language), + ), + IconButton( + onPressed: () {}, + icon: Icon(Icons.favorite), + ), + IconButton( + onPressed: () {}, + icon: Icon(ZetaIcons.more_vertical_round), + ) + ] + : null, ), - type: type, - title: Text(title), - actions: enabledActions - ? [ - IconButton( - onPressed: () {}, - icon: Icon(Icons.language), - ), - IconButton( - onPressed: () {}, - icon: Icon(Icons.favorite), - ), - IconButton( - onPressed: () {}, - icon: Icon(ZetaIcons.more_vertical_round), - ) - ] - : null, - ); - }, - ), - ); + ], + )); } Widget searchTopAppBarUseCase(BuildContext context) { return WidgetbookTestWidget( - widget: _SearchUseCase(), + backgroundColor: Colors.green, + removeBody: true, + widget: Column(children: [_SearchUseCase()]), ); } @@ -95,37 +80,19 @@ class _SearchUseCaseState extends State<_SearchUseCase> { @override Widget build(BuildContext context) { final title = context.knobs.string(label: "Title", initialValue: "Title"); - final type = context.knobs.list( - label: "Type", + label: "Title Alignment", options: [ ZetaTopAppBarType.defaultAppBar, ZetaTopAppBarType.centeredTitle, - ZetaTopAppBarType.extendedTitle, ], initialOption: ZetaTopAppBarType.defaultAppBar, - labelBuilder: (type) => type.name, + labelBuilder: (option) { + return option == ZetaTopAppBarType.defaultAppBar ? 'Left' : 'Center'; + }, ); - final leadingIcon = context.knobs.list( - label: "Leading Icon", - options: [ - Icon( - key: Key("Menu"), - Icons.menu_rounded, - ), - Icon( - key: Key("Close"), - ZetaIcons.close_round, - ), - Icon( - key: Key("Arrow back"), - ZetaIcons.arrow_back_round, - ), - ], - initialOption: Icon(Icons.menu_rounded), - labelBuilder: (icon) => icon.key.toString(), - ); + final leadingIcon = iconKnob(context, name: 'Leading Icon', initial: ZetaIcons.hamburger_menu_round); final enabledSpeechRecognition = context.knobs.boolean( label: "Enabled speech recognition", @@ -137,7 +104,7 @@ class _SearchUseCaseState extends State<_SearchUseCase> { return ZetaTopAppBar( leading: IconButton( onPressed: () {}, - icon: leadingIcon, + icon: Icon(leadingIcon), ), type: type, title: Text(title), @@ -161,3 +128,122 @@ class _SearchUseCaseState extends State<_SearchUseCase> { ); } } + +Widget extendedTopAppBarUseCase(BuildContext context) => ExtendedSearch(); + +class ExtendedSearch extends StatefulWidget { + const ExtendedSearch({super.key}); + + @override + State createState() => _ExtendedSearchState(); +} + +class _ExtendedSearchState extends State { + final _searchControllerExtended = AppBarSearchController(); + + void _showHideSearchExtended() { + _searchControllerExtended.isEnabled + ? _searchControllerExtended.closeSearch() + : _searchControllerExtended.startSearch(); + } + + @override + Widget build(BuildContext context) { + final title = context.knobs.string(label: "Title", initialValue: "Title"); + + final leadingIcon = iconKnob(context, name: 'Leading Icon', initial: ZetaIcons.hamburger_menu_round); + + final showSearch = context.knobs.boolean(label: 'Search variant', initialValue: false); + + return WidgetbookTestWidget( + removeBody: true, + widget: SafeArea( + child: LayoutBuilder(builder: (context, constraints) { + return StatefulBuilder( + builder: ((context, setState) { + return SizedBox( + width: constraints.maxWidth, + height: constraints.maxHeight, + child: CustomScrollView( + slivers: [ + ZetaTopAppBar.extended( + leading: IconButton(icon: Icon(leadingIcon), onPressed: () {}), + title: Text(title), + actions: showSearch + ? [ + IconButton( + onPressed: _showHideSearchExtended, + icon: Icon(ZetaIcons.search_round), + ) + ] + : [ + IconButton( + onPressed: () {}, + icon: Icon(Icons.language), + ), + IconButton( + onPressed: () {}, + icon: Icon(Icons.favorite), + ), + IconButton( + onPressed: () {}, + icon: Icon(ZetaIcons.more_vertical_round), + ) + ], + searchController: showSearch ? _searchControllerExtended : null, + onSearch: showSearch ? (text) => debugPrint('search text: $text') : null, + onSearchMicrophoneIconPressed: showSearch + ? () async { + var sampleTexts = [ + 'This is a sample text', + 'Another sample', + 'Speech recognition text', + 'Example' + ]; + var generatedText = sampleTexts[Random().nextInt(sampleTexts.length)]; + _searchControllerExtended.text = generatedText; + } + : null, + ), + SliverToBoxAdapter( + child: Container( + width: constraints.maxWidth, + height: constraints.maxHeight * 4, + color: Zeta.of(context).colors.surfaceSecondary, + child: CustomPaint( + painter: Painter(colors: Zeta.of(context).colors, constraints: constraints), + size: Size(constraints.maxWidth, constraints.maxHeight * 4), + ), + ), + ), + ], + ), + ); + }), + ); + }), + ), + ); + } +} + +class Painter extends CustomPainter { + final ZetaColors colors; + final BoxConstraints constraints; + Painter({super.repaint, required this.colors, required this.constraints}); + + @override + void paint(Canvas canvas, Size size) { + for (double i = -(constraints.maxWidth + 1000); i < constraints.maxWidth; i += 50) { + var p1 = Offset(i, -10); + var p2 = Offset(constraints.maxHeight + i, constraints.maxHeight * 4); + var paint = Paint() + ..color = colors.primary + ..strokeWidth = ZetaSpacing.x1; + canvas.drawLine(p1, p2, paint); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => oldDelegate != this; +} diff --git a/example/widgetbook/test/test_components.dart b/example/widgetbook/test/test_components.dart index 5087588e..e1d5d476 100644 --- a/example/widgetbook/test/test_components.dart +++ b/example/widgetbook/test/test_components.dart @@ -4,12 +4,14 @@ class WidgetbookTestWidget extends StatelessWidget { final Size? screenSize; final Widget widget; final bool removeBody; + final Color? backgroundColor; const WidgetbookTestWidget({ required this.widget, this.screenSize, super.key, this.removeBody = false, + this.backgroundColor, }); @override @@ -17,7 +19,7 @@ class WidgetbookTestWidget extends StatelessWidget { final size = screenSize ?? const Size(1280, 720); return Scaffold( - backgroundColor: Colors.transparent, + backgroundColor: backgroundColor ?? Colors.transparent, body: removeBody ? widget : Center( diff --git a/example/widgetbook/utils/utils.dart b/example/widgetbook/utils/utils.dart index df07891c..d994643b 100644 --- a/example/widgetbook/utils/utils.dart +++ b/example/widgetbook/utils/utils.dart @@ -18,8 +18,13 @@ List iconOptions(rounded) => rounded ? iconsRound.values.toList() : ic String enumLabelBuilder(Enum? value) => value?.name.split('.').last.capitalize() ?? ''; -IconData? iconKnob(BuildContext context, - {bool rounded = true, bool nullable = false, String name = 'Icon', final IconData? initial}) { +IconData? iconKnob( + BuildContext context, { + bool rounded = true, + bool nullable = false, + String name = 'Icon', + final IconData? initial, +}) { return nullable ? context.knobs.listOrNull( label: name, diff --git a/lib/src/components/screen_header_bar/screen_header_bar.dart b/lib/src/components/screen_header_bar/screen_header_bar.dart index 2760038f..f5b08622 100644 --- a/lib/src/components/screen_header_bar/screen_header_bar.dart +++ b/lib/src/components/screen_header_bar/screen_header_bar.dart @@ -33,7 +33,6 @@ class ZetaScreenHeaderBar extends StatelessWidget { icon: Icon(rounded ? ZetaIcons.chevron_left_round : ZetaIcons.chevron_left_sharp), ), title: title, - titleSpacing: 0, titleTextStyle: ZetaTextStyles.titleLarge, actions: actionButtonLabel == null ? null diff --git a/lib/src/components/top_app_bar/extended_top_app_bar.dart b/lib/src/components/top_app_bar/extended_top_app_bar.dart new file mode 100644 index 00000000..7866d3e6 --- /dev/null +++ b/lib/src/components/top_app_bar/extended_top_app_bar.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +import '../../../zeta_flutter.dart'; + +const _searchBarOffsetTop = ZetaSpacing.x1 * 1.5; +const _searchBarOffsetRight = ZetaSpacing.x1 * 22; +const _maxExtent = ZetaSpacing.x1 * 26; +const _minExtent = ZetaSpacing.x16; +const _leftMin = ZetaSpacing.x4; +const _leftMax = ZetaSpacing.x14; +const _topMin = ZetaSpacing.x5; +const _topMax = ZetaSpacing.x1 * 15; + +/// Delegate for creating an extended app bar, that grows and shrinks when scrolling. +class ZetaExtendedAppBarDelegate extends SliverPersistentHeaderDelegate { + /// Constructs a [ZetaExtendedAppBarDelegate]. + ZetaExtendedAppBarDelegate({ + required this.title, + required this.shrinks, + this.actions, + this.leading, + this.searchController, + }); + + /// Title of the app bar. + final Widget title; + + /// A list of Widgets to display in a row after the [title] widget. + final List? actions; + + /// Widget displayed first in the app bar row. + final Widget? leading; + + /// Used to control the search textfield and states. + final AppBarSearchController? searchController; + + /// If `ZetaTopAppBarType.extend` shrinks. Does not affect other types of app bar. + final bool shrinks; + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: ZetaSpacing.x16, maxHeight: _maxExtent), + child: ColoredBox( + color: Zeta.of(context).colors.surfacePrimary, + child: Stack( + children: [ + Positioned( + top: shrinks + ? (_topMax + (-1 * shrinkOffset)).clamp( + _topMin - (searchController != null && searchController!.isEnabled ? _searchBarOffsetTop : 0), + _topMax, + ) + : _topMax, + left: shrinks ? ((shrinkOffset / _maxExtent) * ZetaSpacing.x50).clamp(_leftMin, _leftMax) : _leftMin, + right: searchController != null && searchController!.isEnabled ? _searchBarOffsetRight : 0, + child: title, + ), + if (leading != null) Positioned(top: ZetaSpacing.x3, left: ZetaSpacing.x2, child: leading!), + if (actions != null) Positioned(top: ZetaSpacing.x3, right: ZetaSpacing.x2, child: Row(children: actions!)), + ], + ), + ), + ); + } + + @override + double get maxExtent => _maxExtent; + + @override + double get minExtent => shrinks ? _minExtent : _maxExtent; + + @override + bool shouldRebuild(covariant ZetaExtendedAppBarDelegate oldDelegate) => oldDelegate != this; +} diff --git a/lib/src/components/top_app_bar/search_top_app_bar.dart b/lib/src/components/top_app_bar/search_top_app_bar.dart new file mode 100644 index 00000000..d79a3991 --- /dev/null +++ b/lib/src/components/top_app_bar/search_top_app_bar.dart @@ -0,0 +1,225 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../zeta_flutter.dart'; + +const _extendedOffset = ZetaSpacing.x1 * 6.5; + +/// Creates a search field used on a [ZetaTopAppBar]. +class ZetaTopAppBarSearchField extends StatefulWidget { + /// Constructs a [ZetaTopAppBarSearchField]. + const ZetaTopAppBarSearchField({ + super.key, + required this.child, + required this.onSearch, + required this.searchController, + required this.hintText, + required this.type, + required this.isExtended, + }); + + /// Called when text in the search field is submitted. + final void Function(String value)? onSearch; + + /// Child of widget. + final Widget? child; + + /// Label used as hint text. If null, displays 'Search'. + final String hintText; + + /// Used to control the search textfield and states. + final AppBarSearchController? searchController; + + /// Defines the styles of the app bar. + final ZetaTopAppBarType type; + + /// Whether top app bar is extended. + final bool isExtended; + + @override + State createState() => _ZetaTopAppBarSearchFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ObjectFlagProperty.has('onSearch', onSearch)) + ..add(StringProperty('hintText', hintText)) + ..add(DiagnosticsProperty('searchController', searchController)) + ..add(EnumProperty('type', type)) + ..add(DiagnosticsProperty('isExtended', isExtended)); + } +} + +class _ZetaTopAppBarSearchFieldState extends State with SingleTickerProviderStateMixin { + late final _animationController = AnimationController( + vsync: this, + duration: kThemeAnimationDuration, + ); + + late bool _isSearching = widget.searchController?.isEnabled ?? false; + late final _textFocusNode = FocusNode(); + + @override + void initState() { + _textFocusNode.addListener(_onFocusChanged); + widget.searchController?.addListener(_onSearchControllerChanged); + widget.searchController?.textEditingController ??= TextEditingController(); + + super.initState(); + } + + void _onFocusChanged() { + final text = widget.searchController?.text ?? ''; + final shouldCloseSearch = _isSearching && text.isEmpty && !_textFocusNode.hasFocus; + + if (shouldCloseSearch) _closeSearch(); + } + + void _onSearchControllerChanged() { + final controller = widget.searchController; + if (controller == null) return; + + controller.isEnabled ? _startSearch() : _closeSearch(); + } + + void _setNextSearchState() { + if (!_isSearching) return _startSearch(); + + _closeSearch(); + } + + void _startSearch() { + widget.searchController?.startSearch(); + setState(() => _isSearching = true); + + _animationController.forward(); + FocusScope.of(context).requestFocus(_textFocusNode); + } + + void _closeSearch() { + widget.searchController?.closeSearch(); + setState(() => _isSearching = false); + _animationController.reverse(); + _removeFocus(context); + } + + void _submitSearch() { + widget.onSearch?.call(widget.searchController?.text ?? ''); + widget.searchController?.text = ''; + _closeSearch(); + } + + void _removeFocus(BuildContext context) { + final currentFocus = FocusScope.of(context); + if (!currentFocus.hasPrimaryFocus && currentFocus.focusedChild != null) { + FocusManager.instance.primaryFocus?.unfocus(); + } + } + + @override + void didUpdateWidget(covariant ZetaTopAppBarSearchField oldWidget) { + if (oldWidget.searchController != widget.searchController) { + _setNextSearchState(); + } + + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + _animationController.dispose(); + _textFocusNode.dispose(); + widget.searchController?.removeListener(_onSearchControllerChanged); + widget.searchController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + return Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + Row( + mainAxisAlignment: + widget.type == ZetaTopAppBarType.centeredTitle ? MainAxisAlignment.center : MainAxisAlignment.start, + children: [ + widget.child ?? const SizedBox(), + ], + ), + ConstrainedBox( + constraints: BoxConstraints(maxHeight: widget.isExtended ? _extendedOffset : double.infinity), + child: AnimatedBuilder( + animation: _animationController, + builder: (context, child) => Transform.scale( + scaleX: _animationController.value, + alignment: Alignment.centerRight, + origin: Offset.zero, + child: TextField( + controller: widget.searchController?.textEditingController, + focusNode: _textFocusNode, + style: ZetaTextStyles.bodyMedium, + cursorColor: colors.cool.shade90, + decoration: InputDecoration( + iconColor: colors.cool.shade90, + filled: true, + border: InputBorder.none, + hintStyle: ZetaTextStyles.bodyMedium.copyWith( + color: colors.textDisabled, + ), + hintText: widget.hintText, + ), + onEditingComplete: _submitSearch, + textInputAction: TextInputAction.search, + ), + ), + ), + ), + ], + ); + } +} + +/// Controls the search. +class AppBarSearchController extends ChangeNotifier { + bool _enabled = false; + + /// Controller used for the search field. + TextEditingController? textEditingController; + + /// Whether the search is currently visible. + bool get isEnabled => _enabled; + + /// The current text in the search field. + String get text => textEditingController?.text ?? ''; + + /// Displays text in the search field and overrides the existing. + set text(String text) => textEditingController?.text = text; + + /// Displays the search field over the title in the app bar. + void startSearch() { + if (_enabled) return; + + _enabled = true; + notifyListeners(); + } + + /// Hides the search field from the app bar. + void closeSearch() { + if (!_enabled) return; + + _enabled = false; + notifyListeners(); + } + + /// Removes the text from search field. + void clearText() => textEditingController?.clear(); + + @override + void dispose() { + textEditingController?.dispose(); + super.dispose(); + } +} diff --git a/lib/src/components/top_app_bar/top_app_bar.dart b/lib/src/components/top_app_bar/top_app_bar.dart index a3ea0282..10df8e87 100644 --- a/lib/src/components/top_app_bar/top_app_bar.dart +++ b/lib/src/components/top_app_bar/top_app_bar.dart @@ -1,26 +1,62 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; + import '../../../zeta_flutter.dart'; +import 'extended_top_app_bar.dart'; +import 'search_top_app_bar.dart'; + +export 'search_top_app_bar.dart' show AppBarSearchController; -/// Zeta app bar. +/// Top app bars provide content and actions related to the current screen. class ZetaTopAppBar extends StatefulWidget implements PreferredSizeWidget { - /// Creates a Zeta app bar. + /// Creates a ZetaTopAppBar. const ZetaTopAppBar({ this.actions, this.automaticallyImplyLeading = true, this.searchController, this.leading, this.title, - this.titleSpacing, this.titleTextStyle, this.type = ZetaTopAppBarType.defaultAppBar, this.onSearch, this.searchHintText = 'Search', this.onSearchMicrophoneIconPressed, super.key, - }); + }) : shrinks = false; + + /// Creates a ZetaTopAppBar with centered title. + const ZetaTopAppBar.centered({ + this.actions, + this.automaticallyImplyLeading = true, + this.searchController, + this.leading, + this.title, + this.titleTextStyle, + this.onSearch, + this.searchHintText = 'Search', + this.onSearchMicrophoneIconPressed, + super.key, + }) : type = ZetaTopAppBarType.centeredTitle, + shrinks = false; + + /// Creates a ZetaTopAppBar with an extended title over 2 lines. + /// + /// This component **must** be placed within a [CustomScrollView]. + const ZetaTopAppBar.extended({ + this.actions, + this.automaticallyImplyLeading = true, + this.searchController, + this.leading, + this.title, + this.titleTextStyle, + this.onSearch, + this.searchHintText = 'Search', + this.onSearchMicrophoneIconPressed, + this.shrinks = true, + super.key, + }) : type = ZetaTopAppBarType.extendedTitle; - /// Called when text in the search field is submited. + /// Called when text in the search field is submitted. final void Function(String)? onSearch; /// A list of Widgets to display in a row after the [title] widget. @@ -35,24 +71,24 @@ class ZetaTopAppBar extends StatefulWidget implements PreferredSizeWidget { /// If omitted the microphone icon won't show up. Called when the icon button is pressed. Normally used for speech recognition/speech to text. final VoidCallback? onSearchMicrophoneIconPressed; - /// Used to controll the search textfield and states. + /// Used to control the search textfield and states. final AppBarSearchController? searchController; - /// Label used as hint text. - final String searchHintText; + /// Label used as hint text. If null, displays 'Search'. + final String? searchHintText; - /// Title of the app bar. Normally a [Text] widget. + /// Title of the app bar. final Widget? title; - /// AppBar titleSpacing - final double? titleSpacing; - /// AppBar titleTextStyle final TextStyle? titleTextStyle; /// Defines the styles of the app bar. final ZetaTopAppBarType type; + /// If `ZetaTopAppBarType.extend` shrinks. Does not affect other types of app bar. + final bool shrinks; + @override State createState() => _ZetaTopAppBarState(); @@ -62,32 +98,14 @@ class ZetaTopAppBar extends StatefulWidget implements PreferredSizeWidget { void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties - ..add( - ObjectFlagProperty.has('onSearch', onSearch), - ) - ..add( - DiagnosticsProperty( - 'automaticallyImplyLeading', - automaticallyImplyLeading, - ), - ) - ..add( - DiagnosticsProperty( - 'searchController', - searchController, - ), - ) - ..add( - ObjectFlagProperty.has( - 'onSearchMicrophoneIconPressed', - onSearchMicrophoneIconPressed, - ), - ) + ..add(DiagnosticsProperty('titleTextStyle', titleTextStyle)) + ..add(ObjectFlagProperty.has('onSearch', onSearch)) + ..add(DiagnosticsProperty('automaticallyImplyLeading', automaticallyImplyLeading)) + ..add(ObjectFlagProperty.has('onSearchMicrophoneIconPressed', onSearchMicrophoneIconPressed)) + ..add(DiagnosticsProperty('searchController', searchController)) ..add(StringProperty('searchHintText', searchHintText)) ..add(EnumProperty('type', type)) - ..add(EnumProperty('type', type)) - ..add(DoubleProperty('titleSpacing', titleSpacing)) - ..add(DiagnosticsProperty('titleTextStyle', titleTextStyle)); + ..add(DiagnosticsProperty('shrinks', shrinks)); } } @@ -113,100 +131,119 @@ class _ZetaTopAppBarState extends State { super.dispose(); } - Widget? _getTitle() { - return widget.type != ZetaTopAppBarType.extendedTitle - ? Padding( - padding: EdgeInsets.symmetric(horizontal: widget.titleSpacing ?? ZetaSpacing.b), - child: widget.title, - ) - : null; + Widget _getTitleText(ZetaColors colors) { + var title = widget.title; + if (widget.title is Row) { + final oldRow = widget.title! as Row; + title = Row( + crossAxisAlignment: oldRow.crossAxisAlignment, + key: oldRow.key, + mainAxisAlignment: oldRow.mainAxisAlignment, + mainAxisSize: oldRow.mainAxisSize, + textBaseline: oldRow.textBaseline, + textDirection: oldRow.textDirection, + verticalDirection: oldRow.verticalDirection, + children: oldRow.children.map( + (item) { + if (item is ZetaAvatar) { + item = item.copyWith(size: ZetaAvatarSize.xxxs); + } + return item; + }, + ).toList(), + ); + } + + return DefaultTextStyle( + style: (widget.titleTextStyle ?? ZetaTextStyles.bodyLarge).copyWith(color: colors.textDefault), + child: title ?? const Text(' '), + ); + } + + List? _getActions(ZetaColors colors) { + return _isSearchEnabled + ? [ + IconButtonTheme( + data: IconButtonThemeData( + style: IconButton.styleFrom(iconSize: ZetaSpacing.x5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + color: colors.cool.shade50, + onPressed: () => widget.searchController?.clearText(), + icon: const Icon(ZetaIcons.cancel_round), + ), + if (widget.onSearchMicrophoneIconPressed != null) ...[ + SizedBox( + height: ZetaSpacing.m, + child: VerticalDivider(width: ZetaSpacing.x0_5, color: colors.cool.shade70), + ), + IconButton( + onPressed: widget.onSearchMicrophoneIconPressed, + icon: const Icon(ZetaIcons.microphone_round), + ), + ], + ], + ), + ), + ] + : widget.actions; } @override Widget build(BuildContext context) { final colors = Zeta.of(context).colors; - return IconButtonTheme( - data: IconButtonThemeData( - style: IconButton.styleFrom( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: ZetaSpacing.b), - child: AppBar( - elevation: 0, - iconTheme: IconThemeData(color: colors.cool.shade90), - leadingWidth: ZetaSpacing.x10, + final actions = _getActions(colors); + final titleText = _getTitleText(colors); + + final title = widget.searchController != null + ? ZetaTopAppBarSearchField( + searchController: widget.searchController, + hintText: widget.searchHintText ?? 'Search', + onSearch: widget.onSearch, + type: widget.type, + isExtended: widget.type == ZetaTopAppBarType.extendedTitle, + child: titleText, + ) + : titleText; + + if (widget.type == ZetaTopAppBarType.extendedTitle) { + return SliverPersistentHeader( + pinned: true, + delegate: ZetaExtendedAppBarDelegate( + actions: actions, leading: widget.leading, - automaticallyImplyLeading: widget.automaticallyImplyLeading, - centerTitle: widget.type == ZetaTopAppBarType.centeredTitle, - titleSpacing: 0, - titleTextStyle: widget.titleTextStyle == null - ? ZetaTextStyles.bodyLarge.copyWith( - color: colors.textDefault, - ) - : widget.titleTextStyle!.copyWith( - color: colors.textDefault, - ), - title: widget.searchController != null - ? _SearchField( - searchController: widget.searchController, - hintText: widget.searchHintText, - onSearch: widget.onSearch, - type: widget.type, - child: _getTitle(), - ) - : _getTitle(), - actions: _isSearchEnabled - ? [ - IconButtonTheme( - data: IconButtonThemeData( - style: IconButton.styleFrom( - iconSize: ZetaSpacing.x5, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - color: colors.cool.shade50, - onPressed: () => widget.searchController?.clearText(), - icon: const Icon(ZetaIcons.cancel_round), - ), - if (widget.onSearchMicrophoneIconPressed != null) ...[ - SizedBox( - height: ZetaSpacing.m, - child: VerticalDivider( - width: ZetaSpacing.x0_5, - color: colors.cool.shade70, - ), - ), - IconButton( - onPressed: widget.onSearchMicrophoneIconPressed, - icon: const Icon(ZetaIcons.microphone_round), - ), - ], - ], - ), - ), - ] - : widget.actions, - flexibleSpace: widget.type == ZetaTopAppBarType.extendedTitle - ? Padding( - padding: EdgeInsets.only( - top: widget.preferredSize.height, - left: ZetaSpacing.s, - right: ZetaSpacing.s, - ), - child: DefaultTextStyle( - style: ZetaTextStyles.bodyLarge.copyWith( - color: colors.textDefault, - ), - child: widget.title ?? const SizedBox(), - ), - ) - : null, + searchController: widget.searchController, + title: title, + shrinks: widget.shrinks, + ), + ); + } + + return ColoredBox( + color: colors.surfacePrimary, + child: IconButtonTheme( + data: IconButtonThemeData(style: IconButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: ZetaSpacing.x1), + child: AppBar( + elevation: 0, + scrolledUnderElevation: 0, + iconTheme: IconThemeData(color: colors.cool.shade90), + leadingWidth: ZetaSpacing.x10, + leading: widget.leading, + automaticallyImplyLeading: widget.automaticallyImplyLeading, + surfaceTintColor: Colors.transparent, + centerTitle: widget.type == ZetaTopAppBarType.centeredTitle, + titleTextStyle: widget.titleTextStyle == null + ? ZetaTextStyles.bodyLarge.copyWith(color: colors.textDefault) + : widget.titleTextStyle!.copyWith(color: colors.textDefault), + title: title, + actions: actions, + ), ), ), ); @@ -224,213 +261,3 @@ enum ZetaTopAppBarType { /// Title below the app bar. extendedTitle, } - -class _SearchField extends StatefulWidget { - const _SearchField({ - required this.child, - required this.onSearch, - required this.searchController, - required this.hintText, - required this.type, - }); - - final void Function(String value)? onSearch; - final Widget? child; - final String hintText; - final AppBarSearchController? searchController; - final ZetaTopAppBarType type; - - @override - State<_SearchField> createState() => _SearchFieldState(); - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add( - ObjectFlagProperty.has( - 'onSearch', - onSearch, - ), - ) - ..add(StringProperty('hintText', hintText)) - ..add( - DiagnosticsProperty( - 'searchController', - searchController, - ), - ) - ..add(EnumProperty('type', type)); - } -} - -class _SearchFieldState extends State<_SearchField> with SingleTickerProviderStateMixin { - late final _animationController = AnimationController( - vsync: this, - duration: kThemeAnimationDuration, - ); - - late bool _isSearching = widget.searchController?.isEnabled ?? false; - late final _textFocusNode = FocusNode(); - - @override - void initState() { - _textFocusNode.addListener(_onFocusChanged); - widget.searchController?.addListener(_onSearchControllerChanged); - widget.searchController?.textEditingController ??= TextEditingController(); - - super.initState(); - } - - void _onFocusChanged() { - final text = widget.searchController?.text ?? ''; - final shouldCloseSearch = _isSearching && text.isEmpty && !_textFocusNode.hasFocus; - - if (shouldCloseSearch) _closeSearch(); - } - - void _onSearchControllerChanged() { - final controller = widget.searchController; - if (controller == null) return; - - controller.isEnabled ? _startSearch() : _closeSearch(); - } - - void _setNextSearchState() { - if (!_isSearching) return _startSearch(); - - _closeSearch(); - } - - void _startSearch() { - widget.searchController?.startSearch(); - setState(() => _isSearching = true); - - _animationController.forward(); - FocusScope.of(context).requestFocus(_textFocusNode); - } - - void _closeSearch() { - widget.searchController?.closeSearch(); - setState(() => _isSearching = false); - _animationController.reverse(); - _removeFocus(context); - } - - void _submitSearch() { - widget.onSearch?.call(widget.searchController?.text ?? ''); - widget.searchController?.text = ''; - _closeSearch(); - } - - void _removeFocus(BuildContext context) { - final currentFocus = FocusScope.of(context); - if (!currentFocus.hasPrimaryFocus && currentFocus.focusedChild != null) { - FocusManager.instance.primaryFocus?.unfocus(); - } - } - - @override - void didUpdateWidget(covariant _SearchField oldWidget) { - if (oldWidget.searchController != widget.searchController) { - _setNextSearchState(); - } - - super.didUpdateWidget(oldWidget); - } - - @override - void dispose() { - _animationController.dispose(); - _textFocusNode.dispose(); - widget.searchController?.removeListener(_onSearchControllerChanged); - widget.searchController?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final colors = Zeta.of(context).colors; - - return Stack( - alignment: Alignment.center, - clipBehavior: Clip.none, - children: [ - Row( - mainAxisAlignment: - widget.type == ZetaTopAppBarType.centeredTitle ? MainAxisAlignment.center : MainAxisAlignment.start, - children: [ - widget.child ?? const SizedBox(), - ], - ), - AnimatedBuilder( - animation: _animationController, - builder: (context, child) => Transform.scale( - scaleX: _animationController.value * 1, - alignment: Alignment.centerRight, - origin: Offset.zero, - child: TextField( - controller: widget.searchController?.textEditingController, - focusNode: _textFocusNode, - style: ZetaTextStyles.bodyMedium, - cursorColor: colors.cool.shade90, - decoration: InputDecoration( - iconColor: colors.cool.shade90, - filled: true, - border: InputBorder.none, - hintStyle: ZetaTextStyles.bodyMedium.copyWith( - color: colors.textDisabled, - ), - hintText: widget.hintText, - ), - onEditingComplete: _submitSearch, - textInputAction: TextInputAction.search, - ), - ), - ), - ], - ); - } -} - -/// Controlls the search. -class AppBarSearchController extends ChangeNotifier { - bool _enabled = false; - - /// Controller used for the search field. - TextEditingController? textEditingController; - - /// Whether the search is currently vissible. - bool get isEnabled => _enabled; - - /// The current text in the search field. - String get text => textEditingController?.text ?? ''; - - /// Displayes text in the search field and overrides the existing. - set text(String text) => textEditingController?.text = text; - - /// Displays the search field over the title in the app bar. - void startSearch() { - if (_enabled) return; - - _enabled = true; - notifyListeners(); - } - - /// Hides the search field from the app bar. - void closeSearch() { - if (!_enabled) return; - - _enabled = false; - notifyListeners(); - } - - /// Removes the text from search field. - void clearText() => textEditingController?.clear(); - - @override - void dispose() { - textEditingController?.dispose(); - super.dispose(); - } -}