From e8e9ce4b0ccf2ffb106359818cc3ae9c0d654372 Mon Sep 17 00:00:00 2001 From: Andy Horn Date: Wed, 27 May 2026 07:35:47 -0700 Subject: [PATCH 1/6] perf!: skip widget rebuilds during divider drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-tick ChangeNotifier-driven rebuild of the entire container subtree with a render-object listener on a dedicated pixel channel. Drag updates now relayout the children directly without rebuilding any widgets. * `ResizableController` exposes `pixelsListenable` — a `ValueListenable>` that fires on every pixel change (drag, post-layout, structural). The main controller listener no longer fires during a drag. * `ResizableLayoutRenderObject` accepts the listenable, subscribes on attach, and `markNeedsLayout`s on change. `performLayout` branches: when the listenable is present, lay out children directly from its value (fast path); otherwise resolve from the declared sizes (cold path) and report the resolved values via `onComplete`. * `ResizableContainer` replaces its outer `AnimatedBuilder` with `ListenableBuilder` and drops `_flexFromFullSizes` for the idle phase; hide-animation phases continue to use the flex path. Each child is wrapped in `RepaintBoundary` so paint dirtiness doesn't propagate to siblings during a drag. * RTL offset reversal moves into the render object, since the live path no longer goes through `Flex`. BREAKING CHANGE: `ResizableController`'s `addListener` no longer fires on divider drag updates — it now fires only on structural changes (children list, declared sizes, hidden set, layout invalidation). Callers that watched the controller for live size changes should subscribe to `controller.pixelsListenable` instead. Closes #120 Co-Authored-By: Claude Opus 4.7 --- lib/src/layout/resizable_layout.dart | 127 +++++++- .../layout/resizable_layout_direction.dart | 7 + lib/src/resizable_container.dart | 91 ++++-- lib/src/resizable_controller.dart | 62 +++- .../resizable_layout_live_pixels_test.dart | 304 ++++++++++++++++++ test/resizable_container_rebuild_test.dart | 100 ++++++ test/resizable_controller_test.dart | 140 +++++++- 7 files changed, 801 insertions(+), 30 deletions(-) create mode 100644 test/layout/resizable_layout_live_pixels_test.dart create mode 100644 test/resizable_container_rebuild_test.dart diff --git a/lib/src/layout/resizable_layout.dart b/lib/src/layout/resizable_layout.dart index cc6087e..1285bce 100644 --- a/lib/src/layout/resizable_layout.dart +++ b/lib/src/layout/resizable_layout.dart @@ -22,6 +22,7 @@ class ResizableLayout extends MultiChildRenderObjectWidget { required this.sizes, required this.resizableChildren, this.hiddenIndices = const {}, + this.livePixels, }); final Axis direction; @@ -30,6 +31,16 @@ class ResizableLayout extends MultiChildRenderObjectWidget { final List resizableChildren; final Set hiddenIndices; + /// Source of authoritative per-child pixel sizes. When non-null, the + /// render object lays out children directly from `livePixels.value` and + /// subscribes for change notifications so drag updates can trigger + /// `markNeedsLayout` without rebuilding the widget tree. When `null`, the + /// render object falls back to resolving [sizes] from the + /// [ResizableSize] declarations and reports the resolved values via + /// [onComplete] — used for the initial layout pass and the offstage + /// measurement pass. + final ValueListenable>? livePixels; + @override RenderObject createRenderObject(BuildContext context) { return ResizableLayoutRenderObject( @@ -38,6 +49,8 @@ class ResizableLayout extends MultiChildRenderObjectWidget { onComplete: onComplete, resizableChildren: resizableChildren, hiddenIndices: hiddenIndices, + livePixels: livePixels, + textDirection: Directionality.maybeOf(context), ); } @@ -51,7 +64,9 @@ class ResizableLayout extends MultiChildRenderObjectWidget { ..sizes = sizes ..onComplete = onComplete ..resizableChildren = resizableChildren - ..hiddenIndices = hiddenIndices; + ..hiddenIndices = hiddenIndices + ..livePixels = livePixels + ..textDirection = Directionality.maybeOf(context); } } @@ -63,17 +78,23 @@ class ResizableLayoutRenderObject extends RenderBox required ValueChanged> onComplete, required List resizableChildren, Set hiddenIndices = const {}, + ValueListenable>? livePixels, + TextDirection? textDirection, }) : _layoutDirection = layoutDirection, _sizes = sizes, _onComplete = onComplete, _resizableChildren = resizableChildren, - _hiddenIndices = hiddenIndices; + _hiddenIndices = hiddenIndices, + _livePixels = livePixels, + _textDirection = textDirection; ResizableLayoutDirection _layoutDirection; List _sizes; ValueChanged> _onComplete; List _resizableChildren; Set _hiddenIndices; + ValueListenable>? _livePixels; + TextDirection? _textDirection; double _currentPosition = 0.0; final Map _shrinkSizes = {}; @@ -82,6 +103,46 @@ class ResizableLayoutRenderObject extends RenderBox ValueChanged> get onComplete => _onComplete; List get resizableChildren => _resizableChildren; Set get hiddenIndices => _hiddenIndices; + ValueListenable>? get livePixels => _livePixels; + TextDirection? get textDirection => _textDirection; + + set textDirection(TextDirection? value) { + if (_textDirection == value) { + return; + } + _textDirection = value; + // RTL reversal only affects horizontal layouts; vertical layouts are + // unchanged by directionality so a relayout would be wasted work. + if (_layoutDirection.isHorizontal) { + markNeedsLayout(); + } + } + + set livePixels(ValueListenable>? value) { + if (identical(_livePixels, value)) { + return; + } + if (attached) { + _livePixels?.removeListener(_handleLivePixelsChanged); + value?.addListener(_handleLivePixelsChanged); + } + _livePixels = value; + markNeedsLayout(); + } + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _livePixels?.addListener(_handleLivePixelsChanged); + } + + @override + void detach() { + _livePixels?.removeListener(_handleLivePixelsChanged); + super.detach(); + } + + void _handleLivePixelsChanged() => markNeedsLayout(); set hiddenIndices(Set hiddenIndices) { if (setEquals(_hiddenIndices, hiddenIndices)) { @@ -144,6 +205,12 @@ class ResizableLayoutRenderObject extends RenderBox _shrinkSizes.clear(); final children = getChildrenAsList(); + + if (_canUseLivePixels()) { + _performLiveLayout(children); + return; + } + final dividerSpace = _getDividerSpace(); final pixelSpace = _getPixelsSpace(); final shrinkCap = layoutDirection.getMaxConstraint(constraints) - @@ -197,9 +264,65 @@ class ResizableLayoutRenderObject extends RenderBox } size = constraints.biggest; + _maybeReverseOffsetsForRtl(); onComplete(finalSizes); } + bool _canUseLivePixels() { + final pixels = _livePixels?.value; + return pixels != null && pixels.length == _resizableChildren.length; + } + + /// Fast path used when [_livePixels] is present and its value matches the + /// expected child count. Lays out children directly from those values and + /// dividers from their static config — no resolution of [ResizableSize] + /// declarations and no [onComplete] callback, since the pixels are + /// already authoritative. + void _performLiveLayout(List children) { + final pixels = _livePixels!.value; + for (var i = 0; i < childCount; i += 2) { + final childIndex = i ~/ 2; + final childMainSize = pixels[childIndex]; + final childConstraints = BoxConstraints.tight( + layoutDirection.getSize(childMainSize, constraints), + ); + _layoutChild(children[i], childConstraints); + + if (i < childCount - 1) { + final dividerIndex = childIndex; + final divider = _resizableChildren[dividerIndex].divider; + final dividerMainSize = _isDividerHidden(dividerIndex) + ? 0.0 + : divider.thickness + divider.padding; + final dividerConstraints = BoxConstraints.tight( + layoutDirection.getSize(dividerMainSize, constraints), + ); + _layoutChild(children[i + 1], dividerConstraints); + } + } + + size = constraints.biggest; + _maybeReverseOffsetsForRtl(); + } + + /// Reverses each child's main-axis offset when the layout is horizontal + /// and the ambient text direction is RTL. The forward layout always + /// positions children left-to-right; this hook flips them to match + /// [Flex]'s RTL semantics. + void _maybeReverseOffsetsForRtl() { + if (_textDirection != TextDirection.rtl) return; + if (!_layoutDirection.isHorizontal) return; + + final totalWidth = constraints.maxWidth; + var child = firstChild; + while (child != null) { + final parentData = child.parentData! as _ResizableLayoutParentData; + final width = child.size.width; + parentData.offset = Offset(totalWidth - parentData.offset.dx - width, 0); + child = parentData.nextSibling; + } + } + Map _getExpandSizes(double availableSpace) { bool isExpand(ResizableSize size) => size is ResizableSizeExpand; diff --git a/lib/src/layout/resizable_layout_direction.dart b/lib/src/layout/resizable_layout_direction.dart index 4ca7da7..ec65d79 100644 --- a/lib/src/layout/resizable_layout_direction.dart +++ b/lib/src/layout/resizable_layout_direction.dart @@ -10,6 +10,7 @@ sealed class ResizableLayoutDirection { }; } + bool get isHorizontal; double getMaxConstraint(BoxConstraints constraints); double getSizeDimension(Size size); Offset getOffset(double currentPosition); @@ -26,6 +27,9 @@ sealed class ResizableLayoutDirection { class ResizableHorizontalLayout extends ResizableLayoutDirection { const ResizableHorizontalLayout() : super._(); + @override + bool get isHorizontal => true; + @override double getMaxConstraint(BoxConstraints constraints) { return constraints.maxWidth; @@ -74,6 +78,9 @@ class ResizableHorizontalLayout extends ResizableLayoutDirection { class ResizableVerticalLayout extends ResizableLayoutDirection { const ResizableVerticalLayout() : super._(); + @override + bool get isHorizontal => false; + @override double getMaxConstraint(BoxConstraints constraints) { return constraints.maxHeight; diff --git a/lib/src/resizable_container.dart b/lib/src/resizable_container.dart index d57989c..4b020dc 100644 --- a/lib/src/resizable_container.dart +++ b/lib/src/resizable_container.dart @@ -216,8 +216,8 @@ class _ResizableContainerState extends State manager.setAvailableSpace(availableSpace); - return AnimatedBuilder( - animation: controller, + return ListenableBuilder( + listenable: controller, builder: (context, _) => _buildForPhase(constraints), ); }, @@ -245,27 +245,56 @@ class _ResizableContainerState extends State ); case HideAnimationPhase.idle: - if (controller.needsLayout) { - return _buildLayout(_scheduleSetRenderedSizes); - } - return _flexFromFullSizes( - constraints: constraints, - sizes: _deriveFullSizesFromController(), + // The cold (size-resolution) path runs whenever the controller's + // sizes need re-resolving; otherwise feed live pixels into the + // render object so drag updates relayout without rebuilding. + return _buildLayout( + onComplete: _scheduleSetRenderedSizes, + livePixels: + controller.needsLayout ? null : controller.pixelsListenable, ); } } - Widget _buildLayout(ValueChanged> onComplete) { + Widget _buildLayout({ + required ValueChanged> onComplete, + required ValueListenable>? livePixels, + }) { return ResizableLayout( direction: widget.direction, onComplete: onComplete, sizes: controller.sizes, resizableChildren: widget.children, hiddenIndices: controller.hiddenIndices, - children: _buildLayoutChildren((i) => widget.children[i].child), + livePixels: livePixels, + children: _buildLayoutChildren( + childBuilder: (i) => widget.children[i].child, + dividerBuilder: _buildInteractiveDivider, + ), + ); + } + + Widget _buildInteractiveDivider(int dividerIndex) { + if (_isDividerHidden(dividerIndex, controller.hiddenIndices)) { + return const SizedBox.shrink(); + } + return ResizableContainerDivider( + config: widget.children[dividerIndex].divider, + direction: widget.direction, + onResizeUpdate: (delta) => manager.adjustChildSize( + index: dividerIndex, + delta: delta, + ), ); } + /// Whether the divider at [dividerIndex] is hidden — true when either + /// adjacent child is in [hiddenIndices]. + static bool _isDividerHidden(int dividerIndex, Set hiddenIndices) { + return hiddenIndices.contains(dividerIndex) || + hiddenIndices.contains(dividerIndex + 1); + } + Widget _buildOffstageMeasureLayout() { // Run the layout offstage with placeholder children so the real widgets // aren't inflated twice. Any [ResizableSizeShrink] entry is replaced with @@ -287,24 +316,32 @@ class _ResizableContainerState extends State sizes: overrideSizes, resizableChildren: widget.children, hiddenIndices: controller.hiddenIndices, - children: _buildLayoutChildren((_) => const SizedBox.shrink()), + children: _buildLayoutChildren( + childBuilder: (_) => const SizedBox.shrink(), + dividerBuilder: (i) => ResizableContainerDivider.placeholder( + config: widget.children[i].divider, + direction: widget.direction, + ), + ), ), ); } /// Builds the alternating child/divider list that [ResizableLayout] /// 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) { + /// slot; [dividerBuilder] supplies the widget rendered for each divider + /// slot (interactive divider for the live layout, placeholder for the + /// offstage measurement). Children are wrapped in [RepaintBoundary] so a + /// single child's paint dirtiness doesn't propagate to siblings during a + /// drag. + List _buildLayoutChildren({ + required Widget Function(int index) childBuilder, + required Widget Function(int dividerIndex) dividerBuilder, + }) { return [ for (var i = 0; i < widget.children.length; i++) ...[ - childBuilder(i), - if (i < widget.children.length - 1) - ResizableContainerDivider.placeholder( - config: widget.children[i].divider, - direction: widget.direction, - ), + RepaintBoundary(child: childBuilder(i)), + if (i < widget.children.length - 1) dividerBuilder(i), ], ]; } @@ -337,15 +374,19 @@ class _ResizableContainerState extends State }); } - List _deriveFullSizesFromController({Set? hiddenIndices}) { - final hidden = hiddenIndices ?? controller.hiddenIndices; + List _deriveFullSizesFromController({ + required Set hiddenIndices, + }) { final result = []; for (var i = 0; i < widget.children.length; i++) { result.add(controller.pixels[i]); if (i < widget.children.length - 1) { - final dividerHidden = hidden.contains(i) || hidden.contains(i + 1); final config = widget.children[i].divider; - result.add(dividerHidden ? 0.0 : config.thickness + config.padding); + result.add( + _isDividerHidden(i, hiddenIndices) + ? 0.0 + : config.thickness + config.padding, + ); } } return result; @@ -371,7 +412,7 @@ class _ResizableContainerState extends State height: widget.direction == Axis.vertical ? mainSize : constraints.maxForDirection(Axis.vertical), - child: widget.children[i].child, + child: RepaintBoundary(child: widget.children[i].child), ); }, ), diff --git a/lib/src/resizable_controller.dart b/lib/src/resizable_controller.dart index e8a4592..c32c54d 100644 --- a/lib/src/resizable_controller.dart +++ b/lib/src/resizable_controller.dart @@ -1,7 +1,7 @@ import "dart:collection"; import "dart:math"; -import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import "package:flutter_resizable_container/flutter_resizable_container.dart"; import "package:flutter_resizable_container/src/extensions/num_ext.dart"; import "package:flutter_resizable_container/src/resizable_size.dart"; @@ -10,7 +10,18 @@ import "package:flutter_resizable_container/src/resizable_size.dart"; const ResizableSize _hiddenSize = ResizableSize.pixels(0, min: 0, max: 0); /// A controller to provide a programmatic interface to a [ResizableContainer]. +/// +/// Notification surface: +/// +/// * The controller's own [Listenable] (via [addListener]) fires on +/// *structural* changes only — children list, declared sizes, hidden set, +/// layout invalidation. It does **not** fire during a divider drag. +/// * [pixelsListenable] fires on every pixel change, including the per-tick +/// updates produced by a divider drag. Subscribe here when you need to +/// react to live size changes. class ResizableController with ChangeNotifier { + ResizableController(); + double _availableSpace = -1; List _pixels = []; List _sizes = const []; @@ -19,6 +30,8 @@ class ResizableController with ChangeNotifier { final Map _savedSizes = {}; bool _needsLayout = false; bool _cascadeNegativeDelta = false; + late final _PixelsListenable _pixelsListenable = + _PixelsListenable(() => _pixels); /// Whether or not the container needs to (re)layout its children. bool get needsLayout => _needsLayout; @@ -26,6 +39,20 @@ class ResizableController with ChangeNotifier { /// The physical size, in pixels, of each child. UnmodifiableListView get pixels => UnmodifiableListView(_pixels); + /// A [ValueListenable] that exposes the current per-child pixel sizes and + /// fires on drag-induced and post-layout updates (`_adjustChildSize`, + /// `_setRenderedSizes`, structural changes). + /// + /// Does **not** fire when pixels shift purely due to a viewport-driven + /// available-space change — those mutations happen mid-build and are + /// picked up by the next layout pass via the listenable's [value]. Read + /// [pixels] (or `pixelsListenable.value`) inside [WidgetsBinding.addPostFrameCallback] + /// if you need the post-resize snapshot. + /// + /// Prefer this over [addListener] when you only care about live size + /// changes — the main controller listener fires only on structural events. + ValueListenable> get pixelsListenable => _pixelsListenable; + /// The [ResizableSize] of each child. UnmodifiableListView get sizes => UnmodifiableListView(_sizes); @@ -75,6 +102,7 @@ class ResizableController with ChangeNotifier { } _needsLayout = true; + _pixelsListenable.notify(); notifyListeners(); } @@ -125,9 +153,16 @@ class ResizableController with ChangeNotifier { _sizes = effective; _needsLayout = true; + _pixelsListenable.notify(); notifyListeners(); } + @override + void dispose() { + _pixelsListenable.dispose(); + super.dispose(); + } + void _validateIndex(int index) { if (index < 0 || index >= _children.length) { throw RangeError.index(index, _children, 'index'); @@ -203,7 +238,7 @@ class ResizableController with ChangeNotifier { _pixels[index + 1] -= adjustedDelta; } - notifyListeners(); + _pixelsListenable.notify(); } void setChildren(List children) { @@ -222,6 +257,8 @@ class ResizableController with ChangeNotifier { _savedSizes.clear(); _needsLayout = true; + _pixelsListenable.notify(); + if (notify) { notifyListeners(); } @@ -240,6 +277,7 @@ class ResizableController with ChangeNotifier { void _setRenderedSizes(List pixels) { _pixels = pixels; _needsLayout = false; + _pixelsListenable.notify(); notifyListeners(); } @@ -570,3 +608,23 @@ abstract class ResizableControllerTestHelper { static List getChildren(ResizableController controller) => controller._children; } + +/// A [ChangeNotifier] that exposes the controller's current pixel snapshot +/// and serves as the pixel-update channel for [ResizableController]. +/// +/// Exposed via [ResizableController.pixelsListenable] — kept private so +/// callers cannot fire it themselves. The getter is closure-based rather +/// than a stored field so [value] always returns the controller's current +/// `_pixels` list without the controller having to push every mutation +/// through a separate update method. +class _PixelsListenable extends ChangeNotifier + implements ValueListenable> { + _PixelsListenable(this._getPixels); + + final List Function() _getPixels; + + @override + List get value => UnmodifiableListView(_getPixels()); + + void notify() => notifyListeners(); +} diff --git a/test/layout/resizable_layout_live_pixels_test.dart b/test/layout/resizable_layout_live_pixels_test.dart new file mode 100644 index 0000000..074cfbb --- /dev/null +++ b/test/layout/resizable_layout_live_pixels_test.dart @@ -0,0 +1,304 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_resizable_container/flutter_resizable_container.dart'; +import 'package:flutter_resizable_container/src/layout/resizable_layout.dart'; +import 'package:flutter_resizable_container/src/resizable_container_divider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ResizableLayout live-pixel path', () { + testWidgets( + 'lays out children using the supplied pixel values', + (tester) async { + final pixels = ValueNotifier>(const [120, 280]); + addTearDown(pixels.dispose); + + await _pumpLayout(tester, pixels: pixels); + + expect(_widthOf(tester, const Key('A')), 120); + expect(_widthOf(tester, const Key('B')), 280); + }, + ); + + testWidgets( + 'relayouts in response to pixel changes without rebuilding children', + (tester) async { + final pixels = ValueNotifier>(const [120, 280]); + addTearDown(pixels.dispose); + var aBuildCount = 0; + var bBuildCount = 0; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 402, + height: 100, + child: ResizableLayout( + direction: Axis.horizontal, + onComplete: (_) {}, + sizes: const [ + ResizableSize.pixels(120), + ResizableSize.pixels(280), + ], + resizableChildren: const [ + ResizableChild( + size: ResizableSize.pixels(120), + divider: ResizableDivider(thickness: 2), + child: SizedBox(key: Key('A')), + ), + ResizableChild( + size: ResizableSize.pixels(280), + child: SizedBox(key: Key('B')), + ), + ], + livePixels: pixels, + children: [ + Builder( + key: const Key('A'), + builder: (context) { + aBuildCount++; + return const SizedBox(); + }, + ), + const ResizableContainerDivider.placeholder( + config: ResizableDivider(thickness: 2), + direction: Axis.horizontal, + ), + Builder( + key: const Key('B'), + builder: (context) { + bBuildCount++; + return const SizedBox(); + }, + ), + ], + ), + ), + ), + ); + + final aInitial = aBuildCount; + final bInitial = bBuildCount; + expect(_widthOf(tester, const Key('A')), 120); + expect(_widthOf(tester, const Key('B')), 280); + + pixels.value = const [200, 200]; + await tester.pump(); + + // The render object relayouts but the child Builders are not asked + // to rebuild. + expect(aBuildCount, aInitial); + expect(bBuildCount, bInitial); + expect(_widthOf(tester, const Key('A')), 200); + expect(_widthOf(tester, const Key('B')), 200); + }, + ); + + testWidgets( + 'switching livePixels from null to a notifier picks up the new source', + (tester) async { + final pixels = ValueNotifier>(const [200, 200]); + addTearDown(pixels.dispose); + + // First pump: livePixels=null forces the cold path, which resolves + // the declared sizes (120 + 280). + await _pumpLayout(tester, pixels: null); + expect(_widthOf(tester, const Key('A')), 120); + expect(_widthOf(tester, const Key('B')), 280); + + // Second pump: livePixels supplied — the render object switches to + // the live path and the notifier's values become authoritative. + await _pumpLayout(tester, pixels: pixels); + expect(_widthOf(tester, const Key('A')), 200); + expect(_widthOf(tester, const Key('B')), 200); + }, + ); + + testWidgets( + 'switching livePixels from a notifier to null falls back to cold path', + (tester) async { + final pixels = ValueNotifier>(const [200, 200]); + addTearDown(pixels.dispose); + + await _pumpLayout(tester, pixels: pixels); + expect(_widthOf(tester, const Key('A')), 200); + + // Drop the listenable — should fall back to resolving from sizes. + await _pumpLayout(tester, pixels: null); + expect(_widthOf(tester, const Key('A')), 120); + expect(_widthOf(tester, const Key('B')), 280); + + // And the old notifier must no longer drive the render object — + // mutating it after the swap should not affect layout. + pixels.value = const [50, 50]; + await tester.pump(); + expect(_widthOf(tester, const Key('A')), 120); + }, + ); + + testWidgets( + 'swapping in a fresh notifier rebinds the subscription', + (tester) async { + final first = ValueNotifier>(const [100, 300]); + final second = ValueNotifier>(const [300, 100]); + addTearDown(first.dispose); + addTearDown(second.dispose); + + await _pumpLayout(tester, pixels: first); + expect(_widthOf(tester, const Key('A')), 100); + + await _pumpLayout(tester, pixels: second); + expect(_widthOf(tester, const Key('A')), 300); + + // The old notifier must be detached after the swap. + first.value = const [50, 350]; + await tester.pump(); + expect(_widthOf(tester, const Key('A')), 300); + + // The new notifier drives layout. + second.value = const [200, 200]; + await tester.pump(); + expect(_widthOf(tester, const Key('A')), 200); + }, + ); + + testWidgets( + 'reverses offsets when text direction is RTL', + (tester) async { + const surfaceWidth = 402.0; + await tester.binding.setSurfaceSize(const Size(surfaceWidth, 100)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final pixels = ValueNotifier>(const [100, 300]); + addTearDown(pixels.dispose); + + await _pumpLayout( + tester, + pixels: pixels, + textDirection: TextDirection.rtl, + ); + + // 100 + 2 (divider) + 300 = 402. In RTL, A (size 100) starts at + // surfaceWidth - 100; B (size 300) starts at 0. + final boxA = tester.getTopLeft(find.byKey(const Key('A'))); + final boxB = tester.getTopLeft(find.byKey(const Key('B'))); + expect(boxA.dx, surfaceWidth - 100); + expect(boxB.dx, 0); + }, + ); + + testWidgets( + 'collapses divider to zero when adjacent child is hidden', + (tester) async { + final pixels = ValueNotifier>(const [100, 0, 300]); + addTearDown(pixels.dispose); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 404, + height: 100, + child: ResizableLayout( + direction: Axis.horizontal, + onComplete: (_) {}, + sizes: const [ + ResizableSize.pixels(100), + ResizableSize.pixels(0), + ResizableSize.pixels(300), + ], + resizableChildren: const [ + ResizableChild( + size: ResizableSize.pixels(100), + divider: ResizableDivider(thickness: 2), + child: SizedBox(key: Key('A')), + ), + ResizableChild( + size: ResizableSize.pixels(0), + divider: ResizableDivider(thickness: 2), + child: SizedBox(key: Key('B')), + ), + ResizableChild( + size: ResizableSize.pixels(300), + child: SizedBox(key: Key('C')), + ), + ], + hiddenIndices: const {1}, + livePixels: pixels, + children: const [ + SizedBox(key: Key('A')), + ResizableContainerDivider.placeholder( + config: ResizableDivider(thickness: 2), + direction: Axis.horizontal, + ), + SizedBox(key: Key('B')), + ResizableContainerDivider.placeholder( + config: ResizableDivider(thickness: 2), + direction: Axis.horizontal, + ), + SizedBox(key: Key('C')), + ], + ), + ), + ), + ); + + // C should be flush against A's right edge — both adjacent dividers + // collapse to zero because index 1 is hidden. + final boxA = tester.getTopLeft(find.byKey(const Key('A'))); + final boxC = tester.getTopLeft(find.byKey(const Key('C'))); + expect(boxA.dx, 0); + expect(boxC.dx, 100); + }, + ); + }); +} + +double _widthOf(WidgetTester tester, Key key) { + return tester.getSize(find.byKey(key)).width; +} + +Future _pumpLayout( + WidgetTester tester, { + required ValueListenable>? pixels, + TextDirection textDirection = TextDirection.ltr, +}) { + return tester.pumpWidget( + Directionality( + textDirection: textDirection, + child: SizedBox( + width: 402, + height: 100, + child: ResizableLayout( + direction: Axis.horizontal, + onComplete: (_) {}, + sizes: const [ + ResizableSize.pixels(120), + ResizableSize.pixels(280), + ], + resizableChildren: const [ + ResizableChild( + size: ResizableSize.pixels(120), + divider: ResizableDivider(thickness: 2), + child: SizedBox(key: Key('A')), + ), + ResizableChild( + size: ResizableSize.pixels(280), + child: SizedBox(key: Key('B')), + ), + ], + livePixels: pixels, + children: const [ + SizedBox(key: Key('A')), + ResizableContainerDivider.placeholder( + config: ResizableDivider(thickness: 2), + direction: Axis.horizontal, + ), + SizedBox(key: Key('B')), + ], + ), + ), + ), + ); +} diff --git a/test/resizable_container_rebuild_test.dart b/test/resizable_container_rebuild_test.dart new file mode 100644 index 0000000..15d0c17 --- /dev/null +++ b/test/resizable_container_rebuild_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +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('ResizableContainer rebuilds', () { + testWidgets( + 'child widgets do not rebuild while a divider is dragged', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 1000)); + + final aBuildCounter = _BuildCounter(); + final bBuildCounter = _BuildCounter(); + + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResizableContainer( + controller: controller, + direction: Axis.horizontal, + children: [ + ResizableChild( + size: const ResizableSize.ratio(0.5), + child: _CountingChild( + counter: aBuildCounter, + key: const Key('BoxA'), + ), + ), + ResizableChild( + size: const ResizableSize.ratio(0.5), + child: _CountingChild( + counter: bBuildCounter, + key: const Key('BoxB'), + ), + ), + ], + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Capture build counts after the initial layout has settled. Multiple + // setup builds are expected here; the assertions below only care + // about *additional* builds during the drag. + final aBaseline = aBuildCounter.count; + final bBaseline = bBuildCounter.count; + final preDragWidth = + tester.getSize(find.byKey(const Key('BoxA'))).width; + + // Track main controller listener firings; the new contract is that + // drag updates flow through `pixelsListenable` only, leaving the + // main listener silent. + var mainNotifyCount = 0; + controller.addListener(() => mainNotifyCount++); + + // Drive a multi-tick drag — every tick pushes a pixel update through + // the controller. + await tester.timedDrag( + find.byType(ResizableContainerDivider), + const Offset(120, 0), + const Duration(milliseconds: 200), + ); + await tester.pump(); + + // Guard against a silent regression where the drag fails to reach + // the divider (e.g., a placeholder divider with no gesture handler): + // assert the drag actually moved sizes before claiming no rebuilds. + final postDragWidth = + tester.getSize(find.byKey(const Key('BoxA'))).width; + expect(postDragWidth, greaterThan(preDragWidth)); + + expect(aBuildCounter.count, aBaseline); + expect(bBuildCounter.count, bBaseline); + expect(mainNotifyCount, 0); + }, + ); + }); +} + +class _BuildCounter { + int count = 0; +} + +class _CountingChild extends StatelessWidget { + const _CountingChild({required this.counter, super.key}); + + final _BuildCounter counter; + + @override + Widget build(BuildContext context) { + counter.count++; + return const SizedBox.expand(); + } +} diff --git a/test/resizable_controller_test.dart b/test/resizable_controller_test.dart index 2935cf3..68c66e1 100644 --- a/test/resizable_controller_test.dart +++ b/test/resizable_controller_test.dart @@ -278,12 +278,150 @@ void main() { expect(controller.pixels[2], equals(40)); }); - test('notifies listeners', () { + test('does not notify the main controller listener', () { + // Drag is a pixel-only event; the main listener is reserved for + // structural changes. Drag updates flow through + // [ResizableController.pixelsListenable] instead. var notified = false; controller.addListener(() => notified = true); manager.adjustChildSize(index: 1, delta: 10); + expect(notified, isFalse); + }); + + test('notifies pixelsListenable', () { + var notified = false; + controller.pixelsListenable.addListener(() => notified = true); + manager.adjustChildSize(index: 1, delta: 10); expect(notified, isTrue); }); + + test('pixelsListenable exposes the latest pixel values', () { + final snapshots = >[]; + controller.pixelsListenable.addListener(() { + snapshots.add(List.of(controller.pixelsListenable.value)); + }); + manager.adjustChildSize(index: 1, delta: 10); + expect(snapshots.single, equals([100, 60, 40])); + }); + + test('pixelsListenable.value is unmodifiable', () { + expect( + () => controller.pixelsListenable.value[0] = 999, + throwsUnsupportedError, + ); + }); + }); + }); + + group('pixelsListenable', () { + test('is stable across reads (same instance returned)', () { + expect( + identical( + controller.pixelsListenable, + controller.pixelsListenable, + ), + isTrue, + ); + }); + + test('fires from setRenderedSizes', () { + controller.setChildren(const [ + ResizableChild( + size: ResizableSize.pixels(100), + child: SizedBox.shrink(), + ), + ResizableChild( + size: ResizableSize.pixels(100), + child: SizedBox.shrink(), + ), + ]); + manager.setAvailableSpace(200); + + var notified = false; + controller.pixelsListenable.addListener(() => notified = true); + manager.setRenderedSizes([120, 80]); + + expect(notified, isTrue); + expect(controller.pixelsListenable.value, equals([120, 80])); + }); + + test('fires from setSizes', () { + controller.setChildren(const [ + ResizableChild( + size: ResizableSize.pixels(100), + child: SizedBox.shrink(), + ), + ResizableChild( + size: ResizableSize.pixels(100), + child: SizedBox.shrink(), + ), + ]); + manager.setAvailableSpace(200); + manager.setRenderedSizes([100, 100]); + + var notified = false; + controller.pixelsListenable.addListener(() => notified = true); + controller.setSizes(const [ + ResizableSize.pixels(150), + ResizableSize.pixels(50), + ]); + + expect(notified, isTrue); + }); + + test('fires from setHidden', () { + controller.setChildren(const [ + ResizableChild( + size: ResizableSize.pixels(100), + child: SizedBox.shrink(), + ), + ResizableChild( + size: ResizableSize.pixels(100), + child: SizedBox.shrink(), + ), + ]); + manager.setAvailableSpace(200); + manager.setRenderedSizes([100, 100]); + + var notified = false; + controller.pixelsListenable.addListener(() => notified = true); + controller.hide(0); + + expect(notified, isTrue); + }); + + test('fires from setChildren', () { + controller.setChildren(const [ + ResizableChild( + size: ResizableSize.pixels(100), + child: SizedBox.shrink(), + ), + ]); + + var notified = false; + controller.pixelsListenable.addListener(() => notified = true); + controller.setChildren(const [ + ResizableChild( + size: ResizableSize.pixels(50), + child: SizedBox.shrink(), + ), + ResizableChild( + size: ResizableSize.pixels(50), + child: SizedBox.shrink(), + ), + ]); + + expect(notified, isTrue); + }); + + test('throws after the controller is disposed', () { + final localController = ResizableController(); + localController.dispose(); + + expect( + () => localController.pixelsListenable.addListener(() {}), + throwsFlutterError, + ); }); }); From d793bcb8d623ad47903856f4c6582c292704cc0b Mon Sep 17 00:00:00 2001 From: Andy Horn Date: Wed, 27 May 2026 08:23:26 -0700 Subject: [PATCH 2/6] refactor: snapshot pixelsListenable + tighten contract docs Address review findings on PR #134: - Replace `_PixelsListenable` wrapper with a `ValueNotifier>` that publishes a fresh `UnmodifiableListView` snapshot per notification, restoring proper `ValueListenable` old/new comparison semantics. - Push a fresh snapshot from `_setAvailableSpace` so the live-pixel render path sees redistributed pixels after a LayoutBuilder-driven rebuild. - Rewrite the `pixelsListenable` dartdoc: drop private-symbol references and the negative contract, add a migration note for callers that watched the controller for size changes. - Acknowledge in the controller class doc that the main listener also fires on the post-layout rendered-size update. - Update the README size-tracking snippet to use `pixelsListenable`. - Assert `constraints.maxWidth.isFinite` in the RTL horizontal reversal path now that the assumption is no longer enforced by a `Flex` parent. - Add tests: `show()` notification, render-object detach/reattach lifecycle, vertical-axis rebuild and live-pixel coverage, and the `_canUseLivePixels` length-mismatch cold-path fallback. --- README.md | 7 +- lib/src/layout/resizable_layout.dart | 4 + lib/src/resizable_controller.dart | 75 +++++------ .../resizable_layout_live_pixels_test.dart | 123 ++++++++++++++++++ test/resizable_container_rebuild_test.dart | 65 +++++++++ test/resizable_controller_test.dart | 20 +++ 6 files changed, 253 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 5eac3be..be0f650 100644 --- a/README.md +++ b/README.md @@ -107,9 +107,12 @@ final controller = ResizableController(); void initState() { super.initState(); - controller.addListener(() { + // Use `pixelsListenable` for live pixel updates (including during a + // divider drag). `controller.addListener` only fires on structural + // changes (children list, hidden set, declared sizes). + controller.pixelsListenable.addListener(() { // ... react to size change events - final List pixels = controller.pixels; + final List pixels = controller.pixelsListenable.value; print(pixels.join(', ')); }); } diff --git a/lib/src/layout/resizable_layout.dart b/lib/src/layout/resizable_layout.dart index 1285bce..dc84c9d 100644 --- a/lib/src/layout/resizable_layout.dart +++ b/lib/src/layout/resizable_layout.dart @@ -313,6 +313,10 @@ class ResizableLayoutRenderObject extends RenderBox if (_textDirection != TextDirection.rtl) return; if (!_layoutDirection.isHorizontal) return; + assert( + constraints.maxWidth.isFinite, + 'Resizable in RTL horizontal layout requires bounded width.', + ); final totalWidth = constraints.maxWidth; var child = firstChild; while (child != null) { diff --git a/lib/src/resizable_controller.dart b/lib/src/resizable_controller.dart index c32c54d..81537d3 100644 --- a/lib/src/resizable_controller.dart +++ b/lib/src/resizable_controller.dart @@ -14,8 +14,9 @@ const ResizableSize _hiddenSize = ResizableSize.pixels(0, min: 0, max: 0); /// Notification surface: /// /// * The controller's own [Listenable] (via [addListener]) fires on -/// *structural* changes only — children list, declared sizes, hidden set, -/// layout invalidation. It does **not** fire during a divider drag. +/// *structural* changes — children list, declared sizes, hidden set, +/// layout invalidation — and on the post-layout rendered-size update that +/// follows each layout pass. It does **not** fire during a divider drag. /// * [pixelsListenable] fires on every pixel change, including the per-tick /// updates produced by a divider drag. Subscribe here when you need to /// react to live size changes. @@ -30,8 +31,8 @@ class ResizableController with ChangeNotifier { final Map _savedSizes = {}; bool _needsLayout = false; bool _cascadeNegativeDelta = false; - late final _PixelsListenable _pixelsListenable = - _PixelsListenable(() => _pixels); + final ValueNotifier> _pixelsListenable = + ValueNotifier>(UnmodifiableListView(const [])); /// Whether or not the container needs to (re)layout its children. bool get needsLayout => _needsLayout; @@ -39,18 +40,16 @@ class ResizableController with ChangeNotifier { /// The physical size, in pixels, of each child. UnmodifiableListView get pixels => UnmodifiableListView(_pixels); - /// A [ValueListenable] that exposes the current per-child pixel sizes and - /// fires on drag-induced and post-layout updates (`_adjustChildSize`, - /// `_setRenderedSizes`, structural changes). + /// A [ValueListenable] exposing the current per-child pixel sizes. /// - /// Does **not** fire when pixels shift purely due to a viewport-driven - /// available-space change — those mutations happen mid-build and are - /// picked up by the next layout pass via the listenable's [value]. Read - /// [pixels] (or `pixelsListenable.value`) inside [WidgetsBinding.addPostFrameCallback] - /// if you need the post-resize snapshot. + /// Fires whenever the pixel sizes change: divider drag ticks, programmatic + /// size updates (e.g. [setSizes]), hide/show, and children replacement. + /// Each notification publishes a fresh immutable snapshot as [value]. /// - /// Prefer this over [addListener] when you only care about live size - /// changes — the main controller listener fires only on structural events. + /// Migration: callers that previously listened to the controller via + /// [addListener] to observe size changes should listen to this listenable + /// instead — the main controller listener is reserved for structural + /// changes and post-layout updates. ValueListenable> get pixelsListenable => _pixelsListenable; /// The [ResizableSize] of each child. @@ -102,7 +101,9 @@ class ResizableController with ChangeNotifier { } _needsLayout = true; - _pixelsListenable.notify(); + _pixelsListenable.value = UnmodifiableListView( + List.from(_pixels), + ); notifyListeners(); } @@ -153,7 +154,9 @@ class ResizableController with ChangeNotifier { _sizes = effective; _needsLayout = true; - _pixelsListenable.notify(); + _pixelsListenable.value = UnmodifiableListView( + List.from(_pixels), + ); notifyListeners(); } @@ -238,7 +241,9 @@ class ResizableController with ChangeNotifier { _pixels[index + 1] -= adjustedDelta; } - _pixelsListenable.notify(); + _pixelsListenable.value = UnmodifiableListView( + List.from(_pixels), + ); } void setChildren(List children) { @@ -257,7 +262,9 @@ class ResizableController with ChangeNotifier { _savedSizes.clear(); _needsLayout = true; - _pixelsListenable.notify(); + _pixelsListenable.value = UnmodifiableListView( + List.from(_pixels), + ); if (notify) { notifyListeners(); @@ -277,7 +284,9 @@ class ResizableController with ChangeNotifier { void _setRenderedSizes(List pixels) { _pixels = pixels; _needsLayout = false; - _pixelsListenable.notify(); + _pixelsListenable.value = UnmodifiableListView( + List.from(_pixels), + ); notifyListeners(); } @@ -316,6 +325,14 @@ class ResizableController with ChangeNotifier { } _availableSpace = availableSpace; + + // Publish the redistributed pixels so the live-pixel render path reads + // current values. This is called during the enclosing LayoutBuilder build + // pass; the render object's listener marks itself needs-layout, which is + // a no-op when the widget rebuild already dirties it in the same frame. + _pixelsListenable.value = UnmodifiableListView( + List.from(_pixels), + ); } double _getDelta(double availableSpace) { @@ -608,23 +625,3 @@ abstract class ResizableControllerTestHelper { static List getChildren(ResizableController controller) => controller._children; } - -/// A [ChangeNotifier] that exposes the controller's current pixel snapshot -/// and serves as the pixel-update channel for [ResizableController]. -/// -/// Exposed via [ResizableController.pixelsListenable] — kept private so -/// callers cannot fire it themselves. The getter is closure-based rather -/// than a stored field so [value] always returns the controller's current -/// `_pixels` list without the controller having to push every mutation -/// through a separate update method. -class _PixelsListenable extends ChangeNotifier - implements ValueListenable> { - _PixelsListenable(this._getPixels); - - final List Function() _getPixels; - - @override - List get value => UnmodifiableListView(_getPixels()); - - void notify() => notifyListeners(); -} diff --git a/test/layout/resizable_layout_live_pixels_test.dart b/test/layout/resizable_layout_live_pixels_test.dart index 074cfbb..804352b 100644 --- a/test/layout/resizable_layout_live_pixels_test.dart +++ b/test/layout/resizable_layout_live_pixels_test.dart @@ -188,6 +188,125 @@ void main() { }, ); + testWidgets( + 'detached render object does not relayout from pixel mutations, ' + 'and rewires when reattached', + (tester) async { + final pixels = ValueNotifier>(const [120, 280]); + addTearDown(pixels.dispose); + + await _pumpLayout(tester, pixels: pixels); + expect(_widthOf(tester, const Key('A')), 120); + + // Unmount the layout: the render object detaches and must remove its + // listener from the notifier so subsequent value changes do not + // schedule layout against a dead pipeline. + await tester.pumpWidget(const SizedBox()); + + // Mutating pixels while detached must not crash and must not produce + // a relayout request (there is no layout to observe — the assertion + // here is the absence of a thrown error after pumping a frame). + pixels.value = const [50, 50]; + await tester.pump(); + + // Re-mount with the same controller / notifier. The render object + // re-attaches and must re-subscribe so the live path drives layout. + await _pumpLayout(tester, pixels: pixels); + expect(_widthOf(tester, const Key('A')), 50); + + pixels.value = const [200, 200]; + await tester.pump(); + expect(_widthOf(tester, const Key('A')), 200); + expect(_widthOf(tester, const Key('B')), 200); + }, + ); + + testWidgets( + 'vertical layout lays out children using the supplied pixel values ' + 'and relayouts on pixel changes', + (tester) async { + final pixels = ValueNotifier>(const [120, 280]); + addTearDown(pixels.dispose); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 100, + height: 402, + child: ResizableLayout( + direction: Axis.vertical, + onComplete: (_) {}, + sizes: const [ + ResizableSize.pixels(120), + ResizableSize.pixels(280), + ], + resizableChildren: const [ + ResizableChild( + size: ResizableSize.pixels(120), + divider: ResizableDivider(thickness: 2), + child: SizedBox(key: Key('A')), + ), + ResizableChild( + size: ResizableSize.pixels(280), + child: SizedBox(key: Key('B')), + ), + ], + livePixels: pixels, + children: const [ + SizedBox(key: Key('A')), + ResizableContainerDivider.placeholder( + config: ResizableDivider(thickness: 2), + direction: Axis.vertical, + ), + SizedBox(key: Key('B')), + ], + ), + ), + ), + ); + + expect(_heightOf(tester, const Key('A')), 120); + expect(_heightOf(tester, const Key('B')), 280); + + pixels.value = const [200, 200]; + await tester.pump(); + + expect(_heightOf(tester, const Key('A')), 200); + expect(_heightOf(tester, const Key('B')), 200); + }, + ); + + testWidgets( + 'falls back to cold path when livePixels length does not match the ' + 'resizable child count', + (tester) async { + // Mid-children-swap, the controller's pixels list and the widget's + // resizableChildren list briefly disagree on length. The render + // object must not index past pixels.length — _canUseLivePixels + // detects the mismatch and routes through the cold (resolve-from- + // sizes) path. We stage that transient state directly by handing + // the layout a notifier whose value length differs from the child + // count. + final pixels = ValueNotifier>(const [50, 50, 50]); + addTearDown(pixels.dispose); + + await _pumpLayout(tester, pixels: pixels); + + // Cold-path sizes from the declared ResizableSize.pixels values + // (120 + 280), not from the mismatched live pixel list. + expect(_widthOf(tester, const Key('A')), 120); + expect(_widthOf(tester, const Key('B')), 280); + + // Recovering: align the pixel list length with the child count + // and confirm the live path resumes. + pixels.value = const [100, 300]; + await tester.pump(); + expect(_widthOf(tester, const Key('A')), 100); + expect(_widthOf(tester, const Key('B')), 300); + }, + ); + testWidgets( 'collapses divider to zero when adjacent child is hidden', (tester) async { @@ -259,6 +378,10 @@ double _widthOf(WidgetTester tester, Key key) { return tester.getSize(find.byKey(key)).width; } +double _heightOf(WidgetTester tester, Key key) { + return tester.getSize(find.byKey(key)).height; +} + Future _pumpLayout( WidgetTester tester, { required ValueListenable>? pixels, diff --git a/test/resizable_container_rebuild_test.dart b/test/resizable_container_rebuild_test.dart index 15d0c17..be74150 100644 --- a/test/resizable_container_rebuild_test.dart +++ b/test/resizable_container_rebuild_test.dart @@ -80,6 +80,71 @@ void main() { expect(mainNotifyCount, 0); }, ); + + testWidgets( + 'child widgets do not rebuild while a vertical divider is dragged', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 1000)); + + final aBuildCounter = _BuildCounter(); + final bBuildCounter = _BuildCounter(); + + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResizableContainer( + controller: controller, + direction: Axis.vertical, + children: [ + ResizableChild( + size: const ResizableSize.ratio(0.5), + child: _CountingChild( + counter: aBuildCounter, + key: const Key('BoxA'), + ), + ), + ResizableChild( + size: const ResizableSize.ratio(0.5), + child: _CountingChild( + counter: bBuildCounter, + key: const Key('BoxB'), + ), + ), + ], + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + final aBaseline = aBuildCounter.count; + final bBaseline = bBuildCounter.count; + final preDragHeight = + tester.getSize(find.byKey(const Key('BoxA'))).height; + + var mainNotifyCount = 0; + controller.addListener(() => mainNotifyCount++); + + await tester.timedDrag( + find.byType(ResizableContainerDivider), + const Offset(0, 120), + const Duration(milliseconds: 200), + ); + await tester.pump(); + + final postDragHeight = + tester.getSize(find.byKey(const Key('BoxA'))).height; + expect(postDragHeight, greaterThan(preDragHeight)); + + expect(aBuildCounter.count, aBaseline); + expect(bBuildCounter.count, bBaseline); + expect(mainNotifyCount, 0); + }, + ); }); } diff --git a/test/resizable_controller_test.dart b/test/resizable_controller_test.dart index 68c66e1..ba12b3f 100644 --- a/test/resizable_controller_test.dart +++ b/test/resizable_controller_test.dart @@ -684,6 +684,26 @@ void main() { expect(notified, isFalse); }); + test('show notifies pixelsListenable with the restored distribution', () { + controller.hide(1); + + var notified = false; + final snapshots = >[]; + controller.pixelsListenable.addListener(() { + notified = true; + snapshots.add(List.of(controller.pixelsListenable.value)); + }); + + controller.show(1); + + expect(notified, isTrue); + // The visible-index sizes are unchanged by show(); only the hidden + // entry is restored from zero. The exact distribution is owned by + // the next layout pass, but the listenable must reflect the + // controller's current `_pixels` list at notification time. + expect(snapshots.single, equals(controller.pixels)); + }); + test('hide throws when index is out of range', () { expect(() => controller.hide(-1), throwsRangeError); expect(() => controller.hide(3), throwsRangeError); From 05ed4448999127185cbd7c26fc719bace6637067 Mon Sep 17 00:00:00 2001 From: Andy Horn Date: Wed, 27 May 2026 08:59:22 -0700 Subject: [PATCH 3/6] refactor!: split needsLayout signal from main controller listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose `ResizableController.needsLayoutListenable: ValueListenable`, which fires when the controller's `needsLayout` flag transitions. The container now subscribes to this listenable alongside the controller via `Listenable.merge`, decoupling the cold↔live build-path swap from the main listener. With this in place, `_setRenderedSizes` no longer calls `notifyListeners`. The main controller listener is now reserved strictly for structural changes (children list, declared sizes, hidden set); post-layout updates flow through `needsLayoutListenable` (for the build-path swap) and `pixelsListenable` (for the rendered pixel values). Breaking: callers that watched the controller via `addListener` to react to *any* post-layout state change now see fires only on structural events. Subscribe to `needsLayoutListenable` for layout-state transitions or to `pixelsListenable` for live pixel values. Update the `notify count` tests in `resizable_container_test.dart` to assert the new contract on both listenables. --- lib/src/resizable_container.dart | 9 +++++- lib/src/resizable_controller.dart | 39 +++++++++++++++-------- test/resizable_container_test.dart | 51 +++++++++++++++++++----------- 3 files changed, 66 insertions(+), 33 deletions(-) diff --git a/lib/src/resizable_container.dart b/lib/src/resizable_container.dart index 4b020dc..d5291b3 100644 --- a/lib/src/resizable_container.dart +++ b/lib/src/resizable_container.dart @@ -217,7 +217,14 @@ class _ResizableContainerState extends State manager.setAvailableSpace(availableSpace); return ListenableBuilder( - listenable: controller, + // Listen to both the controller (structural changes) and the + // needsLayout flag so the build path swaps between the cold and + // live paths without depending on the main listener firing after + // every layout. + listenable: Listenable.merge([ + controller, + controller.needsLayoutListenable, + ]), builder: (context, _) => _buildForPhase(constraints), ); }, diff --git a/lib/src/resizable_controller.dart b/lib/src/resizable_controller.dart index 81537d3..561061e 100644 --- a/lib/src/resizable_controller.dart +++ b/lib/src/resizable_controller.dart @@ -14,12 +14,15 @@ const ResizableSize _hiddenSize = ResizableSize.pixels(0, min: 0, max: 0); /// Notification surface: /// /// * The controller's own [Listenable] (via [addListener]) fires on -/// *structural* changes — children list, declared sizes, hidden set, -/// layout invalidation — and on the post-layout rendered-size update that -/// follows each layout pass. It does **not** fire during a divider drag. +/// *structural* changes only — children list, declared sizes, and hidden +/// set. It does **not** fire during a divider drag or on post-layout +/// rendered-size updates. /// * [pixelsListenable] fires on every pixel change, including the per-tick /// updates produced by a divider drag. Subscribe here when you need to /// react to live size changes. +/// * [needsLayoutListenable] fires when [needsLayout] flips, signalling that +/// the container's build path is about to switch between the cold +/// (size-resolution) path and the live (pixel-driven) path. class ResizableController with ChangeNotifier { ResizableController(); @@ -29,13 +32,20 @@ class ResizableController with ChangeNotifier { List _children = const []; final Set _hiddenIndices = {}; final Map _savedSizes = {}; - bool _needsLayout = false; bool _cascadeNegativeDelta = false; + final ValueNotifier _needsLayoutListenable = ValueNotifier(false); final ValueNotifier> _pixelsListenable = ValueNotifier>(UnmodifiableListView(const [])); /// Whether or not the container needs to (re)layout its children. - bool get needsLayout => _needsLayout; + bool get needsLayout => _needsLayoutListenable.value; + + /// A [ValueListenable] exposing the current [needsLayout] state. + /// + /// Fires whenever [needsLayout] transitions, which the container uses to + /// swap between the cold-path layout (driven by [ResizableSize] resolution) + /// and the live-pixel path (driven by [pixelsListenable]). + ValueListenable get needsLayoutListenable => _needsLayoutListenable; /// The physical size, in pixels, of each child. UnmodifiableListView get pixels => UnmodifiableListView(_pixels); @@ -49,7 +59,7 @@ class ResizableController with ChangeNotifier { /// Migration: callers that previously listened to the controller via /// [addListener] to observe size changes should listen to this listenable /// instead — the main controller listener is reserved for structural - /// changes and post-layout updates. + /// changes. ValueListenable> get pixelsListenable => _pixelsListenable; /// The [ResizableSize] of each child. @@ -100,7 +110,7 @@ class ResizableController with ChangeNotifier { _hiddenIndices.remove(index); } - _needsLayout = true; + _needsLayoutListenable.value = true; _pixelsListenable.value = UnmodifiableListView( List.from(_pixels), ); @@ -153,7 +163,7 @@ class ResizableController with ChangeNotifier { } _sizes = effective; - _needsLayout = true; + _needsLayoutListenable.value = true; _pixelsListenable.value = UnmodifiableListView( List.from(_pixels), ); @@ -162,6 +172,7 @@ class ResizableController with ChangeNotifier { @override void dispose() { + _needsLayoutListenable.dispose(); _pixelsListenable.dispose(); super.dispose(); } @@ -260,7 +271,7 @@ class ResizableController with ChangeNotifier { _pixels = List.filled(children.length, 0); _hiddenIndices.clear(); _savedSizes.clear(); - _needsLayout = true; + _needsLayoutListenable.value = true; _pixelsListenable.value = UnmodifiableListView( List.from(_pixels), @@ -283,16 +294,18 @@ class ResizableController with ChangeNotifier { void _setRenderedSizes(List pixels) { _pixels = pixels; - _needsLayout = false; + // The build-path swap (live ↔ cold) is signalled via + // [needsLayoutListenable]; the main listener is reserved for structural + // changes only and intentionally does not fire here. + _needsLayoutListenable.value = false; _pixelsListenable.value = UnmodifiableListView( List.from(_pixels), ); - notifyListeners(); } void _setAvailableSpace(double availableSpace) { if (_availableSpace == -1) { - _needsLayout = true; + _needsLayoutListenable.value = true; _availableSpace = availableSpace; return; } @@ -600,7 +613,7 @@ final class ResizableControllerManager { } void setNeedsLayout() { - _controller._needsLayout = true; + _controller._needsLayoutListenable.value = true; } void initChildren(List children) { diff --git a/test/resizable_container_test.dart b/test/resizable_container_test.dart index 3e039b2..f1f5b1c 100644 --- a/test/resizable_container_test.dart +++ b/test/resizable_container_test.dart @@ -2073,19 +2073,23 @@ void main() { }); group('notify count', () { - // The container schedules a post-frame setRenderedSizes after every - // layout pass. That callback notifies controller listeners so the - // build path can switch from the layout widget (with placeholder - // dividers) to the flex widget (with interactive dividers). These - // tests pin the resulting notification count so a future refactor - // can't silently introduce another redundant notify cycle. - testWidgets('initial mount notifies exactly once', (tester) async { + // The main controller listener is reserved for structural changes + // (children list, declared sizes, hidden set). Build-path swaps + // between the cold and live paths are signalled via + // `needsLayoutListenable` instead. These tests pin the resulting + // notification counts so a future refactor cannot silently + // re-introduce a redundant notify cycle on the main listener. + testWidgets('initial mount fires no main listener', (tester) async { await tester.binding.setSurfaceSize(const Size(600, 400)); final controller = ResizableController(); addTearDown(controller.dispose); - var notifies = 0; - controller.addListener(() => notifies++); + var mainNotifies = 0; + var needsLayoutNotifies = 0; + controller.addListener(() => mainNotifies++); + controller.needsLayoutListenable.addListener( + () => needsLayoutNotifies++, + ); await tester.pumpWidget( MaterialApp( @@ -2113,12 +2117,15 @@ void main() { ); await tester.pumpAndSettle(); - // Exactly one notify: the post-frame setRenderedSizes that - // populates controller.pixels and switches the build path. - expect(notifies, 1); + // Main listener: no structural changes after mount, so zero fires. + expect(mainNotifies, 0); + // needsLayoutListenable: true on the first available-space call, + // false on the post-frame setRenderedSizes that completes the cold + // layout. The container observes this to swap to the live path. + expect(needsLayoutNotifies, 2); }); - testWidgets('hide produces exactly two notifies', (tester) async { + testWidgets('hide fires the main listener once', (tester) async { await tester.binding.setSurfaceSize(const Size(600, 400)); final controller = ResizableController(); addTearDown(controller.dispose); @@ -2149,16 +2156,22 @@ void main() { ); await tester.pumpAndSettle(); - var notifies = 0; - controller.addListener(() => notifies++); + var mainNotifies = 0; + var needsLayoutNotifies = 0; + controller.addListener(() => mainNotifies++); + controller.needsLayoutListenable.addListener( + () => needsLayoutNotifies++, + ); controller.hide(1); await tester.pumpAndSettle(); - // Two notifies: one from setHidden (sizes/hiddenIndices changed) - // and one from the post-frame setRenderedSizes (rendered pixels - // changed and the build path switches back to the flex layout). - expect(notifies, 2); + // Main listener: exactly one fire from the structural hide change. + expect(mainNotifies, 1); + // needsLayoutListenable: true from setHidden invalidating layout, + // false from the post-frame setRenderedSizes completing the + // subsequent cold layout. + expect(needsLayoutNotifies, 2); }); }); From 5759fc8aea15a5bc4c48b608eb697ecf3c0b1a52 Mon Sep 17 00:00:00 2001 From: Andy Horn Date: Wed, 27 May 2026 13:52:52 -0700 Subject: [PATCH 4/6] test: pin listener contracts for main/pixels/needsLayout listenables Cover the three-listenable surface from a consumer's POV: main ChangeNotifier fires only on structural changes, pixelsListenable carries drag updates and snapshot-stable values, needsLayoutListenable tracks build-path swap. Also pins the Listenable.merge coalescing the container relies on and the README migration pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...able_container_listener_contract_test.dart | 639 ++++++++++++++++++ 1 file changed, 639 insertions(+) create mode 100644 test/resizable_container_listener_contract_test.dart diff --git a/test/resizable_container_listener_contract_test.dart b/test/resizable_container_listener_contract_test.dart new file mode 100644 index 0000000..f800d24 --- /dev/null +++ b/test/resizable_container_listener_contract_test.dart @@ -0,0 +1,639 @@ +// Verifies the public listener contract on [ResizableController] from a +// consumer's perspective after the b413c10 + 3d4b65c refactor: +// +// * [ChangeNotifier.addListener] — structural changes only. +// * [pixelsListenable] — fires per-pixel-change with a fresh +// unmodifiable snapshot. +// * [needsLayoutListenable] — flips around build-path swaps. +// +// The [ResizableContainer] now subscribes to +// `Listenable.merge([controller, controller.needsLayoutListenable])` instead +// of the controller alone, so these tests double as the migration-pinning +// suite for downstream consumers. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +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'; + +Widget _harness({ + required ResizableController controller, + Axis direction = Axis.horizontal, + List? children, +}) { + return MaterialApp( + home: Scaffold( + body: ResizableContainer( + controller: controller, + direction: direction, + children: children ?? + const [ + ResizableChild( + size: ResizableSize.ratio(0.5), + child: SizedBox.expand(key: Key('A')), + ), + ResizableChild( + size: ResizableSize.ratio(0.5), + child: SizedBox.expand(key: Key('B')), + ), + ], + ), + ), + ); +} + +void main() { + group('main controller listener (addListener) — structural only', () { + testWidgets('does not fire on initial mount + first frame', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + var mainNotifies = 0; + controller.addListener(() => mainNotifies++); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + expect(mainNotifies, 0); + }); + + testWidgets('does not fire during a horizontal drag', (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + var mainNotifies = 0; + controller.addListener(() => mainNotifies++); + + final preDragWidth = tester.getSize(find.byKey(const Key('A'))).width; + await tester.timedDrag( + find.byType(ResizableContainerDivider), + const Offset(120, 0), + const Duration(milliseconds: 200), + ); + await tester.pump(); + + // Guard against a silent regression where the drag fails to reach the + // divider — only meaningful if the drag actually moved sizes. + final postDragWidth = tester.getSize(find.byKey(const Key('A'))).width; + expect(postDragWidth, greaterThan(preDragWidth)); + + expect(mainNotifies, 0); + }); + + testWidgets('does not fire during a vertical drag', (tester) async { + await tester.binding.setSurfaceSize(const Size(600, 1000)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _harness(controller: controller, direction: Axis.vertical), + ); + await tester.pumpAndSettle(); + + var mainNotifies = 0; + controller.addListener(() => mainNotifies++); + + final preDragHeight = tester.getSize(find.byKey(const Key('A'))).height; + await tester.timedDrag( + find.byType(ResizableContainerDivider), + const Offset(0, 120), + const Duration(milliseconds: 200), + ); + await tester.pump(); + + final postDragHeight = tester.getSize(find.byKey(const Key('A'))).height; + expect(postDragHeight, greaterThan(preDragHeight)); + + expect(mainNotifies, 0); + }); + + testWidgets('fires exactly once on controller.setChildren', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + var mainNotifies = 0; + controller.addListener(() => mainNotifies++); + + controller.setChildren(const [ + ResizableChild( + size: ResizableSize.ratio(0.3), + child: SizedBox.expand(), + ), + ResizableChild( + size: ResizableSize.ratio(0.7), + child: SizedBox.expand(), + ), + ]); + await tester.pumpAndSettle(); + + expect(mainNotifies, 1); + }); + + testWidgets( + 'fires exactly once on controller.setSizes (the public update API)', + (tester) async { + // Note: there is no `updateChildSize` on the controller. `setSizes` is + // the public size-update API and is the structural-change surface the + // main listener was designed for. + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + var mainNotifies = 0; + controller.addListener(() => mainNotifies++); + + controller.setSizes(const [ + ResizableSize.ratio(0.25), + ResizableSize.ratio(0.75), + ]); + await tester.pumpAndSettle(); + + expect(mainNotifies, 1); + }, + ); + + testWidgets('fires exactly once on controller.hide', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + var mainNotifies = 0; + controller.addListener(() => mainNotifies++); + + controller.hide(0); + await tester.pumpAndSettle(); + + expect(mainNotifies, 1); + }); + + testWidgets('fires exactly once on controller.show', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + controller.hide(0); + await tester.pumpAndSettle(); + + var mainNotifies = 0; + controller.addListener(() => mainNotifies++); + + controller.show(0); + await tester.pumpAndSettle(); + + expect(mainNotifies, 1); + }); + + testWidgets('does not fire on screen-size change (live redistribute)', + (tester) async { + // The screen-resize live-redistribute path runs inside + // `_setAvailableSpace` and never calls `notifyListeners()`. This is the + // critical assertion that drag and screen-resize both flow through + // `pixelsListenable` exclusively. + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + var mainNotifies = 0; + controller.addListener(() => mainNotifies++); + + await tester.binding.setSurfaceSize(const Size(1200, 600)); + await tester.pumpAndSettle(); + + expect(mainNotifies, 0); + }); + }); + + group('needsLayoutListenable lifecycle', () { + test( + 'bare-construct state is false — the controller defaults to false ' + 'and only flips to true once the container calls initChildren ' + '(deviation from spec)', + () { + // Deviation: the task spec claimed bare-construct = true, but + // `ValueNotifier(false)` in [ResizableController] is the source + // of truth. The true→false→true cycle only begins once the controller + // is wired into a container's initState (which calls `_initChildren`, + // which sets the flag to true). + final controller = ResizableController(); + addTearDown(controller.dispose); + + expect(controller.needsLayout, isFalse); + expect(controller.needsLayoutListenable.value, isFalse); + }, + ); + + testWidgets('is false after mounting + first frame settles', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + expect(controller.needsLayoutListenable.value, isFalse); + }); + + testWidgets('does not fire during a drag', (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + var needsLayoutNotifies = 0; + controller.needsLayoutListenable.addListener( + () => needsLayoutNotifies++, + ); + + final preDragWidth = tester.getSize(find.byKey(const Key('A'))).width; + await tester.timedDrag( + find.byType(ResizableContainerDivider), + const Offset(120, 0), + const Duration(milliseconds: 200), + ); + await tester.pump(); + + final postDragWidth = tester.getSize(find.byKey(const Key('A'))).width; + expect(postDragWidth, greaterThan(preDragWidth)); + + expect(needsLayoutNotifies, 0); + expect(controller.needsLayoutListenable.value, isFalse); + }); + + testWidgets( + 'fires around setChildren and settles back to false', + (tester) async { + // Both flips (false → true → false) occur within the same + // pumpAndSettle cycle: `setChildren` synchronously flips to true, and + // the post-frame `_setRenderedSizes` flips back to false. We pin the + // observable notify count == 2 and the post-frame value == false. + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + var needsLayoutNotifies = 0; + controller.needsLayoutListenable.addListener( + () => needsLayoutNotifies++, + ); + + controller.setChildren(const [ + ResizableChild( + size: ResizableSize.ratio(0.3), + child: SizedBox.expand(), + ), + ResizableChild( + size: ResizableSize.ratio(0.7), + child: SizedBox.expand(), + ), + ]); + await tester.pumpAndSettle(); + + expect(needsLayoutNotifies, 2); + expect(controller.needsLayoutListenable.value, isFalse); + }, + ); + + testWidgets( + 'stays false on a screen-resize that does not reset availableSpace', + (tester) async { + // `_setAvailableSpace` only flips `needsLayout` to true when + // `_availableSpace == -1` (first call only). Subsequent calls + // redistribute pixels live without invalidating layout, so the + // needs-layout listener stays silent and pixels fire. + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + var needsLayoutNotifies = 0; + var pixelsNotifies = 0; + controller.needsLayoutListenable.addListener( + () => needsLayoutNotifies++, + ); + controller.pixelsListenable.addListener(() => pixelsNotifies++); + + await tester.binding.setSurfaceSize(const Size(1200, 600)); + await tester.pumpAndSettle(); + + expect(needsLayoutNotifies, 0); + expect(controller.needsLayoutListenable.value, isFalse); + expect(pixelsNotifies, greaterThan(0)); + }, + ); + }); + + group('pixelsListenable snapshot semantics', () { + testWidgets( + 'after initial mount, value is non-empty, matches sizes, and is ' + 'unmodifiable', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + final snapshot = controller.pixelsListenable.value; + expect(snapshot, isNotEmpty); + expect(snapshot, equals(controller.pixels)); + expect(() => snapshot[0] = 999, throwsUnsupportedError); + }, + ); + + testWidgets( + 'fires multiple times during a drag with snapshots that differ', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + final snapshots = >[]; + controller.pixelsListenable.addListener(() { + // Capture a deep copy so the assertion compares values, not + // references; the snapshot-aliasing assertion below uses a + // separate raw-reference capture. + snapshots.add(List.from(controller.pixelsListenable.value)); + }); + + await tester.timedDrag( + find.byType(ResizableContainerDivider), + const Offset(120, 0), + const Duration(milliseconds: 200), + ); + await tester.pump(); + + expect(snapshots.length, greaterThan(1)); + // At least one pair of adjacent snapshots must differ — proves a + // mid-drag value actually changed, not just that the listener fired + // multiple times against the same data. + var anyChange = false; + for (var i = 1; i < snapshots.length; i++) { + if (!listEquals(snapshots[i], snapshots[i - 1])) { + anyChange = true; + break; + } + } + expect(anyChange, isTrue); + }, + ); + + testWidgets( + 'a stale snapshot captured by an earlier listener retains its old ' + 'values (proves snapshot vs. live-view — the load-bearing 3d4b65c ' + 'assertion)', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + // Capture the raw reference (NOT a copy) on the first fire after the + // baseline. If `pixelsListenable.value` were a live view onto a + // shared mutable list, the snapshot's contents would mutate alongside + // subsequent updates. + List? firstSnapshot; + List? firstSnapshotValuesAtCapture; + controller.pixelsListenable.addListener(() { + firstSnapshot ??= controller.pixelsListenable.value; + firstSnapshotValuesAtCapture ??= + List.from(controller.pixelsListenable.value); + }); + + await tester.timedDrag( + find.byType(ResizableContainerDivider), + const Offset(150, 0), + const Duration(milliseconds: 250), + ); + await tester.pump(); + + expect(firstSnapshot, isNotNull); + expect(firstSnapshotValuesAtCapture, isNotNull); + + // The current live value should have moved away from the captured + // snapshot — i.e. the drag actually produced subsequent updates. + expect( + listEquals(controller.pixelsListenable.value, firstSnapshot), + isFalse, + reason: 'sanity check that the drag produced ≥2 fires', + ); + + // The captured snapshot's contents are unchanged — equal to what + // they were at capture time. This is the snapshot guarantee that + // 3d4b65c introduced via `UnmodifiableListView(List.from(_pixels))`. + expect(firstSnapshot, equals(firstSnapshotValuesAtCapture)); + }, + ); + }); + + group( + 'Migration — old addListener pattern', + () { + // This is the migration-pinning test: it mimics the pre-refactor + // README pattern in which consumers called `controller.addListener` + // and read `controller.sizes` to observe drag updates. After b413c10, + // that listener no longer fires during a drag. The fix is to listen + // to `controller.pixelsListenable` instead. + testWidgets( + 'addListener observing controller.sizes receives 0 callbacks during ' + 'a drag (the breaking change)', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + final observedSizes = >[]; + controller.addListener(() { + observedSizes.add(List.of(controller.sizes)); + }); + + final preDragWidth = tester.getSize(find.byKey(const Key('A'))).width; + await tester.timedDrag( + find.byType(ResizableContainerDivider), + const Offset(120, 0), + const Duration(milliseconds: 200), + ); + await tester.pump(); + + final postDragWidth = + tester.getSize(find.byKey(const Key('A'))).width; + expect(postDragWidth, greaterThan(preDragWidth)); + + expect(observedSizes, isEmpty); + }, + ); + + testWidgets( + 'the same listener fires ≥1 time after hide() and after structural ' + 'setSizes/setChildren updates', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + var notifies = 0; + controller.addListener(() => notifies++); + + controller.hide(0); + await tester.pumpAndSettle(); + expect(notifies, greaterThanOrEqualTo(1)); + + notifies = 0; + controller.show(0); + await tester.pumpAndSettle(); + expect(notifies, greaterThanOrEqualTo(1)); + + notifies = 0; + controller.setSizes(const [ + ResizableSize.ratio(0.4), + ResizableSize.ratio(0.6), + ]); + await tester.pumpAndSettle(); + expect(notifies, greaterThanOrEqualTo(1)); + + notifies = 0; + controller.setChildren(const [ + ResizableChild( + size: ResizableSize.ratio(0.5), + child: SizedBox.expand(), + ), + ResizableChild( + size: ResizableSize.ratio(0.5), + child: SizedBox.expand(), + ), + ]); + await tester.pumpAndSettle(); + expect(notifies, greaterThanOrEqualTo(1)); + }, + ); + }, + ); + + group('Listenable.merge coalescing', () { + testWidgets( + 'one setChildren call results in at most one merge-listener fire ' + 'per microtask drain', + (tester) async { + // The container itself subscribes to + // `Listenable.merge([controller, controller.needsLayoutListenable])`. + // We mirror that subscription here and count merge fires across a + // single structural update. `setChildren` fires the main listener + // once and the needs-layout listener twice (true then false after + // the post-frame), but those happen across separate frames — we + // pin total merge fires <= 3 to leave room for the legitimate + // needs-layout flip cycle while guarding against a pathological + // "every-pixel-fire" regression on the merge channel. + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + final merged = Listenable.merge([ + controller, + controller.needsLayoutListenable, + ]); + + var mergeFires = 0; + void listener() => mergeFires++; + merged.addListener(listener); + addTearDown(() => merged.removeListener(listener)); + + controller.setChildren(const [ + ResizableChild( + size: ResizableSize.ratio(0.3), + child: SizedBox.expand(), + ), + ResizableChild( + size: ResizableSize.ratio(0.7), + child: SizedBox.expand(), + ), + ]); + await tester.pumpAndSettle(); + + // 1 fire from main + 2 from needsLayout (true, then false after the + // post-frame _setRenderedSizes) === 3. Pin to that exact count; + // anything higher means a regression has fed pixel updates into the + // merge channel. + expect(mergeFires, 3); + }, + ); + + testWidgets('merge listener fires 0 times during a drag', (tester) async { + // `_adjustChildSize` only writes `_pixelsListenable`, which is NOT in + // the merge tuple. Neither the main controller listener nor + // `needsLayoutListenable` fires during a drag, so the merge listener + // must stay silent for the entire gesture. + await tester.binding.setSurfaceSize(const Size(1000, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + final merged = Listenable.merge([ + controller, + controller.needsLayoutListenable, + ]); + + var mergeFires = 0; + void listener() => mergeFires++; + merged.addListener(listener); + addTearDown(() => merged.removeListener(listener)); + + final preDragWidth = tester.getSize(find.byKey(const Key('A'))).width; + await tester.timedDrag( + find.byType(ResizableContainerDivider), + const Offset(120, 0), + const Duration(milliseconds: 200), + ); + await tester.pump(); + + final postDragWidth = tester.getSize(find.byKey(const Key('A'))).width; + expect(postDragWidth, greaterThan(preDragWidth)); + + expect(mergeFires, 0); + }); + }); +} From c1c23165887da13deb5a12f506e878c827f4baf4 Mon Sep 17 00:00:00 2001 From: Andy Horn Date: Wed, 27 May 2026 14:02:34 -0700 Subject: [PATCH 5/6] test: cover clamping, hide-animation, and lifecycle for the live path - Pin that drag clamping happens in the controller before publishing to pixelsListenable, not in the render-object live layout. A documenting test asserts the live layout renders constraint-violating pixels verbatim, so any future re-clamping there fails loudly. - Pin the hide/show animation contract under the new dual-listenable surface: pixelsListenable fires a bounded number of times per hide/show (the animation lives in HideAnimationCoordinator, not the controller, so it is not per-frame), main listener fires exactly once per call, and a concurrent drag does not bump main. - Pin controller swap via didUpdateWidget (old detached, new attached, consumer-owned), late subscriber delivery, dispose ordering, and the documented multi-container-sharing limitation (each initChildren wipes hidden indices, so containers cannot share a controller). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../resizable_layout_live_pixels_test.dart | 360 +++++++++++- test/resizable_container_lifecycle_test.dart | 550 ++++++++++++++++++ ...esizable_hide_animation_contract_test.dart | 393 +++++++++++++ 3 files changed, 1302 insertions(+), 1 deletion(-) create mode 100644 test/resizable_container_lifecycle_test.dart create mode 100644 test/resizable_hide_animation_contract_test.dart diff --git a/test/layout/resizable_layout_live_pixels_test.dart b/test/layout/resizable_layout_live_pixels_test.dart index 804352b..ef71b6f 100644 --- a/test/layout/resizable_layout_live_pixels_test.dart +++ b/test/layout/resizable_layout_live_pixels_test.dart @@ -1,5 +1,5 @@ import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_resizable_container/flutter_resizable_container.dart'; import 'package:flutter_resizable_container/src/layout/resizable_layout.dart'; import 'package:flutter_resizable_container/src/resizable_container_divider.dart'; @@ -371,6 +371,364 @@ void main() { expect(boxC.dx, 100); }, ); + + // These tests pin the contract that during a live divider drag, the + // pixels published to `pixelsListenable` — and therefore the sizes the + // live render path uses — already respect each child's `ResizableSize` + // min/max constraints. Clamping is enforced by the controller's + // `_adjustChildSize` (which calls `_getAdjustedReducingDelta` / + // `_getAdjustedIncreasingDelta`) before publication. The render object's + // `_performLiveLayout` reads those pixels verbatim and does NOT re-clamp, + // so the controller-side enforcement is the only line of defense. + group('drag clamping', () { + testWidgets( + 'horizontal drag past child A max stops A at max and gives ' + 'overflow to neighbor', + (tester) async { + await tester.binding.setSurfaceSize(const Size(402, 100)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ResizableContainer( + direction: Axis.horizontal, + children: [ + ResizableChild( + size: ResizableSize.pixels(100, max: 150), + divider: ResizableDivider(thickness: 2), + child: SizedBox.expand(key: Key('A')), + ), + ResizableChild( + size: ResizableSize.pixels(300, min: 50), + child: SizedBox.expand(key: Key('B')), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Sanity: starting widths reflect the declared sizes. + expect(_widthOf(tester, const Key('A')), 100); + expect(_widthOf(tester, const Key('B')), 300); + + // Drag the divider far to the right — naive math would push A to + // 100 + 200 = 300, but A's max is 150. The live drag path must + // stop A at 150 and absorb the overflow into B. + final handle = find.byType(ResizableContainerDivider).first; + await tester.drag(handle, const Offset(kDragSlopDefault + 200, 0)); + await tester.pump(); + + expect(_widthOf(tester, const Key('A')), 150); + // A grew by 50; B shrinks by the same amount. Divider is 2px so + // A + B = 400. + expect(_widthOf(tester, const Key('B')), 250); + }, + ); + + testWidgets( + 'horizontal drag past child A min stops A at min', + (tester) async { + await tester.binding.setSurfaceSize(const Size(402, 100)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ResizableContainer( + direction: Axis.horizontal, + children: [ + ResizableChild( + size: ResizableSize.pixels(200, min: 150), + divider: ResizableDivider(thickness: 2), + child: SizedBox.expand(key: Key('A')), + ), + ResizableChild( + size: ResizableSize.pixels(200), + child: SizedBox.expand(key: Key('B')), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(_widthOf(tester, const Key('A')), 200); + expect(_widthOf(tester, const Key('B')), 200); + + // Drag far left — naive math would push A to 0, but A's min is 150. + final handle = find.byType(ResizableContainerDivider).first; + await tester.drag(handle, const Offset(-(kDragSlopDefault + 200), 0)); + await tester.pump(); + + expect(_widthOf(tester, const Key('A')), 150); + expect(_widthOf(tester, const Key('B')), 250); + }, + ); + + testWidgets( + 'horizontal drag halts when sender is at max and receiver is at min', + (tester) async { + await tester.binding.setSurfaceSize(const Size(402, 100)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ResizableContainer( + direction: Axis.horizontal, + children: [ + ResizableChild( + // A is already at its max; dragging right cannot grow it + size: ResizableSize.pixels(150, max: 150), + divider: ResizableDivider(thickness: 2), + child: SizedBox.expand(key: Key('A')), + ), + ResizableChild( + // B is already at its min; dragging right cannot shrink + // it further either. + size: ResizableSize.pixels(250, min: 250), + child: SizedBox.expand(key: Key('B')), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(_widthOf(tester, const Key('A')), 150); + expect(_widthOf(tester, const Key('B')), 250); + + final handle = find.byType(ResizableContainerDivider).first; + await tester.drag(handle, const Offset(kDragSlopDefault + 100, 0)); + await tester.pump(); + + // Neither end can move — widths are unchanged. + expect(_widthOf(tester, const Key('A')), 150); + expect(_widthOf(tester, const Key('B')), 250); + }, + ); + + testWidgets( + 'vertical drag past child A max stops A at max', + (tester) async { + await tester.binding.setSurfaceSize(const Size(100, 402)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ResizableContainer( + direction: Axis.vertical, + children: [ + ResizableChild( + size: ResizableSize.pixels(100, max: 150), + divider: ResizableDivider(thickness: 2), + child: SizedBox.expand(key: Key('A')), + ), + ResizableChild( + size: ResizableSize.pixels(300), + child: SizedBox.expand(key: Key('B')), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(_heightOf(tester, const Key('A')), 100); + expect(_heightOf(tester, const Key('B')), 300); + + final handle = find.byType(ResizableContainerDivider).first; + await tester.drag(handle, const Offset(0, kDragSlopDefault + 200)); + await tester.pump(); + + expect(_heightOf(tester, const Key('A')), 150); + expect(_heightOf(tester, const Key('B')), 250); + }, + ); + + testWidgets( + 'vertical drag past child A min stops A at min', + (tester) async { + await tester.binding.setSurfaceSize(const Size(100, 402)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ResizableContainer( + direction: Axis.vertical, + children: [ + ResizableChild( + size: ResizableSize.pixels(200, min: 150), + divider: ResizableDivider(thickness: 2), + child: SizedBox.expand(key: Key('A')), + ), + ResizableChild( + size: ResizableSize.pixels(200), + child: SizedBox.expand(key: Key('B')), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(_heightOf(tester, const Key('A')), 200); + expect(_heightOf(tester, const Key('B')), 200); + + final handle = find.byType(ResizableContainerDivider).first; + await tester.drag(handle, const Offset(0, -(kDragSlopDefault + 200))); + await tester.pump(); + + expect(_heightOf(tester, const Key('A')), 150); + expect(_heightOf(tester, const Key('B')), 250); + }, + ); + + testWidgets( + 'cascading negative delta drag does not grow receiver past its max ' + '(regression for 9f06c32)', + (tester) async { + // Mirrors the controller-level cascade test at + // test/resizable_controller_test.dart:505 but pins the same + // property at the rendered-pixel level — i.e. the value the live + // layout path actually consumes. + await tester.binding.setSurfaceSize(const Size(206, 100)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ResizableContainer( + cascadeNegativeDelta: true, + direction: Axis.horizontal, + children: [ + ResizableChild( + size: ResizableSize.pixels(40, min: 20), + divider: ResizableDivider(thickness: 2), + child: SizedBox.expand(key: Key('A')), + ), + ResizableChild( + size: ResizableSize.pixels(50, min: 20), + divider: ResizableDivider(thickness: 2), + child: SizedBox.expand(key: Key('B')), + ), + ResizableChild( + size: ResizableSize.pixels(60, min: 20), + divider: ResizableDivider(thickness: 2), + child: SizedBox.expand(key: Key('C')), + ), + ResizableChild( + size: ResizableSize.pixels(50, max: 60), + child: SizedBox.expand(key: Key('D')), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // 40 + 2 + 50 + 2 + 60 + 2 + 50 = 206. Sanity check. + expect(_widthOf(tester, const Key('A')), 40); + expect(_widthOf(tester, const Key('B')), 50); + expect(_widthOf(tester, const Key('C')), 60); + expect(_widthOf(tester, const Key('D')), 50); + + // Drag the divider between C and D (the third one) far to the + // left. Naive cascading would free up to 40 + 30 + 20 = 90 from C, + // B, A — pushing D past its max of 60. The clamp at 9f06c32 must + // hold through the live path: D stops at 60. + final handle = find.byType(ResizableContainerDivider).at(2); + await tester.drag( + handle, + const Offset(-(kDragSlopDefault + 100), 0), + ); + await tester.pump(); + + expect(_widthOf(tester, const Key('D')), lessThanOrEqualTo(60)); + expect(_widthOf(tester, const Key('D')), 60); + // Container width is preserved (children + 3 dividers = 206). + final total = _widthOf(tester, const Key('A')) + + _widthOf(tester, const Key('B')) + + _widthOf(tester, const Key('C')) + + _widthOf(tester, const Key('D')); + expect(total, 200); + }, + ); + + testWidgets( + 'live render path itself does not re-clamp: violating pixels are ' + 'rendered as-is (defense-in-depth lives in the controller)', + (tester) async { + // Documents the architectural split: the controller is the sole + // enforcer of min/max during drag. If a caller feeds the layout a + // hand-built `ValueListenable` whose values violate the declared + // `ResizableSize` constraints, the live path will render them + // verbatim — there is no second clamp inside the render object. + // + // This test exists so a future change that adds (or removes) + // re-clamping in `_performLiveLayout` flips a meaningful signal. + final pixels = ValueNotifier>(const [50, 350]); + addTearDown(pixels.dispose); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 402, + height: 100, + child: ResizableLayout( + direction: Axis.horizontal, + onComplete: (_) {}, + sizes: const [ + // Declared min 100 / max 200 — both deliberately violated + // by the live pixel list (50, 350). + ResizableSize.pixels(120, min: 100, max: 200), + ResizableSize.pixels(280, min: 100, max: 200), + ], + resizableChildren: const [ + ResizableChild( + size: ResizableSize.pixels(120, min: 100, max: 200), + divider: ResizableDivider(thickness: 2), + child: SizedBox(key: Key('A')), + ), + ResizableChild( + size: ResizableSize.pixels(280, min: 100, max: 200), + child: SizedBox(key: Key('B')), + ), + ], + livePixels: pixels, + children: const [ + SizedBox(key: Key('A')), + ResizableContainerDivider.placeholder( + config: ResizableDivider(thickness: 2), + direction: Axis.horizontal, + ), + SizedBox(key: Key('B')), + ], + ), + ), + ), + ); + + // The render object renders the supplied (constraint-violating) + // values exactly. If this ever fails because A == 100 / B == 200, + // someone added re-clamping in the live path — update the test + // and the architectural note above. + expect(_widthOf(tester, const Key('A')), 50); + expect(_widthOf(tester, const Key('B')), 350); + }, + ); + }); }); } diff --git a/test/resizable_container_lifecycle_test.dart b/test/resizable_container_lifecycle_test.dart new file mode 100644 index 0000000..42760b9 --- /dev/null +++ b/test/resizable_container_lifecycle_test.dart @@ -0,0 +1,550 @@ +// Pins the lifecycle contract introduced by PR #134's listener refactor: +// +// * `ResizableController` now owns two `ValueNotifier`s +// (`_needsLayoutListenable`, `_pixelsListenable`) in addition to its +// inherited `ChangeNotifier`. All three are disposed in +// `ResizableController.dispose()` (in that order, before `super.dispose()`). +// * `ResizableContainer` subscribes via +// `Listenable.merge([controller, controller.needsLayoutListenable])`. +// * `_ResizableContainerState.didUpdateWidget` swaps the controller by +// detaching the listener from the old controller (and disposing it only if +// the container created it), then attaching to the new one. +// +// This suite covers the controller-swap path, late-subscriber delivery, +// dispose ordering, and (documents) the multi-container-sharing limitation. + +import 'package:flutter/material.dart'; +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'; + +/// A child that increments [buildCount] on every build. Wrapped by the +/// container in a `RepaintBoundary`, but the inner `Builder` still runs every +/// time the surrounding [ResizableContainer] rebuilds. +class _CountingChild extends StatelessWidget { + const _CountingChild({required this.label, required this.buildCount}); + + final String label; + final ValueNotifier buildCount; + + @override + Widget build(BuildContext context) { + return Builder( + builder: (context) { + buildCount.value++; + return SizedBox.expand(key: Key(label)); + }, + ); + } +} + +Widget _harness({ + required ResizableController? controller, + Axis direction = Axis.horizontal, + List? children, +}) { + return MaterialApp( + home: Scaffold( + body: ResizableContainer( + controller: controller, + direction: direction, + children: children ?? + const [ + ResizableChild( + size: ResizableSize.ratio(0.5), + child: SizedBox.expand(key: Key('A')), + ), + ResizableChild( + size: ResizableSize.ratio(0.5), + child: SizedBox.expand(key: Key('B')), + ), + ], + ), + ), + ); +} + +List _countingChildren(ValueNotifier buildCount) { + return [ + ResizableChild( + size: const ResizableSize.ratio(0.5), + child: _CountingChild(label: 'A', buildCount: buildCount), + ), + ResizableChild( + size: const ResizableSize.ratio(0.5), + child: _CountingChild(label: 'B', buildCount: buildCount), + ), + ]; +} + +void main() { + group('controller swap via didUpdateWidget', () { + testWidgets( + "B's needsLayoutListenable drives the first layout after swap " + '(true → false, exactly 2 fires)', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controllerA = ResizableController(); + addTearDown(controllerA.dispose); + final controllerB = ResizableController(); + addTearDown(controllerB.dispose); + + await tester.pumpWidget(_harness(controller: controllerA)); + await tester.pumpAndSettle(); + + // Attach BEFORE the swap so the listener observes both transitions: + // `_initChildren` flips needsLayout to true (1 fire), then the post- + // frame `_setRenderedSizes` flips it back to false (2 fires). + var bNeedsLayoutFires = 0; + controllerB.needsLayoutListenable.addListener( + () => bNeedsLayoutFires++, + ); + + await tester.pumpWidget(_harness(controller: controllerB)); + await tester.pumpAndSettle(); + + expect(bNeedsLayoutFires, 2); + expect(controllerB.needsLayoutListenable.value, isFalse); + }, + ); + + testWidgets( + "A's listeners are detached from the container after swap — mutating A " + 'does not trigger ResizableChild rebuilds', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controllerA = ResizableController(); + addTearDown(controllerA.dispose); + final controllerB = ResizableController(); + addTearDown(controllerB.dispose); + + final buildCount = ValueNotifier(0); + addTearDown(buildCount.dispose); + + await tester.pumpWidget( + _harness( + controller: controllerA, + children: _countingChildren(buildCount), + ), + ); + await tester.pumpAndSettle(); + + // Swap A → B with the same children list (B will get a fresh + // _initChildren). The buildCount carries over because the children + // refer to the same ValueNotifier. + await tester.pumpWidget( + _harness( + controller: controllerB, + children: _countingChildren(buildCount), + ), + ); + await tester.pumpAndSettle(); + + final buildsAfterSwap = buildCount.value; + + // Mutating A after the swap must not flow into the container. + controllerA.setChildren(const [ + ResizableChild( + size: ResizableSize.ratio(0.3), + child: SizedBox.expand(), + ), + ResizableChild( + size: ResizableSize.ratio(0.7), + child: SizedBox.expand(), + ), + ]); + await tester.pumpAndSettle(); + + expect(buildCount.value, buildsAfterSwap); + }, + ); + + testWidgets( + "B's listeners are attached after swap — mutating B with hide() " + 'changes the rendered child layout (proving the merge listener fired)', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controllerA = ResizableController(); + addTearDown(controllerA.dispose); + final controllerB = ResizableController(); + addTearDown(controllerB.dispose); + + await tester.pumpWidget(_harness(controller: controllerA)); + await tester.pumpAndSettle(); + + await tester.pumpWidget(_harness(controller: controllerB)); + await tester.pumpAndSettle(); + + // Sanity check: child A is rendered at ~half the surface width. + final preHideWidth = tester.getSize(find.byKey(const Key('A'))).width; + expect(preHideWidth, greaterThan(0)); + + // Mutate B — hide child 0. If B's listeners weren't attached, the + // container would not rebuild and A's rendered size would be + // unchanged. The hide path notifies via the merge tuple. + controllerB.hide(0); + await tester.pumpAndSettle(); + + final postHideWidth = tester.getSize(find.byKey(const Key('A'))).width; + expect(postHideWidth, 0); + expect(controllerB.hiddenIndices, contains(0)); + }, + ); + + testWidgets( + 'consumer-provided controller is NOT disposed when the container ' + 'rebuilds with a new controller', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controllerA = ResizableController(); + addTearDown(controllerA.dispose); + final controllerB = ResizableController(); + addTearDown(controllerB.dispose); + + await tester.pumpWidget(_harness(controller: controllerA)); + await tester.pumpAndSettle(); + + await tester.pumpWidget(_harness(controller: controllerB)); + await tester.pumpAndSettle(); + + // `ChangeNotifier.addListener` asserts-not-disposed in debug. A + // successful add proves A is still alive after the swap. + expect(() => controllerA.addListener(() {}), returnsNormally); + }, + ); + + testWidgets( + 'swapping consumer controller → null creates a fresh default and ' + 'leaves the consumer controller alive', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controllerA = ResizableController(); + addTearDown(controllerA.dispose); + + await tester.pumpWidget(_harness(controller: controllerA)); + await tester.pumpAndSettle(); + + await tester.pumpWidget(_harness(controller: null)); + await tester.pumpAndSettle(); + + // A is still owned by the test — must not have been disposed. + expect(() => controllerA.addListener(() {}), returnsNormally); + }, + ); + }); + + group('late subscribers', () { + testWidgets( + 'a listener added on `controller` AFTER first layout fires on a ' + 'structural update', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + var fires = 0; + controller.addListener(() => fires++); + + controller.setSizes(const [ + ResizableSize.ratio(0.3), + ResizableSize.ratio(0.7), + ]); + await tester.pumpAndSettle(); + + expect(fires, 1); + }, + ); + + testWidgets( + 'a listener added on `pixelsListenable` AFTER first layout fires on ' + 'a drag', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + var fires = 0; + controller.pixelsListenable.addListener(() => fires++); + + await tester.timedDrag( + find.byType(ResizableContainerDivider), + const Offset(120, 0), + const Duration(milliseconds: 200), + ); + await tester.pump(); + + expect(fires, greaterThan(0)); + }, + ); + + testWidgets( + 'a listener added on `needsLayoutListenable` AFTER first layout ' + 'observes the true→false cycle around setChildren', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + var fires = 0; + controller.needsLayoutListenable.addListener(() => fires++); + + controller.setChildren(const [ + ResizableChild( + size: ResizableSize.ratio(0.4), + child: SizedBox.expand(), + ), + ResizableChild( + size: ResizableSize.ratio(0.6), + child: SizedBox.expand(), + ), + ]); + await tester.pumpAndSettle(); + + expect(fires, 2); + expect(controller.needsLayoutListenable.value, isFalse); + }, + ); + + testWidgets( + 'add → fire → remove → fire — listener is called exactly once', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + var fires = 0; + void listener() => fires++; + controller.addListener(listener); + + controller.setSizes(const [ + ResizableSize.ratio(0.3), + ResizableSize.ratio(0.7), + ]); + await tester.pumpAndSettle(); + + expect(fires, 1); + + controller.removeListener(listener); + + controller.setSizes(const [ + ResizableSize.ratio(0.6), + ResizableSize.ratio(0.4), + ]); + await tester.pumpAndSettle(); + + // Still 1 — the listener was removed before the second notify. + expect(fires, 1); + }, + ); + }); + + group('dispose ordering', () { + test( + 'standalone controller with listeners on all three listenables ' + 'disposes cleanly', + () { + final controller = ResizableController(); + + controller.addListener(() {}); + controller.pixelsListenable.addListener(() {}); + controller.needsLayoutListenable.addListener(() {}); + + expect(controller.dispose, returnsNormally); + }, + ); + + testWidgets( + 'consumer pattern — unmount container, then dispose controller, ' + 'no exceptions', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controller = ResizableController(); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + // Unmount the container. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + + expect(controller.dispose, returnsNormally); + expect(tester.takeException(), isNull); + }, + ); + + test( + 'addListener / notifyListeners after dispose assert in debug; ' + 'removeListener and the ValueNotifier `.value` getters tolerate it', + () { + final controller = ResizableController(); + // Snapshot the listenables before dispose — both getters return the + // private field, which remains readable after dispose. + final pixelsListenable = controller.pixelsListenable; + final needsLayoutListenable = controller.needsLayoutListenable; + controller.dispose(); + + // ChangeNotifier asserts not-disposed in debug. + expect( + () => controller.addListener(() {}), + throwsA(isA()), + ); + + // removeListener is documented as safe after dispose. + expect(() => controller.removeListener(() {}), returnsNormally); + + // The ValueNotifier `.value` getter just reads the backing field; + // it does not assert-not-disposed. + expect(() => pixelsListenable.value, returnsNormally); + expect(() => needsLayoutListenable.value, returnsNormally); + }, + ); + + testWidgets( + 'negative — consumer disposes controller while container is still ' + 'mounted; the crash surfaces when the container unmounts and ' + "tries to remove its listener from the disposed controller", + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 600)); + final controller = ResizableController(); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + // Consumer error: dispose while still mounted. + controller.dispose(); + + // No notify will ever fire (controller is dead), so the mounted + // container does not observe the dispose. Unmount triggers the + // container's own dispose path, which calls + // `controller.removeListener` — documented as safe — and is a no-op + // here. The animation coordinator disposes independently. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + + // Pin the actual observed behavior: no exception surfaces. If a + // future refactor makes the container call into the disposed + // controller during teardown, `takeException` will catch it and + // this assertion will need updating. + expect(tester.takeException(), isNull); + }, + ); + }); + + group( + 'multi-container sharing (NOT supported)', + () { + // The controller has no single-use guard, but `_initChildren` (called + // from each container's `initState`) **resets `_pixels`, clears + // `_hiddenIndices`, clears `_savedSizes`**. The second mount clobbers + // the first container's layout state. This group pins that behavior + // so a future refactor that introduces a guard (or fixes the + // interference) updates this expectation. + + testWidgets( + 'mounting two containers with the same controller — the second ' + "container's initState calls `_initChildren`, which clears " + "`_hiddenIndices` set by interaction with the first", + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 1200)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + // First mount: a single container, hide child 0 to seed state. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResizableContainer( + controller: controller, + direction: Axis.horizontal, + children: const [ + ResizableChild( + size: ResizableSize.ratio(0.5), + child: SizedBox.expand(), + ), + ResizableChild( + size: ResizableSize.ratio(0.5), + child: SizedBox.expand(), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + controller.hide(0); + await tester.pumpAndSettle(); + expect(controller.hiddenIndices, contains(0)); + + // Now mount BOTH containers simultaneously — one horizontal, one + // vertical — so the second one's `initState` runs and its + // `manager.initChildren` resets the controller's `_hiddenIndices`. + // (The widget shape changed: Scaffold body became a Column, so the + // first container's Element is rebuilt fresh too, but the key + // observation is the same controller fed into two containers.) + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + Expanded( + child: ResizableContainer( + controller: controller, + direction: Axis.horizontal, + children: const [ + ResizableChild( + size: ResizableSize.ratio(0.5), + child: SizedBox.expand(), + ), + ResizableChild( + size: ResizableSize.ratio(0.5), + child: SizedBox.expand(), + ), + ], + ), + ), + Expanded( + child: ResizableContainer( + controller: controller, + direction: Axis.horizontal, + children: const [ + ResizableChild( + size: ResizableSize.ratio(0.5), + child: SizedBox.expand(), + ), + ResizableChild( + size: ResizableSize.ratio(0.5), + child: SizedBox.expand(), + ), + ], + ), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // The hidden state seeded against the first single container has + // been wiped by at least one of the two `_initChildren` calls run + // by the new containers' `initState`. Multi-container sharing is + // therefore not supported by the current controller design. + expect(controller.hiddenIndices, isEmpty); + }, + ); + }, + ); +} diff --git a/test/resizable_hide_animation_contract_test.dart b/test/resizable_hide_animation_contract_test.dart new file mode 100644 index 0000000..6e7c931 --- /dev/null +++ b/test/resizable_hide_animation_contract_test.dart @@ -0,0 +1,393 @@ +// Pins the listener-and-build-path contract for `ResizableContainer.hideAnimation` +// (b102ae9 + the dual-path refactor in PR #134). +// +// Key finding — the animation lives in the widget, not the controller: +// +// * `ResizableController.hide(index)` / `show(index)` are synchronous calls +// that flip the hidden set, set `needsLayout = true`, and fire the main +// listener exactly once. They do NOT animate. +// * `HideAnimationCoordinator` (owned by `_ResizableContainerState`) drives +// the cross-frame tween via `AnimationController` + `setState`. The +// controller is untouched during the animation. +// +// Consequently, the listener cadence across an animated hide/show is NOT +// "once per animated frame" on `pixelsListenable`: +// +// * `mainNotifies` fires exactly 1× per hide/show call, regardless of +// whether the animation is configured. +// * `pixelsListenable` fires at structural boundaries only: +// - once when `hide()`/`show()` republishes pixels (the "capturing" +// pre-flight); +// - once from `_captureTarget`'s post-frame `setRenderedSizes` (publishes +// the measured target and flips `needsLayout` → false); +// - once after the animation completes when idle re-enters `_buildLayout` +// and the post-frame `_scheduleSetRenderedSizes` fires. +// So the realistic upper bound is ~3 pixel notifies for one animated +// hide(), NOT one per frame. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +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'; + +const _animationDuration = Duration(milliseconds: 300); +const _hideAnimation = ResizableHideAnimation(duration: _animationDuration); + +Widget _harness({ + required ResizableController controller, + Axis direction = Axis.horizontal, + List? children, + ResizableHideAnimation? hideAnimation = _hideAnimation, +}) { + return MaterialApp( + home: Scaffold( + body: ResizableContainer( + controller: controller, + direction: direction, + hideAnimation: hideAnimation, + children: children ?? + const [ + ResizableChild( + size: ResizableSize.ratio(0.34), + child: SizedBox.expand(key: Key('A')), + ), + ResizableChild( + size: ResizableSize.ratio(0.33), + child: SizedBox.expand(key: Key('B')), + ), + ResizableChild( + size: ResizableSize.ratio(0.33), + child: SizedBox.expand(key: Key('C')), + ), + ], + ), + ), + ); +} + +/// Pumps frames at a fixed cadence across at least [total]. Returns the +/// number of frames pumped. +Future _pumpAcross( + WidgetTester tester, + Duration total, { + Duration frame = const Duration(milliseconds: 16), +}) async { + var elapsed = Duration.zero; + var frames = 0; + while (elapsed < total) { + await tester.pump(frame); + elapsed += frame; + frames++; + } + return frames; +} + +void main() { + group('hide() with hideAnimation configured', () { + testWidgets( + 'mainNotifies fires exactly once across the full animation ' + '(the initial hide call; not per animated frame)', + (tester) async { + await tester.binding.setSurfaceSize(const Size(900, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + var mainNotifies = 0; + controller.addListener(() => mainNotifies++); + + controller.hide(0); + // Span the full animation duration with frame-cadence pumps. + await _pumpAcross(tester, _animationDuration * 1.5); + await tester.pumpAndSettle(); + + expect(mainNotifies, 1); + }, + ); + + testWidgets( + 'pixelsListenable fires a small bounded number of times across the ' + 'animation (NOT once-per-frame — the animation drives setState in the ' + 'widget, not pixel writes on the controller)', + (tester) async { + await tester.binding.setSurfaceSize(const Size(900, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + var pixelsNotifies = 0; + controller.pixelsListenable.addListener(() => pixelsNotifies++); + + controller.hide(0); + await _pumpAcross(tester, _animationDuration * 1.5); + await tester.pumpAndSettle(); + + // Observed cadence: ~3 fires total + // 1. hide() republishes pixels (synchronous); + // 2. _captureTarget's post-frame setRenderedSizes; + // 3. post-animation _scheduleSetRenderedSizes after idle re-entry. + // Pin a generous upper bound that still rules out per-frame regression + // (300ms / 16ms ≈ 18 frames; per-frame regression would land ≥18). + expect( + pixelsNotifies, + lessThanOrEqualTo(6), + reason: 'per-frame pixel writes would push this far higher', + ); + // Sanity: at least one structural pixel publish occurred. + expect(pixelsNotifies, greaterThanOrEqualTo(1)); + }, + ); + + testWidgets( + 'final state after hide completes: needsLayout=false, hidden child ' + 'pixel is 0, hiddenIndices contains the index', + (tester) async { + await tester.binding.setSurfaceSize(const Size(900, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + controller.hide(0); + await _pumpAcross(tester, _animationDuration * 1.5); + await tester.pumpAndSettle(); + + expect(controller.needsLayoutListenable.value, isFalse); + expect(controller.isHidden(0), isTrue); + expect(controller.pixels[0], 0); + }, + ); + + testWidgets( + 'drag on a non-adjacent divider during a hide animation: animation ' + 'completes, mainNotifies still 1, no exceptions thrown', + (tester) async { + await tester.binding.setSurfaceSize(const Size(900, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + var mainNotifies = 0; + controller.addListener(() => mainNotifies++); + + controller.hide(0); + // Give the capture phase a couple of frames to settle into animating. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + + // Drag the divider between B (index 1) and C (index 2). With child 0 + // hidden, divider 0 is hidden too, so divider 1 is the only + // interactive one and is found via `find.byType(...).at(0)` — but + // during animation the layout renders via _flexFromFullSizes which + // builds dividers as ResizableContainerDivider as well. We allow the + // drag to land on whichever divider is hit-testable. + final dividers = find.byType(ResizableContainerDivider); + if (dividers.evaluate().isNotEmpty) { + await tester.timedDrag( + dividers.first, + const Offset(40, 0), + const Duration(milliseconds: 60), + ); + } + + await _pumpAcross(tester, _animationDuration * 1.5); + await tester.pumpAndSettle(); + + // The hide animation should still complete to its terminal state. + expect(controller.isHidden(0), isTrue); + expect(controller.pixels[0], 0); + // A drag does NOT fire the main listener (drag flows through + // pixelsListenable only). So mainNotifies stays at 1. + expect(mainNotifies, 1); + }, + ); + }); + + group('show() with hideAnimation configured (symmetric)', () { + testWidgets( + 'mainNotifies fires exactly once across the full show animation', + (tester) async { + await tester.binding.setSurfaceSize(const Size(900, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + controller.hide(0); + await _pumpAcross(tester, _animationDuration * 1.5); + await tester.pumpAndSettle(); + + var mainNotifies = 0; + controller.addListener(() => mainNotifies++); + + controller.show(0); + await _pumpAcross(tester, _animationDuration * 1.5); + await tester.pumpAndSettle(); + + expect(mainNotifies, 1); + }, + ); + + testWidgets( + 'pixelsListenable stays bounded across a show animation (same cadence ' + 'as hide — not per-frame)', + (tester) async { + await tester.binding.setSurfaceSize(const Size(900, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + controller.hide(0); + await _pumpAcross(tester, _animationDuration * 1.5); + await tester.pumpAndSettle(); + + var pixelsNotifies = 0; + controller.pixelsListenable.addListener(() => pixelsNotifies++); + + controller.show(0); + await _pumpAcross(tester, _animationDuration * 1.5); + await tester.pumpAndSettle(); + + expect(pixelsNotifies, lessThanOrEqualTo(6)); + expect(pixelsNotifies, greaterThanOrEqualTo(1)); + }, + ); + + testWidgets( + 'final state after show completes: child is no longer hidden, pixel > 0', + (tester) async { + await tester.binding.setSurfaceSize(const Size(900, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + controller.hide(0); + await _pumpAcross(tester, _animationDuration * 1.5); + await tester.pumpAndSettle(); + + controller.show(0); + await _pumpAcross(tester, _animationDuration * 1.5); + await tester.pumpAndSettle(); + + expect(controller.isHidden(0), isFalse); + expect(controller.needsLayoutListenable.value, isFalse); + expect(controller.pixels[0], greaterThan(0)); + }, + ); + }); + + group('sequential hide/show/hide during one animation', () { + testWidgets( + 'three back-to-back calls: mainNotifies = 3 (one per call), no ' + 'exceptions, final state matches the last call (hidden)', + (tester) async { + // Pins the reversal-mid-flight behavior the coordinator handles via + // `beginCapture`. We do not assert anything about the exact tween + // values — only that the controller bookkeeping is correct and the + // final state is consistent with the last call. + await tester.binding.setSurfaceSize(const Size(900, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + var mainNotifies = 0; + controller.addListener(() => mainNotifies++); + + controller.hide(0); + await tester.pump(const Duration(milliseconds: 50)); + controller.show(0); + await tester.pump(const Duration(milliseconds: 50)); + controller.hide(0); + await _pumpAcross(tester, _animationDuration * 2); + await tester.pumpAndSettle(); + + expect(mainNotifies, 3); + expect(controller.isHidden(0), isTrue); + expect(controller.pixels[0], 0); + expect(controller.needsLayoutListenable.value, isFalse); + expect(tester.takeException(), isNull); + }, + ); + }); + + group('build-path coherence across animation phases', () { + testWidgets( + 'needsLayoutListenable settles to false after a hide animation ' + 'completes (the live-path is restored once idle re-enters)', + (tester) async { + await tester.binding.setSurfaceSize(const Size(900, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_harness(controller: controller)); + await tester.pumpAndSettle(); + + final needsLayoutValues = []; + controller.needsLayoutListenable.addListener(() { + needsLayoutValues.add(controller.needsLayoutListenable.value); + }); + + controller.hide(0); + await _pumpAcross(tester, _animationDuration * 1.5); + await tester.pumpAndSettle(); + + // The listener must have seen at least one transition and settled at + // false. Exact count is implementation-specific (capture pass + post- + // animation re-entry both write needsLayout); pin the terminal state. + expect(needsLayoutValues, isNotEmpty); + expect(needsLayoutValues.last, isFalse); + expect(controller.needsLayoutListenable.value, isFalse); + }, + ); + + testWidgets( + 'when hideAnimation is null, hide() snaps in a single frame and the ' + 'observable listener cadence matches the existing contract test ' + '(this is the control case)', + (tester) async { + await tester.binding.setSurfaceSize(const Size(900, 600)); + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _harness(controller: controller, hideAnimation: null), + ); + await tester.pumpAndSettle(); + + var mainNotifies = 0; + var pixelsNotifies = 0; + controller.addListener(() => mainNotifies++); + controller.pixelsListenable.addListener(() => pixelsNotifies++); + + controller.hide(0); + await tester.pumpAndSettle(); + + expect(mainNotifies, 1); + // Without an animation, the cadence is still bounded (hide publishes + // pixels once + post-frame setRenderedSizes publishes once). + expect(pixelsNotifies, lessThanOrEqualTo(4)); + expect(controller.isHidden(0), isTrue); + expect(controller.pixels[0], 0); + // Sanity that the snap path leaves needsLayout settled. + expect(controller.needsLayoutListenable.value, isFalse); + // Verify no listEquals dependency: the test purely counts and reads. + debugDefaultTargetPlatformOverride = null; + }, + ); + }); +} From b9b0c5b711107670d985389324358692a8a81888 Mon Sep 17 00:00:00 2001 From: Andy Horn Date: Wed, 27 May 2026 21:30:04 -0700 Subject: [PATCH 6/6] fix: honor divider lock state on the live build path The perf live path built dividers without the enabled flag, so locked dividers (resizable: false or ResizableDivider.enabled: false) still responded to drag, tap, and hover. Pass enabled through to match the hide-animation path. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/resizable_container.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/resizable_container.dart b/lib/src/resizable_container.dart index d5291b3..b761268 100644 --- a/lib/src/resizable_container.dart +++ b/lib/src/resizable_container.dart @@ -285,9 +285,11 @@ class _ResizableContainerState extends State if (_isDividerHidden(dividerIndex, controller.hiddenIndices)) { return const SizedBox.shrink(); } + final config = widget.children[dividerIndex].divider; return ResizableContainerDivider( - config: widget.children[dividerIndex].divider, + config: config, direction: widget.direction, + enabled: widget.resizable && config.enabled, onResizeUpdate: (delta) => manager.adjustChildSize( index: dividerIndex, delta: delta,