resizable_splitter 1.1.0
resizable_splitter: ^1.1.0 copied to clipboard
Accessible splitter widget for Flutter layouts that need drag-to-resize panels.
Resizable Splitter #
Flutter widget for building drag-to-resize layouts that feel native on every platform. Resizable Splitter focuses on fluid pointer gestures, keyboard accessibility, and easy customization.
Live Demo #
Test it in the browser: resizable-splitter demo
Features #
- Global pointer routing keeps drags alive even when platform views (WebView, Maps, video) try to steal focus; enable/disable with
overlayEnabledandblockerColor. - Built-in snapping via
snapPoints+snapTolerance, so handles land exactly on your breakpoints. - First-class keyboard support (Arrow/Page/Home/End) with semantics describing the current ratio, next/previous values, and how to interact.
- Flexible layout constraints:
minRatio, asymmetricminStartPanelSize/minEndPanelSize, and a safe defaultminPanelSizefallback. - Theme once, reuse everywhere via
ResizableSplitterThemeor theResizableSplitterThemeOverridesThemeExtension. - Opt-in policies for unbounded layouts (
UnboundedBehavior+fallbackMainAxisExtent), anti-aliasing, and cramped minima (CrampedBehavior).
Installation #
Add to your pubspec.yaml:
dependencies:
resizable_splitter: ^1.1.0
Then fetch packages:
flutter pub get
Quick Start #
import 'package:flutter/material.dart';
import 'package:resizable_splitter/resizable_splitter.dart';
class DemoPage extends StatelessWidget {
const DemoPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: ResizableSplitter(
axis: Axis.horizontal,
startPanel: const Center(child: Text('Navigation')),
endPanel: const Center(child: Text('Content')),
dividerThickness: 8,
onRatioChanged: (ratio) => debugPrint('ratio: $ratio'),
),
);
}
}
Advanced Example #
final controller = SplitterController(initialRatio: 0.6);
ResizableSplitter(
axis: Axis.horizontal,
controller: controller,
dividerThickness: 6,
minStartPanelSize: 180,
minEndPanelSize: 120,
snapPoints: const [0.25, 0.5, 0.75],
snapTolerance: 0.03,
overlayEnabled: true,
blockerColor: Colors.black.withOpacity(0.05),
handleBuilder: (context, details) => Center(
child: Container(
width: details.axis == Axis.horizontal ? 2 : 24,
height: details.axis == Axis.horizontal ? 24 : 2,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.onSurface.withAlpha(90),
borderRadius: BorderRadius.circular(1),
),
),
),
onRatioChanged: (ratio) => debugPrint('ratio=$ratio'),
startPanel: const YourMainPane(),
endPanel: const YourSidebar(),
);
API Quick Reference #
SplitterController #
final controller = SplitterController(initialRatio: 0.6); // start 60/40
controller.isDraggingListenable.addListener(() {
if (controller.isDragging) {
debugPrint('user started dragging');
}
});
controller.updateRatio(0.4); // clamp to 0-1 with a noise threshold
controller.reset(); // jump back to 0.5
await controller.animateTo(
0.8,
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
frames: 12,
); // simple animation without a ticker
ResizableSplitter options #
ResizableSplitter(
startPanel: const NavigationPane(), // required left/top child
endPanel: const ContentPane(), // required right/bottom child
controller: controller, // reuse to persist ratios
axis: Axis.horizontal, // Axis.vertical for top/bottom split
initialRatio: 0.5, // used only when controller is null
minRatio: 0.1, // clamp lower bound (0-1)
maxRatio: 0.9, // clamp upper bound (0-1)
minPanelSize: 120, // default pixel minimum for both panels
minStartPanelSize: 180, // specific pixel minimum for start pane
minEndPanelSize: 140, // specific pixel minimum for end pane
dividerThickness: 6, // drag handle width/height in px
dividerColor: Colors.grey, // idle divider color
dividerHoverColor: Colors.grey.shade700, // pointer hover color
dividerActiveColor: Colors.blue, // active drag color
onRatioChanged: (ratio) => save(ratio), // fires on every update
onDragStart: (ratio) => pauseWork(), // first pointer down
onDragEnd: (ratio) => resumeWork(), // pointer up (after snapping)
enableKeyboard: true, // arrow/page/home/end shortcuts
keyboardStep: 0.02, // arrow key delta (2%)
pageStep: 0.15, // page key delta (15%)
semanticsLabel: 'Resize panels', // screen-reader label
blockerColor: Colors.black12, // overlay tint during drag
overlayEnabled: true, // shield platform views
unboundedBehavior: UnboundedBehavior.flexExpand, // LimitedBox fallback via .limitedBox
fallbackMainAxisExtent: 420, // used when unboundedBehavior == limitedBox
antiAliasingWorkaround: false, // floor start panel to whole pixels
crampedBehavior: CrampedBehavior.favorStart, // pick who keeps their minimum first
snapPoints: const [0.25, 0.5, 0.75], // optional ratio targets
snapTolerance: 0.03, // how close before snapping
resizable: true, // disable to render a static divider
onHandleTap: () => logTap(), // tap without dragging
onHandleDoubleTap: () => logDoubleTap(), // fires before optional reset
doubleTapResetTo: 0.5, // animate back to mid on double tap
handleBuilder: (context, details) {
final color = details.isDragging ? Colors.blue : Colors.grey;
return Center(
child: Container(
width: details.axis == Axis.horizontal ? 2 : details.thickness - 2,
height: details.axis == Axis.horizontal ? details.thickness - 2 : 2,
color: color,
),
);
},
);
SplitterHandleDetails #
handleBuilder: (context, details) {
return DecoratedBox(
decoration: BoxDecoration(
color: details.isHovering ? Colors.white24 : Colors.white10,
borderRadius: BorderRadius.circular(details.thickness / 3),
),
child: SizedBox.expand(
child: Icon(
details.axis == Axis.horizontal ? Icons.drag_indicator : Icons.more_vert,
color: details.isDragging ? Colors.blue : Colors.white54,
),
),
);
};
Callbacks receive the live ratio so you can store it, pause work, or react to snapping. Keyboard shortcuts honor enableKeyboard, keyboardStep, and pageStep, and semantics read out semanticsLabel plus the percentage.
Theming #
Wrap a subtree with ResizableSplitterTheme when you want bespoke styling:
ResizableSplitterTheme(
data: const ResizableSplitterThemeData(
dividerThickness: 8,
dividerHoverColor: Colors.indigoAccent,
overlayEnabled: false,
unboundedBehavior: UnboundedBehavior.limitedBox,
fallbackMainAxisExtent: 360,
),
child: const ResizableSplitter(
startPanel: NavPane(),
endPanel: ContentPane(),
),
);
For app-wide overrides hook into Material theming via the provided ThemeExtension:
final theme = ThemeData.light().copyWith(
extensions: const [
ResizableSplitterThemeOverrides(
keyboardStep: 0.2,
pageStep: 0.4,
handleHitSlop: 8,
overlayEnabled: false,
),
],
);
return MaterialApp(theme: theme, home: const SplitterShowcase());
Precedence: explicit widget parameters → ResizableSplitterTheme → ThemeData.extension<ResizableSplitterThemeOverrides>() → derived Material defaults.
Unbounded constraints #
If your splitter lives inside an unbounded constraint (e.g. UnconstrainedBox), opt into the LimitedBox fallback:
ResizableSplitterTheme(
data: const ResizableSplitterThemeData(
unboundedBehavior: UnboundedBehavior.limitedBox,
fallbackMainAxisExtent: 420,
),
child: const ResizableSplitter(
startPanel: LeftPane(),
endPanel: RightPane(),
),
);
The legacy flexExpand behavior is still the default so existing layouts keep working.
Example App #
An end-to-end demo lives under example/. It showcases persistence, snapping, asymmetric minimums, and custom handles. Run it locally:
cd example
flutter run
Testing #
Widget and controller tests live under test/. Run them all:
flutter test
Core scenarios include controller thresholds, drag snapping, keyboard shortcuts, layout constraints, and semantics coverage.
License #
Resizable Splitter is available under the MIT License.