Week 15: Custom Painting & Slivers for Advanced UI

Widgets cover most UI needs, but two categories genuinely need lower-level tools: graphics no widget composition can produce (a custom chart, a progress ring), and scroll effects — a collapsing header, mixed scrolling regions — that a plain ListView can't express. This week covers both.

Phase 7 of 8 Week 15 of 20 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Draw custom graphics with CustomPainter and a Canvas
  • Understand when shouldRepaint matters for performance
  • Build a collapsing app bar with slivers and CustomScrollView
  • Mix a grid, a list, and a header inside one coordinated scroll view

1. CustomPainter & Canvas

a simple progress ring, drawn by hand
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;
}
using it
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.

a collapsing header above a list
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

a grid section followed by a list section, one scroll
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

Hands-on

Build a product screen with a collapsing header and a custom-painted rating

Combine slivers and CustomPainter into one polished screen.

Requirements:

  1. A CustomScrollView with a SliverAppBar (pinned: true, an expandedHeight, and a background image) above a SliverList or SliverGrid of products.
  2. A CustomPainter drawing a simple 5-star or ring-style rating indicator, taking a rating value as a constructor parameter.
  3. A correct shouldRepaint implementation, verified by adding a print statement and confirming it only fires when the rating value actually changes across rebuilds.
  4. At least one section mixing two different sliver types (a grid and a list) in the same scroll view.
  5. Try pinned: false on the SliverAppBar temporarily and compare the scroll behavior against pinned: true, noting the difference in a comment.
Hint

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?

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?

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?

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?

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.