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
overlayEnabled
andblockerColor
. - 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 defaultminPanelSize
fallback. - Theme once, reuse everywhere via
ResizableSplitterTheme
or theResizableSplitterThemeOverrides
ThemeExtension
. - 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.
Libraries
- resizable_splitter
- Resizable Splitter helps you build responsive, drag-to-resize layouts in Flutter.