Created
October 29, 2025 09:35
-
-
Save ltOgt/28ec8e3683adc06445df8ba1b0a5e4a6 to your computer and use it in GitHub Desktop.
Sticky sliver overlay
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
| import 'package:flutter/rendering.dart'; | |
| import 'package:flutter/widgets.dart'; | |
| import 'dart:math' as math; | |
| /// A sliver that displays a sticky overlay (like a sidebar) | |
| /// whose top/bottom edges stick to the visible content bounds | |
| /// and can be pinned to the left or right edge of the viewport. | |
| class SliverStickyOverlay extends RenderObjectWidget { | |
| const SliverStickyOverlay({ | |
| super.key, | |
| required this.overlay, | |
| required this.content, | |
| this.onRightEdge = true, | |
| }); | |
| /// The overlay widget (e.g. sidebar) | |
| final Widget overlay; | |
| /// The main scrollable sliver content | |
| final Widget content; | |
| /// Whether to pin the overlay to the right edge of the viewport. | |
| /// If false, the overlay sticks to the left edge. | |
| final bool onRightEdge; | |
| @override | |
| RenderSliverStickyOverlay createRenderObject(BuildContext context) => | |
| RenderSliverStickyOverlay(onRightEdge: onRightEdge); | |
| @override | |
| void updateRenderObject(BuildContext context, RenderSliverStickyOverlay renderObject) { | |
| renderObject.onRightEdge = onRightEdge; | |
| } | |
| @override | |
| _SliverStickyOverlayElement createElement() => _SliverStickyOverlayElement(this); | |
| } | |
| class _SliverStickyOverlayElement extends RenderObjectElement { | |
| _SliverStickyOverlayElement(SliverStickyOverlay super.widget); | |
| Element? _overlay; | |
| Element? _content; | |
| @override | |
| SliverStickyOverlay get widget => super.widget as SliverStickyOverlay; | |
| @override | |
| void mount(Element? parent, Object? newSlot) { | |
| super.mount(parent, newSlot); | |
| _overlay = updateChild(_overlay, widget.overlay, 0); | |
| _content = updateChild(_content, widget.content, 1); | |
| } | |
| @override | |
| void update(SliverStickyOverlay newWidget) { | |
| super.update(newWidget); | |
| _overlay = updateChild(_overlay, widget.overlay, 0); | |
| _content = updateChild(_content, widget.content, 1); | |
| } | |
| @override | |
| void insertRenderObjectChild(RenderObject child, int? slot) { | |
| final render = renderObject as RenderSliverStickyOverlay; | |
| if (slot == 0) render.overlay = child as RenderBox; | |
| if (slot == 1) render.content = child as RenderSliver; | |
| } | |
| @override | |
| void removeRenderObjectChild(RenderObject child, int? slot) { | |
| final render = renderObject as RenderSliverStickyOverlay; | |
| if (child == render.overlay) render.overlay = null; | |
| if (child == render.content) render.content = null; | |
| } | |
| @override | |
| void visitChildren(ElementVisitor visitor) { | |
| if (_overlay != null) visitor(_overlay!); | |
| if (_content != null) visitor(_content!); | |
| } | |
| @override | |
| void moveRenderObjectChild(RenderObject child, Object? oldSlot, Object? newSlot) { | |
| // NOOP | |
| } | |
| @override | |
| void forgetChild(Element child) { | |
| super.forgetChild(child); | |
| if (child == _overlay) _overlay = null; | |
| if (child == _content) _content = null; | |
| } | |
| @override | |
| void deactivate() { | |
| // Properly detach children during hot reload or removal | |
| if (_overlay != null) { | |
| updateChild(_overlay, null, 0); | |
| _overlay = null; | |
| } | |
| if (_content != null) { | |
| updateChild(_content, null, 1); | |
| _content = null; | |
| } | |
| super.deactivate(); | |
| } | |
| } | |
| class RenderSliverStickyOverlay extends RenderSliver with RenderSliverHelpers { | |
| RenderBox? _overlay; | |
| RenderSliver? _content; | |
| RenderSliverStickyOverlay({bool onRightEdge = true}) : _onRightEdge = onRightEdge; | |
| bool _onRightEdge; | |
| bool get onRightEdge => _onRightEdge; | |
| set onRightEdge(bool value) { | |
| if (_onRightEdge == value) return; | |
| _onRightEdge = value; | |
| markNeedsLayout(); | |
| } | |
| RenderBox? get overlay => _overlay; | |
| set overlay(RenderBox? value) { | |
| if (_overlay != null) dropChild(_overlay!); | |
| _overlay = value; | |
| if (_overlay != null) adoptChild(_overlay!); | |
| markNeedsLayout(); | |
| } | |
| RenderSliver? get content => _content; | |
| set content(RenderSliver? value) { | |
| if (_content != null) dropChild(_content!); | |
| _content = value; | |
| if (_content != null) adoptChild(_content!); | |
| markNeedsLayout(); | |
| } | |
| @override | |
| void setupParentData(RenderObject child) { | |
| if (child.parentData is! SliverPhysicalParentData) { | |
| child.parentData = SliverPhysicalParentData(); | |
| } | |
| } | |
| @override | |
| void visitChildren(RenderObjectVisitor visitor) { | |
| if (_overlay != null) visitor(_overlay!); | |
| if (_content != null) visitor(_content!); | |
| } | |
| @override | |
| void performLayout() { | |
| if (_content == null || _overlay == null) { | |
| geometry = SliverGeometry.zero; | |
| return; | |
| } | |
| // 1) layout content | |
| _content!.layout(constraints, parentUsesSize: true); | |
| final contentGeometry = _content!.geometry!; | |
| final contentExtent = contentGeometry.scrollExtent; | |
| // 2) sliver-local content bounds (your original logic) | |
| final contentTop = -constraints.scrollOffset; | |
| final contentBottom = contentTop + contentExtent; | |
| // 3) viewport bounds in *this sliver’s* paint space | |
| // top stays 0 (was already working); bottom is what's *actually left* for us to paint | |
| final viewportTop = 0.0; | |
| final viewportBottom = constraints.remainingPaintExtent; | |
| // 4) compute visible window | |
| final visibleTop = math.max(contentTop, viewportTop); | |
| final visibleBottom = math.min(contentBottom, viewportBottom); | |
| final visibleHeight = math.max(0.0, visibleBottom - visibleTop); | |
| // 5) layout overlay to match visible height | |
| final overlayConstraints = BoxConstraints( | |
| minHeight: visibleHeight, | |
| maxHeight: visibleHeight, | |
| minWidth: 0, | |
| maxWidth: double.infinity, | |
| ); | |
| _overlay!.layout(overlayConstraints, parentUsesSize: true); | |
| // 6) position + pass-through geometry | |
| final overlayParentData = _overlay!.parentData as SliverPhysicalParentData; | |
| final contentParentData = _content!.parentData as SliverPhysicalParentData; | |
| final dx = _onRightEdge ? constraints.crossAxisExtent - _overlay!.size.width : 0.0; | |
| overlayParentData.paintOffset = Offset(dx, visibleTop); | |
| contentParentData.paintOffset = Offset.zero; | |
| geometry = contentGeometry; | |
| } | |
| @override | |
| void paint(PaintingContext context, Offset offset) { | |
| if (!geometry!.visible) return; | |
| final contentParentData = _content!.parentData as SliverPhysicalParentData; | |
| final overlayParentData = _overlay!.parentData as SliverPhysicalParentData; | |
| context.paintChild(_content!, offset + contentParentData.paintOffset); | |
| context.paintChild(_overlay!, offset + overlayParentData.paintOffset); | |
| } | |
| @override | |
| void applyPaintTransform(RenderObject child, Matrix4 transform) { | |
| final parentData = child.parentData as SliverPhysicalParentData; | |
| parentData.applyPaintTransform(transform); | |
| } | |
| @override | |
| double childMainAxisPosition(RenderObject child) { | |
| if (child == _overlay) { | |
| // overlay is positioned by paintOffset, always within the visible viewport. | |
| // since hitTestBoxChild() already accounts for paintOffset, | |
| // we can safely return 0 here. | |
| return 0.0; | |
| } | |
| if (child == _content) { | |
| // content sliver starts at this sliver’s origin. | |
| return 0.0; | |
| } | |
| return 0.0; | |
| } | |
| @override | |
| bool hitTestChildren( | |
| SliverHitTestResult result, { | |
| required double mainAxisPosition, | |
| required double crossAxisPosition, | |
| }) { | |
| bool hit = false; | |
| // Overlay first (it's visually on top) | |
| if (_overlay != null) { | |
| final overlayParentData = _overlay!.parentData as SliverPhysicalParentData; | |
| // Convert sliver-space positions into overlay-local coordinates | |
| final Offset paintOffset = overlayParentData.paintOffset; | |
| final Offset localOffset = Offset( | |
| crossAxisPosition - paintOffset.dx, | |
| mainAxisPosition - paintOffset.dy, | |
| ); | |
| hit = | |
| _overlay!.hitTest( | |
| BoxHitTestResult.wrap(result), | |
| position: localOffset, | |
| ) || | |
| hit; | |
| } | |
| // Then content | |
| if (_content != null) { | |
| hit = | |
| _content!.hitTest( | |
| result, | |
| mainAxisPosition: mainAxisPosition, | |
| crossAxisPosition: crossAxisPosition, | |
| ) || | |
| hit; | |
| } | |
| return hit; | |
| } | |
| } |
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
| import 'package:flutter/material.dart'; | |
| import 'package:sticky_viewport/dest.dart'; // your SliverStickyOverlay implementation | |
| void main() { | |
| runApp(const MyApp()); | |
| } | |
| class MyApp extends StatelessWidget { | |
| const MyApp({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return MaterialApp( | |
| debugShowCheckedModeBanner: false, | |
| theme: ThemeData.dark(), | |
| home: const Scaffold(body: MyWidget()), | |
| ); | |
| } | |
| } | |
| class MyWidget extends StatefulWidget { | |
| const MyWidget({Key? key}) : super(key: key); | |
| @override | |
| State<MyWidget> createState() => _MyWidgetState(); | |
| } | |
| class _MyWidgetState extends State<MyWidget> { | |
| final List<int> _items = List.generate(10, (i) => i); | |
| void _addItem() { | |
| setState(() => _items.add(_items.length)); | |
| } | |
| void _removeItem(int index) { | |
| setState(() => _items.removeAt(index)); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return CustomScrollView( | |
| slivers: [ | |
| const SliverToBoxAdapter( | |
| child: Padding( | |
| padding: EdgeInsets.all(16), | |
| child: Text("Heyo title", style: TextStyle(fontSize: 24)), | |
| ), | |
| ), | |
| const SliverToBoxAdapter( | |
| child: Padding( | |
| padding: EdgeInsets.symmetric(horizontal: 16), | |
| child: Text("Heyo sub title"), | |
| ), | |
| ), | |
| // Sticky overlay with centered add button | |
| SliverStickyOverlay( | |
| overlay: Container( | |
| width: 60, | |
| color: Colors.blue, | |
| alignment: Alignment.center, | |
| child: Column( | |
| children: [ | |
| const Padding( | |
| padding: EdgeInsets.only(top: 8.0), | |
| child: Text('TOP', style: TextStyle(color: Colors.white)), | |
| ), | |
| const Spacer(), | |
| // Center add button | |
| FloatingActionButton.small( | |
| heroTag: "addBtn", | |
| onPressed: _addItem, | |
| backgroundColor: Colors.white, | |
| child: const Icon(Icons.add, color: Colors.blue), | |
| ), | |
| const Spacer(), | |
| const Padding( | |
| padding: EdgeInsets.only(bottom: 8.0), | |
| child: Text('BOT', style: TextStyle(color: Colors.white)), | |
| ), | |
| ], | |
| ), | |
| ), | |
| content: SliverList( | |
| delegate: SliverChildBuilderDelegate( | |
| (context, i) => Padding( | |
| padding: const EdgeInsets.only(right: 20.0), | |
| child: ListTile( | |
| title: Text('Item ${_items[i]}'), | |
| trailing: IconButton( | |
| icon: const Icon(Icons.remove_circle, color: Colors.red), | |
| onPressed: () => _removeItem(i), | |
| ), | |
| ), | |
| ), | |
| childCount: _items.length, | |
| ), | |
| ), | |
| ), | |
| const SliverToBoxAdapter( | |
| child: Padding( | |
| padding: EdgeInsets.all(16), | |
| child: Text("Heyo page over"), | |
| ), | |
| ), | |
| const SliverToBoxAdapter( | |
| child: Padding( | |
| padding: EdgeInsets.all(16), | |
| child: Text("Heyo fr do"), | |
| ), | |
| ), | |
| ], | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment