Flutter Web Scrollbar With Generic Desktop Feel

A generic desktop feel scrollbar is not yet available in the Flutter framework, but with a bit of a work, it can be implemented. That’s what we are going to do today.

This scrollbar should be used only on the desktop version of your web application. It is not common to use a scrollbar on mobile, but if you want to use one, than Flutter has one ready for you, and that is the ScrollBar widget. But it is alien on the web, to use such scrollbar.

Why develop a scrollbar?

Because scrolling is one of the most important things on the web, it should not be taken lightly and should be handled very professionally. If not, a buggy, jerky scroll and scrollbar could ruin the user experience of your web application.
Scrolling alone is not enough, because there are always users, who are using old laptops, without a mouse, without a scrollwheel, their only option is to have a scrollbar to drag onto.

What makes a great scrollbar?

The scrollbar should be very responsive to resizing, it should readjust itself, to fit the window. So here are my requirements for the perfect scrollbar:

  • The height of the scroll thumb is perfectly sized in ratio of the visible height, and the unvisible height.
  • The scrollbar should perfectly move along with the mouse that’s dragging it.
  • The scrolling speed should match up with the amount of the drag movement.
  • It goes without saying, but the direction of the scroll thumb and the scrolling should be opposite
  • It’s not neccesary, but in this example we’re going to make a fade in/ fade out scrollbar.

How does the scrollbar work?

The hardest part of making the perfect, bugfree scrollbar, is coming up with the formulas that are going to be used to calculate all the stuff that I have written in the requirements. The formulas are not that hard to make at first glance, but it took me 1 or 2 hours to satisfy every requirement. It is easy to mess up. I am going to show you the formulas with a very easy example for it to be easier to understand.

The first question should be: Should I have a scrollbar, does it make sense now to have a scrollbar? To answer that question we need to calculate the remainder height of the child widget. So basically we need to subtract the visible height from the child’s full height.
If the result is negative, than we need to have the scrollbar, otherwise, we can just ditch the scrollbar for now.

The next step is to determine the height of the scroll thumb. For that we need the ratio of the visible height and the child’s full height. Take the reciprocal of the ratio, and multiply it by the height of the window. It makes sense believe me. Formula:

ratio = fullHeight / visibleHeight
thumbHeight = (1 / ratio) * visibleHeight.

That’s it, we have everything to make the scrollbar look accurate. But what about the movement? Next step.

You can move the scrollbar 2 ways. Either scroll with the mousewheel, or by dragging it with the mouse. In the future, I will add keyboard support as well for the up and down arrows.

There are problems with each one, and we need some other formulas that we need to get right.

Scrolling with the mouse wheel: We have to move the scroll thumb proportionally to the mouse scrolling. So we need a formula that tells us how the scroll thumb should move properly. The formula will give us the top position of the scroll thumb.
We need to divide the visible height with the full height then multiply by the amount of space the scroll has moved. That’s how we get the top position of the scroll thumb.
The formula:

scrollThumbTop = ((visibleHeight) / fullHeight) * scrollExtentBefore

It will make sense in a minute.

For when the scrollbar is being dragged, the scroll position should be moved the accurate spot. The formula for that is:

(dragDelta * ratio) – offsetTop

The offsetTop is the spot on the scrollThumb, where the user has clicked on, multiplied by the ratio. It could mess up the calculations if we did not normalize the position that the scrollbar was touched.

Okay, we are done figuring out the formulas that we need to use, so let’s put it into action.

Implementing the scrollbar with Flutter

Before starting, I would suggest checking out my previous blogpost, I have written on making the scrolling smoother with Flutter web. This guide uses the SmoothScrollWeb component, that I have made in that post.

So the ScrollBarWeb component will be our wrapper for the code, but that wrapper is going in another wrapper, that is the SmoothScrollWeb component.
It’s important to disable the scrolling on the child widget, by adding the NeverScrollableScrollPhysics to its physics, otherwise it won’t work. We need the complete control over the scrolling, that’s why we are adding the physics.

return Container(
  color: Colors.red,
  child: SmoothScrollWeb(
    controller: controller,
    child: ScrollBar(
      child: _getChild(),
      controller: controller,
      visibleHeight: MediaQuery.of(context).size.height,
    ),
  ),
);

Example child

Widget _getChild() {
  return Container(
    child: SingleChildScrollView(
      physics: NeverScrollableScrollPhysics(),
      controller: controller,
      child: Column(
        children: [
          for (int i = 0; i < 200; i++)
            Container(
              height: 10,
              color: RandomColor.generate(),
            ),
        ],
      ),
    ),
  );
}

The ScrollBar class will have the following properties:

///Same ScrollController as the child widget's.
final ScrollController controller;

///Child widget.
final Widget child;

///The height of the child widget.
final double visibleHeight;

///Lenght of the Thumb fade in out animations in milliseconds.
final int animationLength;

///The color of the scroll thumb
final Color scrollThumbColor;

///The background color of the scrollbar.
final Color scrollbarColor;

///The width of the scrollbar, when it is 'hidden'
final double scrollbarMinWidth;

///The width of the scrollbar, when it is 'showing'
final double scrollbarMaxWidth;

Getting the full height of the child

It is a crucial point of the scroll bar. It would be really great if we could get the full height of the child somehow and not fill in the full height manually, which would be a drag. But how should it be done? Luckily we have a way of finding the height. But it is only available, once the widget has loaded. It is not desireable, but with a little bit of trickery we can make it work in the build method.

if (fullHeight == null) {
  Future.delayed(Duration.zero, () {
    setState(() {
      fullHeight = widget.controller.position.maxScrollExtent + widget.controller.position.viewportDimension;
    });
  });
  return widget.child;
}

We need to build twice. first without the scrollbar, and once we have the scrollController initialized, we can have the scrollController.position.maxScrollExtent, which is the maximum scrollable height, and the viewportDimension, which is basically the visible height. Add these 2 together, and you will get the fullHeight. Next step.

Remainder calculation

So do you need the scrollbar? Calculate it:

final remainder = (fullHeight - widget.visibleHeight);

if (remainder < 0) {
  return widget.child;
}

Ratio and thumbHeight:

ratio = fullHeight / widget.visibleHeight;
thumbHeight = (/ ratio) * widget.visibleHeight;

Then we are going to return a Stack widget, with the child widget on top, then the scrollbar background and lastly the scroll thumb. The order is important, because the later the widget, the higher layer it will be.

return Stack(
  fit: StackFit.loose,
  alignment: Alignment.topRight,
  children: [
    widget.child,
    _getScrollbarBackground(width),
    _getScrollThumb(width),
  ],
);

The scrollbar background will have the length of the visible height, with a width of your choice, but please match the width with the thumb width that you are going to use.

Widget _getScrollbarBackground(double width) {
  return Container(
    width: width,
    height: widget.visibleHeight,
    decoration: BoxDecoration(
      borderRadius: BorderRadius.circular(3),
      color: widget.scrollbarColor,
      border: Border.all(color: widget.scrollbarColor, width: 1),
    ),
  );
}

One of the reasons I am using a Stack and not a Row, is because I am going to animate the scrollbar to appear and dissappear due to mouse movement. In the next post, I am going to show you how to do animations and we will go more in depth with the scrollbar’s width property.

Building the scroll thumb

Lastly there is the scroll thumb builder function _getScrollThumb. We are going to take advantage of flutter’s Positioned widget, which is a great sollution for moving the scrollthumb anytime we scroll, or touch it. The most important property of the Positioned widget is the top property.
The top property positions its child. We need to use the formula that we have put together for calculating the top property.

double calculateTop() {
 return (widget.visibleHeight / fullHeight) * widget.controller.position.extentBefore;
}

That takes care of the scrolling with the mouse wheel part.

But we do need to take care of the dragging, and we are going to use the GestureDetector widget, with the onVerticalDragDown and the onVerticalDragUpdate properties. With the onVerticalDragDown callback normalizing the dragging point, and giving us the offsetTop and the onVerticalDragUpdate handling the scrolling.

Widget _getScrollThumb(double width) {
  return Positioned(
    top: fullHeight != null calculateTop() : 0,
    child: GestureDetector(
      onVerticalDragDown: (s) {
        offsetTop = widget.controller.offset.toDouble() - (s.localPosition.dy * ratio);
      },
      onVerticalDragUpdate: (dragDetails) {
        final newPosition = (dragDetails.localPosition.dy * ratio) + offsetTop;
        widget.controller.jumpTo(newPosition);
      },
      child: Container(
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(3),
          color: widget.scrollThumbColor,
          border: Border.all(color:widget.scrollThumbColor, width:1),
        ),
        width: width,
        height: thumbHeight,
      ),
    ),
  );
}

One more finishing touch will be needed for it to work as it’s intended. The scroll thumb will not be moved by the scrollController, unless we refresh the ScrollBar widget, every the scrollController is scrolled. The easiest way is to add a listener to the scrollController.

@override
void initState() {
  widget.controller.addListener(() {
    setState(() {});
  });
  super.initState();
}

That’s it. We are done. Now the scrolling is finally just like the page was written in html. The whole code is available on my gitlab account. Or you can get the package from pubdev.

The results

Conclusion

Although Flutter Web is in beta stages, and lack some basic features on desktop, we can easily create our own component, that will fit on desktop, so our site will blend in as a generic webpage, and not stand out with it’s lack of desktop features.

2 thoughts on “Flutter Web Scrollbar With Generic Desktop Feel

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s