I have following layout:
Scaffold
which contains a fixed Appbar
and a SingleChildScrollView
. The ScrollView usually contains Column with some kind of content (I call it ContentWidget) followed by several sections I call OutputWidgets.
Maybe it matters, so: each OutputWidget consists of a Row
with - left - an Expanded
with some kind of textual output and - right - a small container at the top right corner (which contains a Column
with several buttons):
Now I want achieve, what sometimes is done on websites: I want the button bar to stay visible as long as possible during scrolling:
That way I ensure, that the OutputWidgets' button bars keep visible as long as possible.
Long story short:
In my opinion, I need a way to get the position of direct children of a SingleChildScrollView
and the current visible offset. In that case I could use a GestureDetector
at the ScrollView
to do something like:
If (child top < visible top)
stickyWidget.top = child top - visible top
So, if my idea is correct:
Or is there a completely different way?
Do you have an idea how to do this? Thanks in advance!
Edit:
I believe, this is a nice approach: https://pub.dev/packages/flutter_sticky_header
But I don't need a full header, just a side container. Nonetheless, the behaviour is quite similar.
This seems like an excelent use case for Slivers
, because each widget needs to be aware of its constraints inside the scroll view.
A solution might be achievable using a package like flutter_sticky_header
or sliver_tools
but it might not be trivial because these packages help you create pinned slivers that take their own space inside the scroll view instead of being stacked on the item.
So, let's implement our own widget: SliverContentWithStickyOverlay
, not sure if it's the best name but naming things is hard.
Since we need our widget to have two slots, content
and overlay
, I'm thinking that we can use SlottedMultiChildRenderObjectWidget
as the base class. There's a nice example in the documentation of this class but not quite what we need, because the render object there is a box (extends RenderBox
) and we need a sliver (extends RenderSliver
).
The relevant classes bellow are:
SliverContentWithStickyOverlay
- our special sliver widgetSliverContentWithStickyOverlaySlot
- defines the slots of the widgetRenderSliverContentWithStickyOverlay
- extends RenderSliver
to achieve the effecthttps://dartpad.dev/?id=46ff491cb3ba82c78fdabec6c9707402
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
body: Center(
child: SizedBox(
// We limit the width of the content intentionally to force the slivers take more space vertically.
// Otherwise, on very wide screens the result might not be visible.
width: 320,
child: const MainPage(),
),
),
),
);
}
}
class MainPage extends StatelessWidget {
const MainPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Main Page'),
),
body: SafeArea(
child: CustomScrollView(
slivers: [
SliverContentWithStickyOverlay(
content: Container(
color: const Color(0xFF002B7F).withValues(alpha: 0.5),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 54, 16),
child: Text(
'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 nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
textAlign: TextAlign.justify,
),
),
),
overlay: SideButtonsWidget(),
),
SliverContentWithStickyOverlay(
content: Container(
color: const Color(0xFFFCD116).withValues(alpha: 0.5),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 54, 16),
child: Text(
'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?',
textAlign: TextAlign.justify,
),
),
),
overlay: SideButtonsWidget(),
),
SliverContentWithStickyOverlay(
content: Container(
color: const Color(0xFFCE1126).withValues(alpha: 0.5),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 54, 16),
child: Text(
'At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.',
textAlign: TextAlign.justify,
),
),
),
overlay: SideButtonsWidget(),
),
SliverToBoxAdapter(
child: Container(
color: Colors.grey.withValues(alpha: 0.5),
height: 800,
child: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'Some space used to demonstrate how the slivers behave on scroll',
textAlign: TextAlign.center,
),
),
),
),
),
],
),
),
);
}
}
class SideButtonsWidget extends StatelessWidget {
const SideButtonsWidget({super.key});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 0),
child: Column(
children: [
IconButton(
onPressed: () {
print('Share pressed');
},
icon: Icon(Icons.share),
color: Colors.blue.shade900,
),
IconButton(
onPressed: () {
print('Favorite pressed');
},
icon: Icon(Icons.favorite),
color: Colors.red.shade900,
),
],
),
)
],
);
}
}
enum SliverContentWithStickyOverlaySlot {
content,
overlay,
}
class SliverContentWithStickyOverlay
extends SlottedMultiChildRenderObjectWidget<
SliverContentWithStickyOverlaySlot, RenderBox> {
final Widget content;
final Widget overlay;
const SliverContentWithStickyOverlay({
super.key,
required this.content,
required this.overlay,
});
@override
Iterable<SliverContentWithStickyOverlaySlot> get slots =>
SliverContentWithStickyOverlaySlot.values;
@override
Widget? childForSlot(SliverContentWithStickyOverlaySlot slot) {
switch (slot) {
case SliverContentWithStickyOverlaySlot.content:
return content;
case SliverContentWithStickyOverlaySlot.overlay:
return overlay;
}
}
@override
SlottedContainerRenderObjectMixin<SliverContentWithStickyOverlaySlot,
RenderBox> createRenderObject(
BuildContext context,
) {
return RenderSliverContentWithStickyOverlay();
}
}
class RenderSliverContentWithStickyOverlay extends RenderSliver
with
SlottedContainerRenderObjectMixin<SliverContentWithStickyOverlaySlot, RenderBox>,
RenderSliverHelpers {
// Constructor
RenderSliverContentWithStickyOverlay();
RenderBox? get _contentRenderBox => childForSlot(SliverContentWithStickyOverlaySlot.content);
RenderBox? get _overlayRenderBox => childForSlot(SliverContentWithStickyOverlaySlot.overlay);
@override
Iterable<RenderBox> get children sync* {
if (_contentRenderBox != null) yield _contentRenderBox!;
if (_overlayRenderBox != null) yield _overlayRenderBox!;
}
@override
void performLayout() {
final boxConstraints = constraints.asBoxConstraints();
Size contentSize = Size.zero;
final contentRenderBox = _contentRenderBox;
if (contentRenderBox != null) {
contentRenderBox.layout(boxConstraints, parentUsesSize: true);
contentSize = contentRenderBox.size;
}
Size overlaySize = Size.zero;
final overlayRenderBox = _overlayRenderBox;
if (overlayRenderBox != null) {
overlayRenderBox.layout(boxConstraints, parentUsesSize: true);
overlaySize = overlayRenderBox.size;
}
final size = Size(
max(contentSize.width, overlaySize.width),
max(contentSize.height, overlaySize.height),
);
final childrenExtent = switch (constraints.axis) {
Axis.horizontal => size.width,
Axis.vertical => size.height,
};
final paintExtent = calculatePaintExtent(constraints, from: 0.0, to: childrenExtent);
final cacheExtent = calculateCacheExtent(constraints, from: 0.0, to: childrenExtent);
geometry = SliverGeometry(
scrollExtent: childrenExtent,
paintExtent: paintExtent,
cacheExtent: cacheExtent,
maxPaintExtent: childrenExtent,
hitTestExtent: paintExtent,
hasVisualOverflow:
childrenExtent > constraints.remainingPaintExtent || constraints.scrollOffset > 0.0,
);
}
@override
void paint(PaintingContext context, Offset offset) {
if (_contentRenderBox != null) {
final paintOffset = switch (constraints.axis) {
Axis.horizontal => offset.translate(-constraints.scrollOffset, 0),
Axis.vertical => offset.translate(0, -constraints.scrollOffset),
};
context.paintChild(
_contentRenderBox!,
paintOffset,
);
}
if (_overlayRenderBox != null) {
final paintOffset = switch (constraints.axis) {
Axis.horizontal => offset.translate(
min(
0,
geometry!.maxPaintExtent -
_overlayRenderBox!.size.width -
constraints.scrollOffset),
0,
),
Axis.vertical => offset.translate(
0,
min(
0,
geometry!.maxPaintExtent -
_overlayRenderBox!.size.height -
constraints.scrollOffset),
),
};
context.paintChild(
_overlayRenderBox!,
paintOffset,
);
}
}
@override
double childMainAxisPosition(covariant RenderObject child) {
if (child != _overlayRenderBox) {
return 0;
}
final box = _overlayRenderBox!;
// INFO: up/right/left cases are not tested
return switch (constraints.axisDirection) {
AxisDirection.down => min(0, geometry!.layoutExtent - box.size.height),
AxisDirection.up => max(0, geometry!.layoutExtent - box.size.height),
AxisDirection.right => min(0, geometry!.layoutExtent - box.size.width),
AxisDirection.left => max(0, geometry!.layoutExtent - box.size.width),
};
}
@override
bool hitTestChildren(
SliverHitTestResult result, {
required double mainAxisPosition,
required double crossAxisPosition,
}) {
final boxHitTestResult = BoxHitTestResult.wrap(result);
if (_overlayRenderBox != null) {
if (hitTestBoxChild(boxHitTestResult, _overlayRenderBox!,
mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition)) {
return true;
}
}
if (_contentRenderBox != null) {
if (hitTestBoxChild(boxHitTestResult, _contentRenderBox!,
mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition)) {
return true;
}
}
return false;
}
@override
void applyPaintTransform(covariant RenderObject child, Matrix4 transform) {
if (child is RenderBox) {
applyPaintTransformForBoxChild(child, transform);
}
}
double calculatePaintExtent(SliverConstraints constraints,
{required double from, required double to}) {
assert(from <= to);
final double a = constraints.scrollOffset;
final double b = constraints.scrollOffset + constraints.remainingPaintExtent;
// the clamp on the next line is to avoid floating point rounding errors
return clampDouble(
clampDouble(to, a, b) - clampDouble(from, a, b), 0.0, constraints.remainingPaintExtent);
}
double calculateCacheExtent(SliverConstraints constraints,
{required double from, required double to}) {
assert(from <= to);
final double a = constraints.scrollOffset + constraints.cacheOrigin;
final double b = constraints.scrollOffset + constraints.remainingCacheExtent;
// the clamp on the next line is to avoid floating point rounding errors
return clampDouble(
clampDouble(to, a, b) - clampDouble(from, a, b), 0.0, constraints.remainingCacheExtent);
}
}
The heavy lifting is done in the RenderSliverContentWithStickyOverlay
in the overrides of the methods:
performLayout
paint
hitTestChildren
The Sliver
API is not very simple so, some math in here is obtained through trial and error. In short, for the layout
part you have some constraints
provided to you and based on them you can set the geometry
of the Sliver
. Then for paint
you can use the layout
and the geometry
to paint the children. Then we have to handle hit tests to support gestures for content
and overlay
.
Please note that in this widget, both the content
and the overlay
will attempt to fill the crossAxis
dimension, so it's your responsibility to set some constraints to avoid overlapping. For example, in a vertical scroll view, the overlay
will behave like a sticky header, it will take the full width, but it will not create its own vertical extent (it will stay stacked over the content
).