From b4504fc76a06e1ec48569982818e2734cc1a3231 Mon Sep 17 00:00:00 2001 From: Andy Horn Date: Tue, 26 May 2026 11:42:54 -0700 Subject: [PATCH 1/2] feat: lock individual dividers or the whole container (#73) Adds `ResizableDivider.enabled` for per-divider locking and `ResizableContainer.resizable` for container-wide locking. Both default to `true`. A divider is interactive only when both flags are `true`; when locked, drag/tap/hover handlers and the resize cursor are suppressed. Programmatic resizing via `ResizableController` is unaffected. Co-Authored-By: Claude Opus 4.7 --- README.md | 2 + lib/src/resizable_container.dart | 16 +++- lib/src/resizable_container_divider.dart | 22 ++++- lib/src/resizable_divider.dart | 13 +++ test/resizable_container_test.dart | 95 +++++++++++++++++++++ test/resizable_divider_test.dart | 100 +++++++++++++++++++++++ 6 files changed, 246 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cf7ad92..5eac3be 100644 --- a/README.md +++ b/README.md @@ -341,6 +341,8 @@ Use the `ResizableDivider` class to customize the look and feel of the dividers You can customize the `thickness`, `length`, `crossAxisAlignment`, `mainAxisAlignment`, and `color` of the divider, as well as display a custom mouse cursor on hover and respond to `onDragStart`, `onDragEnd`, `onHoverEnter`, `onHoverExit`, `onTapDown`, and `onTapUp` events. +Set `enabled: false` to lock a single divider so it cannot be dragged, tapped, or hovered. To lock every divider in a container at once, pass `resizable: false` to the `ResizableContainer`. A divider is interactive only when both flags are `true`; programmatic resizing through `ResizableController` is unaffected in either case. + ```dart divider: ResizableDivider( thickness: 2, diff --git a/lib/src/resizable_container.dart b/lib/src/resizable_container.dart index 58b448e..d57989c 100644 --- a/lib/src/resizable_container.dart +++ b/lib/src/resizable_container.dart @@ -24,6 +24,7 @@ class ResizableContainer extends StatefulWidget { this.controller, this.cascadeNegativeDelta = false, this.hideAnimation, + this.resizable = true, }); /// A list of [ResizableChild] containing the child [Widget]s and @@ -51,6 +52,17 @@ class ResizableContainer extends StatefulWidget { /// [ResizableController.setSizes], available-space changes) remain instant. final ResizableHideAnimation? hideAnimation; + /// Whether dividers in this container respond to user input. + /// + /// When `false`, every divider is locked — drag, tap, and hover callbacks + /// will not fire and the resize cursor is not shown. Individual dividers + /// can also be locked via [ResizableDivider.enabled]; a divider is + /// interactive only when both this flag and its own `enabled` flag are + /// `true`. Programmatic resizing via [ResizableController] is unaffected. + /// + /// Defaults to `true`. + final bool resizable; + @override State createState() => _ResizableContainerState(); } @@ -382,9 +394,11 @@ class _ResizableContainerState extends State if (size == 0) { return const SizedBox.shrink(); } + final config = widget.children[dividerIndex].divider; final divider = ResizableContainerDivider( - config: widget.children[dividerIndex].divider, + config: config, direction: widget.direction, + enabled: widget.resizable && config.enabled, onResizeUpdate: (delta) => manager.adjustChildSize( index: dividerIndex, delta: delta, diff --git a/lib/src/resizable_container_divider.dart b/lib/src/resizable_container_divider.dart index 4257b47..c1c63a6 100644 --- a/lib/src/resizable_container_divider.dart +++ b/lib/src/resizable_container_divider.dart @@ -13,18 +13,25 @@ class ResizableContainerDivider extends StatefulWidget { required this.direction, required this.config, required void Function(double) this.onResizeUpdate, + this.enabled = true, }); const ResizableContainerDivider.placeholder({ super.key, required this.config, required this.direction, - }) : onResizeUpdate = null; + }) : onResizeUpdate = null, + enabled = true; final Axis direction; final void Function(double)? onResizeUpdate; final ResizableDivider config; + /// Whether this divider responds to drag, tap, and hover input. When + /// `false`, gesture handlers and the resize cursor are suppressed; the + /// divider remains visible. + final bool enabled; + @override State createState() => _ResizableContainerDividerState(); @@ -86,6 +93,9 @@ class _ResizableContainerDividerState extends State { } MouseCursor _getCursor() { + if (!widget.enabled) { + return widget.config.cursor ?? MouseCursor.defer; + } return switch (widget.direction) { Axis.horizontal => widget.config.cursor ?? SystemMouseCursors.resizeLeftRight, @@ -118,11 +128,13 @@ class _ResizableContainerDividerState extends State { } void _onEnter(PointerEnterEvent _) { + if (!widget.enabled) return; setState(() => isHovered = true); widget.config.onHoverEnter?.call(); } void _onExit(PointerExitEvent _) { + if (!widget.enabled) return; setState(() => isHovered = false); if (!isDragging) { @@ -131,6 +143,7 @@ class _ResizableContainerDividerState extends State { } void _onVerticalDragStart(DragStartDetails _) { + if (!widget.enabled) return; if (widget.direction == Axis.vertical) { setState(() => isDragging = true); widget.config.onDragStart?.call(); @@ -138,12 +151,14 @@ class _ResizableContainerDividerState extends State { } void _onVerticalDragUpdate(DragUpdateDetails details) { + if (!widget.enabled) return; if (widget.direction == Axis.vertical) { widget.onResizeUpdate?.call(details.delta.dy); } } void _onVerticalDragEnd(DragEndDetails _) { + if (!widget.enabled) return; if (widget.direction == Axis.vertical) { setState(() => isDragging = false); widget.config.onDragEnd?.call(); @@ -155,6 +170,7 @@ class _ResizableContainerDividerState extends State { } void _onHorizontalDragStart(DragStartDetails _) { + if (!widget.enabled) return; if (widget.direction == Axis.horizontal) { setState(() => isDragging = true); widget.config.onDragStart?.call(); @@ -165,6 +181,7 @@ class _ResizableContainerDividerState extends State { TextDirection textDirection, ) { return (details) { + if (!widget.enabled) return; if (widget.direction == Axis.horizontal) { final delta = details.delta.dx; @@ -177,6 +194,7 @@ class _ResizableContainerDividerState extends State { } void _onHorizontalDragEnd(DragEndDetails _) { + if (!widget.enabled) return; if (widget.direction == Axis.horizontal) { setState(() => isDragging = false); widget.config.onDragEnd?.call(); @@ -188,10 +206,12 @@ class _ResizableContainerDividerState extends State { } void _onTapDown(TapDownDetails _) { + if (!widget.enabled) return; widget.config.onTapDown?.call(); } void _onTapUp(TapUpDetails _) { + if (!widget.enabled) return; widget.config.onTapUp?.call(); } } diff --git a/lib/src/resizable_divider.dart b/lib/src/resizable_divider.dart index a5cf16d..be1c51d 100644 --- a/lib/src/resizable_divider.dart +++ b/lib/src/resizable_divider.dart @@ -17,6 +17,7 @@ class ResizableDivider extends Equatable { this.cursor, this.mainAxisAlignment = MainAxisAlignment.center, this.crossAxisAlignment = CrossAxisAlignment.center, + this.enabled = true, }) : assert(thickness > 0, '[thickness] must be > 0.'), assert( length is! ResizableSizeShrink, @@ -84,6 +85,17 @@ class ResizableDivider extends Equatable { /// The cursor to display when hovering over this divider. final MouseCursor? cursor; + /// Whether this divider is interactive. + /// + /// When `false`, the divider is rendered but cannot be dragged, tapped, or + /// hovered — its drag, tap, and hover callbacks will not fire and the + /// resize cursor is not shown. Programmatic resizing via + /// [ResizableController] is unaffected. + /// + /// Defaults to `true`. See also [ResizableContainer.resizable], which + /// disables every divider in the container at once. + final bool enabled; + @override List get props => [ thickness, @@ -97,5 +109,6 @@ class ResizableDivider extends Equatable { cursor, mainAxisAlignment, crossAxisAlignment, + enabled, ]; } diff --git a/test/resizable_container_test.dart b/test/resizable_container_test.dart index e303d70..3e039b2 100644 --- a/test/resizable_container_test.dart +++ b/test/resizable_container_test.dart @@ -2161,6 +2161,101 @@ void main() { expect(notifies, 2); }); }); + + group('resizable', () { + testWidgets( + 'locks every divider when false', + (tester) async { + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.binding.setSurfaceSize(const Size(600, 100)); + addTearDown(() async => await tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResizableContainer( + controller: controller, + direction: Axis.horizontal, + resizable: false, + children: const [ + ResizableChild( + size: ResizableSize.ratio(0.33), + child: SizedBox.expand(), + ), + ResizableChild( + size: ResizableSize.ratio(0.33), + child: SizedBox.expand(), + ), + ResizableChild( + size: ResizableSize.ratio(0.34), + child: SizedBox.expand(), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final beforeSizes = List.of(controller.pixels); + final dividers = find.byType(ResizableContainerDivider); + expect(dividers, findsNWidgets(2)); + + await tester.drag(dividers.first, const Offset(80, 0)); + await tester.pumpAndSettle(); + await tester.drag(dividers.last, const Offset(-80, 0)); + await tester.pumpAndSettle(); + + expect(controller.pixels, beforeSizes); + }, + ); + + testWidgets( + 'programmatic resize still works when false', + (tester) async { + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.binding.setSurfaceSize(const Size(600, 100)); + addTearDown(() async => await tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResizableContainer( + controller: controller, + direction: Axis.horizontal, + resizable: false, + children: const [ + ResizableChild( + size: ResizableSize.ratio(0.5), + child: SizedBox.expand(), + ), + ResizableChild( + size: ResizableSize.ratio(0.5), + child: SizedBox.expand(), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + controller.setSizes(const [ + ResizableSize.ratio(0.25), + ResizableSize.ratio(0.75), + ]); + await tester.pumpAndSettle(); + + const available = 600 - 1; + expect(controller.pixels[0], closeTo(available * 0.25, 0.001)); + expect(controller.pixels[1], closeTo(available * 0.75, 0.001)); + }, + ); + }); }); } diff --git a/test/resizable_divider_test.dart b/test/resizable_divider_test.dart index 1b98aa8..042ece4 100644 --- a/test/resizable_divider_test.dart +++ b/test/resizable_divider_test.dart @@ -254,6 +254,106 @@ void main() { }); }); + group('enabled', () { + testWidgets('suppresses drag-driven resizing when false', (tester) async { + final controller = ResizableController(); + addTearDown(controller.dispose); + + await tester.binding.setSurfaceSize(const Size(400, 100)); + addTearDown(() async => await tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResizableContainer( + controller: controller, + direction: Axis.horizontal, + children: const [ + ResizableChild( + size: ResizableSize.ratio(0.5), + divider: ResizableDivider(enabled: false), + child: SizedBox.expand(), + ), + ResizableChild( + size: ResizableSize.ratio(0.5), + child: SizedBox.expand(), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final beforeSizes = List.of(controller.pixels); + final divider = find.byType(ResizableContainerDivider); + + await tester.drag(divider, const Offset(50, 0)); + await tester.pumpAndSettle(); + + expect(controller.pixels, beforeSizes); + }); + + testWidgets('suppresses tap, hover, and drag callbacks when false', + (tester) async { + var hoverEnter = false; + var hoverExit = false; + var tappedDown = false; + var tappedUp = false; + var dragStarted = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResizableContainer( + controller: ResizableController(), + direction: Axis.horizontal, + children: [ + ResizableChild( + divider: ResizableDivider( + enabled: false, + onHoverEnter: () => hoverEnter = true, + onHoverExit: () => hoverExit = true, + onTapDown: () => tappedDown = true, + onTapUp: () => tappedUp = true, + onDragStart: () => dragStarted = true, + ), + child: const SizedBox.expand(), + ), + const ResizableChild(child: SizedBox.expand()), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final divider = find.byType(ResizableContainerDivider); + + final gesture = + await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(() => gesture.removePointer()); + await tester.pump(); + await gesture.moveTo(tester.getCenter(divider)); + await tester.pumpAndSettle(); + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + + await tester.tap(divider, kind: PointerDeviceKind.touch); + await tester.pumpAndSettle(); + + await tester.drag(divider, const Offset(40, 0)); + await tester.pumpAndSettle(); + + expect(hoverEnter, isFalse); + expect(hoverExit, isFalse); + expect(tappedDown, isFalse); + expect(tappedUp, isFalse); + expect(dragStarted, isFalse); + }); + }); + group('onTapUp', () { testWidgets('fires when the divider tap is released', (tester) async { bool tappedUp = false; From ce92f5d512b4a6763c3de0ae43455f669eb49029 Mon Sep 17 00:00:00 2001 From: Andy Horn Date: Wed, 27 May 2026 06:50:01 -0700 Subject: [PATCH 2/2] feat(example): add lock toggle to basic example Co-Authored-By: Claude Opus 4.7 --- .../lib/screens/basic/basic_example_screen.dart | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/example/lib/screens/basic/basic_example_screen.dart b/example/lib/screens/basic/basic_example_screen.dart index dd22be6..73b801e 100644 --- a/example/lib/screens/basic/basic_example_screen.dart +++ b/example/lib/screens/basic/basic_example_screen.dart @@ -6,15 +6,27 @@ import 'package:example/widgets/size_label.dart'; import 'package:flutter/material.dart'; import 'package:flutter_resizable_container/flutter_resizable_container.dart'; -class BasicExampleScreen extends StatelessWidget { +class BasicExampleScreen extends StatefulWidget { const BasicExampleScreen({super.key}); + @override + State createState() => _BasicExampleScreenState(); +} + +class _BasicExampleScreenState extends State { + bool _locked = false; + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Basic two-pane example'), actions: [ + IconButton( + icon: Icon(_locked ? Icons.lock : Icons.lock_open), + tooltip: _locked ? 'Unlock divider' : 'Lock divider', + onPressed: () => setState(() => _locked = !_locked), + ), IconButton( icon: const Icon(Icons.help_center), onPressed: () => BasicExampleHelpDialog.show(context: context), @@ -31,6 +43,7 @@ class BasicExampleScreen extends StatelessWidget { drawer: const NavDrawer(), body: ResizableContainer( direction: Axis.horizontal, + resizable: !_locked, children: [ ResizableChild( child: ColoredBox(