Last active
March 13, 2026 14:00
-
-
Save PlugFox/53636071587beeff1527f1ff48e5a969 to your computer and use it in GitHub Desktop.
SliverLayout
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * 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