From 09de9e2c6bbcf3ad840074a9d12fcfff8108e756 Mon Sep 17 00:00:00 2001 From: PartyDonut Date: Mon, 27 Oct 2025 13:22:53 +0100 Subject: [PATCH] chore: Custom implementation of overflow bar for side_nav_bar --- .../components/side_navigation_bar.dart | 111 +++++++------ .../shared/simple_overflow_widget.dart | 148 ++++++++++++++++++ 2 files changed, 203 insertions(+), 56 deletions(-) create mode 100644 lib/widgets/shared/simple_overflow_widget.dart diff --git a/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart b/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart index 4e0f8cb..d082542 100644 --- a/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart +++ b/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart @@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:iconsax_plus/iconsax_plus.dart'; -import 'package:overflow_view/overflow_view.dart'; import 'package:fladder/models/collection_types.dart'; import 'package:fladder/models/library_filter_model.dart'; @@ -26,6 +25,7 @@ import 'package:fladder/widgets/navigation_scaffold/components/settings_user_ico import 'package:fladder/widgets/shared/custom_tooltip.dart'; import 'package:fladder/widgets/shared/item_actions.dart'; import 'package:fladder/widgets/shared/modal_bottom_sheet.dart'; +import 'package:fladder/widgets/shared/simple_overflow_widget.dart'; final navBarNode = FocusNode(); @@ -87,7 +87,7 @@ class _SideNavigationBarState extends ConsumerState { duration: const Duration(milliseconds: 250), opacity: !fullScreenChildRoute ? 1 : 0, child: Container( - color: Theme.of(context).colorScheme.surface.withValues(alpha: shouldExpand ? 0.95 : 0.85), + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.65), width: shouldExpand ? expandedWidth : collapsedWidth, child: Padding( key: const Key('navigation_rail'), @@ -158,9 +158,8 @@ class _SideNavigationBarState extends ConsumerState { endIndent: 32, ), Flexible( - child: OverflowView.flexible( - direction: Axis.vertical, - spacing: 4, + child: SimpleOverflowWidget( + axis: Axis.vertical, children: views.map( (view) { final selected = context.router.currentUrl.contains(view.id); @@ -225,25 +224,25 @@ class _SideNavigationBarState extends ConsumerState { ); }, ).toList(), - builder: (context, remaining) { - return CustomTooltip( - tooltipContent: expandedSideBar - ? null - : Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Text( - context.localized.moreOptions, - style: Theme.of(context).textTheme.titleSmall, - ), + overflowBuilder: (remainingCount) => CustomTooltip( + tooltipContent: expandedSideBar + ? null + : Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + context.localized.moreOptions, + style: Theme.of(context).textTheme.titleSmall, ), ), - position: TooltipPosition.right, - child: PopupMenuButton( - iconColor: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.45), - padding: EdgeInsets.zero, - tooltip: "", - icon: NavigationButton( + ), + position: TooltipPosition.right, + child: PopupMenuButton( + iconColor: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.45), + padding: EdgeInsets.zero, + tooltip: "", + icon: ExcludeFocus( + child: NavigationButton( label: context.localized.other, selectedIcon: const Icon(IconsaxPlusLinear.arrow_square_down), icon: const Icon(IconsaxPlusLinear.arrow_square_down), @@ -261,45 +260,45 @@ class _SideNavigationBarState extends ConsumerState { : null, horizontal: true, ), - itemBuilder: (context) => views - .sublist(views.length - remaining) - .map( - (e) => PopupMenuItem( - onTap: () => context.pushRoute(LibrarySearchRoute( - viewModelId: e.id, - ).withFilter(e.collectionType.defaultFilters)), - child: Row( - spacing: 8, - children: [ - usePostersForLibrary - ? Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: ClipRRect( - borderRadius: FladderTheme.smallShape.borderRadius, - child: SizedBox.square( - dimension: 45, - child: FladderImage( - image: e.imageData?.primary, - placeHolder: Card( - child: Icon( - e.collectionType.iconOutlined, - ), + ), + itemBuilder: (context) => views + .sublist(views.length - remainingCount) + .map( + (e) => PopupMenuItem( + onTap: () => context.pushRoute(LibrarySearchRoute( + viewModelId: e.id, + ).withFilter(e.collectionType.defaultFilters)), + child: Row( + spacing: 8, + children: [ + usePostersForLibrary + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: ClipRRect( + borderRadius: FladderTheme.smallShape.borderRadius, + child: SizedBox.square( + dimension: 45, + child: FladderImage( + image: e.imageData?.primary, + placeHolder: Card( + child: Icon( + e.collectionType.iconOutlined, ), - decodeHeight: 64, ), + decodeHeight: 64, ), ), - ) - : Icon(e.collectionType.iconOutlined), - Text(e.name), - ], - ), + ), + ) + : Icon(e.collectionType.iconOutlined), + Text(e.name), + ], ), - ) - .toList(), - ), - ); - }, + ), + ) + .toList(), + ), + ), ), ), ], diff --git a/lib/widgets/shared/simple_overflow_widget.dart b/lib/widgets/shared/simple_overflow_widget.dart new file mode 100644 index 0000000..b9bfca6 --- /dev/null +++ b/lib/widgets/shared/simple_overflow_widget.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; + +class SimpleOverflowWidget extends StatefulWidget { + const SimpleOverflowWidget({ + super.key, + required this.children, + required this.overflowBuilder, + this.axis = Axis.horizontal, + }); + + final List children; + final Widget Function(int remaining) overflowBuilder; + final Axis axis; + + @override + State createState() => _SimpleOverflowWidgetState(); +} + +class _SimpleOverflowWidgetState extends State { + int _remaining = 0; + + @override + Widget build(BuildContext context) { + final overflowChild = _remaining != 0 ? widget.overflowBuilder(_remaining) : const SizedBox.shrink(); + + final List layoutChildren = []; + for (int i = 0; i < widget.children.length; i++) { + layoutChildren.add( + LayoutId( + id: i, + child: widget.children[i], + ), + ); + } + + layoutChildren.add( + LayoutId( + id: 'overflow', + child: overflowChild, + ), + ); + + return CustomMultiChildLayout( + delegate: _OverflowDelegate( + axis: widget.axis, + childCount: widget.children.length, + onLayoutCalculated: (int newRemaining) { + if (_remaining != newRemaining) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _remaining = newRemaining; + }); + } + }); + } + }, + ), + children: layoutChildren, + ); + } +} + +class _OverflowDelegate extends MultiChildLayoutDelegate { + _OverflowDelegate({ + required this.axis, + required this.childCount, + required this.onLayoutCalculated, + }); + + final Axis axis; + final int childCount; + final void Function(int) onLayoutCalculated; + static const Offset _offscreen = Offset(-9999, -9999); + + @override + void performLayout(Size size) { + final double maxSpace = (axis == Axis.horizontal ? size.width : size.height); + double usedSpace = 0.0; + int visibleCount = 0; + + final BoxConstraints overflowConstraints = + (axis == Axis.horizontal) ? BoxConstraints(maxHeight: size.height) : BoxConstraints(maxWidth: size.width); + + double overflowSpace = 0.0; + Size? overflowSize; + + if (hasChild('overflow')) { + overflowSize = layoutChild('overflow', overflowConstraints); + overflowSpace = (axis == Axis.horizontal ? overflowSize.width : overflowSize.height); + } + + bool isOverflowing = false; + + for (int i = 0; i < childCount; i++) { + if (!hasChild(i)) continue; + + final Size childSize = layoutChild(i, overflowConstraints); + + if (isOverflowing) { + positionChild(i, _offscreen); + continue; + } + + final double childSpace = (axis == Axis.horizontal ? childSize.width : childSize.height); + + final int potentialRemaining = childCount - i; + final double spaceToReserve = (potentialRemaining > 1) ? overflowSpace : 0.0; + + if (usedSpace + childSpace + spaceToReserve > maxSpace) { + isOverflowing = true; + positionChild(i, _offscreen); + continue; + } + + final Offset offset; + if (axis == Axis.horizontal) { + offset = Offset(usedSpace, (size.height - childSize.height) / 2); + } else { + offset = Offset((size.width - childSize.width) / 2, usedSpace); + } + + positionChild(i, offset); + usedSpace += childSpace; + visibleCount++; + } + + final int newRemaining = childCount - visibleCount; + + if (hasChild('overflow')) { + if (newRemaining > 0 && overflowSize != null) { + final Offset offset = (axis == Axis.horizontal) + ? Offset(usedSpace, (size.height - overflowSize.height) / 2) + : Offset((size.width - overflowSize.width) / 2, usedSpace); + positionChild('overflow', offset); + } else { + positionChild('overflow', _offscreen); + } + } + + onLayoutCalculated(newRemaining); + } + + @override + bool shouldRelayout(_OverflowDelegate oldDelegate) { + return oldDelegate.axis != axis || oldDelegate.childCount != childCount; + } +}