Last active
March 26, 2020 17:26
-
-
Save Abhilash-Chandran/53c1e3d7fcc8865b8cdab7b57bd45236 to your computer and use it in GitHub Desktop.
scrollable form in flutter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:async'; | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/material.dart'; | |
final Color darkBlue = Color.fromARGB(255, 18, 32, 47); | |
void main() { | |
runApp(MyApp()); | |
} | |
class MyApp extends StatelessWidget { | |
@override | |
Widget build(BuildContext context) { | |
return MaterialApp( | |
theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue), | |
debugShowCheckedModeBanner: false, | |
home: Scaffold( | |
body: Center( | |
child: MainScreen(), | |
), | |
), | |
); | |
} | |
} | |
class MainScreen extends StatelessWidget { | |
final _formKey = GlobalKey<FormState>(); | |
final ScrollController _scrollController = ScrollController(); | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
appBar: AppBar( | |
title: Text('My form'), | |
), | |
body: Padding( | |
padding: const EdgeInsets.all(16.0), | |
child: Form( | |
key: _formKey, | |
child: DraggableScrollbar.rrect( | |
controller: _scrollController, | |
alwaysVisibleScrollThumb: true, | |
child: ListView( | |
controller: _scrollController, | |
children: [ | |
...List<Widget>.generate(15, (i) => TextFormField( | |
controller: TextEditingController()..text='Field $i', | |
validator: (value) { | |
if (value.isEmpty) { | |
return 'Please enter some text'; | |
} | |
return null; | |
}, | |
)), | |
Padding( | |
padding: const EdgeInsets.symmetric(vertical: 16.0), | |
child: RaisedButton( | |
onPressed: () { | |
// Validate returns true if the form is valid, or false | |
// otherwise. | |
if (_formKey.currentState.validate()) { | |
// If the form is valid, display a Snackbar. | |
Scaffold.of(context).showSnackBar( | |
SnackBar(content: Text('Processing Data'))); | |
} | |
}, | |
child: Text('Submit'), | |
), | |
), | |
], | |
), | |
), | |
), | |
), | |
); | |
} | |
} | |
/// Build the Scroll Thumb and label using the current configuration | |
typedef Widget ScrollThumbBuilder( | |
Color backgroundColor, | |
Animation<double> thumbAnimation, | |
Animation<double> labelAnimation, | |
double height, { | |
Text labelText, | |
BoxConstraints labelConstraints, | |
}); | |
/// Build a Text widget using the current scroll offset | |
typedef Text LabelTextBuilder(double offsetY); | |
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged | |
/// for quick navigation of the BoxScrollView. | |
class DraggableScrollbar extends StatefulWidget { | |
/// The view that will be scrolled with the scroll thumb | |
final BoxScrollView child; | |
/// A function that builds a thumb using the current configuration | |
final ScrollThumbBuilder scrollThumbBuilder; | |
/// The height of the scroll thumb | |
final double heightScrollThumb; | |
/// The background color of the label and thumb | |
final Color backgroundColor; | |
/// The amount of padding that should surround the thumb | |
final EdgeInsetsGeometry padding; | |
/// Determines how quickly the scrollbar will animate in and out | |
final Duration scrollbarAnimationDuration; | |
/// How long should the thumb be visible before fading out | |
final Duration scrollbarTimeToFade; | |
/// Build a Text widget from the current offset in the BoxScrollView | |
final LabelTextBuilder labelTextBuilder; | |
/// Determines box constraints for Container displaying label | |
final BoxConstraints labelConstraints; | |
/// The ScrollController for the BoxScrollView | |
final ScrollController controller; | |
/// Determines scrollThumb displaying. If you draw own ScrollThumb and it is true you just don't need to use animation parameters in [scrollThumbBuilder] | |
final bool alwaysVisibleScrollThumb; | |
DraggableScrollbar({ | |
Key key, | |
this.alwaysVisibleScrollThumb = false, | |
@required this.heightScrollThumb, | |
@required this.backgroundColor, | |
@required this.scrollThumbBuilder, | |
@required this.child, | |
@required this.controller, | |
this.padding, | |
this.scrollbarAnimationDuration = const Duration(milliseconds: 300), | |
this.scrollbarTimeToFade = const Duration(milliseconds: 600), | |
this.labelTextBuilder, | |
this.labelConstraints, | |
}) : assert(controller != null), | |
assert(scrollThumbBuilder != null), | |
assert(child.scrollDirection == Axis.vertical), | |
super(key: key); | |
DraggableScrollbar.rrect({ | |
Key key, | |
Key scrollThumbKey, | |
this.alwaysVisibleScrollThumb = false, | |
@required this.child, | |
@required this.controller, | |
this.heightScrollThumb = 48.0, | |
this.backgroundColor = Colors.white, | |
this.padding, | |
this.scrollbarAnimationDuration = const Duration(milliseconds: 300), | |
this.scrollbarTimeToFade = const Duration(milliseconds: 600), | |
this.labelTextBuilder, | |
this.labelConstraints, | |
}) : assert(child.scrollDirection == Axis.vertical), | |
scrollThumbBuilder = | |
_thumbRRectBuilder(scrollThumbKey, alwaysVisibleScrollThumb), | |
super(key: key); | |
DraggableScrollbar.arrows({ | |
Key key, | |
Key scrollThumbKey, | |
this.alwaysVisibleScrollThumb = false, | |
@required this.child, | |
@required this.controller, | |
this.heightScrollThumb = 48.0, | |
this.backgroundColor = Colors.white, | |
this.padding, | |
this.scrollbarAnimationDuration = const Duration(milliseconds: 300), | |
this.scrollbarTimeToFade = const Duration(milliseconds: 600), | |
this.labelTextBuilder, | |
this.labelConstraints, | |
}) : assert(child.scrollDirection == Axis.vertical), | |
scrollThumbBuilder = | |
_thumbArrowBuilder(scrollThumbKey, alwaysVisibleScrollThumb), | |
super(key: key); | |
DraggableScrollbar.semicircle({ | |
Key key, | |
Key scrollThumbKey, | |
this.alwaysVisibleScrollThumb = false, | |
@required this.child, | |
@required this.controller, | |
this.heightScrollThumb = 48.0, | |
this.backgroundColor = Colors.white, | |
this.padding, | |
this.scrollbarAnimationDuration = const Duration(milliseconds: 300), | |
this.scrollbarTimeToFade = const Duration(milliseconds: 600), | |
this.labelTextBuilder, | |
this.labelConstraints, | |
}) : assert(child.scrollDirection == Axis.vertical), | |
scrollThumbBuilder = _thumbSemicircleBuilder( | |
heightScrollThumb * 0.6, scrollThumbKey, alwaysVisibleScrollThumb), | |
super(key: key); | |
@override | |
_DraggableScrollbarState createState() => _DraggableScrollbarState(); | |
static buildScrollThumbAndLabel( | |
{@required Widget scrollThumb, | |
@required Color backgroundColor, | |
@required Animation<double> thumbAnimation, | |
@required Animation<double> labelAnimation, | |
@required Text labelText, | |
@required BoxConstraints labelConstraints, | |
@required bool alwaysVisibleScrollThumb}) { | |
var scrollThumbAndLabel = labelText == null | |
? scrollThumb | |
: Row( | |
mainAxisSize: MainAxisSize.min, | |
mainAxisAlignment: MainAxisAlignment.end, | |
children: [ | |
ScrollLabel( | |
animation: labelAnimation, | |
child: labelText, | |
backgroundColor: backgroundColor, | |
constraints: labelConstraints, | |
), | |
scrollThumb, | |
], | |
); | |
if (alwaysVisibleScrollThumb) { | |
return scrollThumbAndLabel; | |
} | |
return SlideFadeTransition( | |
animation: thumbAnimation, | |
child: scrollThumbAndLabel, | |
); | |
} | |
static ScrollThumbBuilder _thumbSemicircleBuilder( | |
double width, Key scrollThumbKey, bool alwaysVisibleScrollThumb) { | |
return ( | |
Color backgroundColor, | |
Animation<double> thumbAnimation, | |
Animation<double> labelAnimation, | |
double height, { | |
Text labelText, | |
BoxConstraints labelConstraints, | |
}) { | |
final scrollThumb = CustomPaint( | |
key: scrollThumbKey, | |
foregroundPainter: ArrowCustomPainter(Colors.grey), | |
child: Material( | |
elevation: 4.0, | |
child: Container( | |
constraints: BoxConstraints.tight(Size(width, height)), | |
), | |
color: backgroundColor, | |
borderRadius: BorderRadius.only( | |
topLeft: Radius.circular(height), | |
bottomLeft: Radius.circular(height), | |
topRight: Radius.circular(4.0), | |
bottomRight: Radius.circular(4.0), | |
), | |
), | |
); | |
return buildScrollThumbAndLabel( | |
scrollThumb: scrollThumb, | |
backgroundColor: backgroundColor, | |
thumbAnimation: thumbAnimation, | |
labelAnimation: labelAnimation, | |
labelText: labelText, | |
labelConstraints: labelConstraints, | |
alwaysVisibleScrollThumb: alwaysVisibleScrollThumb, | |
); | |
}; | |
} | |
static ScrollThumbBuilder _thumbArrowBuilder( | |
Key scrollThumbKey, bool alwaysVisibleScrollThumb) { | |
return ( | |
Color backgroundColor, | |
Animation<double> thumbAnimation, | |
Animation<double> labelAnimation, | |
double height, { | |
Text labelText, | |
BoxConstraints labelConstraints, | |
}) { | |
final scrollThumb = ClipPath( | |
child: Container( | |
height: height, | |
width: 20.0, | |
decoration: BoxDecoration( | |
color: backgroundColor, | |
borderRadius: BorderRadius.all( | |
Radius.circular(12.0), | |
), | |
), | |
), | |
clipper: ArrowClipper(), | |
); | |
return buildScrollThumbAndLabel( | |
scrollThumb: scrollThumb, | |
backgroundColor: backgroundColor, | |
thumbAnimation: thumbAnimation, | |
labelAnimation: labelAnimation, | |
labelText: labelText, | |
labelConstraints: labelConstraints, | |
alwaysVisibleScrollThumb: alwaysVisibleScrollThumb, | |
); | |
}; | |
} | |
static ScrollThumbBuilder _thumbRRectBuilder( | |
Key scrollThumbKey, bool alwaysVisibleScrollThumb) { | |
return ( | |
Color backgroundColor, | |
Animation<double> thumbAnimation, | |
Animation<double> labelAnimation, | |
double height, { | |
Text labelText, | |
BoxConstraints labelConstraints, | |
}) { | |
final scrollThumb = Material( | |
elevation: 4.0, | |
child: Container( | |
constraints: BoxConstraints.tight( | |
Size(16.0, height), | |
), | |
), | |
color: backgroundColor, | |
borderRadius: BorderRadius.all(Radius.circular(7.0)), | |
); | |
return buildScrollThumbAndLabel( | |
scrollThumb: scrollThumb, | |
backgroundColor: backgroundColor, | |
thumbAnimation: thumbAnimation, | |
labelAnimation: labelAnimation, | |
labelText: labelText, | |
labelConstraints: labelConstraints, | |
alwaysVisibleScrollThumb: alwaysVisibleScrollThumb, | |
); | |
}; | |
} | |
} | |
class ScrollLabel extends StatelessWidget { | |
final Animation<double> animation; | |
final Color backgroundColor; | |
final Text child; | |
final BoxConstraints constraints; | |
static const BoxConstraints _defaultConstraints = | |
BoxConstraints.tightFor(width: 72.0, height: 28.0); | |
const ScrollLabel({ | |
Key key, | |
@required this.child, | |
@required this.animation, | |
@required this.backgroundColor, | |
this.constraints = _defaultConstraints, | |
}) : super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
return FadeTransition( | |
opacity: animation, | |
child: Container( | |
margin: EdgeInsets.only(right: 12.0), | |
child: Material( | |
elevation: 4.0, | |
color: backgroundColor, | |
borderRadius: BorderRadius.all(Radius.circular(16.0)), | |
child: Container( | |
constraints: constraints ?? _defaultConstraints, | |
alignment: Alignment.center, | |
child: child, | |
), | |
), | |
), | |
); | |
} | |
} | |
class _DraggableScrollbarState extends State<DraggableScrollbar> | |
with TickerProviderStateMixin { | |
double _barOffset; | |
double _viewOffset; | |
bool _isDragInProcess; | |
AnimationController _thumbAnimationController; | |
Animation<double> _thumbAnimation; | |
AnimationController _labelAnimationController; | |
Animation<double> _labelAnimation; | |
Timer _fadeoutTimer; | |
@override | |
void initState() { | |
super.initState(); | |
_barOffset = 0.0; | |
_viewOffset = 0.0; | |
_isDragInProcess = false; | |
_thumbAnimationController = AnimationController( | |
vsync: this, | |
duration: widget.scrollbarAnimationDuration, | |
); | |
_thumbAnimation = CurvedAnimation( | |
parent: _thumbAnimationController, | |
curve: Curves.fastOutSlowIn, | |
); | |
_labelAnimationController = AnimationController( | |
vsync: this, | |
duration: widget.scrollbarAnimationDuration, | |
); | |
_labelAnimation = CurvedAnimation( | |
parent: _labelAnimationController, | |
curve: Curves.fastOutSlowIn, | |
); | |
} | |
@override | |
void dispose() { | |
_thumbAnimationController.dispose(); | |
_fadeoutTimer?.cancel(); | |
super.dispose(); | |
} | |
double get barMaxScrollExtent => | |
context.size.height - widget.heightScrollThumb; | |
double get barMinScrollExtent => 0.0; | |
double get viewMaxScrollExtent => widget.controller.position.maxScrollExtent; | |
double get viewMinScrollExtent => widget.controller.position.minScrollExtent; | |
@override | |
Widget build(BuildContext context) { | |
Widget labelText; | |
if (widget.labelTextBuilder != null && _isDragInProcess) { | |
labelText = widget.labelTextBuilder( | |
_viewOffset + _barOffset + widget.heightScrollThumb / 2, | |
); | |
} | |
return LayoutBuilder( | |
builder: (BuildContext context, BoxConstraints constraints) { | |
//print("LayoutBuilder constraints=$constraints"); | |
return NotificationListener<ScrollNotification>( | |
onNotification: (ScrollNotification notification) { | |
changePosition(notification); | |
return true; | |
}, | |
child: Stack( | |
children: <Widget>[ | |
RepaintBoundary( | |
child: widget.child, | |
), | |
RepaintBoundary( | |
child: GestureDetector( | |
onVerticalDragStart: _onVerticalDragStart, | |
onVerticalDragUpdate: _onVerticalDragUpdate, | |
onVerticalDragEnd: _onVerticalDragEnd, | |
child: Container( | |
alignment: Alignment.topRight, | |
margin: EdgeInsets.only(top: _barOffset), | |
padding: widget.padding, | |
child: widget.scrollThumbBuilder( | |
widget.backgroundColor, | |
_thumbAnimation, | |
_labelAnimation, | |
widget.heightScrollThumb, | |
labelText: labelText, | |
labelConstraints: widget.labelConstraints, | |
), | |
), | |
)), | |
], | |
), | |
); | |
}); | |
} | |
//scroll bar has received notification that it's view was scrolled | |
//so it should also changes his position | |
//but only if it isn't dragged | |
changePosition(ScrollNotification notification) { | |
if (_isDragInProcess) { | |
return; | |
} | |
setState(() { | |
if (notification is ScrollUpdateNotification) { | |
_barOffset += getBarDelta( | |
notification.scrollDelta, | |
barMaxScrollExtent, | |
viewMaxScrollExtent, | |
); | |
if (_barOffset < barMinScrollExtent) { | |
_barOffset = barMinScrollExtent; | |
} | |
if (_barOffset > barMaxScrollExtent) { | |
_barOffset = barMaxScrollExtent; | |
} | |
_viewOffset += notification.scrollDelta; | |
if (_viewOffset < widget.controller.position.minScrollExtent) { | |
_viewOffset = widget.controller.position.minScrollExtent; | |
} | |
if (_viewOffset > viewMaxScrollExtent) { | |
_viewOffset = viewMaxScrollExtent; | |
} | |
} | |
if (notification is ScrollUpdateNotification || | |
notification is OverscrollNotification) { | |
if (_thumbAnimationController.status != AnimationStatus.forward) { | |
_thumbAnimationController.forward(); | |
} | |
_fadeoutTimer?.cancel(); | |
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () { | |
_thumbAnimationController.reverse(); | |
_labelAnimationController.reverse(); | |
_fadeoutTimer = null; | |
}); | |
} | |
}); | |
} | |
double getBarDelta( | |
double scrollViewDelta, | |
double barMaxScrollExtent, | |
double viewMaxScrollExtent, | |
) { | |
return scrollViewDelta * barMaxScrollExtent / viewMaxScrollExtent; | |
} | |
double getScrollViewDelta( | |
double barDelta, | |
double barMaxScrollExtent, | |
double viewMaxScrollExtent, | |
) { | |
return barDelta * viewMaxScrollExtent / barMaxScrollExtent; | |
} | |
void _onVerticalDragStart(DragStartDetails details) { | |
setState(() { | |
_isDragInProcess = true; | |
_labelAnimationController.forward(); | |
_fadeoutTimer?.cancel(); | |
}); | |
} | |
void _onVerticalDragUpdate(DragUpdateDetails details) { | |
setState(() { | |
if (_thumbAnimationController.status != AnimationStatus.forward) { | |
_thumbAnimationController.forward(); | |
} | |
if (_isDragInProcess) { | |
_barOffset += details.delta.dy; | |
if (_barOffset < barMinScrollExtent) { | |
_barOffset = barMinScrollExtent; | |
} | |
if (_barOffset > barMaxScrollExtent) { | |
_barOffset = barMaxScrollExtent; | |
} | |
double viewDelta = getScrollViewDelta( | |
details.delta.dy, barMaxScrollExtent, viewMaxScrollExtent); | |
_viewOffset = widget.controller.position.pixels + viewDelta; | |
if (_viewOffset < widget.controller.position.minScrollExtent) { | |
_viewOffset = widget.controller.position.minScrollExtent; | |
} | |
if (_viewOffset > viewMaxScrollExtent) { | |
_viewOffset = viewMaxScrollExtent; | |
} | |
widget.controller.jumpTo(_viewOffset); | |
} | |
}); | |
} | |
void _onVerticalDragEnd(DragEndDetails details) { | |
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () { | |
_thumbAnimationController.reverse(); | |
_labelAnimationController.reverse(); | |
_fadeoutTimer = null; | |
}); | |
setState(() { | |
_isDragInProcess = false; | |
}); | |
} | |
} | |
/// Draws 2 triangles like arrow up and arrow down | |
class ArrowCustomPainter extends CustomPainter { | |
Color color; | |
ArrowCustomPainter(this.color); | |
@override | |
bool shouldRepaint(covariant CustomPainter oldDelegate) => false; | |
@override | |
void paint(Canvas canvas, Size size) { | |
final paint = Paint()..color = color; | |
const width = 12.0; | |
const height = 8.0; | |
final baseX = size.width / 2; | |
final baseY = size.height / 2; | |
canvas.drawPath( | |
_trianglePath(Offset(baseX, baseY - 2.0), width, height, true), | |
paint, | |
); | |
canvas.drawPath( | |
_trianglePath(Offset(baseX, baseY + 2.0), width, height, false), | |
paint, | |
); | |
} | |
static Path _trianglePath(Offset o, double width, double height, bool isUp) { | |
return Path() | |
..moveTo(o.dx, o.dy) | |
..lineTo(o.dx + width, o.dy) | |
..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height) | |
..close(); | |
} | |
} | |
///This cut 2 lines in arrow shape | |
class ArrowClipper extends CustomClipper<Path> { | |
@override | |
Path getClip(Size size) { | |
Path path = Path(); | |
path.lineTo(0.0, size.height); | |
path.lineTo(size.width, size.height); | |
path.lineTo(size.width, 0.0); | |
path.lineTo(0.0, 0.0); | |
path.close(); | |
double arrowWidth = 8.0; | |
double startPointX = (size.width - arrowWidth) / 2; | |
double startPointY = size.height / 2 - arrowWidth / 2; | |
path.moveTo(startPointX, startPointY); | |
path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2); | |
path.lineTo(startPointX + arrowWidth, startPointY); | |
path.lineTo(startPointX + arrowWidth, startPointY + 1.0); | |
path.lineTo( | |
startPointX + arrowWidth / 2, startPointY - arrowWidth / 2 + 1.0); | |
path.lineTo(startPointX, startPointY + 1.0); | |
path.close(); | |
startPointY = size.height / 2 + arrowWidth / 2; | |
path.moveTo(startPointX + arrowWidth, startPointY); | |
path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2); | |
path.lineTo(startPointX, startPointY); | |
path.lineTo(startPointX, startPointY - 1.0); | |
path.lineTo( | |
startPointX + arrowWidth / 2, startPointY + arrowWidth / 2 - 1.0); | |
path.lineTo(startPointX + arrowWidth, startPointY - 1.0); | |
path.close(); | |
return path; | |
} | |
@override | |
bool shouldReclip(CustomClipper<Path> oldClipper) => false; | |
} | |
class SlideFadeTransition extends StatelessWidget { | |
final Animation<double> animation; | |
final Widget child; | |
const SlideFadeTransition({ | |
Key key, | |
@required this.animation, | |
@required this.child, | |
}) : super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
return AnimatedBuilder( | |
animation: animation, | |
builder: (context, child) => animation.value == 0.0 ? Container() : child, | |
child: SlideTransition( | |
position: Tween( | |
begin: Offset(0.3, 0.0), | |
end: Offset(0.0, 0.0), | |
).animate(animation), | |
child: FadeTransition( | |
opacity: animation, | |
child: child, | |
), | |
), | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment