How does your sliver leave the screen?

Mikhail <mbixjkee> Zotyev
5 min readJul 5, 2023

--

Hi everyone! My name is Mikhail Zotyev, I’m a software engineer, passionate about Dart and Flutter.

I explained a theoretical part of Sliver’s work in my previous article. Why do we need to know the theoretical part, if we don’t use it in practice? So in this article, I’ll share an example of how to build a specific effect with a custom sliver.

Do you remember Russ Hanneman from the Silicon Valley series and his remarkable attitude to car doors?

Today we will create a sliver that Russ would like. No more boring leaving of the screen. Our sliver will rotate around the corner while moving away from the visible part of the screen. Let’s get this party started.

Implementation

First of all, our widget should contain something to show, and we will use special implementation on the rendering level. So we need to use SingleChildRenderObjectWidget to do this.

class Spinner extends SingleChildRenderObjectWidget {
const Spinner({
Key? key,
required Widget child,
}) : super(key: key, child: child);

@override
RenderObject createRenderObject(BuildContext context) {
return _SpinnerRenderSliver();
}
}

And the main part of this behavior will be implemented inside the RenderObject.

class _SpinnerRenderSliver extends RenderSliver with
RenderObjectWithChildMixin<RenderBox> {
// ...
}

So, how can we implement this behavior? First of all, we need to understand where the sliver is positioned. Depending on this position we need to decide, whether should it be rotated or not. If we need to rotate we also need to decide which angle it should be. And all these things can change in every new position, so we need to do these decisions in the layout method. But for the beginning, we need a few additional properties which will help us to store and apply our decisions.

final _transformLayer = LayerHandle<TransformLayer>();
Matrix4? _paintTransform;

Should content be rotated or not, _paintTransform shows. And when rotation is needed we need a _transformLayer for applying.

Let’s move to the performLayout method. As we’ve already discussed the rotation can change with position change. So to be independent of the previous calculation, we’ll reset _paintTransform.

@override
void performLayout() {
_paintTransform = null;

// …
}

The next step is the calculation of the child size because it is crucial information for all other decisions. In order to do this, we need to convert SliverConstraints to BoxConstraints and provide it to the child.

final constraints = this.constraints;
child!.layout(constraints.asBoxConstraints(), parentUsesSize: true);
final double childExtent;
switch (constraints.axis) {
case Axis.horizontal:
childExtent = child!.size.width;
break;
case Axis.vertical:
childExtent = child!.size.height;
break;
}

Now we have the size of the child, which means we know the maximum amount of space that our sliver can use. Based on this fact let’s calculate a few additional properties for SliverGeometry. We’ll use common methods of RenderSliver to do this.

The first of them is painted part of our sliver in current conditions.

final paintedChildSize = calculatePaintOffset(
constraints,
from: 0.0,
to: childExtent,
);

The second is the part in the cache area in current constraints.

final cacheExtent = calculateCacheOffset(
constraints,
from: 0.0,
to: childExtent,
);

So now we are ready to describe the geometry.

geometry = SliverGeometry(
scrollExtent: childExtent,
paintExtent: paintedChildSize,
cacheExtent: cacheExtent,
maxPaintExtent: childExtent,
hitTestExtent: paintedChildSize,
hasVisualOverflow: childExtent > constraints.remainingPaintExtent ||
constraints.scrollOffset > 0.0,
);

And additionally, we need to adjust the child using this geometry by setting the paintOffset according to the axisDirection and growthDirection.

void _setChildParentData(
RenderObject child,
SliverConstraints constraints,
SliverGeometry geometry,
) {
final childParentData = child.parentData! as SliverPhysicalParentData;
var dx = 0.0;
var dy = 0.0;
switch (applyGrowthDirectionToAxisDirection(
constraints.axisDirection,
constraints.growthDirection,
)) {
case AxisDirection.up:
dy = -(geometry.scrollExtent -
(geometry.paintExtent + constraints.scrollOffset));
break;
case AxisDirection.right:
dx = -constraints.scrollOffset;
break;
case AxisDirection.down:
dy = -constraints.scrollOffset;
break;
case AxisDirection.left:
dx = -(geometry.scrollExtent -
(geometry.paintExtent + constraints.scrollOffset));
break;
}

childParentData.paintOffset = Offset(dx, dy);
}

Okay, we’ve done base things regarding the size of the sliver and now we can care about rotation. We need to rotate our sliver only if part of it has already left the viewport. It is easy to detect with the scrollOffset value.

final scrollOffset = constraints.scrollOffset;

if (scrollOffset > 0 && paintedChildSize > 0) {
// …
}

Let’s calculate the angle. The more part is outside the screen, the more angle we will use. We know the maximum size and the painted size, so it will be easy for us

final angle = _kQuarterTurnsInRadians * (1 - paintedChildSize / childExtent);

But we cannot just apply the turn, because we need to turn around the left bottom corner, but the default point of turn is center. We need to make a small trick

final translation = FractionalOffset.bottomLeft.alongSize(
Size(
child!.size.width,
paintedChildSize,
),
);

_paintTransform = Matrix4.identity()
..translate(0.0, translation.dy)
..rotateZ(-angle)
..translate(0.0, -translation.dy);

Let’s put everything together and look at the whole method

@override
void performLayout() {
_paintTransform = null;

final constraints = this.constraints;
child!.layout(constraints.asBoxConstraints(), parentUsesSize: true);
final double childExtent;
switch (constraints.axis) {
case Axis.horizontal:
childExtent = child!.size.width;
break;
case Axis.vertical:
childExtent = child!.size.height;
break;
}

final paintedChildSize = calculatePaintOffset(
constraints,
from: 0.0,
to: childExtent,
);
final cacheExtent = calculateCacheOffset(
constraints,
from: 0.0,
to: childExtent,
);

final scrollOffset = constraints.scrollOffset;

if (scrollOffset > 0 && paintedChildSize > 0) {
final translation = FractionalOffset.bottomLeft.alongSize(
Size(
child!.size.width,
paintedChildSize,
),
);

final angle =
_kQuarterTurnsInRadians * (1 - paintedChildSize / childExtent);

_paintTransform = Matrix4.identity()
..translate(0.0, translation.dy)
..rotateZ(-angle)
..translate(0.0, -translation.dy);
}

assert(paintedChildSize.isFinite);
assert(paintedChildSize >= 0.0);
geometry = SliverGeometry(
scrollExtent: childExtent,
paintExtent: paintedChildSize,
cacheExtent: cacheExtent,
maxPaintExtent: childExtent,
hitTestExtent: paintedChildSize,
hasVisualOverflow: childExtent > constraints.remainingPaintExtent ||
constraints.scrollOffset > 0.0,
);

_setChildParentData(child!, constraints, geometry!);
}

The layout is ready, but it is not enough only calculate it, we need paint. And for the painting, we will use that special layer, that we created in the beginning

@override
void paint(PaintingContext context, Offset offset) {
if (child != null && geometry!.visible) {
_transformLayer.layer = context.pushTransform(
needsCompositing,
offset,
_paintTransform ?? Matrix4.identity(),
_paintChild,
oldLayer: _transformLayer.layer,
);
} else {
_transformLayer.layer = null;
}
}

The _paintChild method just paints the child

void _paintChild(PaintingContext context, Offset offset) {
final childParentData = child!.parentData! as SliverPhysicalParentData;
context.paintChild(child!, offset + childParentData.paintOffset);
}

And the final touch, we need to handle cases when beyond our transform we have another one:

@override
void applyPaintTransform(covariant RenderObject child, Matrix4 transform) {
if (_paintTransform != null) {
transform.multiply(_paintTransform!);
}
final childParentData = child.parentData! as SliverPhysicalParentData;

childParentData.applyPaintTransform(transform);
}

So that’s it. Now we have a widget with interesting behavior that we wanted to implement.

Conclusion

Hope this example will help you don’t be afraid of internal work with the Sliver protocol and create a lot of perfect scroll effects.

I’ve formed this implementation in a small package that is available in pub — sliver_spinner.

And the source code you can find in GitHub.

Stay on the 🎯 Dart side of the Force ✌️

--

--