From 48d30cfa20d4077865ff04d8a765c7c6bbace0aa Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sat, 20 Jul 2019 18:51:02 +0900 Subject: [PATCH] poc: sticky headers + fast scroll --- lib/common/draggable_scrollbar.dart | 613 ++++++++++++++++++++++++++++ lib/image_fullscreen_page.dart | 16 +- lib/main.dart | 2 +- lib/{ => model}/image_fetcher.dart | 0 lib/thumbnail.dart | 2 +- lib/thumbnail_collection.dart | 73 ++-- pubspec.lock | 7 - pubspec.yaml | 1 - 8 files changed, 655 insertions(+), 59 deletions(-) create mode 100644 lib/common/draggable_scrollbar.dart rename lib/{ => model}/image_fetcher.dart (100%) diff --git a/lib/common/draggable_scrollbar.dart b/lib/common/draggable_scrollbar.dart new file mode 100644 index 000000000..8d2991a3b --- /dev/null +++ b/lib/common/draggable_scrollbar.dart @@ -0,0 +1,613 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// TLAD: copied from https://pub.dev/packages/draggable_scrollbar 0.0.4 +/// modified to allow any `ScrollView` as a child, not just `BoxScrollView` + +/// Build the Scroll Thumb and label using the current configuration +typedef Widget ScrollThumbBuilder( + Color backgroundColor, + Animation thumbAnimation, + Animation 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 ScrollView 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 thumbAnimation, @required Animation 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 thumbAnimation, + Animation 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 thumbAnimation, + Animation 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 thumbAnimation, + Animation 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 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 with TickerProviderStateMixin { + double _barOffset; + double _viewOffset; + bool _isDragInProcess; + + AnimationController _thumbAnimationController; + Animation _thumbAnimation; + AnimationController _labelAnimationController; + Animation _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( + onNotification: (ScrollNotification notification) { + changePosition(notification); + }, + child: Stack( + children: [ + 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 { + @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 oldClipper) => false; +} + +class SlideFadeTransition extends StatelessWidget { + final Animation 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, + ), + ), + ); + } +} diff --git a/lib/image_fullscreen_page.dart b/lib/image_fullscreen_page.dart index a654d179c..60b4d064d 100644 --- a/lib/image_fullscreen_page.dart +++ b/lib/image_fullscreen_page.dart @@ -1,7 +1,7 @@ import 'dart:math'; import 'dart:typed_data'; -import 'package:aves/image_fetcher.dart'; +import 'package:aves/model/image_fetcher.dart'; import 'package:flutter/material.dart'; class ImageFullscreenPage extends StatefulWidget { @@ -57,11 +57,15 @@ class ImageFullscreenPageState extends State { tag: uri, child: Stack( children: [ - Image.memory( - widget.thumbnail, - width: imageWidth.toDouble(), - height: imageHeight.toDouble(), - fit: BoxFit.contain, + Center( + child: widget.thumbnail == null + ? CircularProgressIndicator() + : Image.memory( + widget.thumbnail, + width: imageWidth.toDouble(), + height: imageHeight.toDouble(), + fit: BoxFit.contain, + ), ), if (ready) Image.memory( diff --git a/lib/main.dart b/lib/main.dart index c61ca0a9b..a4b555a76 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,4 @@ -import 'package:aves/image_fetcher.dart'; +import 'package:aves/model/image_fetcher.dart'; import 'package:aves/thumbnail_collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; diff --git a/lib/image_fetcher.dart b/lib/model/image_fetcher.dart similarity index 100% rename from lib/image_fetcher.dart rename to lib/model/image_fetcher.dart diff --git a/lib/thumbnail.dart b/lib/thumbnail.dart index be0bd4d91..60c157c93 100644 --- a/lib/thumbnail.dart +++ b/lib/thumbnail.dart @@ -1,8 +1,8 @@ import 'dart:math'; import 'dart:typed_data'; -import 'package:aves/image_fetcher.dart'; import 'package:aves/image_fullscreen_page.dart'; +import 'package:aves/model/image_fetcher.dart'; import 'package:aves/model/mime_types.dart'; import 'package:flutter/material.dart'; import 'package:transparent_image/transparent_image.dart'; diff --git a/lib/thumbnail_collection.dart b/lib/thumbnail_collection.dart index ca7c5262e..753ff4c75 100644 --- a/lib/thumbnail_collection.dart +++ b/lib/thumbnail_collection.dart @@ -1,7 +1,7 @@ +import 'package:aves/common/draggable_scrollbar.dart'; import 'package:aves/common/outlined_text.dart'; import 'package:aves/thumbnail.dart'; import "package:collection/collection.dart"; -import 'package:draggable_scrollbar/draggable_scrollbar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_sticky_header/flutter_sticky_header.dart'; import 'package:intl/intl.dart'; @@ -34,50 +34,37 @@ class ThumbnailCollection extends StatelessWidget { var columnCount = 4; var extent = MediaQuery.of(context).size.width / columnCount; - return CustomScrollView( - slivers: sections.keys - .map((sectionKey) => SliverStickyHeader( - header: SectionHeader(sectionKey), - sliver: SliverGrid( - delegate: SliverChildBuilderDelegate( - (context, index) { - var entries = sections[sectionKey]; - if (index >= entries.length) return null; - return Thumbnail( - entry: entries[index], - extent: extent, - ); - }, - childCount: sections[sectionKey].length, + return DraggableScrollbar.arrows( + labelTextBuilder: (double offset) => Text( + "${offset ~/ 1}", + style: TextStyle(color: Colors.blueGrey), + ), + controller: scrollController, + child: CustomScrollView( + controller: scrollController, + slivers: sections.keys + .map((sectionKey) => SliverStickyHeader( + header: SectionHeader(sectionKey), + sliver: SliverGrid( + delegate: SliverChildBuilderDelegate( + (context, index) { + var entries = sections[sectionKey]; + if (index >= entries.length) return null; + return Thumbnail( + entry: entries[index], + extent: extent, + ); + }, + childCount: sections[sectionKey].length, + ), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columnCount, + ), ), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: columnCount, - ), - ), - )) - .toList(), + )) + .toList(), + ), ); - -// return DraggableScrollbar.arrows( -// labelTextBuilder: (double offset) => Text( -// "${offset ~/ 1}", -// style: TextStyle(color: Colors.blueGrey), -// ), -// controller: scrollController, -// child: GridView.builder( -// controller: scrollController, -// gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( -// maxCrossAxisExtent: extent, -// ), -// itemBuilder: (gridContext, index) { -// return Thumbnail( -// entry: imageEntryList[index], -// extent: extent, -// ); -// }, -// itemCount: imageEntryList.length, -// ), -// ); } } diff --git a/pubspec.lock b/pubspec.lock index c838be640..9840a092d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -29,13 +29,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.14.11" - draggable_scrollbar: - dependency: "direct main" - description: - name: draggable_scrollbar - url: "https://pub.dartlang.org" - source: hosted - version: "0.0.4" flutter: dependency: "direct main" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 63ee668eb..e38725eba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,7 +20,6 @@ dependencies: flutter: sdk: flutter collection: - draggable_scrollbar: flutter_sticky_header: intl: transparent_image: