Last active
April 15, 2026 08:36
-
-
Save pskink/7d383f08d119d21603fad784256028c4 to your computer and use it in GitHub Desktop.
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 'dart:math'; | |
| import 'dart:ui'; | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/rendering.dart'; | |
| import 'package:collection/collection.dart'; | |
| // TL;DR see 'NOTE:' in RenderFooSliver.childMainAxisPosition | |
| // | |
| // To activate debugPaint (enabling the FooDebugPaintMixin visualization): | |
| // - In 'flutter run': press the 'p' key in the terminal. | |
| // - In VS Code: use the "Flutter: Toggle Paint Size" command from the Command Palette (Cmd/Ctrl+Shift+P). | |
| // - In Android Studio: click the "Toggle Paint Size" button in the Flutter Inspector tool window. | |
| // - Programmatically: set 'debugPaintSizeEnabled = true;' inside the main() function (requires 'package:flutter/rendering.dart'). | |
| // | |
| void main() { | |
| final app = MaterialApp( | |
| scrollBehavior: const MaterialScrollBehavior().copyWith( | |
| dragDevices: {PointerDeviceKind.mouse, PointerDeviceKind.touch}, | |
| ), | |
| home: Scaffold(body: FooPageSwitcher()), | |
| ); | |
| runApp(app); | |
| } | |
| class FooPageSwitcher extends StatefulWidget { | |
| @override | |
| State<FooPageSwitcher> createState() => _FooPageSwitcherState(); | |
| } | |
| class _FooPageSwitcherState extends State<FooPageSwitcher> { | |
| bool dynamicPage = false; | |
| @override | |
| Widget build(BuildContext context) { | |
| return Column( | |
| children: [ | |
| CheckboxListTile( | |
| tileColor: Colors.blueGrey.shade100, | |
| title: Text('use dynamic sliver'), | |
| subtitle: Text('warning: experimental stuff'), | |
| value: dynamicPage, | |
| onChanged: (v) { | |
| setState(() => dynamicPage = v ?? false); | |
| }, | |
| ), | |
| Expanded(child: dynamicPage ? FooPageDynamic() : FooPage()), | |
| ], | |
| ); | |
| } | |
| } | |
| class FooPage extends StatelessWidget { | |
| @override | |
| Widget build(BuildContext context) { | |
| final sliverData = [ | |
| ('Jan', 10, Colors.blueGrey), | |
| ('Feb', 6, Colors.pink), | |
| ('Mar', 10, Colors.purple), | |
| ('Apr', 5, Colors.blueGrey), | |
| ('May', 25, Colors.pink), | |
| ('Jun', 6, Colors.purple), | |
| ('Jul', 12, Colors.blueGrey), | |
| ('Aug', 5, Colors.pink), | |
| ('Sep', 5, Colors.purple), | |
| ('Oct', 7, Colors.blueGrey), | |
| ('Nov', 12, Colors.pink), | |
| ('Dec', 10, Colors.purple), | |
| ]; | |
| final textTheme = Theme.of(context).textTheme; | |
| final bodyStyle = textTheme.titleLarge; | |
| final decoratorStyle0 = textTheme.titleMedium?.copyWith( | |
| color: Colors.white, | |
| fontWeight: FontWeight.bold, | |
| ); | |
| final decoratorStyle1 = textTheme.titleSmall?.copyWith( | |
| color: Colors.white70, | |
| ); | |
| return Scaffold( | |
| body: CustomScrollView( | |
| slivers: [ | |
| SliverToBoxAdapter( | |
| child: Padding( | |
| padding: const EdgeInsets.all(8), | |
| child: Text('try to scroll the list and see how the settings button for each month is trying to stay visible until the end of the month section'), | |
| ), | |
| ), | |
| for (final (month, days, color) in sliverData) | |
| FooSliver( | |
| body: GestureDetector( | |
| onTap: () => print('body for $month'), | |
| child: Container( | |
| padding: EdgeInsets.only( | |
| top: 8, | |
| left: 32, | |
| bottom: 8, | |
| ), | |
| color: color.shade400, | |
| child: Column( | |
| crossAxisAlignment: CrossAxisAlignment.start, | |
| children: [ | |
| for (var i = 0; i < days; i++) | |
| Text('day #${i + 1}', style: bodyStyle), | |
| ], | |
| ), | |
| ), | |
| ), | |
| decorator: Container( | |
| margin: const EdgeInsets.all(6), | |
| padding: const EdgeInsets.all(6), | |
| decoration: BoxDecoration( | |
| color: color.shade500, | |
| borderRadius: BorderRadius.circular(6), | |
| boxShadow: [...?kElevationToShadow[2], ...?kElevationToShadow[3]], | |
| ), | |
| child: Column( | |
| children: [ | |
| Text(month, style: decoratorStyle0), | |
| Text('$days days', style: decoratorStyle1), | |
| IconButton.filled( | |
| onPressed: () => print('settings for $month'), | |
| icon: Icon(Icons.settings), | |
| style: IconButton.styleFrom( | |
| backgroundColor: color.shade800, | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ), | |
| SliverFillRemaining( | |
| child: Text('bottom filler sliver'), | |
| ), | |
| ], | |
| ), | |
| ); | |
| } | |
| } | |
| /// A mixin that provides custom debug painting for [RenderSliver] objects. | |
| /// | |
| /// When [debugPaintSizeEnabled] is true, this mixin draws a diamond-shaped | |
| /// outline to visualize the sliver's paint origin and extent. | |
| mixin FooDebugPaintMixin on RenderSliver { | |
| @override | |
| void debugPaint(PaintingContext context, Offset offset) { | |
| // super.debugPaint(context, offset); | |
| assert(() { | |
| if (debugPaintSizeEnabled) { | |
| final paintOrigin = geometry!.paintOrigin; | |
| final paintExtent = geometry!.paintExtent; | |
| final crossAxisExtent = geometry!.crossAxisExtent ?? constraints.crossAxisExtent; | |
| // print('$offset $paintOrigin $paintExtent $crossAxisExtent'); | |
| final paint = Paint() | |
| ..strokeWidth = 4.0 | |
| ..style = PaintingStyle.stroke; | |
| final paintOffset = offset.translate(0, paintOrigin); | |
| final paintSize = Size(crossAxisExtent, paintExtent); | |
| final layoutExtent = geometry!.layoutExtent; | |
| final layoutSize = Size(crossAxisExtent, layoutExtent); | |
| final cacheOffset = offset.translate(0, constraints.cacheOrigin); | |
| final cacheSize = Size(crossAxisExtent, geometry!.cacheExtent); | |
| final paintDataGrouped = [ | |
| (paintOffset & paintSize, const Color(0xFF33CC33)), | |
| (paintOffset & layoutSize, const Color(0xFF3333CC)), | |
| (cacheOffset & cacheSize, const Color(0xFFCC3333)), | |
| ].groupListsBy((r) => r.$1); | |
| final delta = offset.dy != 0? offset.dy : -constraints.scrollOffset; | |
| const pad = 4.0; | |
| var r = offset.translate(pad, pad) & Size.square(16); | |
| paintDataGrouped.forEach((rect, v) { | |
| final colors = v.map((r) => r.$2); | |
| switch (rect.isEmpty || rect.isInfinite || !rect.isFinite) { | |
| case false: | |
| final segments = _diamondSegments(rect, colors, delta); | |
| for (final (path, color) in segments) { | |
| context.canvas.drawPath(path, paint..color = color); | |
| } | |
| case true: | |
| for (final color in colors) { | |
| final paint = Paint()..color = color; | |
| switch ((rect.isEmpty, rect.isFinite, rect.isInfinite)) { | |
| case (true, true, _): | |
| paint | |
| ..style = PaintingStyle.stroke | |
| ..strokeWidth = 2; | |
| case (_, false, false): | |
| paint | |
| ..maskFilter = MaskFilter.blur(BlurStyle.normal, 2); | |
| default: | |
| } | |
| context.canvas.drawRect(r, paint); | |
| r = r.translate(r.width + pad, 0); | |
| } | |
| } | |
| }); | |
| } | |
| return true; | |
| }()); | |
| } | |
| Iterable<(Path, Color)> _diamondSegments(Rect rect, Iterable<Color> colors, double delta) sync* { | |
| final center = rect.center; | |
| final path = Path() | |
| ..moveTo(center.dx, rect.top) | |
| ..lineTo(rect.right, center.dy) | |
| ..lineTo(center.dx, rect.bottom) | |
| ..lineTo(rect.left, center.dy) | |
| ..close(); | |
| if (colors.length == 1) { | |
| yield (path, colors.first); | |
| } else { | |
| final metric = path.computeMetrics().first; | |
| final numSegments = 4 * colors.length; | |
| int c = 0; | |
| for(final color in colors) { | |
| for (int i = 0; i < 4; i++) { | |
| final s = (i * colors.length + c) * metric.length / numSegments; | |
| final e = (i * colors.length + (c + 1)) * metric.length / numSegments; | |
| final start = (s + delta) % metric.length; | |
| final end = (e + delta) % metric.length; | |
| if (start < end) { | |
| yield (metric.extractPath(start, end), color); | |
| } else { | |
| yield (metric.extractPath(start, metric.length), color); | |
| yield (metric.extractPath(0, end), color); | |
| } | |
| } | |
| c++; | |
| } | |
| } | |
| } | |
| } | |
| enum FooSlot { | |
| body, | |
| decorator, | |
| } | |
| class FooSliver extends SlottedMultiChildRenderObjectWidget<FooSlot, RenderBox> { | |
| const FooSliver({required this.body, required this.decorator, this.label}); | |
| final Widget body; | |
| final Widget decorator; | |
| final String? label; | |
| @override | |
| Widget? childForSlot(FooSlot slot) => switch (slot) { | |
| .body => body, | |
| .decorator => decorator, | |
| }; | |
| @override | |
| SlottedContainerRenderObjectMixin<FooSlot, RenderBox> createRenderObject(BuildContext context) { | |
| return RenderFooSliver(label); | |
| } | |
| @override | |
| Iterable<FooSlot> get slots => FooSlot.values; | |
| } | |
| class RenderFooSliver extends RenderSliver with SlottedContainerRenderObjectMixin<FooSlot, RenderBox>, RenderSliverHelpers, FooDebugPaintMixin { | |
| RenderFooSliver(this.label); | |
| final String? label; | |
| @override | |
| void performLayout() { | |
| final body = childForSlot(.body)!; | |
| final decorator = childForSlot(.decorator)!; | |
| final bc = constraints.asBoxConstraints(); | |
| body.layout(bc, parentUsesSize: true); | |
| decorator.layout(bc.loosen(), parentUsesSize: true); | |
| final childExtent = body.size.height; | |
| final offset = calculatePaintOffset(constraints, from: 0, to: childExtent); | |
| geometry = SliverGeometry( | |
| scrollExtent: childExtent, | |
| paintExtent: offset, | |
| maxPaintExtent: childExtent, | |
| layoutExtent: offset, | |
| hitTestExtent: offset, | |
| cacheExtent: calculateCacheOffset(constraints, from: 0, to: childExtent), | |
| hasVisualOverflow: childExtent > constraints.remainingPaintExtent || constraints.scrollOffset > 0.0, | |
| ); | |
| } | |
| @override | |
| void applyPaintTransform(covariant RenderObject child, Matrix4 transform) { | |
| if (child is RenderBox) { | |
| applyPaintTransformForBoxChild(child, transform); | |
| } | |
| } | |
| @override | |
| double childMainAxisPosition(covariant RenderObject child) { | |
| final decorator = childForSlot(.decorator)!; | |
| return child != decorator? | |
| -constraints.scrollOffset : | |
| // NOTE: this line makes the whole effect | |
| min(0, geometry!.maxPaintExtent - decorator.size.height - constraints.scrollOffset); | |
| } | |
| @override | |
| double childCrossAxisPosition(covariant RenderObject child) { | |
| final decorator = childForSlot(.decorator)!; | |
| return child != decorator? 0 : constraints.crossAxisExtent - decorator.size.width; | |
| } | |
| @override | |
| bool hitTest(SliverHitTestResult result, {required double mainAxisPosition, required double crossAxisPosition}) { | |
| for (final child in [childForSlot(.decorator)!, childForSlot(.body)!]) { | |
| final isHit = hitTestBoxChild( | |
| BoxHitTestResult.wrap(result), | |
| child, | |
| mainAxisPosition: mainAxisPosition, | |
| crossAxisPosition: crossAxisPosition, | |
| ); | |
| if (isHit) return true; | |
| } | |
| return false; | |
| } | |
| @override | |
| void paint(PaintingContext context, Offset offset) { | |
| for (final child in children) { | |
| final x = childCrossAxisPosition(child); | |
| final y = childMainAxisPosition(child); | |
| context.paintChild(child, offset.translate(x, y)); | |
| } | |
| } | |
| } | |
| // | |
| // experimental stuff with a decoratorBuilder that can rebuild itself | |
| // with the normalized scroll offset of the sliver | |
| // | |
| class FooPageDynamic extends StatelessWidget { | |
| @override | |
| Widget build(BuildContext context) { | |
| final sliverData = [ | |
| ('Jan', 10, Colors.blueGrey), | |
| ('Feb', 6, Colors.pink), | |
| ('Mar', 10, Colors.purple), | |
| ('Apr', 5, Colors.blueGrey), | |
| ('May', 25, Colors.pink), | |
| ('Jun', 6, Colors.purple), | |
| ('Jul', 12, Colors.blueGrey), | |
| ('Aug', 5, Colors.pink), | |
| ('Sep', 5, Colors.purple), | |
| ('Oct', 7, Colors.blueGrey), | |
| ('Nov', 12, Colors.pink), | |
| ('Dec', 10, Colors.purple), | |
| ]; | |
| final textTheme = Theme.of(context).textTheme; | |
| final bodyStyle = textTheme.titleLarge; | |
| final decoratorStyle0 = textTheme.titleMedium?.copyWith( | |
| color: Colors.white, | |
| fontWeight: FontWeight.bold, | |
| ); | |
| final decoratorStyle1 = textTheme.titleSmall?.copyWith( | |
| color: Colors.white70, | |
| ); | |
| return Scaffold( | |
| body: CustomScrollView( | |
| slivers: [ | |
| SliverToBoxAdapter( | |
| child: Padding( | |
| padding: const EdgeInsets.all(8), | |
| child: Text('now the top-right decorator is build while scrolling: notice how the progress indicator updates its value based on the scroll offset of the sliver'), | |
| ), | |
| ), | |
| for (final (month, days, color) in sliverData) | |
| FooSliverDynamic( | |
| body: GestureDetector( | |
| onTap: () => print('body for $month'), | |
| child: Container( | |
| padding: EdgeInsets.only( | |
| top: 8, | |
| left: 32, | |
| bottom: 8, | |
| ), | |
| color: color.shade400, | |
| child: Column( | |
| crossAxisAlignment: CrossAxisAlignment.start, | |
| children: [ | |
| for (var i = 0; i < days; i++) | |
| Text('day #${i + 1}', style: bodyStyle), | |
| ], | |
| ), | |
| ), | |
| ), | |
| decoratorBuilder: (context, normalizedScrollOffset) => Container( | |
| margin: const EdgeInsets.all(6), | |
| padding: const EdgeInsets.all(6), | |
| decoration: BoxDecoration( | |
| color: color.shade500, | |
| borderRadius: BorderRadius.circular(6), | |
| boxShadow: [...?kElevationToShadow[2], ...?kElevationToShadow[3]], | |
| ), | |
| child: IntrinsicWidth( | |
| child: Column( | |
| children: [ | |
| Text(month, style: decoratorStyle0), | |
| Text('$days days', style: decoratorStyle1), | |
| IconButton.filled( | |
| onPressed: () => print('settings for $month'), | |
| icon: Icon(Icons.settings), | |
| style: IconButton.styleFrom( | |
| backgroundColor: color.shade800, | |
| ), | |
| ), | |
| Padding( | |
| padding: const EdgeInsets.symmetric(vertical: 4), | |
| child: LinearProgressIndicator( | |
| value: normalizedScrollOffset, | |
| color: color.shade800, | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ), | |
| SliverFillRemaining( | |
| child: Text('bottom filler sliver'), | |
| ), | |
| ], | |
| ), | |
| ); | |
| } | |
| } | |
| class FooSliverDynamic extends SlottedMultiChildRenderObjectWidget<FooSlot, RenderBox> { | |
| const FooSliverDynamic({required this.body, required this.decoratorBuilder, this.label}); | |
| final Widget body; | |
| final Widget Function(BuildContext, double) decoratorBuilder; | |
| final String? label; | |
| @override | |
| Widget? childForSlot(FooSlot slot) => switch (slot) { | |
| .body => body, | |
| .decorator => UnconstrainedBox(), | |
| }; | |
| @override | |
| SlottedContainerRenderObjectMixin<FooSlot, RenderBox> createRenderObject(BuildContext context) { | |
| return RenderFooSliverDynamic(decoratorBuilder, label); | |
| } | |
| @override | |
| Iterable<FooSlot> get slots => FooSlot.values; | |
| @override | |
| SlottedRenderObjectElement<FooSlot, RenderBox> createElement() { | |
| return FooSliverDynamicElement(this); | |
| } | |
| } | |
| class RenderFooSliverDynamic extends RenderSliver with SlottedContainerRenderObjectMixin<FooSlot, RenderBox>, RenderSliverHelpers { | |
| RenderFooSliverDynamic(this.decoratorBuilder, this.label); | |
| final Widget Function(BuildContext, double) decoratorBuilder; | |
| final String? label; | |
| FooSliverDynamicElement? _element; | |
| @override | |
| void performLayout() { | |
| final bc = constraints.asBoxConstraints(); | |
| final body = childForSlot(.body)!; | |
| body.layout(bc, parentUsesSize: true); | |
| invokeLayoutCallback<SliverConstraints>((SliverConstraints constraints) { | |
| final normalizedScrollOffset = constraints.scrollOffset / body.size.height; | |
| _element?.rebuildDecorator(normalizedScrollOffset.clamp(0, 1).toDouble()); | |
| }); | |
| final decorator = childForSlot(.decorator)!; | |
| decorator.layout(bc.loosen(), parentUsesSize: true); | |
| final childExtent = body.size.height; | |
| final offset = calculatePaintOffset(constraints, from: 0, to: childExtent); | |
| geometry = SliverGeometry( | |
| scrollExtent: childExtent, | |
| paintExtent: offset, | |
| maxPaintExtent: childExtent, | |
| layoutExtent: offset, | |
| hitTestExtent: offset, | |
| cacheExtent: calculateCacheOffset(constraints, from: 0, to: childExtent), | |
| hasVisualOverflow: childExtent > constraints.remainingPaintExtent || constraints.scrollOffset > 0.0, | |
| ); | |
| } | |
| @override | |
| void applyPaintTransform(covariant RenderObject child, Matrix4 transform) { | |
| if (child is RenderBox) { | |
| applyPaintTransformForBoxChild(child, transform); | |
| } | |
| } | |
| @override | |
| double childMainAxisPosition(covariant RenderObject child) { | |
| final decorator = childForSlot(.decorator)!; | |
| return child != decorator? | |
| -constraints.scrollOffset : | |
| // NOTE: this line makes the whole effect | |
| min(0, geometry!.maxPaintExtent - decorator.size.height - constraints.scrollOffset); | |
| } | |
| @override | |
| double childCrossAxisPosition(covariant RenderObject child) { | |
| final decorator = childForSlot(.decorator)!; | |
| return child != decorator? 0 : constraints.crossAxisExtent - decorator.size.width; | |
| } | |
| @override | |
| bool hitTest(SliverHitTestResult result, {required double mainAxisPosition, required double crossAxisPosition}) { | |
| for (final child in [childForSlot(.decorator)!, childForSlot(.body)!]) { | |
| final isHit = hitTestBoxChild( | |
| BoxHitTestResult.wrap(result), | |
| child, | |
| mainAxisPosition: mainAxisPosition, | |
| crossAxisPosition: crossAxisPosition, | |
| ); | |
| if (isHit) return true; | |
| } | |
| return false; | |
| } | |
| @override | |
| void paint(PaintingContext context, Offset offset) { | |
| for (final child in children) { | |
| final x = childCrossAxisPosition(child); | |
| final y = childMainAxisPosition(child); | |
| context.paintChild(child, offset.translate(x, y)); | |
| } | |
| } | |
| } | |
| class FooSliverDynamicElement extends SlottedRenderObjectElement<FooSlot, RenderBox> { | |
| FooSliverDynamicElement(super.widget); | |
| Element? _decoratorChild; | |
| @override | |
| RenderFooSliverDynamic get renderObject => super.renderObject as RenderFooSliverDynamic; | |
| @override | |
| void mount(Element? parent, Object? newSlot) { | |
| super.mount(parent, newSlot); | |
| renderObject._element = this; | |
| } | |
| void rebuildDecorator(double normalizedScrollOffset) { | |
| owner!.buildScope(this, () { | |
| final newDecorator = renderObject.decoratorBuilder(this, normalizedScrollOffset); | |
| _decoratorChild = updateChild(_decoratorChild, newDecorator, FooSlot.decorator); | |
| }); | |
| } | |
| @override | |
| void visitChildren(ElementVisitor visitor) { | |
| super.visitChildren(visitor); | |
| if (_decoratorChild != null) { | |
| visitor(_decoratorChild!); | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment