Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 14 additions & 1 deletion example/lib/screens/basic/basic_example_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<BasicExampleScreen> createState() => _BasicExampleScreenState();
}

class _BasicExampleScreenState extends State<BasicExampleScreen> {
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),
Expand All @@ -31,6 +43,7 @@ class BasicExampleScreen extends StatelessWidget {
drawer: const NavDrawer(),
body: ResizableContainer(
direction: Axis.horizontal,
resizable: !_locked,
children: [
ResizableChild(
child: ColoredBox(
Expand Down
16 changes: 15 additions & 1 deletion lib/src/resizable_container.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ResizableContainer> createState() => _ResizableContainerState();
}
Expand Down Expand Up @@ -382,9 +394,11 @@ class _ResizableContainerState extends State<ResizableContainer>
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,
Expand Down
22 changes: 21 additions & 1 deletion lib/src/resizable_container_divider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResizableContainerDivider> createState() =>
_ResizableContainerDividerState();
Expand Down Expand Up @@ -86,6 +93,9 @@ class _ResizableContainerDividerState extends State<ResizableContainerDivider> {
}

MouseCursor _getCursor() {
if (!widget.enabled) {
return widget.config.cursor ?? MouseCursor.defer;
}
return switch (widget.direction) {
Axis.horizontal =>
widget.config.cursor ?? SystemMouseCursors.resizeLeftRight,
Expand Down Expand Up @@ -118,11 +128,13 @@ class _ResizableContainerDividerState extends State<ResizableContainerDivider> {
}

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) {
Expand All @@ -131,19 +143,22 @@ class _ResizableContainerDividerState extends State<ResizableContainerDivider> {
}

void _onVerticalDragStart(DragStartDetails _) {
if (!widget.enabled) return;
if (widget.direction == Axis.vertical) {
setState(() => isDragging = true);
widget.config.onDragStart?.call();
}
}

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();
Expand All @@ -155,6 +170,7 @@ class _ResizableContainerDividerState extends State<ResizableContainerDivider> {
}

void _onHorizontalDragStart(DragStartDetails _) {
if (!widget.enabled) return;
if (widget.direction == Axis.horizontal) {
setState(() => isDragging = true);
widget.config.onDragStart?.call();
Expand All @@ -165,6 +181,7 @@ class _ResizableContainerDividerState extends State<ResizableContainerDivider> {
TextDirection textDirection,
) {
return (details) {
if (!widget.enabled) return;
if (widget.direction == Axis.horizontal) {
final delta = details.delta.dx;

Expand All @@ -177,6 +194,7 @@ class _ResizableContainerDividerState extends State<ResizableContainerDivider> {
}

void _onHorizontalDragEnd(DragEndDetails _) {
if (!widget.enabled) return;
if (widget.direction == Axis.horizontal) {
setState(() => isDragging = false);
widget.config.onDragEnd?.call();
Expand All @@ -188,10 +206,12 @@ class _ResizableContainerDividerState extends State<ResizableContainerDivider> {
}

void _onTapDown(TapDownDetails _) {
if (!widget.enabled) return;
widget.config.onTapDown?.call();
}

void _onTapUp(TapUpDetails _) {
if (!widget.enabled) return;
widget.config.onTapUp?.call();
}
}
13 changes: 13 additions & 0 deletions lib/src/resizable_divider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Object?> get props => [
thickness,
Expand All @@ -97,5 +109,6 @@ class ResizableDivider extends Equatable {
cursor,
mainAxisAlignment,
crossAxisAlignment,
enabled,
];
}
95 changes: 95 additions & 0 deletions test/resizable_container_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<double>.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));
},
);
});
});
}

Expand Down
Loading
Loading