diff --git a/example/lib/main.dart b/example/lib/main.dart index 02c977617..1166f3fed 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -82,11 +82,12 @@ class _CompactPage extends StatelessWidget { final width = MediaQuery.of(context).size.width; return YaruCompactLayout( - extendNavigationRail: width > 1000, + style: width > 1000 + ? YaruNavigationRailStyle.labelledExtended + : width > 500 + ? YaruNavigationRailStyle.labelled + : YaruNavigationRailStyle.compact, pageItems: [configItem] + examplePageItems, - backgroundColor: Theme.of(context).brightness == Brightness.light - ? Colors.white - : Theme.of(context).colorScheme.onSurface.withOpacity(0.03), ); } } diff --git a/lib/src/pages/layouts/yaru_compact_layout.dart b/lib/src/pages/layouts/yaru_compact_layout.dart index 44b80db25..016b97793 100644 --- a/lib/src/pages/layouts/yaru_compact_layout.dart +++ b/lib/src/pages/layouts/yaru_compact_layout.dart @@ -2,41 +2,24 @@ import 'package:flutter/material.dart'; import 'package:yaru/yaru.dart'; import '../../../yaru_widgets.dart'; -/// A responsive layout switching between [YaruWideLayout] -/// and [YaruNarrowLayout] depening on the screen width. +/// A page layout which use a [YaruNavigationRail] on left for page navigation class YaruCompactLayout extends StatefulWidget { const YaruCompactLayout({ super.key, required this.pageItems, - this.showSelectedLabels = true, - this.showUnselectedLabels = true, - this.labelType = NavigationRailLabelType.none, - this.extendNavigationRail = false, + this.style = YaruNavigationRailStyle.compact, this.initialIndex = 0, - this.backgroundColor, }); - /// The list of [YaruPageItem] has to be provided. + /// A list of page destinations final List pageItems; - /// Optional bool to hide selected labels in the [BottomNavigationBar] - final bool showSelectedLabels; - - /// Optional bool to hide unselected labels in the [BottomNavigationBar] - final bool showUnselectedLabels; - - /// Optionally control the labels of the [NavigationRail] - final NavigationRailLabelType labelType; - - /// Defines if the labels are shown right to the icon - /// of the [NavigationRail] in the wide layout - final bool extendNavigationRail; + /// Define the navigation rail style, see [YaruNavigationRailStyle] + final YaruNavigationRailStyle style; /// The index of the [YaruPageItem] that is selected from [pageItems] final int initialIndex; - final Color? backgroundColor; - @override State createState() => _YaruCompactLayoutState(); } @@ -61,9 +44,6 @@ class _YaruCompactLayoutState extends State { @override Widget build(BuildContext context) { - final unselectedTextColor = - Theme.of(context).colorScheme.onSurface.withOpacity(0.8); - final selectedTextColor = Theme.of(context).colorScheme.onSurface; return LayoutBuilder( builder: (context, constraint) { return SafeArea( @@ -72,92 +52,9 @@ class _YaruCompactLayoutState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SizedBox( - height: MediaQuery.of(context).size.height, - child: SingleChildScrollView( - controller: _controller, - child: ConstrainedBox( - constraints: - BoxConstraints(minHeight: constraint.maxHeight), - child: IntrinsicHeight( - child: NavigationRail( - extended: - widget.labelType == NavigationRailLabelType.none - ? widget.extendNavigationRail - : false, - unselectedIconTheme: IconThemeData( - color: unselectedTextColor, - ), - indicatorColor: Theme.of(context) - .colorScheme - .onSurface - .withOpacity(0.1), - selectedIconTheme: IconThemeData( - color: selectedTextColor, - ), - selectedLabelTextStyle: TextStyle( - overflow: TextOverflow.ellipsis, - color: selectedTextColor, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - unselectedLabelTextStyle: TextStyle( - color: unselectedTextColor, - overflow: TextOverflow.ellipsis, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - backgroundColor: widget.backgroundColor ?? - Theme.of(context).colorScheme.background, - selectedIndex: _index, - onDestinationSelected: (index) { - if (widget.pageItems[index].onTap != null) { - widget.pageItems[index].onTap?.call(context); - } - setState(() { - _index = index; - }); - }, - labelType: widget.labelType, - destinations: [ - for (int i = 0; i < widget.pageItems.length; i++) - NavigationRailDestination( - icon: widget.pageItems[i].iconBuilder( - context, - i == _index, - ), - selectedIcon: widget.pageItems[i].iconBuilder( - context, - i == _index, - ), - label: - widget.pageItems[i].titleBuilder(context), - ) - ], - ), - ), - ), - ), - ), - const VerticalDivider(thickness: 1, width: 1), - Expanded( - child: Theme( - data: Theme.of(context).copyWith( - pageTransitionsTheme: YaruPageTransitionsTheme.vertical, - ), - child: Navigator( - pages: [ - MaterialPage( - key: ValueKey(_index), - child: widget.pageItems.length > _index - ? widget.pageItems[_index].builder(context) - : widget.pageItems[0].builder(context), - ), - ], - onPopPage: (route, result) => route.didPop(result), - ), - ), - ) + _buildNavigationRail(context, constraint), + _buildVerticalSeparator(), + _buildPageView(context), ], ), ), @@ -165,4 +62,54 @@ class _YaruCompactLayoutState extends State { }, ); } + + Widget _buildNavigationRail(BuildContext context, BoxConstraints constraint) { + return SizedBox( + height: MediaQuery.of(context).size.height, + child: SingleChildScrollView( + controller: _controller, + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraint.maxHeight), + child: YaruNavigationRail( + style: widget.style, + selectedIndex: _index, + onDestinationSelected: (index) { + if (widget.pageItems[index].onTap != null) { + widget.pageItems[index].onTap!.call(context); + } + setState(() { + _index = index; + }); + }, + destinations: widget.pageItems, + ), + ), + ), + ); + } + + Widget _buildVerticalSeparator() { + return const VerticalDivider(thickness: 1, width: 1); + } + + Widget _buildPageView(BuildContext context) { + return Expanded( + child: Theme( + data: Theme.of(context).copyWith( + pageTransitionsTheme: YaruPageTransitionsTheme.vertical, + ), + child: Navigator( + pages: [ + MaterialPage( + key: ValueKey(_index), + child: widget.pageItems.length > _index + ? widget.pageItems[_index].builder(context) + : widget.pageItems[0].builder(context), + ), + ], + onPopPage: (route, result) => route.didPop(result), + ), + ), + ); + } } diff --git a/lib/src/pages/layouts/yaru_navigation_rail.dart b/lib/src/pages/layouts/yaru_navigation_rail.dart new file mode 100644 index 000000000..dd729fc14 --- /dev/null +++ b/lib/src/pages/layouts/yaru_navigation_rail.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; +import '../../../yaru_widgets.dart'; + +/// Defines the look of a [YaruNavigationRail] +enum YaruNavigationRailStyle { + /// Will only show icons + compact, + + /// Will show both icons and labels vertically + labelled, + + /// Will show both icons and labels horizontally + labelledExtended, +} + +const _kSizeAnimationDuration = Duration(milliseconds: 200); +const _kSelectedIconAnimationDuration = Duration(milliseconds: 250); + +class YaruNavigationRail extends StatelessWidget { + const YaruNavigationRail({ + super.key, + required this.destinations, + required this.selectedIndex, + required this.onDestinationSelected, + this.style = YaruNavigationRailStyle.compact, + }) : assert(destinations.length >= 2), + assert( + selectedIndex == null || + (0 <= selectedIndex && selectedIndex < destinations.length), + ); + + /// Defines the appearance of the button items that are arrayed within the + /// navigation rail. + /// + /// The value must be a list of two or more [YaruPageItem] + /// values. + final List destinations; + + /// The index into [destinations] for the current selected + /// [YaruPageItem] or null if no destination is selected. + final int? selectedIndex; + + /// Called when one of the [destinations] is selected. + /// + /// The stateful widget that creates the navigation rail needs to keep + /// track of the index of the selected [NavigationRailDestination] and call + /// `setState` to rebuild the navigation rail with the new [selectedIndex]. + final ValueChanged? onDestinationSelected; + + /// Define the navigation rail style, see [YaruNavigationRailStyle] + final YaruNavigationRailStyle style; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Column( + children: [ + for (int i = 0; i < destinations.length; i += 1) + _YaruNavigationRailItem( + i, + i == selectedIndex, + destinations[i], + onDestinationSelected, + style, + ) + ], + ), + ); + } +} + +class _YaruNavigationRailItem extends StatefulWidget { + const _YaruNavigationRailItem( + this.index, + this.selected, + this.destination, + this.onDestinationSelected, + this.style, + ); + + final int index; + final bool selected; + final YaruPageItem destination; + final ValueChanged? onDestinationSelected; + final YaruNavigationRailStyle style; + + @override + State<_YaruNavigationRailItem> createState() => + _YaruNavigationRailItemState(); +} + +class _YaruNavigationRailItemState extends State<_YaruNavigationRailItem> { + YaruNavigationRailStyle? oldStyle; + + @override + void didUpdateWidget(_YaruNavigationRailItem oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.style != oldWidget.style) { + oldStyle = oldWidget.style; + } + } + + @override + Widget build(BuildContext context) { + return _buildSizedContainer( + Material( + child: InkWell( + onTap: () { + if (widget.onDestinationSelected != null) { + widget.onDestinationSelected!.call(widget.index); + } + }, + child: Center( + child: Padding( + padding: EdgeInsets.symmetric( + vertical: + widget.style == YaruNavigationRailStyle.labelledExtended + ? 10 + : 5, + horizontal: + widget.style == YaruNavigationRailStyle.labelledExtended + ? 8 + : 5, + ), + child: _buildColumnOrRow([ + _buildIcon(context), + if (widget.style != YaruNavigationRailStyle.compact) ...[ + _buildGap(), + _buildLabel(context), + ] + ]), + ), + ), + ), + ), + ); + } + + Alignment get _alignement { + return widget.style == YaruNavigationRailStyle.labelledExtended || + oldStyle == YaruNavigationRailStyle.labelledExtended + ? Alignment.centerLeft + : Alignment.topCenter; + } + + double get _width { + switch (widget.style) { + case YaruNavigationRailStyle.labelledExtended: + return 250; + case YaruNavigationRailStyle.labelled: + return 100; + case YaruNavigationRailStyle.compact: + return 60; + } + } + + Widget _buildSizedContainer(Widget child) { + return AnimatedSize( + duration: _kSizeAnimationDuration, + alignment: _alignement, + child: Align( + alignment: _alignement, + child: SizedBox( + width: _width, + child: child, + ), + ), + ); + } + + Widget _buildColumnOrRow(List children) { + const mainAxisAlignment = MainAxisAlignment.start; + + if (widget.style == YaruNavigationRailStyle.labelledExtended) { + return Row( + mainAxisAlignment: mainAxisAlignment, + children: children, + ); + } + + return Column( + mainAxisAlignment: mainAxisAlignment, + children: children, + ); + } + + Widget _buildIcon(BuildContext context) { + return AnimatedContainer( + duration: _kSelectedIconAnimationDuration, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(100), + color: widget.selected + ? Theme.of(context).colorScheme.onSurface.withOpacity(.1) + : null, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 2, + horizontal: 10, + ), + child: widget.destination.iconBuilder( + context, + widget.selected, + ), + ), + ); + } + + Widget _buildGap() { + if (widget.style == YaruNavigationRailStyle.labelledExtended) { + return const SizedBox(width: 10); + } + + return const SizedBox(height: 5); + } + + Widget _buildLabel(BuildContext context) { + var label = widget.destination.titleBuilder(context); + + if (label is YaruPageItemTitle) { + label = DefaultTextStyle.merge( + child: label, + style: TextStyle( + fontSize: widget.style == YaruNavigationRailStyle.labelledExtended + ? 13 + : 12, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + softWrap: true, + textAlign: widget.style == YaruNavigationRailStyle.labelledExtended + ? TextAlign.left + : TextAlign.center, + maxLines: 1, + ); + } + + return widget.style == YaruNavigationRailStyle.labelledExtended + ? Expanded(child: label) + : label; + } +} diff --git a/lib/src/pages/layouts/yaru_page_item.dart b/lib/src/pages/layouts/yaru_page_item.dart index 0ed865af2..9474c1053 100644 --- a/lib/src/pages/layouts/yaru_page_item.dart +++ b/lib/src/pages/layouts/yaru_page_item.dart @@ -8,7 +8,7 @@ class YaruPageItem { this.onTap, }); - /// We recommend to use [YaruPageItemTitle] here to avoid line wrap + /// We recommend to use [YaruPageItemTitle] here to have correct styling final WidgetBuilder titleBuilder; final WidgetBuilder builder; diff --git a/lib/src/pages/layouts/yaru_page_item_list_view.dart b/lib/src/pages/layouts/yaru_page_item_list_view.dart index 66d004002..a90785cd4 100644 --- a/lib/src/pages/layouts/yaru_page_item_list_view.dart +++ b/lib/src/pages/layouts/yaru_page_item_list_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; + +import '../../../yaru_widgets.dart'; import '../../constants.dart'; -import 'yaru_page_item.dart'; const double _kScrollbarThickness = 8.0; const double _kScrollbarMargin = 2.0; @@ -99,10 +100,26 @@ class _YaruListTile extends StatelessWidget { borderRadius: BorderRadius.all(Radius.circular(kDefaultButtonRadius)), ), leading: icon, - title: title, + title: _buildTitle(), selected: selected, onTap: onTap, ), ); } + + Widget? _buildTitle() { + if (title == null) { + return title; + } + + if (title is YaruPageItemTitle) { + return DefaultTextStyle.merge( + child: title!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + + return title; + } } diff --git a/lib/src/pages/layouts/yaru_page_item_title.dart b/lib/src/pages/layouts/yaru_page_item_title.dart index dd8d7201f..9ce7e6f2a 100644 --- a/lib/src/pages/layouts/yaru_page_item_title.dart +++ b/lib/src/pages/layouts/yaru_page_item_title.dart @@ -1,8 +1,9 @@ import 'package:flutter/widgets.dart'; +import '../../../yaru_widgets.dart'; -/// This is an optional widget, which avoid line wrap in sidebar -/// It alters the children text style with: maxLines=1 and overflow=TextOverflow.ellipsis -/// This allow to have the same look as the nautilus sidebar when horizontal space becomes too small +/// This is an optional widget, which do nothing by itself. +/// Is used to notify the layout builder to use some default text style. +/// For example, [YaruMasterDetailPage] will have maxLines: 1 and textOverflow: ellipsis class YaruPageItemTitle extends StatelessWidget { const YaruPageItemTitle(this.child, {super.key}); @@ -13,10 +14,6 @@ class YaruPageItemTitle extends StatelessWidget { @override Widget build(BuildContext context) { - return DefaultTextStyle.merge( - child: child, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); + return child; } } diff --git a/lib/yaru_widgets.dart b/lib/yaru_widgets.dart index 843f989dd..41d467f11 100644 --- a/lib/yaru_widgets.dart +++ b/lib/yaru_widgets.dart @@ -15,8 +15,10 @@ export 'src/extensions/border_radius_extension.dart'; // Pages layouts export 'src/pages/layouts/yaru_compact_layout.dart'; export 'src/pages/layouts/yaru_master_detail_page.dart'; +export 'src/pages/layouts/yaru_navigation_rail.dart'; export 'src/pages/layouts/yaru_page_item.dart'; export 'src/pages/layouts/yaru_page_item_title.dart'; +export 'src/pages/yaru_page.dart'; // Pages export 'src/pages/yaru_page.dart'; export 'src/pages/yaru_section.dart';