Skip to content

Instantly share code, notes, and snippets.

@PlugFox
Last active March 13, 2026 14:00
Show Gist options
  • Select an option

  • Save PlugFox/53636071587beeff1527f1ff48e5a969 to your computer and use it in GitHub Desktop.

Select an option

Save PlugFox/53636071587beeff1527f1ff48e5a969 to your computer and use it in GitHub Desktop.
SliverLayout
/*
* SliverLayout
* https://gist.github.com/PlugFox/53636071587beeff1527f1ff48e5a969
* https://dartpad.dev?id=53636071587beeff1527f1ff48e5a969
* Mike Matiunin <plugfox@gmail.com>, 13 March 2026
*/
// ignore_for_file: library_private_types_in_public_api
import 'dart:math' as math;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
void main() => runApp(const App());
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'SliverLayout',
home: Scaffold(
appBar: AppBar(title: const Text('SliverLayout Demo')),
body: SafeArea(
child: SliverLayout(
padding: 16,
header: Material(
type: .canvas,
color: Colors.blue.shade100,
child: const Padding(
padding: .all(16),
child: Column(
mainAxisSize: .min,
mainAxisAlignment: .start,
crossAxisAlignment: .stretch,
spacing: 8,
children: <Widget>[
Text('Header (pinned)', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
Text('This header stays pinned at the top.'),
],
),
),
),
body: Material(
type: .canvas,
color: Colors.orange.shade50,
child: const Padding(
padding: .all(16),
child: Column(
mainAxisSize: .min,
mainAxisAlignment: .start,
crossAxisAlignment: .stretch,
spacing: 8,
children: <Widget>[
Text('Body', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
Text(
'This is the body content.\n\n'
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '
'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. '
'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.\n\n'
'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum '
'dolore eu fugiat nulla pariatur.',
),
],
),
),
),
footer: Material(
type: .canvas,
color: Colors.green.shade100,
child: const Padding(
padding: .all(16),
child: Column(
mainAxisSize: .min,
mainAxisAlignment: .start,
crossAxisAlignment: .stretch,
spacing: 8,
children: <Widget>[
Text('Footer', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
Text('This footer scrolls with body, or sticks to the bottom.'),
],
),
),
),
),
),
),
);
}
// ---------------------------------------------------------------------------
// Widget
// ---------------------------------------------------------------------------
/// {@template sliver_layout}
/// A custom sliver layout widget that arranges a header, body, and footer.
/// {@endtemplate}
class SliverLayout extends SlottedMultiChildRenderObjectWidget<_SliverLayoutSlot, RenderBox> {
/// Creates a [SliverLayout] with the given header, body, and footer widgets.
///
/// The [header] is pinned at the top and never scrolls, while the [body] is
/// scrollable content between the header and footer. The [footer] sits at the very
/// bottom when space allows, and scrolls with the body otherwise. The [padding]
/// defines the vertical spacing between the body and footer.
///
/// {@macro sliver_layout}
const SliverLayout({required this.header, required this.body, required this.footer, this.padding = 8.0, super.key});
/// Pinned at the top — never scrolls.
final Widget header;
/// Scrollable content between header and footer.
final Widget body;
/// Sits at the very bottom when space allows, scrolls with body otherwise.
final Widget footer;
/// Vertical spacing between body and footer.
final double padding;
@override
Iterable<_SliverLayoutSlot> get slots => _SliverLayoutSlot.values;
@override
Widget? childForSlot(_SliverLayoutSlot slot) => switch (slot) {
_SliverLayoutSlot.header => header,
_SliverLayoutSlot.body => body,
_SliverLayoutSlot.footer => footer,
};
@override
_RenderSliverLayout createRenderObject(BuildContext context) => _RenderSliverLayout(padding: padding);
@override
void updateRenderObject(BuildContext context, covariant _RenderSliverLayout renderObject) {
renderObject.padding = padding;
}
}
enum _SliverLayoutSlot { header, body, footer }
// ---------------------------------------------------------------------------
// ParentData
// ---------------------------------------------------------------------------
class _SliverParentData extends BoxParentData {}
// ---------------------------------------------------------------------------
// RenderBox
// ---------------------------------------------------------------------------
class _RenderSliverLayout extends RenderBox with SlottedContainerRenderObjectMixin<_SliverLayoutSlot, RenderBox> {
_RenderSliverLayout({required double padding}) : _padding = padding;
// -- Configuration --------------------------------------------------------
double _padding;
double get padding => _padding;
set padding(double value) {
if (_padding == value) return;
_padding = value;
markNeedsLayout();
}
// -- Scroll state ---------------------------------------------------------
double _scrollOffset = 0;
double _maxScrollOffset = 0;
bool _isScrollable = false;
// -- Gesture recognizer ---------------------------------------------------
VerticalDragGestureRecognizer? _dragRecognizer;
// -- Child accessors ------------------------------------------------------
RenderBox? get _header => childForSlot(_SliverLayoutSlot.header);
RenderBox? get _body => childForSlot(_SliverLayoutSlot.body);
RenderBox? get _footer => childForSlot(_SliverLayoutSlot.footer);
// -- Parent data ----------------------------------------------------------
@override
void setupParentData(RenderBox child) {
if (child.parentData is! _SliverParentData) {
child.parentData = _SliverParentData();
}
}
// -- Lifecycle ------------------------------------------------------------
@override
void attach(PipelineOwner owner) {
super.attach(owner);
_dragRecognizer = .new(debugOwner: this)..onUpdate = _onDragUpdate;
}
@override
void detach() {
_dragRecognizer?.dispose();
_dragRecognizer = null;
super.detach();
}
// -- Intrinsics -----------------------------------------------------------
@override
double computeMinIntrinsicWidth(double height) {
final hw = _header?.getMinIntrinsicWidth(double.infinity) ?? 0;
final bw = _body?.getMinIntrinsicWidth(double.infinity) ?? 0;
final fw = _footer?.getMinIntrinsicWidth(double.infinity) ?? 0;
return math.max(hw, math.max(bw, fw));
}
@override
double computeMaxIntrinsicWidth(double height) {
final hw = _header?.getMaxIntrinsicWidth(double.infinity) ?? 0;
final bw = _body?.getMaxIntrinsicWidth(double.infinity) ?? 0;
final fw = _footer?.getMaxIntrinsicWidth(double.infinity) ?? 0;
return math.max(hw, math.max(bw, fw));
}
@override
double computeMinIntrinsicHeight(double width) {
final hh = _header?.getMinIntrinsicHeight(width) ?? 0;
final bh = _body?.getMinIntrinsicHeight(width) ?? 0;
final fh = _footer?.getMinIntrinsicHeight(width) ?? 0;
return hh + bh + _padding + fh;
}
@override
double computeMaxIntrinsicHeight(double width) {
final hh = _header?.getMaxIntrinsicHeight(width) ?? 0;
final bh = _body?.getMaxIntrinsicHeight(width) ?? 0;
final fh = _footer?.getMaxIntrinsicHeight(width) ?? 0;
return hh + bh + _padding + fh;
}
// -- Layout ---------------------------------------------------------------
//
// Available area for body+footer = maxHeight - headerHeight - padding (top).
//
// scrollableContent = bodyHeight + padding + footerHeight
//
// If scrollableContent <= availableHeight:
// body right below header, footer pinned to bottom. (space-between)
// Else:
// body+footer scroll within the area below the pinned header.
@override
void performLayout() {
final headerChild = _header;
final bodyChild = _body;
final footerChild = _footer;
final maxWidth = constraints.maxWidth;
final maxHeight = constraints.maxHeight;
final childConstraints = BoxConstraints(minWidth: maxWidth, maxWidth: maxWidth);
// 1. Layout header — always present at top.
var headerHeight = .0;
if (headerChild != null) {
headerChild.layout(childConstraints, parentUsesSize: true);
headerHeight = headerChild.size.height;
}
// 2. Layout body & footer with unbounded height.
var bodyHeight = .0;
if (bodyChild != null) {
bodyChild.layout(childConstraints, parentUsesSize: true);
bodyHeight = bodyChild.size.height;
}
var footerHeight = .0;
if (footerChild != null) {
footerChild.layout(childConstraints, parentUsesSize: true);
footerHeight = footerChild.size.height;
}
// Area below the pinned header.
final scrollAreaTop = headerHeight;
final scrollableContent = bodyHeight + _padding + footerHeight;
if (!constraints.hasBoundedHeight) {
// ---- Unbounded: just stack everything ----
_isScrollable = false;
_scrollOffset = 0;
_maxScrollOffset = 0;
if (headerChild != null) {
_pd(headerChild).offset = Offset.zero;
}
if (bodyChild != null) {
_pd(bodyChild).offset = Offset(0, scrollAreaTop);
}
if (footerChild != null) {
_pd(footerChild).offset = Offset(0, scrollAreaTop + bodyHeight + _padding);
}
final totalHeight = scrollAreaTop + scrollableContent;
size = constraints.constrain(Size(maxWidth, totalHeight));
return;
}
final availableForScroll = maxHeight - scrollAreaTop;
// Header is always pinned at y=0.
if (headerChild != null) {
_pd(headerChild).offset = Offset.zero;
}
if (scrollableContent <= availableForScroll) {
// ---- Fits: body under header, footer at very bottom ----
_isScrollable = false;
_scrollOffset = 0;
_maxScrollOffset = 0;
if (bodyChild != null) {
_pd(bodyChild).offset = Offset(0, scrollAreaTop);
}
if (footerChild != null) {
_pd(footerChild).offset = Offset(0, maxHeight - footerHeight);
}
} else {
// ---- Scroll: body+footer scroll under pinned header ----
_isScrollable = true;
_maxScrollOffset = scrollableContent - availableForScroll;
_scrollOffset = _scrollOffset.clamp(0.0, _maxScrollOffset);
if (bodyChild != null) {
_pd(bodyChild).offset = Offset(0, scrollAreaTop - _scrollOffset);
}
if (footerChild != null) {
_pd(footerChild).offset = Offset(0, scrollAreaTop + bodyHeight + _padding - _scrollOffset);
}
}
size = Size(maxWidth, maxHeight);
}
static BoxParentData _pd(RenderBox child) => switch (child.parentData) {
// Cast parent data to the expected type.
// Should always succeed since we set it in setupParentData.
BoxParentData pd => pd,
// Should never happen since we set parent data in setupParentData.
_ => BoxParentData()..offset = Offset.zero,
};
// -- Paint ----------------------------------------------------------------
@override
void paint(PaintingContext context, Offset offset) {
final headerChild = _header;
final headerHeight = headerChild?.size.height ?? 0;
final clipTop = headerHeight;
if (_isScrollable) {
// Paint scrollable area (body+footer) clipped to the region below header.
context.pushClipRect(
needsCompositing,
offset,
.fromLTWH(0, clipTop, size.width, size.height - clipTop),
_paintScrollableChildren,
);
} else {
_paintScrollableChildren(context, offset);
}
// Paint header on top of everything (pinned).
if (headerChild != null) {
final pd = _pd(headerChild);
context.paintChild(headerChild, pd.offset + offset);
}
}
void _paintScrollableChildren(PaintingContext context, Offset offset) {
for (final slot in const <_SliverLayoutSlot>[.body, .footer]) {
final child = childForSlot(slot);
if (child != null) {
final pd = _pd(child);
context.paintChild(child, pd.offset + offset);
}
}
}
// -- Hit testing ----------------------------------------------------------
@override
bool hitTestSelf(Offset position) => _isScrollable;
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
// Test in reverse paint order: header (topmost) first, then footer, body.
for (final slot in [_SliverLayoutSlot.header, _SliverLayoutSlot.footer, _SliverLayoutSlot.body]) {
final child = childForSlot(slot);
if (child != null) {
final pd = _pd(child);
if (result.addWithPaintOffset(
offset: pd.offset,
position: position,
hitTest: (result, transformed) => child.hitTest(result, position: transformed),
)) {
return true;
}
}
}
return false;
}
// -- Event handling -------------------------------------------------------
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
if (!_isScrollable) return;
switch (event) {
case PointerScrollEvent():
final newOffset = (_scrollOffset + event.scrollDelta.dy).clamp(0.0, _maxScrollOffset);
if (newOffset != _scrollOffset) {
_scrollOffset = newOffset;
markNeedsLayout();
}
case PointerDownEvent():
_dragRecognizer?.addPointer(event);
}
}
void _onDragUpdate(DragUpdateDetails details) {
final newOffset = (_scrollOffset - details.delta.dy).clamp(0.0, _maxScrollOffset);
if (newOffset == _scrollOffset) return;
_scrollOffset = newOffset;
markNeedsLayout();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment