Skip to content

Instantly share code, notes, and snippets.

@slightfoot
Created March 4, 2019 18:20
Show Gist options
  • Star 26 You must be signed in to star a gist
  • Fork 6 You must be signed in to fork a gist
  • Save slightfoot/beb74749bf2e743a6da294b37a7dcf8d to your computer and use it in GitHub Desktop.
Save slightfoot/beb74749bf2e743a6da294b37a7dcf8d to your computer and use it in GitHub Desktop.
Always Visible Scrollbar for Flutter - 4th March 2019
import 'package:flutter/gestures.dart' show DragStartBehavior;
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
void main() {
runApp(
MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
primaryColor: Colors.indigo,
accentColor: Colors.pinkAccent,
),
home: ExampleScreen(),
),
);
}
class ExampleScreen extends StatefulWidget {
@override
_ExampleScreenState createState() => _ExampleScreenState();
}
class _ExampleScreenState extends State<ExampleScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('SingleChildScrollView With Scrollbar'),
),
body: SingleChildScrollViewWithScrollbar(
scrollbarColor: Theme.of(context).accentColor.withOpacity(0.75),
scrollbarThickness: 8.0,
child: Container(
height: 1500,
child: Placeholder(),
),
),
);
}
}
class SingleChildScrollViewWithScrollbar extends StatefulWidget {
const SingleChildScrollViewWithScrollbar({
Key key,
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.padding,
this.primary,
this.physics,
this.controller,
this.child,
this.dragStartBehavior = DragStartBehavior.down,
this.scrollbarColor,
this.scrollbarThickness = 6.0,
}) : super(key: key);
final Axis scrollDirection;
final bool reverse;
final EdgeInsets padding;
final bool primary;
final ScrollPhysics physics;
final ScrollController controller;
final Widget child;
final DragStartBehavior dragStartBehavior;
final Color scrollbarColor;
final double scrollbarThickness;
@override
_SingleChildScrollViewWithScrollbarState createState() => _SingleChildScrollViewWithScrollbarState();
}
class _SingleChildScrollViewWithScrollbarState extends State<SingleChildScrollViewWithScrollbar> {
AlwaysVisibleScrollbarPainter _scrollbarPainter;
@override
void didChangeDependencies() {
super.didChangeDependencies();
rebuildPainter();
}
@override
void didUpdateWidget(SingleChildScrollViewWithScrollbar oldWidget) {
super.didUpdateWidget(oldWidget);
rebuildPainter();
}
void rebuildPainter() {
final theme = Theme.of(context);
_scrollbarPainter = AlwaysVisibleScrollbarPainter(
color: widget.scrollbarColor ?? theme.highlightColor.withOpacity(1.0),
textDirection: Directionality.of(context),
thickness: widget.scrollbarThickness,
);
}
@override
void dispose() {
_scrollbarPainter?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: CustomPaint(
foregroundPainter: _scrollbarPainter,
child: RepaintBoundary(
child: SingleChildScrollView(
scrollDirection: widget.scrollDirection,
reverse: widget.reverse,
padding: widget.padding,
primary: widget.primary,
physics: widget.physics,
controller: widget.controller,
dragStartBehavior: widget.dragStartBehavior,
child: Builder(
builder: (BuildContext context) {
_scrollbarPainter.scrollable = Scrollable.of(context);
return widget.child;
},
),
),
),
),
);
}
}
class AlwaysVisibleScrollbarPainter extends ScrollbarPainter {
AlwaysVisibleScrollbarPainter({
@required Color color,
@required TextDirection textDirection,
@required double thickness,
}) : super(
color: color,
textDirection: textDirection,
thickness: thickness,
fadeoutOpacityAnimation: const AlwaysStoppedAnimation(1.0),
);
ScrollableState _scrollable;
ScrollableState get scrollable => _scrollable;
set scrollable(ScrollableState value) {
_scrollable?.position?.removeListener(_onScrollChanged);
_scrollable = value;
_scrollable?.position?.addListener(_onScrollChanged);
_onScrollChanged();
}
void _onScrollChanged() {
update(_scrollable.position, _scrollable.axisDirection);
}
@override
void dispose() {
_scrollable?.position?.removeListener(notifyListeners);
super.dispose();
}
}
@Rameshv
Copy link

Rameshv commented Oct 31, 2019

πŸ‘

@an01f01
Copy link

an01f01 commented Dec 13, 2019

This is great and I integrated it in a demo project I made. I did find a bug, when you press on the back button on the application it crashes if you have not touched the pane with the scrollbar. This is because your disposed method on your _SingleChildScrollViewWithScrollbarState is removing the AlwaysVisibleScrollbarPainter when it should not. Deleting the dispose override on the_SingleChildScrollViewWithScrollbarState fixes it. It would be awesome if you could update and revise my finding (this happened on the Android devices I tested with).

@override void dispose() { _scrollbarPainter?.dispose(); super.dispose(); }

Thank you so much!
Alex

@marcelser
Copy link

I know this thing is named "always visible scrollbar" but is there a possibility to hide it if there are not enough elements that it's scrollable?

@jagmit
Copy link

jagmit commented Feb 27, 2020

@marcelser I don't know how to make the AlwaysVisibleScrollbarPainter paint nothing ("hide") but i think the way to determine whether or not we need a scrollbar at all would be to check _scrollable?.position?.pixels == 0 (preferentially in _onScrollChanged() ?).

@itsrobinm
Copy link

Fantastic example guys, helping me a lot with my project β™₯️

@itsrobinm
Copy link

itsrobinm commented Mar 24, 2020

Actually, I have found using the answer at https://stackoverflow.com/questions/51069712/how-to-know-if-a-widget-is-visible-within-a-viewport/57252652#57252652, I wrapped the last widget inside my ScrollView a VisibilityDetector() and if it was in fact visible, simply change the color of the scrollbar to transparent.

@edgarfroes
Copy link

Why is this not a Flutter Package yet, friend of mine?

@shinriyo
Copy link

shinriyo commented May 4, 2020

════════ Exception caught by widgets library ═══════════════════════════════════════════════════════
The following assertion was thrown while finalizing the widget tree:
A ScrollPositionWithSingleContext was used after being disposed.

Once you have called dispose() on a ScrollPositionWithSingleContext, it can no longer be used.
When the exception was thrown, this was the stack: 
#0      ChangeNotifier._debugAssertNotDisposed.<anonymous closure> (package:flutter/src/foundation/change_notifier.dart:106:9)
#1      ChangeNotifier._debugAssertNotDisposed (package:flutter/src/foundation/change_notifier.dart:112:6)
#2      ChangeNotifier.removeListener (package:flutter/src/foundation/change_notifier.dart:167:12)
#3      AlwaysVisibleScrollbarPainter.dispose (package:MY_APP/parts/always_scrollbar.dart:123:28)
#4      _SingleChildScrollViewWithScrollbarState.dispose (package:MY_APP/parts/always_scrollbar.dart:63:24)
...
════════════════════════════════════════════════════════════════════════════════════════════════════

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment