draggable_scrollbar.dart 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. import 'dart:async';
  2. import 'package:flutter/material.dart';
  3. /// Build the Scroll Thumb and label using the current configuration
  4. typedef ScrollThumbBuilder = Widget Function(
  5. Color backgroundColor,
  6. Animation<double> thumbAnimation,
  7. Animation<double> labelAnimation,
  8. double height, {
  9. Text? labelText,
  10. BoxConstraints? labelConstraints,
  11. });
  12. /// Build a Text widget using the current scroll offset
  13. typedef LabelTextBuilder = Text Function(double offsetY);
  14. /// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
  15. /// for quick navigation of the BoxScrollView.
  16. class DraggableScrollbar extends StatefulWidget {
  17. /// The view that will be scrolled with the scroll thumb
  18. final CustomScrollView child;
  19. /// A function that builds a thumb using the current configuration
  20. final ScrollThumbBuilder scrollThumbBuilder;
  21. /// The height of the scroll thumb
  22. final double heightScrollThumb;
  23. /// The background color of the label and thumb
  24. final Color backgroundColor;
  25. /// The amount of padding that should surround the thumb
  26. final EdgeInsetsGeometry? padding;
  27. /// Determines how quickly the scrollbar will animate in and out
  28. final Duration scrollbarAnimationDuration;
  29. /// How long should the thumb be visible before fading out
  30. final Duration scrollbarTimeToFade;
  31. /// Build a Text widget from the current offset in the BoxScrollView
  32. final LabelTextBuilder? labelTextBuilder;
  33. /// Determines box constraints for Container displaying label
  34. final BoxConstraints? labelConstraints;
  35. /// The ScrollController for the BoxScrollView
  36. final ScrollController controller;
  37. /// Determines scrollThumb displaying. If you draw own ScrollThumb and it is true you just don't need to use animation parameters in [scrollThumbBuilder]
  38. final bool alwaysVisibleScrollThumb;
  39. DraggableScrollbar({
  40. Key? key,
  41. this.alwaysVisibleScrollThumb = false,
  42. required this.heightScrollThumb,
  43. required this.backgroundColor,
  44. required this.scrollThumbBuilder,
  45. required this.child,
  46. required this.controller,
  47. this.padding,
  48. this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
  49. this.scrollbarTimeToFade = const Duration(milliseconds: 600),
  50. this.labelTextBuilder,
  51. this.labelConstraints,
  52. }) : assert(child.scrollDirection == Axis.vertical),
  53. super(key: key);
  54. DraggableScrollbar.rrect({
  55. Key? key,
  56. Key? scrollThumbKey,
  57. this.alwaysVisibleScrollThumb = false,
  58. required this.child,
  59. required this.controller,
  60. this.heightScrollThumb = 48.0,
  61. this.backgroundColor = Colors.white,
  62. this.padding,
  63. this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
  64. this.scrollbarTimeToFade = const Duration(milliseconds: 600),
  65. this.labelTextBuilder,
  66. this.labelConstraints,
  67. }) : assert(child.scrollDirection == Axis.vertical),
  68. scrollThumbBuilder =
  69. _thumbRRectBuilder(scrollThumbKey, alwaysVisibleScrollThumb),
  70. super(key: key);
  71. DraggableScrollbar.arrows({
  72. Key? key,
  73. Key? scrollThumbKey,
  74. this.alwaysVisibleScrollThumb = false,
  75. required this.child,
  76. required this.controller,
  77. this.heightScrollThumb = 48.0,
  78. this.backgroundColor = Colors.white,
  79. this.padding,
  80. this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
  81. this.scrollbarTimeToFade = const Duration(milliseconds: 600),
  82. this.labelTextBuilder,
  83. this.labelConstraints,
  84. }) : assert(child.scrollDirection == Axis.vertical),
  85. scrollThumbBuilder =
  86. _thumbArrowBuilder(scrollThumbKey, alwaysVisibleScrollThumb),
  87. super(key: key);
  88. DraggableScrollbar.semicircle({
  89. Key? key,
  90. Key? scrollThumbKey,
  91. this.alwaysVisibleScrollThumb = false,
  92. required this.child,
  93. required this.controller,
  94. this.heightScrollThumb = 48.0,
  95. this.backgroundColor = Colors.white,
  96. this.padding,
  97. this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
  98. this.scrollbarTimeToFade = const Duration(milliseconds: 600),
  99. this.labelTextBuilder,
  100. this.labelConstraints,
  101. }) : assert(child.scrollDirection == Axis.vertical),
  102. scrollThumbBuilder = _thumbSemicircleBuilder(
  103. heightScrollThumb * 0.6, scrollThumbKey, alwaysVisibleScrollThumb),
  104. super(key: key);
  105. @override
  106. DraggableScrollbarState createState() => DraggableScrollbarState();
  107. static buildScrollThumbAndLabel(
  108. {required Widget scrollThumb,
  109. required Color backgroundColor,
  110. required Animation<double>? thumbAnimation,
  111. required Animation<double>? labelAnimation,
  112. required Text? labelText,
  113. required BoxConstraints? labelConstraints,
  114. required bool alwaysVisibleScrollThumb}) {
  115. var scrollThumbAndLabel = labelText == null
  116. ? scrollThumb
  117. : Row(
  118. mainAxisSize: MainAxisSize.min,
  119. mainAxisAlignment: MainAxisAlignment.end,
  120. children: [
  121. ScrollLabel(
  122. animation: labelAnimation,
  123. backgroundColor: backgroundColor,
  124. constraints: labelConstraints,
  125. child: labelText,
  126. ),
  127. scrollThumb,
  128. ],
  129. );
  130. if (alwaysVisibleScrollThumb) {
  131. return scrollThumbAndLabel;
  132. }
  133. return SlideFadeTransition(
  134. animation: thumbAnimation!,
  135. child: scrollThumbAndLabel,
  136. );
  137. }
  138. static ScrollThumbBuilder _thumbSemicircleBuilder(
  139. double width, Key? scrollThumbKey, bool alwaysVisibleScrollThumb) {
  140. return (
  141. Color backgroundColor,
  142. Animation<double> thumbAnimation,
  143. Animation<double> labelAnimation,
  144. double height, {
  145. Text? labelText,
  146. BoxConstraints? labelConstraints,
  147. }) {
  148. final scrollThumb = CustomPaint(
  149. key: scrollThumbKey,
  150. foregroundPainter: ArrowCustomPainter(Colors.white),
  151. child: Material(
  152. elevation: 4.0,
  153. color: backgroundColor,
  154. borderRadius: BorderRadius.only(
  155. topLeft: Radius.circular(height),
  156. bottomLeft: Radius.circular(height),
  157. topRight: const Radius.circular(4.0),
  158. bottomRight: const Radius.circular(4.0),
  159. ),
  160. child: Container(
  161. constraints: BoxConstraints.tight(Size(width, height)),
  162. ),
  163. ),
  164. );
  165. return buildScrollThumbAndLabel(
  166. scrollThumb: scrollThumb,
  167. backgroundColor: backgroundColor,
  168. thumbAnimation: thumbAnimation,
  169. labelAnimation: labelAnimation,
  170. labelText: labelText,
  171. labelConstraints: labelConstraints,
  172. alwaysVisibleScrollThumb: alwaysVisibleScrollThumb,
  173. );
  174. };
  175. }
  176. static ScrollThumbBuilder _thumbArrowBuilder(
  177. Key? scrollThumbKey, bool alwaysVisibleScrollThumb) {
  178. return (
  179. Color backgroundColor,
  180. Animation<double> thumbAnimation,
  181. Animation<double> labelAnimation,
  182. double height, {
  183. Text? labelText,
  184. BoxConstraints? labelConstraints,
  185. }) {
  186. final scrollThumb = ClipPath(
  187. clipper: ArrowClipper(),
  188. child: Container(
  189. height: height,
  190. width: 20.0,
  191. decoration: BoxDecoration(
  192. color: backgroundColor,
  193. borderRadius: const BorderRadius.all(
  194. Radius.circular(12.0),
  195. ),
  196. ),
  197. ),
  198. );
  199. return buildScrollThumbAndLabel(
  200. scrollThumb: scrollThumb,
  201. backgroundColor: backgroundColor,
  202. thumbAnimation: thumbAnimation,
  203. labelAnimation: labelAnimation,
  204. labelText: labelText,
  205. labelConstraints: labelConstraints,
  206. alwaysVisibleScrollThumb: alwaysVisibleScrollThumb,
  207. );
  208. };
  209. }
  210. static ScrollThumbBuilder _thumbRRectBuilder(
  211. Key? scrollThumbKey, bool alwaysVisibleScrollThumb) {
  212. return (
  213. Color backgroundColor,
  214. Animation<double> thumbAnimation,
  215. Animation<double> labelAnimation,
  216. double height, {
  217. Text? labelText,
  218. BoxConstraints? labelConstraints,
  219. }) {
  220. final scrollThumb = Material(
  221. elevation: 4.0,
  222. color: backgroundColor,
  223. borderRadius: const BorderRadius.all(Radius.circular(7.0)),
  224. child: Container(
  225. constraints: BoxConstraints.tight(
  226. Size(16.0, height),
  227. ),
  228. ),
  229. );
  230. return buildScrollThumbAndLabel(
  231. scrollThumb: scrollThumb,
  232. backgroundColor: backgroundColor,
  233. thumbAnimation: thumbAnimation,
  234. labelAnimation: labelAnimation,
  235. labelText: labelText,
  236. labelConstraints: labelConstraints,
  237. alwaysVisibleScrollThumb: alwaysVisibleScrollThumb,
  238. );
  239. };
  240. }
  241. }
  242. class ScrollLabel extends StatelessWidget {
  243. final Animation<double>? animation;
  244. final Color backgroundColor;
  245. final Text child;
  246. final BoxConstraints? constraints;
  247. static const BoxConstraints _defaultConstraints =
  248. BoxConstraints.tightFor(width: 72.0, height: 28.0);
  249. const ScrollLabel({
  250. Key? key,
  251. required this.child,
  252. required this.animation,
  253. required this.backgroundColor,
  254. this.constraints = _defaultConstraints,
  255. }) : super(key: key);
  256. @override
  257. Widget build(BuildContext context) {
  258. return FadeTransition(
  259. opacity: animation!,
  260. child: Container(
  261. margin: const EdgeInsets.only(right: 12.0),
  262. child: Material(
  263. elevation: 4.0,
  264. color: backgroundColor,
  265. borderRadius: const BorderRadius.all(Radius.circular(16.0)),
  266. child: Container(
  267. constraints: constraints ?? _defaultConstraints,
  268. alignment: Alignment.center,
  269. child: child,
  270. ),
  271. ),
  272. ),
  273. );
  274. }
  275. }
  276. class DraggableScrollbarState extends State<DraggableScrollbar>
  277. with TickerProviderStateMixin {
  278. late double _barOffset;
  279. late double _viewOffset;
  280. late bool _isDragInProcess;
  281. late AnimationController _thumbAnimationController;
  282. late Animation<double> _thumbAnimation;
  283. late AnimationController _labelAnimationController;
  284. late Animation<double> _labelAnimation;
  285. Timer? _fadeoutTimer;
  286. @override
  287. void initState() {
  288. super.initState();
  289. _barOffset = 0.0;
  290. _viewOffset = 0.0;
  291. _isDragInProcess = false;
  292. _thumbAnimationController = AnimationController(
  293. vsync: this,
  294. duration: widget.scrollbarAnimationDuration,
  295. );
  296. _thumbAnimation = CurvedAnimation(
  297. parent: _thumbAnimationController,
  298. curve: Curves.fastOutSlowIn,
  299. );
  300. _labelAnimationController = AnimationController(
  301. vsync: this,
  302. duration: widget.scrollbarAnimationDuration,
  303. );
  304. _labelAnimation = CurvedAnimation(
  305. parent: _labelAnimationController,
  306. curve: Curves.fastOutSlowIn,
  307. );
  308. }
  309. @override
  310. void dispose() {
  311. _thumbAnimationController.dispose();
  312. _labelAnimationController.dispose();
  313. _fadeoutTimer?.cancel();
  314. super.dispose();
  315. }
  316. double get barMaxScrollExtent =>
  317. context.size!.height - widget.heightScrollThumb;
  318. double get barMinScrollExtent => 0;
  319. double get viewMaxScrollExtent => widget.controller.position.maxScrollExtent;
  320. double get viewMinScrollExtent => widget.controller.position.minScrollExtent;
  321. @override
  322. Widget build(BuildContext context) {
  323. Text? labelText;
  324. if (widget.labelTextBuilder != null && _isDragInProcess) {
  325. labelText = widget.labelTextBuilder!(
  326. _viewOffset + _barOffset + widget.heightScrollThumb / 2,
  327. );
  328. }
  329. return LayoutBuilder(
  330. builder: (BuildContext context, BoxConstraints constraints) {
  331. //print("LayoutBuilder constraints=$constraints");
  332. return NotificationListener<ScrollNotification>(
  333. onNotification: (ScrollNotification notification) {
  334. changePosition(notification);
  335. return false;
  336. },
  337. child: Stack(
  338. children: <Widget>[
  339. RepaintBoundary(
  340. child: widget.child,
  341. ),
  342. RepaintBoundary(
  343. child: GestureDetector(
  344. onVerticalDragStart: _onVerticalDragStart,
  345. onVerticalDragUpdate: _onVerticalDragUpdate,
  346. onVerticalDragEnd: _onVerticalDragEnd,
  347. child: Container(
  348. alignment: Alignment.topRight,
  349. margin: EdgeInsets.only(top: _barOffset),
  350. padding: widget.padding,
  351. child: widget.scrollThumbBuilder(
  352. widget.backgroundColor,
  353. _thumbAnimation,
  354. _labelAnimation,
  355. widget.heightScrollThumb,
  356. labelText: labelText,
  357. labelConstraints: widget.labelConstraints,
  358. ),
  359. ),
  360. )),
  361. ],
  362. ),
  363. );
  364. });
  365. }
  366. //scroll bar has received notification that it's view was scrolled
  367. //so it should also changes his position
  368. //but only if it isn't dragged
  369. changePosition(ScrollNotification notification) {
  370. if (_isDragInProcess) {
  371. return;
  372. }
  373. setState(() {
  374. if (notification is ScrollUpdateNotification) {
  375. _barOffset += getBarDelta(
  376. notification.scrollDelta!,
  377. barMaxScrollExtent,
  378. viewMaxScrollExtent,
  379. );
  380. if (_barOffset < barMinScrollExtent) {
  381. _barOffset = barMinScrollExtent;
  382. }
  383. if (_barOffset > barMaxScrollExtent) {
  384. _barOffset = barMaxScrollExtent;
  385. }
  386. _viewOffset += notification.scrollDelta!;
  387. if (_viewOffset < widget.controller.position.minScrollExtent) {
  388. _viewOffset = widget.controller.position.minScrollExtent;
  389. }
  390. if (_viewOffset > viewMaxScrollExtent) {
  391. _viewOffset = viewMaxScrollExtent;
  392. }
  393. }
  394. if (notification is ScrollUpdateNotification ||
  395. notification is OverscrollNotification) {
  396. if (_thumbAnimationController.status != AnimationStatus.forward) {
  397. _thumbAnimationController.forward();
  398. }
  399. _fadeoutTimer?.cancel();
  400. _fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
  401. _thumbAnimationController.reverse();
  402. _labelAnimationController.reverse();
  403. _fadeoutTimer = null;
  404. });
  405. }
  406. });
  407. }
  408. double getBarDelta(
  409. double scrollViewDelta,
  410. double barMaxScrollExtent,
  411. double viewMaxScrollExtent,
  412. ) {
  413. return scrollViewDelta * barMaxScrollExtent / viewMaxScrollExtent;
  414. }
  415. double getScrollViewDelta(
  416. double barDelta,
  417. double barMaxScrollExtent,
  418. double viewMaxScrollExtent,
  419. ) {
  420. return barDelta * viewMaxScrollExtent / barMaxScrollExtent;
  421. }
  422. void _onVerticalDragStart(DragStartDetails details) {
  423. setState(() {
  424. _isDragInProcess = true;
  425. _labelAnimationController.forward();
  426. _fadeoutTimer?.cancel();
  427. });
  428. }
  429. void _onVerticalDragUpdate(DragUpdateDetails details) {
  430. setState(() {
  431. if (_thumbAnimationController.status != AnimationStatus.forward) {
  432. _thumbAnimationController.forward();
  433. }
  434. if (_isDragInProcess) {
  435. _barOffset += details.delta.dy;
  436. if (_barOffset < barMinScrollExtent) {
  437. _barOffset = barMinScrollExtent;
  438. }
  439. if (_barOffset > barMaxScrollExtent) {
  440. _barOffset = barMaxScrollExtent;
  441. }
  442. double viewDelta = getScrollViewDelta(
  443. details.delta.dy, barMaxScrollExtent, viewMaxScrollExtent);
  444. _viewOffset = widget.controller.position.pixels + viewDelta;
  445. if (_viewOffset < widget.controller.position.minScrollExtent) {
  446. _viewOffset = widget.controller.position.minScrollExtent;
  447. }
  448. if (_viewOffset > viewMaxScrollExtent) {
  449. _viewOffset = viewMaxScrollExtent;
  450. }
  451. widget.controller.jumpTo(_viewOffset);
  452. }
  453. });
  454. }
  455. void _onVerticalDragEnd(DragEndDetails details) {
  456. _fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
  457. _thumbAnimationController.reverse();
  458. _labelAnimationController.reverse();
  459. _fadeoutTimer = null;
  460. });
  461. setState(() {
  462. _isDragInProcess = false;
  463. });
  464. }
  465. }
  466. /// Draws 2 triangles like arrow up and arrow down
  467. class ArrowCustomPainter extends CustomPainter {
  468. Color color;
  469. ArrowCustomPainter(this.color);
  470. @override
  471. bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
  472. @override
  473. void paint(Canvas canvas, Size size) {
  474. final paint = Paint()..color = color;
  475. const width = 12.0;
  476. const height = 8.0;
  477. final baseX = size.width / 2;
  478. final baseY = size.height / 2;
  479. canvas.drawPath(
  480. _trianglePath(Offset(baseX, baseY - 2.0), width, height, true),
  481. paint,
  482. );
  483. canvas.drawPath(
  484. _trianglePath(Offset(baseX, baseY + 2.0), width, height, false),
  485. paint,
  486. );
  487. }
  488. static Path _trianglePath(Offset o, double width, double height, bool isUp) {
  489. return Path()
  490. ..moveTo(o.dx, o.dy)
  491. ..lineTo(o.dx + width, o.dy)
  492. ..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height)
  493. ..close();
  494. }
  495. }
  496. ///This cut 2 lines in arrow shape
  497. class ArrowClipper extends CustomClipper<Path> {
  498. @override
  499. Path getClip(Size size) {
  500. Path path = Path();
  501. path.lineTo(0.0, size.height);
  502. path.lineTo(size.width, size.height);
  503. path.lineTo(size.width, 0.0);
  504. path.lineTo(0.0, 0.0);
  505. path.close();
  506. double arrowWidth = 8.0;
  507. double startPointX = (size.width - arrowWidth) / 2;
  508. double startPointY = size.height / 2 - arrowWidth / 2;
  509. path.moveTo(startPointX, startPointY);
  510. path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2);
  511. path.lineTo(startPointX + arrowWidth, startPointY);
  512. path.lineTo(startPointX + arrowWidth, startPointY + 1.0);
  513. path.lineTo(
  514. startPointX + arrowWidth / 2, startPointY - arrowWidth / 2 + 1.0);
  515. path.lineTo(startPointX, startPointY + 1.0);
  516. path.close();
  517. startPointY = size.height / 2 + arrowWidth / 2;
  518. path.moveTo(startPointX + arrowWidth, startPointY);
  519. path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2);
  520. path.lineTo(startPointX, startPointY);
  521. path.lineTo(startPointX, startPointY - 1.0);
  522. path.lineTo(
  523. startPointX + arrowWidth / 2, startPointY + arrowWidth / 2 - 1.0);
  524. path.lineTo(startPointX + arrowWidth, startPointY - 1.0);
  525. path.close();
  526. return path;
  527. }
  528. @override
  529. bool shouldReclip(CustomClipper<Path> oldClipper) => false;
  530. }
  531. class SlideFadeTransition extends StatelessWidget {
  532. final Animation<double> animation;
  533. final Widget child;
  534. const SlideFadeTransition({
  535. Key? key,
  536. required this.animation,
  537. required this.child,
  538. }) : super(key: key);
  539. @override
  540. Widget build(BuildContext context) {
  541. return AnimatedBuilder(
  542. animation: animation,
  543. builder: (context, child) =>
  544. animation.value == 0.0 ? Container() : child!,
  545. child: SlideTransition(
  546. position: Tween(
  547. begin: const Offset(0.3, 0.0),
  548. end: const Offset(0.0, 0.0),
  549. ).animate(animation),
  550. child: FadeTransition(
  551. opacity: animation,
  552. child: child,
  553. ),
  554. ),
  555. );
  556. }
  557. }