1. CustomPainter & Canvas
class ProgressRingPainter extends CustomPainter {
final double progress; // 0.0 to 1.0
ProgressRingPainter(this.progress);
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 4;
final backgroundPaint = Paint()
..color = Colors.grey.shade300
..style = PaintingStyle.stroke
..strokeWidth = 8;
canvas.drawCircle(center, radius, backgroundPaint);
final progressPaint = Paint()
..color = Colors.deepPurple
..style = PaintingStyle.stroke
..strokeWidth = 8
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-pi / 2, // start at the top
2 * pi * progress,
false,
progressPaint,
);
}
@override
bool shouldRepaint(ProgressRingPainter oldDelegate) => oldDelegate.progress != progress;
}
CustomPaint(
size: const Size(80, 80),
painter: ProgressRingPainter(0.65),
)
shouldRepaint is the performance-critical piece — returning
false when nothing that affects the drawing actually changed skips an
unnecessary repaint; returning true unconditionally (or comparing
nothing at all) repaints every frame regardless of whether anything visibly changed.
2. Slivers & CustomScrollView
A regular ListView can't express a header that shrinks as you scroll,
or a grid and a list coexisting in one continuous scroll — CustomScrollView
composes slivers, scrollable building blocks, to do exactly that.
CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 200,
pinned: true, // stays visible, collapsed, once scrolled past
flexibleSpace: FlexibleSpaceBar(
title: const Text('Products'),
background: Image.network('https://picsum.photos/800/400', fit: BoxFit.cover),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('Product $index')),
childCount: 50,
),
),
],
)
pinned: true is what keeps a collapsed app bar visible (rather than
scrolling fully off-screen) — a common pattern worth trying both ways to see the
difference directly.
3. Mixing a Grid, a List & a Header
CustomScrollView(
slivers: [
const SliverAppBar(pinned: true, title: Text('Store')),
SliverPadding(
padding: const EdgeInsets.all(12),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2),
delegate: SliverChildBuilderDelegate(
(context, index) => FeaturedProductCard(index: index),
childCount: 6,
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('More product $index')),
childCount: 30,
),
),
],
)
This is the layout family behind most real e-commerce and content apps — a featured grid up top, an ordinary list below, both scrolling together as one continuous gesture rather than as two separately-scrolling regions.
4. Hands-on Exercise
Build a product screen with a collapsing header and a custom-painted rating
Combine slivers and CustomPainter into one polished screen.
Requirements:
- A
CustomScrollViewwith aSliverAppBar(pinned: true, anexpandedHeight, and a background image) above aSliverListorSliverGridof products. - A
CustomPainterdrawing a simple 5-star or ring-style rating indicator, taking a rating value as a constructor parameter. - A correct
shouldRepaintimplementation, verified by adding a print statement and confirming it only fires when the rating value actually changes across rebuilds. - At least one section mixing two different sliver types (a grid and a list) in the same scroll view.
- Try
pinned: falseon the SliverAppBar temporarily and compare the scroll behavior againstpinned: true, noting the difference in a comment.
If your CustomPainter's paint method is clearly being called every frame regardless of whether the value changed, double-check shouldRepaint is comparing the actual relevant field (like oldDelegate.progress != progress) and not just returning true unconditionally — it's an easy line to leave as a placeholder true while getting the drawing itself working, and easy to forget to fix afterward.
5. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What does returning false from shouldRepaint actually skip, and why does that matter for performance?
What does returning false from shouldRepaint actually skip, and why does that matter for performance?
It skips calling paint again for that frame — the previously rendered pixels are reused as-is. For a painter whose drawing depends on a value that hasn't changed, this avoids redundant CPU/GPU work re-drawing identical output every single frame, which matters especially for a painter inside a widget that rebuilds often for unrelated reasons.
Q2
Why can't a plain ListView with a regular AppBar produce a collapsing header effect?
Why can't a plain ListView with a regular AppBar produce a collapsing header effect?
A regular AppBar is a fixed-height widget outside the scrollable content entirely — it has no participation in the scroll gesture at all. A collapsing effect requires the header itself to be part of the same scrollable, sliver-based coordinate space as the content below it, which is exactly what SliverAppBar inside a CustomScrollView provides and a plain AppBar structurally cannot.
Q3
What does pinned: true change about a SliverAppBar's behavior once its expanded content has scrolled away?
What does pinned: true change about a SliverAppBar's behavior once its expanded content has scrolled away?
With pinned: true, the app bar collapses down to its minimum (toolbar) height and stays visible at the top of the screen as scrolling continues. With pinned: false, the entire app bar — including its collapsed toolbar — scrolls fully off-screen once the user scrolls far enough, leaving no persistent header at all.
Q4
Why can a grid section and a list section coexist in one continuous scroll gesture using slivers, when a naive approach (a Column containing a GridView and a ListView) would not work correctly?
Why can a grid section and a list section coexist in one continuous scroll gesture using slivers, when a naive approach (a Column containing a GridView and a ListView) would not work correctly?
Slivers are specifically designed to compose within one shared scrolling coordinate system — CustomScrollView lays them out and scrolls them together as a single unit. A GridView and ListView each try to own their own scrolling behavior independently, so nesting them inside a plain Column either breaks scrolling entirely (unbounded height errors) or produces two separately-scrolling regions instead of one unified scroll.