Skip to content

Instantly share code, notes, and snippets.

@ltOgt
Created October 29, 2025 09:35
Show Gist options
  • Select an option

  • Save ltOgt/28ec8e3683adc06445df8ba1b0a5e4a6 to your computer and use it in GitHub Desktop.

Select an option

Save ltOgt/28ec8e3683adc06445df8ba1b0a5e4a6 to your computer and use it in GitHub Desktop.
Sticky sliver overlay
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;
}
}
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