From 984c7b819a59e2299944f4a2a796d8723bf141bb Mon Sep 17 00:00:00 2001 From: Andy Horn Date: Wed, 27 May 2026 07:13:57 -0700 Subject: [PATCH] perf: drop per-divider LayoutBuilder; resolve cross-axis upstream (#119) The divider's main-axis size is constant (thickness + padding), and its cross-axis size is already determined by the parent's tight constraints. The inner LayoutBuilder just read back what the parent supplied, so remove it and resolve the cross-axis paint dimension upstream in ResizableContainer (the outer LayoutBuilder) before constructing the divider widget. Co-Authored-By: Claude Opus 4.7 --- lib/src/resizable_container.dart | 42 ++++-- lib/src/resizable_container_divider.dart | 134 +++++++++--------- .../resolve_divider_cross_axis_size_test.dart | 39 +++++ 3 files changed, 138 insertions(+), 77 deletions(-) create mode 100644 test/resolve_divider_cross_axis_size_test.dart diff --git a/lib/src/resizable_container.dart b/lib/src/resizable_container.dart index 58b448e..c8cefa5 100644 --- a/lib/src/resizable_container.dart +++ b/lib/src/resizable_container.dart @@ -224,7 +224,7 @@ class _ResizableContainerState extends State return Stack( fit: StackFit.expand, children: [ - _buildOffstageMeasureLayout(), + _buildOffstageMeasureLayout(constraints), _flexFromFullSizes( constraints: constraints, sizes: _animation.currentSizes!, @@ -234,7 +234,7 @@ class _ResizableContainerState extends State case HideAnimationPhase.idle: if (controller.needsLayout) { - return _buildLayout(_scheduleSetRenderedSizes); + return _buildLayout(_scheduleSetRenderedSizes, constraints); } return _flexFromFullSizes( constraints: constraints, @@ -243,18 +243,24 @@ class _ResizableContainerState extends State } } - Widget _buildLayout(ValueChanged> onComplete) { + Widget _buildLayout( + ValueChanged> onComplete, + BoxConstraints constraints, + ) { return ResizableLayout( direction: widget.direction, onComplete: onComplete, sizes: controller.sizes, resizableChildren: widget.children, hiddenIndices: controller.hiddenIndices, - children: _buildLayoutChildren((i) => widget.children[i].child), + children: _buildLayoutChildren( + (i) => widget.children[i].child, + constraints, + ), ); } - Widget _buildOffstageMeasureLayout() { + Widget _buildOffstageMeasureLayout(BoxConstraints constraints) { // Run the layout offstage with placeholder children so the real widgets // aren't inflated twice. Any [ResizableSizeShrink] entry is replaced with // a fixed-pixel size based on the controller's last-rendered value — the @@ -275,7 +281,10 @@ class _ResizableContainerState extends State sizes: overrideSizes, resizableChildren: widget.children, hiddenIndices: controller.hiddenIndices, - children: _buildLayoutChildren((_) => const SizedBox.shrink()), + children: _buildLayoutChildren( + (_) => const SizedBox.shrink(), + constraints, + ), ), ); } @@ -284,7 +293,13 @@ class _ResizableContainerState extends State /// expects. [childBuilder] supplies the widget rendered for each child /// slot — the real child for the live layout, a placeholder for the /// offstage measurement. - List _buildLayoutChildren(Widget Function(int index) childBuilder) { + List _buildLayoutChildren( + Widget Function(int index) childBuilder, + BoxConstraints constraints, + ) { + final crossAxis = + widget.direction == Axis.horizontal ? Axis.vertical : Axis.horizontal; + final crossAxisMax = constraints.maxForDirection(crossAxis); return [ for (var i = 0; i < widget.children.length; i++) ...[ childBuilder(i), @@ -292,6 +307,10 @@ class _ResizableContainerState extends State ResizableContainerDivider.placeholder( config: widget.children[i].divider, direction: widget.direction, + crossAxisSize: resolveDividerCrossAxisSize( + widget.children[i].divider.length, + crossAxisMax, + ), ), ], ]; @@ -382,9 +401,16 @@ class _ResizableContainerState extends State if (size == 0) { return const SizedBox.shrink(); } + final config = widget.children[dividerIndex].divider; + final crossAxis = + widget.direction == Axis.horizontal ? Axis.vertical : Axis.horizontal; final divider = ResizableContainerDivider( - config: widget.children[dividerIndex].divider, + config: config, direction: widget.direction, + crossAxisSize: resolveDividerCrossAxisSize( + config.length, + constraints.maxForDirection(crossAxis), + ), onResizeUpdate: (delta) => manager.adjustChildSize( index: dividerIndex, delta: delta, diff --git a/lib/src/resizable_container_divider.dart b/lib/src/resizable_container_divider.dart index 4257b47..9762a69 100644 --- a/lib/src/resizable_container_divider.dart +++ b/lib/src/resizable_container_divider.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_resizable_container/flutter_resizable_container.dart'; import 'package:flutter_resizable_container/src/divider_painter.dart'; -import 'package:flutter_resizable_container/src/resizable_divider.dart'; import 'package:flutter_resizable_container/src/resizable_size.dart'; class ResizableContainerDivider extends StatefulWidget { @@ -12,6 +11,7 @@ class ResizableContainerDivider extends StatefulWidget { super.key, required this.direction, required this.config, + required this.crossAxisSize, required void Function(double) this.onResizeUpdate, }); @@ -19,12 +19,18 @@ class ResizableContainerDivider extends StatefulWidget { super.key, required this.config, required this.direction, + required this.crossAxisSize, }) : onResizeUpdate = null; final Axis direction; final void Function(double)? onResizeUpdate; final ResizableDivider config; + /// The resolved cross-axis paint dimension for the divider, computed + /// upstream by applying [ResizableDivider.length] against the parent's + /// cross-axis max. + final double crossAxisSize; + @override State createState() => _ResizableContainerDividerState(); @@ -36,53 +42,54 @@ class _ResizableContainerDividerState extends State { @override Widget build(BuildContext context) { - return LayoutBuilder(builder: (context, constraints) { - final width = _getWidth(constraints.maxWidth); - final height = _getHeight(constraints.maxHeight); - - return Align( - alignment: switch (widget.config.crossAxisAlignment) { - CrossAxisAlignment.start => switch (widget.direction) { - Axis.horizontal => Alignment.topCenter, - Axis.vertical => Alignment.centerLeft, - }, - CrossAxisAlignment.end => switch (widget.direction) { - Axis.horizontal => Alignment.bottomCenter, - Axis.vertical => Alignment.bottomRight, - }, - _ => Alignment.center, - }, - child: MouseRegion( - cursor: _getCursor(), - onEnter: _onEnter, - onExit: _onExit, - child: GestureDetector( - onVerticalDragStart: _onVerticalDragStart, - onVerticalDragUpdate: _onVerticalDragUpdate, - onVerticalDragEnd: _onVerticalDragEnd, - onHorizontalDragStart: _onHorizontalDragStart, - onHorizontalDragUpdate: _getOnHorizontalDragUpdate( - Directionality.of(context), - ), - onHorizontalDragEnd: _onHorizontalDragEnd, - onTapDown: _onTapDown, - onTapUp: _onTapUp, - child: CustomPaint( - size: Size(width, height), - painter: DividerPainter( - direction: widget.direction, - color: widget.config.color ?? Theme.of(context).dividerColor, - thickness: widget.config.thickness, - crossAxisAlignment: widget.config.crossAxisAlignment, - length: widget.config.length, - mainAxisAlignment: widget.config.mainAxisAlignment, - padding: widget.config.padding, - ), + final mainAxisSize = widget.config.thickness + widget.config.padding; + final size = switch (widget.direction) { + Axis.horizontal => Size(mainAxisSize, widget.crossAxisSize), + Axis.vertical => Size(widget.crossAxisSize, mainAxisSize), + }; + + return Align( + alignment: switch (widget.config.crossAxisAlignment) { + CrossAxisAlignment.start => switch (widget.direction) { + Axis.horizontal => Alignment.topCenter, + Axis.vertical => Alignment.centerLeft, + }, + CrossAxisAlignment.end => switch (widget.direction) { + Axis.horizontal => Alignment.bottomCenter, + Axis.vertical => Alignment.bottomRight, + }, + _ => Alignment.center, + }, + child: MouseRegion( + cursor: _getCursor(), + onEnter: _onEnter, + onExit: _onExit, + child: GestureDetector( + onVerticalDragStart: _onVerticalDragStart, + onVerticalDragUpdate: _onVerticalDragUpdate, + onVerticalDragEnd: _onVerticalDragEnd, + onHorizontalDragStart: _onHorizontalDragStart, + onHorizontalDragUpdate: _getOnHorizontalDragUpdate( + Directionality.of(context), + ), + onHorizontalDragEnd: _onHorizontalDragEnd, + onTapDown: _onTapDown, + onTapUp: _onTapUp, + child: CustomPaint( + size: size, + painter: DividerPainter( + direction: widget.direction, + color: widget.config.color ?? Theme.of(context).dividerColor, + thickness: widget.config.thickness, + crossAxisAlignment: widget.config.crossAxisAlignment, + length: widget.config.length, + mainAxisAlignment: widget.config.mainAxisAlignment, + padding: widget.config.padding, ), ), ), - ); - }); + ), + ); } MouseCursor _getCursor() { @@ -93,30 +100,6 @@ class _ResizableContainerDividerState extends State { }; } - double _getHeight(double maxHeight) { - return switch (widget.direction) { - Axis.horizontal => switch (widget.config.length) { - ResizableSizePixels(:final pixels) => min(pixels, maxHeight), - ResizableSizeExpand() => maxHeight, - ResizableSizeRatio(:final ratio) => maxHeight * ratio, - ResizableSizeShrink() => 0.0, - }, - Axis.vertical => widget.config.thickness + widget.config.padding, - }; - } - - double _getWidth(double maxWidth) { - return switch (widget.direction) { - Axis.horizontal => widget.config.thickness + widget.config.padding, - Axis.vertical => switch (widget.config.length) { - ResizableSizePixels(:final pixels) => min(pixels, maxWidth), - ResizableSizeExpand() => maxWidth, - ResizableSizeRatio(:final ratio) => maxWidth * ratio, - ResizableSizeShrink() => 0.0, - }, - }; - } - void _onEnter(PointerEnterEvent _) { setState(() => isHovered = true); widget.config.onHoverEnter?.call(); @@ -195,3 +178,16 @@ class _ResizableContainerDividerState extends State { widget.config.onTapUp?.call(); } } + +/// Resolves [length] against the available cross-axis [max]. +/// +/// Mirrors the per-arm semantics that previously lived inside the divider's +/// `LayoutBuilder`-driven `_getWidth` / `_getHeight` helpers. +double resolveDividerCrossAxisSize(ResizableSize length, double max) { + return switch (length) { + ResizableSizePixels(:final pixels) => min(pixels, max), + ResizableSizeExpand() => max, + ResizableSizeRatio(:final ratio) => max * ratio, + ResizableSizeShrink() => 0.0, + }; +} diff --git a/test/resolve_divider_cross_axis_size_test.dart b/test/resolve_divider_cross_axis_size_test.dart new file mode 100644 index 0000000..8c9ad64 --- /dev/null +++ b/test/resolve_divider_cross_axis_size_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_resizable_container/flutter_resizable_container.dart'; +import 'package:flutter_resizable_container/src/resizable_container_divider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(resolveDividerCrossAxisSize, () { + test('expand returns the full cross-axis max', () { + expect( + resolveDividerCrossAxisSize(const ResizableSize.expand(), 200), + 200, + ); + }); + + test('ratio scales the cross-axis max', () { + expect( + resolveDividerCrossAxisSize(const ResizableSize.ratio(0.25), 200), + 50, + ); + }); + + test('pixels clamps to the cross-axis max', () { + expect( + resolveDividerCrossAxisSize(const ResizableSize.pixels(50), 200), + 50, + ); + expect( + resolveDividerCrossAxisSize(const ResizableSize.pixels(500), 200), + 200, + ); + }); + + test('shrink resolves to zero', () { + expect( + resolveDividerCrossAxisSize(const ResizableSize.shrink(), 200), + 0, + ); + }); + }); +}