diff --git a/CHANGELOG.md b/CHANGELOG.md index cbb205e..0111b53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,17 @@ +## 0.6.0 +### Added +* `reverse` parameter to `OverflowView` and `OverflowView.flexible` constructors. + * When `reverse` is `true`, overflow occurs from the start (left for horizontal, top for vertical) instead of the end. + * Enables right-to-left and bottom-to-top overflow behavior. + +### Changed +* Optimized reverse layout performance to match normal mode efficiency (O(k) for fixed, O(n) for flexible). + ## 0.5.0 ### Changed * Update Flutter constraints. * Update version of value_layout_builder. - + ### Fixed * Flutter 3.32 breaking changes issue. diff --git a/example/pubspec.lock b/example/pubspec.lock index e6d4f72..aadffba 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -113,7 +113,7 @@ packages: path: ".." relative: true source: path - version: "0.5.0" + version: "0.6.0" path: dependency: transitive description: diff --git a/lib/src/rendering/overflow_view.dart b/lib/src/rendering/overflow_view.dart index 9d45af0..2ed998d 100644 --- a/lib/src/rendering/overflow_view.dart +++ b/lib/src/rendering/overflow_view.dart @@ -21,10 +21,12 @@ class RenderOverflowView extends RenderBox List? children, required Axis direction, required double spacing, + required bool reverse, required OverflowViewLayoutBehavior layoutBehavior, }) : assert(spacing > double.negativeInfinity && spacing < double.infinity), _direction = direction, _spacing = spacing, + _reverse = reverse, _layoutBehavior = layoutBehavior, _isHorizontal = direction == Axis.horizontal { addAll(children); @@ -50,6 +52,15 @@ class RenderOverflowView extends RenderBox } } + bool get reverse => _reverse; + bool _reverse; + set reverse(bool value) { + if (_reverse != value) { + _reverse = value; + markNeedsLayout(); + } + } + OverflowViewLayoutBehavior get layoutBehavior => _layoutBehavior; OverflowViewLayoutBehavior _layoutBehavior; set layoutBehavior(OverflowViewLayoutBehavior value) { @@ -140,41 +151,86 @@ class RenderOverflowView extends RenderBox ? count : (maxExtent + spacing) ~/ childStride - 1; final int unRenderedChildCount = count - renderedChildCount; - if (renderedChildCount > 0) { - childParentData.offstage = false; - onstageCount++; - } - for (int i = 1; i < renderedChildCount; i++) { - child = childParentData.nextSibling!; - childParentData = child.parentData as OverflowViewParentData; - child.layout(otherChildConstraints); - childParentData.offset = getChildOffset(i); - childParentData.offstage = false; - onstageCount++; - } + if (_reverse) { + // In reverse mode, hide first children and show last children + final int firstVisibleIndex = unRenderedChildCount; - while (child != lastChild) { - child = childParentData.nextSibling!; - childParentData = child.parentData as OverflowViewParentData; - childParentData.offstage = true; - } + // Skip hidden children efficiently - only mark them as offstage + int childIndex = 0; + while (childIndex < firstVisibleIndex && child != lastChild) { + childParentData.offstage = true; + child = childParentData.nextSibling!; + childParentData = child.parentData as OverflowViewParentData; + childIndex++; + } - if (unRenderedChildCount > 0) { - // We have to layout the overflow indicator. - final RenderBox overflowIndicator = lastChild!; + // Layout overflow indicator first if needed + if (unRenderedChildCount > 0) { + final RenderBox overflowIndicator = lastChild!; + final BoxValueConstraints overflowIndicatorConstraints = + BoxValueConstraints( + value: unRenderedChildCount, + constraints: otherChildConstraints, + ); + overflowIndicator.layout(overflowIndicatorConstraints); + final OverflowViewParentData overflowIndicatorParentData = + overflowIndicator.parentData as OverflowViewParentData; + overflowIndicatorParentData.offset = getChildOffset(0); + overflowIndicatorParentData.offstage = false; + onstageCount++; + } - final BoxValueConstraints overflowIndicatorConstraints = - BoxValueConstraints( - value: unRenderedChildCount, - constraints: otherChildConstraints, - ); - overflowIndicator.layout(overflowIndicatorConstraints); - final OverflowViewParentData overflowIndicatorParentData = - overflowIndicator.parentData as OverflowViewParentData; - overflowIndicatorParentData.offset = getChildOffset(renderedChildCount); - overflowIndicatorParentData.offstage = false; - onstageCount++; + // Layout only visible children + int visualIndex = unRenderedChildCount > 0 ? 1 : 0; + while (child != lastChild) { + child.layout(otherChildConstraints); + childParentData.offset = getChildOffset(visualIndex); + childParentData.offstage = false; + onstageCount++; + + child = childParentData.nextSibling!; + childParentData = child.parentData as OverflowViewParentData; + visualIndex++; + } + } else { + // Normal mode: show first children, hide last children + if (renderedChildCount > 0) { + childParentData.offstage = false; + onstageCount++; + } + + for (int i = 1; i < renderedChildCount; i++) { + child = childParentData.nextSibling!; + childParentData = child.parentData as OverflowViewParentData; + child.layout(otherChildConstraints); + childParentData.offset = getChildOffset(i); + childParentData.offstage = false; + onstageCount++; + } + + while (child != lastChild) { + child = childParentData.nextSibling!; + childParentData = child.parentData as OverflowViewParentData; + childParentData.offstage = true; + } + + if (unRenderedChildCount > 0) { + // We have to layout the overflow indicator. + final RenderBox overflowIndicator = lastChild!; + + final BoxValueConstraints overflowIndicatorConstraints = + BoxValueConstraints( + value: unRenderedChildCount, + constraints: otherChildConstraints, + ); + overflowIndicator.layout(overflowIndicatorConstraints); + final OverflowViewParentData overflowIndicatorParentData = + overflowIndicator.parentData as OverflowViewParentData; + overflowIndicatorParentData.offset = getChildOffset(renderedChildCount); + overflowIndicatorParentData.offstage = false; + onstageCount++; + } } final double mainAxisExtent = onstageCount * childStride - spacing; @@ -186,6 +242,14 @@ class RenderOverflowView extends RenderBox } void performFlexibleLayout() { + if (_reverse) { + _performFlexibleLayoutReverse(); + } else { + _performFlexibleLayoutNormal(); + } + } + + void _performFlexibleLayoutNormal() { RenderBox child = firstChild!; List renderBoxes = []; int unRenderedChildCount = childCount - 1; @@ -329,6 +393,195 @@ class RenderOverflowView extends RenderBox size = constraints.constrain(idealSize); } + void _performFlexibleLayoutReverse() { + // First pass: layout all children in reverse to determine which ones fit + // Store layout info without using expensive insert(0) operations + final List<_ChildLayoutInfo> childrenInfo = <_ChildLayoutInfo>[]; + RenderBox? child = firstChild; + int childIndex = 0; + + // Build list of children (excluding overflow indicator) in forward order + while (child != null && child != lastChild) { + final OverflowViewParentData childParentData = + child.parentData as OverflowViewParentData; + childrenInfo.add(_ChildLayoutInfo(child, childIndex)); + child = childParentData.nextSibling; + childIndex++; + } + + List visibleChildren = []; + int unRenderedChildCount = childrenInfo.length; + double availableExtent = + _isHorizontal ? constraints.maxWidth : constraints.maxHeight; + final double maxCrossExtent = + _isHorizontal ? constraints.maxHeight : constraints.maxWidth; + + final BoxConstraints childConstraints = _isHorizontal + ? BoxConstraints.loose(Size(double.infinity, maxCrossExtent)) + : BoxConstraints.loose(Size(maxCrossExtent, double.infinity)); + + bool showOverflowIndicator = false; + + // Layout children in reverse order (from end to start) + for (int i = childrenInfo.length - 1; i >= 0; i--) { + final _ChildLayoutInfo info = childrenInfo[i]; + final RenderBox currentChild = info.child; + final OverflowViewParentData childParentData = + currentChild.parentData as OverflowViewParentData; + + currentChild.layout(childConstraints, parentUsesSize: true); + + final double childMainSize = _getMainSize(currentChild); + + if (childMainSize <= availableExtent) { + // We have room to paint this child - add to end of list (reverse order) + visibleChildren.add(currentChild); + childParentData.offstage = false; + + final double childStride = spacing + childMainSize; + availableExtent -= childStride; + unRenderedChildCount--; + } else { + // We have no room to paint any further child. + childParentData.offstage = true; + showOverflowIndicator = true; + } + } + + double offset = 0; + if (showOverflowIndicator) { + // We didn't layout all the children. + final RenderBox overflowIndicator = lastChild!; + final BoxValueConstraints overflowIndicatorConstraints = + BoxValueConstraints( + value: unRenderedChildCount, + constraints: childConstraints, + ); + overflowIndicator.layout( + overflowIndicatorConstraints, + parentUsesSize: true, + ); + + final double overflowIndicatorMainSize = _getMainSize(overflowIndicator); + + // We need to remove children from the end (which are at start in visual order) + while (overflowIndicatorMainSize > availableExtent && + visibleChildren.isNotEmpty) { + final RenderBox removedChild = visibleChildren.removeLast(); + final OverflowViewParentData childParentData = + removedChild.parentData as OverflowViewParentData; + childParentData.offstage = true; + final double childStride = _getMainSize(removedChild) + spacing; + + availableExtent += childStride; + unRenderedChildCount++; + } + + if (overflowIndicatorMainSize > availableExtent) { + // We cannot paint any child because there is not enough space. + _hasOverflow = true; + } + + if (overflowIndicatorConstraints.value != unRenderedChildCount) { + // The number of unrendered child changed, we have to layout the + // indicator another time. + overflowIndicator.layout( + BoxValueConstraints( + value: unRenderedChildCount, + constraints: childConstraints, + ), + parentUsesSize: true, + ); + } + + // Place overflow indicator at the start + final OverflowViewParentData overflowIndicatorParentData = + overflowIndicator.parentData as OverflowViewParentData; + overflowIndicatorParentData.offset = + _isHorizontal ? Offset(0, 0) : Offset(0, 0); + overflowIndicatorParentData.offstage = false; + + offset = overflowIndicatorMainSize + spacing; + + // Position visible children (they're in reverse order, so iterate backwards) + for (int i = visibleChildren.length - 1; i >= 0; i--) { + final RenderBox visibleChild = visibleChildren[i]; + final OverflowViewParentData childParentData = + visibleChild.parentData as OverflowViewParentData; + childParentData.offset = + _isHorizontal ? Offset(offset, 0) : Offset(0, offset); + offset += _getMainSize(visibleChild) + spacing; + } + + offset -= spacing; + } else { + // We layout all children. We need to layout the overflowIndicator + // because we may have already laid it out with parentUsesSize: true before. + lastChild?.layout(BoxValueConstraints( + value: 0, + constraints: childConstraints, + )); + + // Because the overflow indicator will be paint outside of the screen, + // we need to say that there is an overflow. + _hasOverflow = true; + + // Position all children (they're in reverse order) + for (int i = visibleChildren.length - 1; i >= 0; i--) { + final RenderBox visibleChild = visibleChildren[i]; + final OverflowViewParentData childParentData = + visibleChild.parentData as OverflowViewParentData; + childParentData.offset = + _isHorizontal ? Offset(offset, 0) : Offset(0, offset); + offset += _getMainSize(visibleChild) + spacing; + } + + if (visibleChildren.isNotEmpty) { + offset -= spacing; + } + } + + // Calculate cross size from all visible children and overflow indicator + double crossSize = 0; + if (showOverflowIndicator) { + crossSize = _getCrossSize(lastChild!); + } + for (int i = visibleChildren.length - 1; i >= 0; i--) { + crossSize = math.max(crossSize, _getCrossSize(visibleChildren[i])); + } + + // Center all visible children in the cross-axis + if (showOverflowIndicator) { + final OverflowViewParentData overflowParentData = + lastChild!.parentData as OverflowViewParentData; + final double childCrossPosition = + crossSize / 2.0 - _getCrossSize(lastChild!) / 2.0; + overflowParentData.offset = _isHorizontal + ? Offset(overflowParentData.offset.dx, childCrossPosition) + : Offset(childCrossPosition, overflowParentData.offset.dy); + } + + for (int i = visibleChildren.length - 1; i >= 0; i--) { + final RenderBox visibleChild = visibleChildren[i]; + final OverflowViewParentData childParentData = + visibleChild.parentData as OverflowViewParentData; + final double childCrossPosition = + crossSize / 2.0 - _getCrossSize(visibleChild) / 2.0; + childParentData.offset = _isHorizontal + ? Offset(childParentData.offset.dx, childCrossPosition) + : Offset(childCrossPosition, childParentData.offset.dy); + } + + Size idealSize; + if (_isHorizontal) { + idealSize = Size(offset, crossSize); + } else { + idealSize = Size(crossSize, offset); + } + + size = constraints.constrain(idealSize); + } + void visitOnlyOnStageChildren(RenderObjectVisitor visitor) { visitChildren((child) { if (child.isOnstage) { @@ -393,6 +646,14 @@ class RenderOverflowView extends RenderBox } } +/// Helper class to store child layout information +class _ChildLayoutInfo { + _ChildLayoutInfo(this.child, this.index); + + final RenderBox child; + final int index; +} + extension on Size { double getMainExtent(Axis axis) { return axis == Axis.horizontal ? width : height; diff --git a/lib/src/widgets/overflow_view.dart b/lib/src/widgets/overflow_view.dart index 4136745..5d561eb 100644 --- a/lib/src/widgets/overflow_view.dart +++ b/lib/src/widgets/overflow_view.dart @@ -20,18 +20,23 @@ class OverflowView extends MultiChildRenderObjectWidget { /// All children will have the same size has the first child. /// /// The [spacing] argument must also be positive and finite. + /// + /// If [reverse] is true, the overflow will occur from the start (left for + /// horizontal, top for vertical) instead of the end. OverflowView({ Key? key, required OverflowIndicatorBuilder builder, Axis direction = Axis.horizontal, required List children, double spacing = 0, + bool reverse = false, }) : this._all( key: key, builder: builder, direction: direction, children: children, spacing: spacing, + reverse: reverse, layoutBehavior: OverflowViewLayoutBehavior.fixed, ); @@ -40,18 +45,23 @@ class OverflowView extends MultiChildRenderObjectWidget { /// All children can have their own size. /// /// The [spacing] argument must also be positive and finite. + /// + /// If [reverse] is true, the overflow will occur from the start (left for + /// horizontal, top for vertical) instead of the end. OverflowView.flexible({ Key? key, required OverflowIndicatorBuilder builder, Axis direction = Axis.horizontal, required List children, double spacing = 0, + bool reverse = false, }) : this._all( key: key, builder: builder, direction: direction, children: children, spacing: spacing, + reverse: reverse, layoutBehavior: OverflowViewLayoutBehavior.flexible, ); @@ -61,9 +71,9 @@ class OverflowView extends MultiChildRenderObjectWidget { this.direction = Axis.horizontal, required List children, this.spacing = 0, + this.reverse = false, required OverflowViewLayoutBehavior layoutBehavior, - }) : assert(spacing > double.negativeInfinity && - spacing < double.infinity), + }) : assert(spacing > double.negativeInfinity && spacing < double.infinity), _layoutBehavior = layoutBehavior, super( key: key, @@ -86,6 +96,12 @@ class OverflowView extends MultiChildRenderObjectWidget { /// The amount of space between successive children. final double spacing; + /// Whether to reverse the direction of overflow. + /// + /// If true, overflow occurs from the start (left for horizontal, top for + /// vertical) instead of the end. + final bool reverse; + final OverflowViewLayoutBehavior _layoutBehavior; @override @@ -98,6 +114,7 @@ class OverflowView extends MultiChildRenderObjectWidget { return RenderOverflowView( direction: direction, spacing: spacing, + reverse: reverse, layoutBehavior: _layoutBehavior, ); } @@ -110,6 +127,7 @@ class OverflowView extends MultiChildRenderObjectWidget { renderObject ..direction = direction ..spacing = spacing + ..reverse = reverse ..layoutBehavior = _layoutBehavior; } } diff --git a/pubspec.yaml b/pubspec.yaml index ca8ee6a..05bafaa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: overflow_view description: A widget displaying children in a line with an overflow indicator at the end if there is not enough space. -version: 0.5.0 +version: 0.6.0 homepage: https://github.com/letsar/overflow_view environment: diff --git a/test/overflow_view_test.dart b/test/overflow_view_test.dart index d31df95..d055de2 100644 --- a/test/overflow_view_test.dart +++ b/test/overflow_view_test.dart @@ -4,7 +4,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:overflow_view/overflow_view.dart'; void main() { - testWidgets( 'the overflow indicator is not built if there is enough room (except for flexible)', (tester) async { @@ -375,6 +374,219 @@ void main() { ); }, ); + + testWidgets( + 'reverse mode trims from the start instead of the end (horizontal)', + (tester) async { + late int remainingCount; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + height: 100, + width: 150, + child: OverflowView( + reverse: true, + builder: (context, count) { + remainingCount = count; + return const SizedBox(width: 50, height: 50); + }, + children: [ + const _Text('A'), + const _Text('B'), + const _Text('C'), + const _Text('D'), + const _Text('E'), + ], + ), + ), + ), + ), + ); + + // Should show overflow indicator + D + E (hiding A, B, and C) + expect(find.text('A'), findsNothing); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsNothing); + expect(find.text('D'), findsOneWidget); + expect(find.text('E'), findsOneWidget); + expect(remainingCount, 3); + + // Overflow indicator should be at the start (position 0) + expect(tester.getTopLeft(find.text('D')), const Offset(50, 0)); + expect(tester.getTopLeft(find.text('E')), const Offset(100, 0)); + }, + ); + + testWidgets( + 'reverse mode trims from the start instead of the end (vertical)', + (tester) async { + late int remainingCount; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + height: 150, + width: 100, + child: OverflowView( + reverse: true, + direction: Axis.vertical, + builder: (context, count) { + remainingCount = count; + return const SizedBox(width: 50, height: 50); + }, + children: [ + const _Text('A'), + const _Text('B'), + const _Text('C'), + const _Text('D'), + const _Text('E'), + ], + ), + ), + ), + ), + ); + + // Should show overflow indicator + D + E (hiding A, B, and C) + expect(find.text('A'), findsNothing); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsNothing); + expect(find.text('D'), findsOneWidget); + expect(find.text('E'), findsOneWidget); + expect(remainingCount, 3); + + // Overflow indicator should be at the start (position 0) + expect(tester.getTopLeft(find.text('D')), const Offset(0, 50)); + expect(tester.getTopLeft(find.text('E')), const Offset(0, 100)); + }, + ); + + testWidgets( + 'reverse mode with flexible layout trims from the start', + (tester) async { + late int remainingCount; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + height: 100, + width: 140, + child: OverflowView.flexible( + reverse: true, + builder: (context, count) { + remainingCount = count; + return const SizedBox(width: 30, height: 50); + }, + children: [ + const SizedBox(width: 40, height: 50), + const SizedBox(width: 50, height: 50), + const SizedBox(width: 60, height: 50), + const SizedBox(width: 20, height: 50), + ], + ), + ), + ), + ), + ); + + // With 140px width and reverse mode, should fit: + // overflow indicator (30px) + 60px + 20px = 110px + // Should hide the first two children (40px and 50px) + expect(remainingCount, 2); + }, + ); + + testWidgets( + 'reverse mode respects spacing', + (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + height: 100, + width: 170, + child: OverflowView( + reverse: true, + spacing: 10, + builder: (context, count) { + return const SizedBox(width: 50, height: 50); + }, + children: [ + const _Text('A'), + const _Text('B'), + const _Text('C'), + const _Text('D'), + ], + ), + ), + ), + ), + ); + + // Should show overflow indicator + C + D + expect(find.text('A'), findsNothing); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsOneWidget); + + // Check positions with spacing (overflow indicator at 0, then 10px spacing) + expect(tester.getTopLeft(find.text('C')), const Offset(60, 0)); + expect(tester.getTopLeft(find.text('D')), const Offset(120, 0)); + }, + ); + + testWidgets( + 'non-reverse mode still works as expected', + (tester) async { + late int remainingCount; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + height: 100, + width: 150, + child: OverflowView( + reverse: false, + builder: (context, count) { + remainingCount = count; + return const SizedBox(width: 50, height: 50); + }, + children: [ + const _Text('A'), + const _Text('B'), + const _Text('C'), + const _Text('D'), + const _Text('E'), + ], + ), + ), + ), + ), + ); + + // Should show A + B + overflow indicator (hiding C, D, E) + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsOneWidget); + expect(find.text('C'), findsNothing); + expect(find.text('D'), findsNothing); + expect(find.text('E'), findsNothing); + expect(remainingCount, 3); + + expect(tester.getTopLeft(find.text('A')), const Offset(0, 0)); + expect(tester.getTopLeft(find.text('B')), const Offset(50, 0)); + }, + ); } class _Text extends StatelessWidget {