Add draggable scroll bar
This commit is contained in:
parent
ba9c9490dc
commit
892d04288a
5 changed files with 748 additions and 48 deletions
641
lib/ui/draggable_scrollbar.dart
Normal file
641
lib/ui/draggable_scrollbar.dart
Normal file
|
@ -0,0 +1,641 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
/// 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 position
|
||||
typedef Text LabelTextBuilder(double position);
|
||||
|
||||
/// 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 ScrollablePositionedList 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;
|
||||
|
||||
/// 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;
|
||||
|
||||
final ValueChanged<double> onChange;
|
||||
|
||||
final itemCount;
|
||||
|
||||
final initialScrollIndex;
|
||||
|
||||
DraggableScrollbar({
|
||||
Key key,
|
||||
this.alwaysVisibleScrollThumb = false,
|
||||
@required this.heightScrollThumb,
|
||||
@required this.backgroundColor,
|
||||
@required this.scrollThumbBuilder,
|
||||
@required this.child,
|
||||
@required this.onChange,
|
||||
@required this.itemCount,
|
||||
this.initialScrollIndex = 0,
|
||||
this.padding,
|
||||
this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
|
||||
this.scrollbarTimeToFade = const Duration(milliseconds: 600),
|
||||
this.labelTextBuilder,
|
||||
this.labelConstraints,
|
||||
}) : assert(onChange != 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.onChange,
|
||||
@required this.itemCount,
|
||||
this.initialScrollIndex = 0,
|
||||
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.onChange,
|
||||
@required this.itemCount,
|
||||
this.initialScrollIndex = 0,
|
||||
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.onChange,
|
||||
@required this.itemCount,
|
||||
this.initialScrollIndex = 0,
|
||||
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 _thumbOffset = 0.0;
|
||||
double _lastPosition = 0;
|
||||
bool _isDragInProcess;
|
||||
|
||||
AnimationController _thumbAnimationController;
|
||||
Animation<double> _thumbAnimation;
|
||||
AnimationController _labelAnimationController;
|
||||
Animation<double> _labelAnimation;
|
||||
Timer _fadeoutTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_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,
|
||||
);
|
||||
|
||||
if (widget.initialScrollIndex > 0 && widget.itemCount > 1) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
setState(() => _thumbOffset =
|
||||
(widget.initialScrollIndex / widget.itemCount) *
|
||||
(thumbMax - thumbMin));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_thumbAnimationController.dispose();
|
||||
_labelAnimationController.dispose();
|
||||
_fadeoutTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double get barMaxScrollExtent =>
|
||||
context.size.height - widget.heightScrollThumb;
|
||||
|
||||
double get barMinScrollExtent => 0.0;
|
||||
|
||||
double get viewMaxScrollExtent => 1;
|
||||
|
||||
double get viewMinScrollExtent => 0;
|
||||
|
||||
double get thumbMin => 0.0;
|
||||
|
||||
double get thumbMax => context.size.height - widget.heightScrollThumb;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget labelText;
|
||||
if (widget.labelTextBuilder != null && _isDragInProcess) {
|
||||
labelText = widget.labelTextBuilder(_lastPosition);
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints 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: _thumbOffset),
|
||||
padding: widget.padding,
|
||||
child: widget.scrollThumbBuilder(
|
||||
widget.backgroundColor,
|
||||
_thumbAnimation,
|
||||
_labelAnimation,
|
||||
widget.heightScrollThumb,
|
||||
labelText: labelText,
|
||||
labelConstraints: widget.labelConstraints,
|
||||
),
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void setPosition(double position) {
|
||||
final currentOffset = _thumbOffset;
|
||||
final newOffset = position * (thumbMax - thumbMin);
|
||||
if (currentOffset == newOffset) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_thumbOffset = newOffset;
|
||||
});
|
||||
}
|
||||
|
||||
//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 ||
|
||||
notification is OverscrollNotification) {
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
|
||||
_fadeoutTimer?.cancel();
|
||||
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
|
||||
_thumbAnimationController.reverse();
|
||||
_labelAnimationController.reverse();
|
||||
_fadeoutTimer = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setState(() {
|
||||
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) {
|
||||
_thumbOffset += details.delta.dy;
|
||||
_thumbOffset = _thumbOffset.clamp(thumbMin, thumbMax);
|
||||
double position = _thumbOffset / (thumbMax - thumbMin);
|
||||
_lastPosition = position;
|
||||
widget.onChange?.call(position);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -9,11 +10,11 @@ import 'package:photos/models/file.dart';
|
|||
import 'package:photos/models/selected_files.dart';
|
||||
import 'package:photos/ui/common_elements.dart';
|
||||
import 'package:photos/ui/detail_page.dart';
|
||||
import 'package:photos/ui/draggable_scrollbar.dart';
|
||||
import 'package:photos/ui/loading_widget.dart';
|
||||
import 'package:photos/ui/sync_indicator.dart';
|
||||
import 'package:photos/ui/thumbnail_widget.dart';
|
||||
import 'package:photos/utils/date_time_util.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
class Gallery extends StatefulWidget {
|
||||
final List<File> Function() syncLoader;
|
||||
|
@ -48,14 +49,15 @@ class _GalleryState extends State<Gallery> {
|
|||
|
||||
final Logger _logger = Logger("Gallery");
|
||||
final List<List<File>> _collatedFiles = List<List<File>>();
|
||||
final _scrollController = ItemScrollController();
|
||||
final _itemPositionsListener = ItemPositionsListener.create();
|
||||
final _scrollKey = GlobalKey<DraggableScrollbarState>();
|
||||
|
||||
ScrollController _scrollController = ScrollController();
|
||||
bool _requiresLoad = false;
|
||||
bool _hasLoadedAll = false;
|
||||
bool _isLoadingNext = false;
|
||||
double _scrollOffset = 0;
|
||||
List<File> _files;
|
||||
RefreshController _refreshController = RefreshController();
|
||||
int _lastIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -70,16 +72,21 @@ class _GalleryState extends State<Gallery> {
|
|||
});
|
||||
}
|
||||
widget.selectedFiles.addListener(() {
|
||||
setState(() {
|
||||
_saveScrollPosition();
|
||||
});
|
||||
setState(() {});
|
||||
});
|
||||
if (widget.asyncLoader == null) {
|
||||
_hasLoadedAll = true;
|
||||
}
|
||||
_itemPositionsListener.itemPositions.addListener(_moveScrollbar);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_itemPositionsListener.itemPositions.removeListener(_moveScrollbar);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_logger.info("Building");
|
||||
|
@ -112,37 +119,44 @@ class _GalleryState extends State<Gallery> {
|
|||
return nothingToSeeHere;
|
||||
}
|
||||
_collateFiles();
|
||||
_scrollController = ScrollController(
|
||||
initialScrollOffset: _scrollOffset,
|
||||
final itemCount =
|
||||
_collatedFiles.length + (widget.headerWidget == null ? 1 : 2);
|
||||
return DraggableScrollbar.semicircle(
|
||||
key: _scrollKey,
|
||||
labelTextBuilder: (position) {
|
||||
final index =
|
||||
min((position * itemCount).floor(), _collatedFiles.length - 1);
|
||||
return Text(
|
||||
getMonthAndYear(DateTime.fromMicrosecondsSinceEpoch(
|
||||
_collatedFiles[index][0].creationTime)),
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
backgroundColor: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
);
|
||||
},
|
||||
labelConstraints: BoxConstraints.tightFor(width: 100.0, height: 36.0),
|
||||
onChange: (position) {
|
||||
final index =
|
||||
min((position * itemCount).floor(), _collatedFiles.length - 1);
|
||||
if (index == _lastIndex) {
|
||||
return;
|
||||
}
|
||||
_lastIndex = index;
|
||||
_scrollController.jumpTo(index: index);
|
||||
},
|
||||
child: ScrollablePositionedList.builder(
|
||||
itemCount: itemCount,
|
||||
itemBuilder: _buildListItem,
|
||||
itemScrollController: _scrollController,
|
||||
minCacheExtent: 1500,
|
||||
addAutomaticKeepAlives: true,
|
||||
physics: _MaxVelocityPhysics(velocityThreshold: 128),
|
||||
itemPositionsListener: _itemPositionsListener,
|
||||
),
|
||||
itemCount: itemCount,
|
||||
);
|
||||
final list = ListView.builder(
|
||||
itemCount:
|
||||
_collatedFiles.length + (widget.headerWidget == null ? 1 : 2), // h4ck
|
||||
itemBuilder: _buildListItem,
|
||||
controller: _scrollController,
|
||||
cacheExtent: 1500,
|
||||
addAutomaticKeepAlives: true,
|
||||
);
|
||||
if (widget.onRefresh != null) {
|
||||
return SmartRefresher(
|
||||
controller: _refreshController,
|
||||
child: list,
|
||||
header: SyncIndicator(_refreshController),
|
||||
onRefresh: () {
|
||||
widget.onRefresh().then((_) {
|
||||
_refreshController.refreshCompleted();
|
||||
setState(() {
|
||||
_requiresLoad = true;
|
||||
});
|
||||
}).catchError((e) {
|
||||
_refreshController.refreshFailed();
|
||||
setState(() {});
|
||||
});
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildListItem(BuildContext context, int index) {
|
||||
|
@ -188,7 +202,6 @@ class _GalleryState extends State<Gallery> {
|
|||
widget.asyncLoader(_files[_files.length - 1], kLoadLimit).then((files) {
|
||||
setState(() {
|
||||
_isLoadingNext = false;
|
||||
_saveScrollPosition();
|
||||
if (files.length < kLoadLimit) {
|
||||
_hasLoadedAll = true;
|
||||
} else {
|
||||
|
@ -198,10 +211,6 @@ class _GalleryState extends State<Gallery> {
|
|||
});
|
||||
}
|
||||
|
||||
void _saveScrollPosition() {
|
||||
_scrollOffset = _scrollController.offset;
|
||||
}
|
||||
|
||||
Widget _getDay(int timestamp) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
|
@ -303,4 +312,28 @@ class _GalleryState extends State<Gallery> {
|
|||
firstDate.month == secondDate.month &&
|
||||
firstDate.day == secondDate.day;
|
||||
}
|
||||
|
||||
void _moveScrollbar() {
|
||||
final index = _itemPositionsListener.itemPositions.value.first.index;
|
||||
_scrollKey.currentState?.setPosition(index / _collatedFiles.length);
|
||||
}
|
||||
}
|
||||
|
||||
class _MaxVelocityPhysics extends AlwaysScrollableScrollPhysics {
|
||||
final double velocityThreshold;
|
||||
|
||||
_MaxVelocityPhysics({@required this.velocityThreshold, ScrollPhysics parent})
|
||||
: super(parent: parent);
|
||||
|
||||
@override
|
||||
bool recommendDeferredLoading(
|
||||
double velocity, ScrollMetrics metrics, BuildContext context) {
|
||||
return velocity.abs() > velocityThreshold;
|
||||
}
|
||||
|
||||
@override
|
||||
_MaxVelocityPhysics applyTo(ScrollPhysics ancestor) {
|
||||
return _MaxVelocityPhysics(
|
||||
velocityThreshold: velocityThreshold, parent: buildParent(ancestor));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -103,13 +103,23 @@ String formatDuration(Duration position) {
|
|||
var minutes = seconds ~/ 60;
|
||||
seconds = seconds % 60;
|
||||
|
||||
final hoursString = hours >= 10 ? '$hours' : hours == 0 ? '00' : '0$hours';
|
||||
final hoursString = hours >= 10
|
||||
? '$hours'
|
||||
: hours == 0
|
||||
? '00'
|
||||
: '0$hours';
|
||||
|
||||
final minutesString =
|
||||
minutes >= 10 ? '$minutes' : minutes == 0 ? '00' : '0$minutes';
|
||||
final minutesString = minutes >= 10
|
||||
? '$minutes'
|
||||
: minutes == 0
|
||||
? '00'
|
||||
: '0$minutes';
|
||||
|
||||
final secondsString =
|
||||
seconds >= 10 ? '$seconds' : seconds == 0 ? '00' : '0$seconds';
|
||||
final secondsString = seconds >= 10
|
||||
? '$seconds'
|
||||
: seconds == 0
|
||||
? '00'
|
||||
: '0$seconds';
|
||||
|
||||
final formattedTime =
|
||||
'${hoursString == '00' ? '' : hoursString + ':'}$minutesString:$secondsString';
|
||||
|
|
14
pubspec.lock
14
pubspec.lock
|
@ -506,6 +506,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.6.2"
|
||||
quiver:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: quiver
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -513,6 +520,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.24.1"
|
||||
scrollable_positioned_list:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: scrollable_positioned_list
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.8"
|
||||
sentry:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
|
@ -60,6 +60,8 @@ dependencies:
|
|||
pedantic: ^1.9.2
|
||||
page_transition: "^1.1.7+2"
|
||||
convex_bottom_bar: ^2.6.0
|
||||
scrollable_positioned_list: ^0.1.8
|
||||
quiver: ^2.1.5
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
Loading…
Add table
Reference in a new issue