Custom scrollbars in Flutter

Dart and Flutter are growing quickly, but there aren’t a lot of packages or blog posts about the smaller details of the widgets you’ll need to build desktop apps. Here’s a quick example of how to build scrollbars that are more flexible, pixel-perfect, and generally better than the default ones.

You start off by using the ScrollbarPainter, and give it some default properties.


var _verticalScrollbarPainter = ScrollbarPainter(
  color: widget.scrollbarColor,
  shape: RoundedRectangleBorder(
    borderRadius: const BorderRadius.all(Radius.circular(4)),
    side: BorderSide(
      color: widget.scrollbarBorderColor,
      width: 1,
    ),
  ),
  textDirection: TextDirection.ltr,
  fadeoutOpacityAnimation: CurvedAnimation(
    parent: AnimationController(
      vsync: this,
      duration: Duration.zero,
      value: 1.0,
    ),
    curve: Curves.linear,
  ),
  thickness: 8,
  crossAxisMargin: 4,
  mainAxisMargin: 16,
  scrollbarOrientation: ScrollbarOrientation.right,
  minLength: 40,
  minOverscrollLength: 40,
);

When you detect a scroll gesture, you just use an update function to tell it about the new properties.

_updateScroll(PointerScrollEvent event) {
  Offset delta = event.scrollDelta;
  setState(() {
    _offset = Offset(
      (_offset.dx - delta.dx).clamp(_min.dx, _max.dx),
      (_offset.dy - delta.dy).clamp(_min.dy, _max.dy),
    );
    _verticalScrollbarPainter.update(
      FixedScrollMetrics(
        minScrollExtent: _min.dy,
        maxScrollExtent: _max.dy,
        pixels: _offset.dy,
        viewportDimension: widget.size.height,
        axisDirection: AxisDirection.up,
      ),
      AxisDirection.up,
    );

    // and the same thing for the horizontal one here...
  });
}

Then you just link your offsets to whatever you’re scrolling. That’s it.

This is one of the things that I actually really like about Flutter. It has all these really detailed objects that are easy to customize. For comparison, doing this in React is not exactly the easiest thing in the world, and involves using a combination of in-line CSS styles, regular CSS styles, and requestAnimationFrame callbacks in order to get the performance you want.

Below is the complete example. Note that we’re really only depending on two things here: gestures, and material.

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

class CanvasScroller extends StatefulWidget {
  const CanvasScroller({
    Key? key,
    required this.child,
    required this.size,
    required this.scrollbarColor,
    required this.scrollbarBorderColor,
  }) : super(key: key);

  final Widget child;

  final Size size;

  final Color scrollbarColor;

  final Color scrollbarBorderColor;

  @override
  State<CanvasScroller> createState() => _CanvasScrollerState();
}

class _CanvasScrollerState extends State<CanvasScroller>
    with TickerProviderStateMixin<CanvasScroller> {
  final Offset _max = const Offset(2000, 2000);
  final Offset _min = const Offset(-2000, -2000);
  final GlobalKey _verticalScrollbarPainterKey = GlobalKey();
  final GlobalKey _horizontalScrollbarPainterKey = GlobalKey();
  late final ScrollbarPainter _verticalScrollbarPainter;
  late final ScrollbarPainter _horizontalScrollbarPainter;

  Offset _offset = Offset.zero;

  @override
  void initState() {
    super.initState();

    _verticalScrollbarPainter = ScrollbarPainter(
      color: widget.scrollbarColor,
      shape: RoundedRectangleBorder(
        borderRadius: const BorderRadius.all(Radius.circular(4)),
        side: BorderSide(
          color: widget.scrollbarBorderColor,
          width: 1,
        ),
      ),
      textDirection: TextDirection.ltr,
      fadeoutOpacityAnimation: CurvedAnimation(
        parent: AnimationController(
          vsync: this,
          duration: Duration.zero,
          value: 1.0,
        ),
        curve: Curves.linear,
      ),
      thickness: 8,
      crossAxisMargin: 4,
      mainAxisMargin: 16,
      scrollbarOrientation: ScrollbarOrientation.right,
      minLength: 40,
      minOverscrollLength: 40,
    );
    _verticalScrollbarPainter.update(
      FixedScrollMetrics(
        minScrollExtent: _min.dy,
        maxScrollExtent: _max.dy,
        pixels: 0,
        viewportDimension: widget.size.height,
        axisDirection: AxisDirection.up,
      ),
      AxisDirection.up,
    );

    _horizontalScrollbarPainter = ScrollbarPainter(
      color: widget.scrollbarColor,
      shape: RoundedRectangleBorder(
        borderRadius: const BorderRadius.all(Radius.circular(4)),
        side: BorderSide(
          color: widget.scrollbarBorderColor,
          width: 1,
        ),
      ),
      textDirection: TextDirection.ltr,
      fadeoutOpacityAnimation: CurvedAnimation(
        parent: AnimationController(
          vsync: this,
          duration: Duration.zero,
          value: 1.0,
        ),
        curve: Curves.linear,
      ),
      thickness: 8,
      crossAxisMargin: 4,
      mainAxisMargin: 16,
      scrollbarOrientation: ScrollbarOrientation.bottom,
      minLength: 40,
      minOverscrollLength: 40,
    );
    _horizontalScrollbarPainter.update(
      FixedScrollMetrics(
        minScrollExtent: _min.dx,
        maxScrollExtent: _max.dx,
        pixels: 0,
        viewportDimension: widget.size.width,
        axisDirection: AxisDirection.left,
      ),
      AxisDirection.left,
    );
  }

  _updateScroll(PointerScrollEvent event) {
    Offset delta = event.scrollDelta;
    setState(() {
      _offset = Offset(
        (_offset.dx - delta.dx).clamp(_min.dx, _max.dx),
        (_offset.dy - delta.dy).clamp(_min.dy, _max.dy),
      );
      _verticalScrollbarPainter.update(
        FixedScrollMetrics(
          minScrollExtent: _min.dy,
          maxScrollExtent: _max.dy,
          pixels: _offset.dy,
          viewportDimension: widget.size.height,
          axisDirection: AxisDirection.up,
        ),
        AxisDirection.up,
      );

      _horizontalScrollbarPainter.update(
        FixedScrollMetrics(
          minScrollExtent: _min.dx,
          maxScrollExtent: _max.dx,
          pixels: _offset.dx,
          viewportDimension: widget.size.width,
          axisDirection: AxisDirection.left,
        ),
        AxisDirection.left,
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    return Listener(
      behavior: HitTestBehavior.translucent,
      onPointerSignal: (event) {
        if (event is PointerScrollEvent) {
          _updateScroll(event);
        }
      },
      child: RepaintBoundary(
        child: SizedBox(
          width: widget.size.width,
          height: widget.size.height,
          child: CustomPaint(
            key: _verticalScrollbarPainterKey,
            foregroundPainter: _verticalScrollbarPainter,
            child: RepaintBoundary(
              child: CustomPaint(
                key: _horizontalScrollbarPainterKey,
                foregroundPainter: _horizontalScrollbarPainter,
                child: RepaintBoundary(
                  child: ClipRect(
                    child: OverflowBox(
                      alignment: Alignment.topLeft,
                      maxHeight: 3800,
                      maxWidth: 3800,
                      /*
                      * I'm just linking it to some sort of
                      * transformable child here, to simulate
                      * canvas scrolling. You can do whatever
                      * you want with the offsets though.
                      * */
                      child: Transform(
                        transform: Matrix4.identity()
                          ..translate(
                            _offset.dx,
                            _offset.dy,
                          ),
                        child: widget.child,
                      ),
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}
dart | design | ui | programming
2022-05-24