draggable_scrollbar.dart 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651
  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,
  104. scrollThumbKey,
  105. alwaysVisibleScrollThumb,
  106. ),
  107. super(key: key);
  108. @override
  109. DraggableScrollbarState createState() => DraggableScrollbarState();
  110. static buildScrollThumbAndLabel({
  111. required Widget scrollThumb,
  112. required Color backgroundColor,
  113. required Animation<double>? thumbAnimation,
  114. required Animation<double>? labelAnimation,
  115. required Text? labelText,
  116. required BoxConstraints? labelConstraints,
  117. required bool alwaysVisibleScrollThumb,
  118. }) {
  119. var scrollThumbAndLabel = labelText == null
  120. ? scrollThumb
  121. : Row(
  122. mainAxisSize: MainAxisSize.min,
  123. mainAxisAlignment: MainAxisAlignment.end,
  124. children: [
  125. ScrollLabel(
  126. animation: labelAnimation,
  127. backgroundColor: backgroundColor,
  128. constraints: labelConstraints,
  129. child: labelText,
  130. ),
  131. scrollThumb,
  132. ],
  133. );
  134. if (alwaysVisibleScrollThumb) {
  135. return scrollThumbAndLabel;
  136. }
  137. return SlideFadeTransition(
  138. animation: thumbAnimation!,
  139. child: scrollThumbAndLabel,
  140. );
  141. }
  142. static ScrollThumbBuilder _thumbSemicircleBuilder(
  143. double width,
  144. Key? scrollThumbKey,
  145. bool alwaysVisibleScrollThumb,
  146. ) {
  147. return (
  148. Color backgroundColor,
  149. Animation<double> thumbAnimation,
  150. Animation<double> labelAnimation,
  151. double height, {
  152. Text? labelText,
  153. BoxConstraints? labelConstraints,
  154. }) {
  155. final scrollThumb = CustomPaint(
  156. key: scrollThumbKey,
  157. foregroundPainter: ArrowCustomPainter(Colors.white),
  158. child: Material(
  159. elevation: 4.0,
  160. color: backgroundColor,
  161. borderRadius: BorderRadius.only(
  162. topLeft: Radius.circular(height),
  163. bottomLeft: Radius.circular(height),
  164. topRight: const Radius.circular(4.0),
  165. bottomRight: const Radius.circular(4.0),
  166. ),
  167. child: Container(
  168. constraints: BoxConstraints.tight(Size(width, height)),
  169. ),
  170. ),
  171. );
  172. return buildScrollThumbAndLabel(
  173. scrollThumb: scrollThumb,
  174. backgroundColor: backgroundColor,
  175. thumbAnimation: thumbAnimation,
  176. labelAnimation: labelAnimation,
  177. labelText: labelText,
  178. labelConstraints: labelConstraints,
  179. alwaysVisibleScrollThumb: alwaysVisibleScrollThumb,
  180. );
  181. };
  182. }
  183. static ScrollThumbBuilder _thumbArrowBuilder(
  184. Key? scrollThumbKey,
  185. bool alwaysVisibleScrollThumb,
  186. ) {
  187. return (
  188. Color backgroundColor,
  189. Animation<double> thumbAnimation,
  190. Animation<double> labelAnimation,
  191. double height, {
  192. Text? labelText,
  193. BoxConstraints? labelConstraints,
  194. }) {
  195. final scrollThumb = ClipPath(
  196. clipper: ArrowClipper(),
  197. child: Container(
  198. height: height,
  199. width: 20.0,
  200. decoration: BoxDecoration(
  201. color: backgroundColor,
  202. borderRadius: const BorderRadius.all(
  203. Radius.circular(12.0),
  204. ),
  205. ),
  206. ),
  207. );
  208. return buildScrollThumbAndLabel(
  209. scrollThumb: scrollThumb,
  210. backgroundColor: backgroundColor,
  211. thumbAnimation: thumbAnimation,
  212. labelAnimation: labelAnimation,
  213. labelText: labelText,
  214. labelConstraints: labelConstraints,
  215. alwaysVisibleScrollThumb: alwaysVisibleScrollThumb,
  216. );
  217. };
  218. }
  219. static ScrollThumbBuilder _thumbRRectBuilder(
  220. Key? scrollThumbKey,
  221. bool alwaysVisibleScrollThumb,
  222. ) {
  223. return (
  224. Color backgroundColor,
  225. Animation<double> thumbAnimation,
  226. Animation<double> labelAnimation,
  227. double height, {
  228. Text? labelText,
  229. BoxConstraints? labelConstraints,
  230. }) {
  231. final scrollThumb = Material(
  232. elevation: 4.0,
  233. color: backgroundColor,
  234. borderRadius: const BorderRadius.all(Radius.circular(7.0)),
  235. child: Container(
  236. constraints: BoxConstraints.tight(
  237. Size(16.0, height),
  238. ),
  239. ),
  240. );
  241. return buildScrollThumbAndLabel(
  242. scrollThumb: scrollThumb,
  243. backgroundColor: backgroundColor,
  244. thumbAnimation: thumbAnimation,
  245. labelAnimation: labelAnimation,
  246. labelText: labelText,
  247. labelConstraints: labelConstraints,
  248. alwaysVisibleScrollThumb: alwaysVisibleScrollThumb,
  249. );
  250. };
  251. }
  252. }
  253. class ScrollLabel extends StatelessWidget {
  254. final Animation<double>? animation;
  255. final Color backgroundColor;
  256. final Text child;
  257. final BoxConstraints? constraints;
  258. static const BoxConstraints _defaultConstraints =
  259. BoxConstraints.tightFor(width: 72.0, height: 28.0);
  260. const ScrollLabel({
  261. Key? key,
  262. required this.child,
  263. required this.animation,
  264. required this.backgroundColor,
  265. this.constraints = _defaultConstraints,
  266. }) : super(key: key);
  267. @override
  268. Widget build(BuildContext context) {
  269. return FadeTransition(
  270. opacity: animation!,
  271. child: Container(
  272. margin: const EdgeInsets.only(right: 12.0),
  273. child: Material(
  274. elevation: 4.0,
  275. color: backgroundColor,
  276. borderRadius: const BorderRadius.all(Radius.circular(16.0)),
  277. child: Container(
  278. constraints: constraints ?? _defaultConstraints,
  279. alignment: Alignment.center,
  280. child: child,
  281. ),
  282. ),
  283. ),
  284. );
  285. }
  286. }
  287. class DraggableScrollbarState extends State<DraggableScrollbar>
  288. with TickerProviderStateMixin {
  289. late double _barOffset;
  290. late double _viewOffset;
  291. late bool _isDragInProcess;
  292. late AnimationController _thumbAnimationController;
  293. late Animation<double> _thumbAnimation;
  294. late AnimationController _labelAnimationController;
  295. late Animation<double> _labelAnimation;
  296. Timer? _fadeoutTimer;
  297. @override
  298. void initState() {
  299. super.initState();
  300. _barOffset = 0.0;
  301. _viewOffset = 0.0;
  302. _isDragInProcess = false;
  303. _thumbAnimationController = AnimationController(
  304. vsync: this,
  305. duration: widget.scrollbarAnimationDuration,
  306. );
  307. _thumbAnimation = CurvedAnimation(
  308. parent: _thumbAnimationController,
  309. curve: Curves.fastOutSlowIn,
  310. );
  311. _labelAnimationController = AnimationController(
  312. vsync: this,
  313. duration: widget.scrollbarAnimationDuration,
  314. );
  315. _labelAnimation = CurvedAnimation(
  316. parent: _labelAnimationController,
  317. curve: Curves.fastOutSlowIn,
  318. );
  319. }
  320. @override
  321. void dispose() {
  322. _thumbAnimationController.dispose();
  323. _labelAnimationController.dispose();
  324. _fadeoutTimer?.cancel();
  325. super.dispose();
  326. }
  327. double get barMaxScrollExtent =>
  328. context.size!.height - widget.heightScrollThumb;
  329. double get barMinScrollExtent => 0;
  330. double get viewMaxScrollExtent => widget.controller.position.maxScrollExtent;
  331. double get viewMinScrollExtent => widget.controller.position.minScrollExtent;
  332. @override
  333. Widget build(BuildContext context) {
  334. Text? labelText;
  335. if (widget.labelTextBuilder != null && _isDragInProcess) {
  336. labelText = widget.labelTextBuilder!(
  337. _viewOffset + _barOffset + widget.heightScrollThumb / 2,
  338. );
  339. }
  340. return LayoutBuilder(
  341. builder: (BuildContext context, BoxConstraints constraints) {
  342. //print("LayoutBuilder constraints=$constraints");
  343. return NotificationListener<ScrollNotification>(
  344. onNotification: (ScrollNotification notification) {
  345. changePosition(notification);
  346. return false;
  347. },
  348. child: Stack(
  349. children: <Widget>[
  350. RepaintBoundary(
  351. child: widget.child,
  352. ),
  353. RepaintBoundary(
  354. child: GestureDetector(
  355. onVerticalDragStart: _onVerticalDragStart,
  356. onVerticalDragUpdate: _onVerticalDragUpdate,
  357. onVerticalDragEnd: _onVerticalDragEnd,
  358. child: Container(
  359. alignment: Alignment.topRight,
  360. margin: EdgeInsets.only(top: _barOffset),
  361. padding: widget.padding,
  362. child: widget.scrollThumbBuilder(
  363. widget.backgroundColor,
  364. _thumbAnimation,
  365. _labelAnimation,
  366. widget.heightScrollThumb,
  367. labelText: labelText,
  368. labelConstraints: widget.labelConstraints,
  369. ),
  370. ),
  371. ),
  372. ),
  373. ],
  374. ),
  375. );
  376. },
  377. );
  378. }
  379. //scroll bar has received notification that it's view was scrolled
  380. //so it should also changes his position
  381. //but only if it isn't dragged
  382. changePosition(ScrollNotification notification) {
  383. if (_isDragInProcess) {
  384. return;
  385. }
  386. setState(() {
  387. if (notification is ScrollUpdateNotification) {
  388. _barOffset += getBarDelta(
  389. notification.scrollDelta!,
  390. barMaxScrollExtent,
  391. viewMaxScrollExtent,
  392. );
  393. if (_barOffset < barMinScrollExtent) {
  394. _barOffset = barMinScrollExtent;
  395. }
  396. if (_barOffset > barMaxScrollExtent) {
  397. _barOffset = barMaxScrollExtent;
  398. }
  399. _viewOffset += notification.scrollDelta!;
  400. if (_viewOffset < widget.controller.position.minScrollExtent) {
  401. _viewOffset = widget.controller.position.minScrollExtent;
  402. }
  403. if (_viewOffset > viewMaxScrollExtent) {
  404. _viewOffset = viewMaxScrollExtent;
  405. }
  406. }
  407. if (notification is ScrollUpdateNotification ||
  408. notification is OverscrollNotification) {
  409. if (_thumbAnimationController.status != AnimationStatus.forward) {
  410. _thumbAnimationController.forward();
  411. }
  412. _fadeoutTimer?.cancel();
  413. _fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
  414. _thumbAnimationController.reverse();
  415. _labelAnimationController.reverse();
  416. _fadeoutTimer = null;
  417. });
  418. }
  419. });
  420. }
  421. double getBarDelta(
  422. double scrollViewDelta,
  423. double barMaxScrollExtent,
  424. double viewMaxScrollExtent,
  425. ) {
  426. return scrollViewDelta * barMaxScrollExtent / viewMaxScrollExtent;
  427. }
  428. double getScrollViewDelta(
  429. double barDelta,
  430. double barMaxScrollExtent,
  431. double viewMaxScrollExtent,
  432. ) {
  433. return barDelta * viewMaxScrollExtent / barMaxScrollExtent;
  434. }
  435. void _onVerticalDragStart(DragStartDetails details) {
  436. setState(() {
  437. _isDragInProcess = true;
  438. _labelAnimationController.forward();
  439. _fadeoutTimer?.cancel();
  440. });
  441. }
  442. void _onVerticalDragUpdate(DragUpdateDetails details) {
  443. setState(() {
  444. if (_thumbAnimationController.status != AnimationStatus.forward) {
  445. _thumbAnimationController.forward();
  446. }
  447. if (_isDragInProcess) {
  448. _barOffset += details.delta.dy;
  449. if (_barOffset < barMinScrollExtent) {
  450. _barOffset = barMinScrollExtent;
  451. }
  452. if (_barOffset > barMaxScrollExtent) {
  453. _barOffset = barMaxScrollExtent;
  454. }
  455. double viewDelta = getScrollViewDelta(
  456. details.delta.dy,
  457. barMaxScrollExtent,
  458. viewMaxScrollExtent,
  459. );
  460. _viewOffset = widget.controller.position.pixels + viewDelta;
  461. if (_viewOffset < widget.controller.position.minScrollExtent) {
  462. _viewOffset = widget.controller.position.minScrollExtent;
  463. }
  464. if (_viewOffset > viewMaxScrollExtent) {
  465. _viewOffset = viewMaxScrollExtent;
  466. }
  467. widget.controller.jumpTo(_viewOffset);
  468. }
  469. });
  470. }
  471. void _onVerticalDragEnd(DragEndDetails details) {
  472. _fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
  473. _thumbAnimationController.reverse();
  474. _labelAnimationController.reverse();
  475. _fadeoutTimer = null;
  476. });
  477. setState(() {
  478. _isDragInProcess = false;
  479. });
  480. }
  481. }
  482. /// Draws 2 triangles like arrow up and arrow down
  483. class ArrowCustomPainter extends CustomPainter {
  484. Color color;
  485. ArrowCustomPainter(this.color);
  486. @override
  487. bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
  488. @override
  489. void paint(Canvas canvas, Size size) {
  490. final paint = Paint()..color = color;
  491. const width = 12.0;
  492. const height = 8.0;
  493. final baseX = size.width / 2;
  494. final baseY = size.height / 2;
  495. canvas.drawPath(
  496. _trianglePath(Offset(baseX, baseY - 2.0), width, height, true),
  497. paint,
  498. );
  499. canvas.drawPath(
  500. _trianglePath(Offset(baseX, baseY + 2.0), width, height, false),
  501. paint,
  502. );
  503. }
  504. static Path _trianglePath(Offset o, double width, double height, bool isUp) {
  505. return Path()
  506. ..moveTo(o.dx, o.dy)
  507. ..lineTo(o.dx + width, o.dy)
  508. ..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height)
  509. ..close();
  510. }
  511. }
  512. ///This cut 2 lines in arrow shape
  513. class ArrowClipper extends CustomClipper<Path> {
  514. @override
  515. Path getClip(Size size) {
  516. Path path = Path();
  517. path.lineTo(0.0, size.height);
  518. path.lineTo(size.width, size.height);
  519. path.lineTo(size.width, 0.0);
  520. path.lineTo(0.0, 0.0);
  521. path.close();
  522. double arrowWidth = 8.0;
  523. double startPointX = (size.width - arrowWidth) / 2;
  524. double startPointY = size.height / 2 - arrowWidth / 2;
  525. path.moveTo(startPointX, startPointY);
  526. path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2);
  527. path.lineTo(startPointX + arrowWidth, startPointY);
  528. path.lineTo(startPointX + arrowWidth, startPointY + 1.0);
  529. path.lineTo(
  530. startPointX + arrowWidth / 2,
  531. startPointY - arrowWidth / 2 + 1.0,
  532. );
  533. path.lineTo(startPointX, startPointY + 1.0);
  534. path.close();
  535. startPointY = size.height / 2 + arrowWidth / 2;
  536. path.moveTo(startPointX + arrowWidth, startPointY);
  537. path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2);
  538. path.lineTo(startPointX, startPointY);
  539. path.lineTo(startPointX, startPointY - 1.0);
  540. path.lineTo(
  541. startPointX + arrowWidth / 2,
  542. startPointY + arrowWidth / 2 - 1.0,
  543. );
  544. path.lineTo(startPointX + arrowWidth, startPointY - 1.0);
  545. path.close();
  546. return path;
  547. }
  548. @override
  549. bool shouldReclip(CustomClipper<Path> oldClipper) => false;
  550. }
  551. class SlideFadeTransition extends StatelessWidget {
  552. final Animation<double> animation;
  553. final Widget child;
  554. const SlideFadeTransition({
  555. Key? key,
  556. required this.animation,
  557. required this.child,
  558. }) : super(key: key);
  559. @override
  560. Widget build(BuildContext context) {
  561. return AnimatedBuilder(
  562. animation: animation,
  563. builder: (context, child) =>
  564. animation.value == 0.0 ? const SizedBox() : child!,
  565. child: SlideTransition(
  566. position: Tween(
  567. begin: const Offset(0.3, 0.0),
  568. end: const Offset(0.0, 0.0),
  569. ).animate(animation),
  570. child: FadeTransition(
  571. opacity: animation,
  572. child: child,
  573. ),
  574. ),
  575. );
  576. }
  577. }