diff --git a/lib/common/widgets/flutter/refresh_indicator.dart b/lib/common/widgets/flutter/refresh_indicator.dart index a24b045ed..c51039977 100644 --- a/lib/common/widgets/flutter/refresh_indicator.dart +++ b/lib/common/widgets/flutter/refresh_indicator.dart @@ -2,18 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// ignore_for_file: uri_does_not_exist_in_doc_import - -/// @docImport 'color_scheme.dart'; -library; - -import 'dart:async'; -import 'dart:math' as math; +import 'dart:async' show Completer; +import 'dart:io' show Platform; +import 'package:PiliPlus/common/widgets/scroll_behavior.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart' show clampDouble; -import 'package:flutter/material.dart' hide RefreshIndicator; +import 'package:flutter/material.dart'; double displacement = Pref.refreshDisplacement; @@ -33,21 +28,13 @@ const Duration _kIndicatorSnapDuration = Duration(milliseconds: 150); // has completed. const Duration _kIndicatorScaleDuration = Duration(milliseconds: 200); -/// The signature for a function that's called when the user has dragged a -/// [RefreshIndicator] far enough to demonstrate that they want the app to -/// refresh. The returned [Future] must complete when the refresh operation is -/// finished. -/// -/// Used by [RefreshIndicator.onRefresh]. -typedef RefreshCallback = Future Function(); - /// Indicates current status of Material `RefreshIndicator`. enum RefreshIndicatorStatus { /// Pointer is down. drag, /// Dragged far enough that an up event will run the onRefresh callback. - armed, + // armed, /// Animating to the indicator's final "displacement". snap, @@ -62,19 +49,6 @@ enum RefreshIndicatorStatus { canceled, } -/// Used to configure how [RefreshIndicator] can be triggered. -enum RefreshIndicatorTriggerMode { - /// The indicator can be triggered regardless of the scroll position - /// of the [Scrollable] when the drag starts. - anywhere, - - /// The indicator can only be triggered if the [Scrollable] is at the edge - /// when the drag starts. - onEdge, -} - -enum _IndicatorType { material, adaptive, noSpinner } - /// A widget that supports the Material "swipe to refresh" idiom. /// /// {@youtube 560 315 https://www.youtube.com/watch?v=ORApMlzwMdM} @@ -155,74 +129,11 @@ class RefreshIndicator extends StatefulWidget { this.color, this.backgroundColor, this.notificationPredicate = defaultScrollNotificationPredicate, - this.semanticsLabel, - this.semanticsValue, this.strokeWidth = RefreshProgressIndicator.defaultStrokeWidth, - this.triggerMode = RefreshIndicatorTriggerMode.onEdge, this.elevation = 2.0, + this.isClampingScrollPhysics = false, required this.child, - }) : _indicatorType = _IndicatorType.material, - onStatusChange = null, - assert(elevation >= 0.0); - - /// Creates an adaptive [RefreshIndicator] based on whether the target - /// platform is iOS or macOS, following Material design's - /// [Cross-platform guidelines](https://material.io/design/platform-guidance/cross-platform-adaptation.html). - /// - /// When the descendant overscrolls, a different spinning progress indicator - /// is shown depending on platform. On iOS and macOS, - /// [CupertinoActivityIndicator] is shown, but on all other platforms, - /// [CircularProgressIndicator] appears. - /// - /// If a [CupertinoActivityIndicator] is shown, the following parameters are ignored: - /// [backgroundColor], [semanticsLabel], [semanticsValue], [strokeWidth]. - /// - /// The target platform is based on the current [Theme]: [ThemeData.platform]. - /// - /// Notably the scrollable widget itself will have slightly different behavior - /// from [CupertinoSliverRefreshControl], due to a difference in structure. - const RefreshIndicator.adaptive({ - super.key, - this.displacement = 40.0, - this.edgeOffset = 0.0, - required this.onRefresh, - this.color, - this.backgroundColor, - this.notificationPredicate = defaultScrollNotificationPredicate, - this.semanticsLabel, - this.semanticsValue, - this.strokeWidth = RefreshProgressIndicator.defaultStrokeWidth, - this.triggerMode = RefreshIndicatorTriggerMode.onEdge, - this.elevation = 2.0, - required this.child, - }) : _indicatorType = _IndicatorType.adaptive, - onStatusChange = null, - assert(elevation >= 0.0); - - /// Creates a [RefreshIndicator] with no spinner and calls `onRefresh` when - /// successfully armed by a drag event. - /// - /// Events can be optionally listened by using the `onStatusChange` callback. - const RefreshIndicator.noSpinner({ - super.key, - required this.onRefresh, - this.onStatusChange, - this.notificationPredicate = defaultScrollNotificationPredicate, - this.semanticsLabel, - this.semanticsValue, - this.triggerMode = RefreshIndicatorTriggerMode.onEdge, - this.elevation = 2.0, - required this.child, - }) : _indicatorType = _IndicatorType.noSpinner, - // The following parameters aren't used because [_IndicatorType.noSpinner] is being used, - // which involves showing no spinner, hence the following parameters are useless since - // their only use is to change the spinner's appearance. - displacement = 0.0, - edgeOffset = 0.0, - color = null, - backgroundColor = null, - strokeWidth = 0.0, - assert(elevation >= 0.0); + }) : assert(elevation >= 0.0); /// The widget below this widget in the tree. /// @@ -262,10 +173,6 @@ class RefreshIndicator extends StatefulWidget { /// [Future] must complete when the refresh operation is finished. final RefreshCallback onRefresh; - /// Called to get the current status of the [RefreshIndicator] to update the UI as needed. - /// This is an optional parameter, used to fine tune app cases. - final ValueChanged? onStatusChange; - /// The progress indicator's foreground color. The current theme's /// [ColorScheme.primary] by default. final Color? color; @@ -281,42 +188,18 @@ class RefreshIndicator extends StatefulWidget { /// else for more complicated layouts. final ScrollNotificationPredicate notificationPredicate; - /// {@macro flutter.progress_indicator.ProgressIndicator.semanticsLabel} - /// - /// This will be defaulted to [MaterialLocalizations.refreshIndicatorSemanticLabel] - /// if it is null. - final String? semanticsLabel; - - /// {@macro flutter.progress_indicator.ProgressIndicator.semanticsValue} - final String? semanticsValue; - /// Defines [strokeWidth] for `RefreshIndicator`. /// /// By default, the value of [strokeWidth] is 2.0 pixels. final double strokeWidth; - final _IndicatorType _indicatorType; - - /// Defines how this [RefreshIndicator] can be triggered when users overscroll. - /// - /// The [RefreshIndicator] can be pulled out in two cases, - /// 1, Keep dragging if the scrollable widget at the edge with zero scroll position - /// when the drag starts. - /// 2, Keep dragging after overscroll occurs if the scrollable widget has - /// a non-zero scroll position when the drag starts. - /// - /// If this is [RefreshIndicatorTriggerMode.anywhere], both of the cases above can be triggered. - /// - /// If this is [RefreshIndicatorTriggerMode.onEdge], only case 1 can be triggered. - /// - /// Defaults to [RefreshIndicatorTriggerMode.onEdge]. - final RefreshIndicatorTriggerMode triggerMode; - /// Defines the elevation of the underlying [RefreshIndicator]. /// /// Defaults to 2.0. final double elevation; + final bool isClampingScrollPhysics; + @override RefreshIndicatorState createState() => RefreshIndicatorState(); } @@ -324,17 +207,14 @@ class RefreshIndicator extends StatefulWidget { /// Contains the state for a [RefreshIndicator]. This class can be used to /// programmatically show the refresh indicator, see the [show] method. class RefreshIndicatorState extends State - with TickerProviderStateMixin { + with SingleTickerProviderStateMixin { late AnimationController _positionController; - late AnimationController _scaleController; late Animation _positionFactor; - late Animation _scaleFactor; late Animation _value; late Animation _valueColor; RefreshIndicatorStatus? _status; late Future _pendingRefreshFuture; - bool? _isIndicatorAtTop; double? _dragOffset; late Color _effectiveValueColor = widget.color ?? Theme.of(context).colorScheme.primary; @@ -349,11 +229,6 @@ class RefreshIndicatorState extends State end: _kDragSizeFactorLimit, ); - static final Animatable _oneToZeroTween = Tween( - begin: 1.0, - end: 0.0, - ); - @protected @override void initState() { @@ -363,9 +238,6 @@ class RefreshIndicatorState extends State // The "value" of the circular progress indicator during a drag. _value = _positionController.drive(_threeQuarterTween); - - _scaleController = AnimationController(vsync: this); - _scaleFactor = _scaleController.drive(_oneToZeroTween); } @protected @@ -388,7 +260,6 @@ class RefreshIndicatorState extends State @override void dispose() { _positionController.dispose(); - _scaleController.dispose(); super.dispose(); } @@ -417,77 +288,52 @@ class RefreshIndicatorState extends State // If the notification.dragDetails is null, this scroll is not triggered by // user dragging. It may be a result of ScrollController.jumpTo or ballistic scroll. // In this case, we don't want to trigger the refresh indicator. - return ((notification is ScrollStartNotification && - notification.dragDetails != null) || - (notification is ScrollUpdateNotification && - notification.dragDetails != null && - widget.triggerMode == RefreshIndicatorTriggerMode.anywhere)) && - ((notification.metrics.axisDirection == AxisDirection.up && - notification.metrics.extentAfter == 0.0) || - (notification.metrics.axisDirection == AxisDirection.down && - notification.metrics.extentBefore == 0.0)) && + return (notification is ScrollStartNotification && + notification.dragDetails != null) && + notification.metrics.extentBefore == 0.0 && _status == null && - _start(notification.metrics.axisDirection); + _start(); } + double? _viewportDimension; + bool _handleScrollNotification(ScrollNotification notification) { if (!widget.notificationPredicate(notification)) { return false; } if (_shouldStart(notification)) { + _viewportDimension = notification.metrics.viewportDimension; setState(() { _status = RefreshIndicatorStatus.drag; - widget.onStatusChange?.call(_status); }); return false; } - final bool? indicatorAtTopNow = - switch (notification.metrics.axisDirection) { - AxisDirection.down || AxisDirection.up => true, - AxisDirection.left || AxisDirection.right => null, - }; - if (indicatorAtTopNow != _isIndicatorAtTop) { - if (_status == RefreshIndicatorStatus.drag || - _status == RefreshIndicatorStatus.armed) { - _dismiss(RefreshIndicatorStatus.canceled); - } - } else if (notification is ScrollUpdateNotification) { - if (_status == RefreshIndicatorStatus.drag || - _status == RefreshIndicatorStatus.armed) { - if (notification.metrics.axisDirection == AxisDirection.down) { - _dragOffset = _dragOffset! - notification.scrollDelta!; - } else if (notification.metrics.axisDirection == AxisDirection.up) { - _dragOffset = _dragOffset! + notification.scrollDelta!; - } + if (notification is ScrollUpdateNotification) { + if (_status == RefreshIndicatorStatus.drag) { + _dragOffset = _dragOffset! - notification.scrollDelta!; _checkDragOffset(notification.metrics.viewportDimension); } - if (_status == RefreshIndicatorStatus.armed && - notification.dragDetails == null) { + if (_status == RefreshIndicatorStatus.drag && + notification.dragDetails == null && + _valueColor.value!.a == _effectiveValueColor.a) { // On iOS start the refresh when the Scrollable bounces back from the // overscroll (ScrollNotification indicating this don't have dragDetails // because the scroll activity is not directly triggered by a drag). _show(); } } else if (notification is OverscrollNotification) { - if (_status == RefreshIndicatorStatus.drag || - _status == RefreshIndicatorStatus.armed) { - if (notification.metrics.axisDirection == AxisDirection.down) { - _dragOffset = _dragOffset! - notification.overscroll; - } else if (notification.metrics.axisDirection == AxisDirection.up) { - _dragOffset = _dragOffset! + notification.overscroll; - } + if (_status == RefreshIndicatorStatus.drag) { + _dragOffset = _dragOffset! - notification.overscroll; _checkDragOffset(notification.metrics.viewportDimension); } } else if (notification is ScrollEndNotification) { switch (_status) { - case RefreshIndicatorStatus.armed: - if (_positionController.value < 1.0) { - _dismiss(RefreshIndicatorStatus.canceled); - } else { - _show(); - } case RefreshIndicatorStatus.drag: - _dismiss(RefreshIndicatorStatus.canceled); + if (_valueColor.value!.a == _effectiveValueColor.a) { + _show(); + } else { + _dismiss(RefreshIndicatorStatus.canceled); + } case RefreshIndicatorStatus.canceled: case RefreshIndicatorStatus.done: case RefreshIndicatorStatus.refresh: @@ -513,46 +359,25 @@ class RefreshIndicatorState extends State return false; } - bool _start(AxisDirection direction) { + bool _start() { assert(_status == null); - assert(_isIndicatorAtTop == null); assert(_dragOffset == null); - switch (direction) { - case AxisDirection.down: - case AxisDirection.up: - _isIndicatorAtTop = true; - case AxisDirection.left: - case AxisDirection.right: - _isIndicatorAtTop = null; - // we do not support horizontal scroll views. - return false; - } _dragOffset = 0.0; - _scaleController.value = 0.0; _positionController.value = 0.0; return true; } void _checkDragOffset(double containerExtent) { assert( - _status == RefreshIndicatorStatus.drag || - _status == RefreshIndicatorStatus.armed, + _status == RefreshIndicatorStatus.drag, ); double newValue = _dragOffset! / (containerExtent * kDragContainerExtentPercentage); - if (_status == RefreshIndicatorStatus.armed) { - newValue = math.max(newValue, 1.0 / _kDragSizeFactorLimit); - } _positionController.value = clampDouble( newValue, 0.0, 1.0, ); // This triggers various rebuilds. - if (_status == RefreshIndicatorStatus.drag && - _valueColor.value!.a == _effectiveValueColor.a) { - _status = RefreshIndicatorStatus.armed; - widget.onStatusChange?.call(_status); - } } // Stop showing the refresh indicator. @@ -567,20 +392,15 @@ class RefreshIndicatorState extends State ); setState(() { _status = newMode; - widget.onStatusChange?.call(_status); }); switch (_status!) { case RefreshIndicatorStatus.done: - await _scaleController.animateTo( - 1.0, - duration: _kIndicatorScaleDuration, - ); + break; case RefreshIndicatorStatus.canceled: await _positionController.animateTo( 0.0, duration: _kIndicatorScaleDuration, ); - case RefreshIndicatorStatus.armed: case RefreshIndicatorStatus.drag: case RefreshIndicatorStatus.refresh: case RefreshIndicatorStatus.snap: @@ -588,7 +408,6 @@ class RefreshIndicatorState extends State } if (mounted && _status == newMode) { _dragOffset = null; - _isIndicatorAtTop = null; setState(() { _status = null; }); @@ -601,7 +420,6 @@ class RefreshIndicatorState extends State final Completer completer = Completer(); _pendingRefreshFuture = completer.future; _status = RefreshIndicatorStatus.snap; - widget.onStatusChange?.call(_status); _positionController .animateTo( 1.0 / _kDragSizeFactorLimit, @@ -640,11 +458,11 @@ class RefreshIndicatorState extends State /// When initiated in this manner, the refresh indicator is independent of any /// actual scroll view. It defaults to showing the indicator at the top. To /// show it at the bottom, set `atTop` to false. - Future show({bool atTop = true}) { + Future show() { if (_status != RefreshIndicatorStatus.refresh && _status != RefreshIndicatorStatus.snap) { if (_status == null) { - _start(atTop ? AxisDirection.down : AxisDirection.up); + _start(); } _show(); } @@ -655,7 +473,7 @@ class RefreshIndicatorState extends State @override Widget build(BuildContext context) { assert(debugCheckHasMaterialLocalizations(context)); - final Widget child = NotificationListener( + Widget child = NotificationListener( onNotification: _handleScrollNotification, child: NotificationListener( onNotification: _handleIndicatorNotification, @@ -665,10 +483,8 @@ class RefreshIndicatorState extends State assert(() { if (_status == null) { assert(_dragOffset == null); - assert(_isIndicatorAtTop == null); } else { assert(_dragOffset != null); - assert(_isIndicatorAtTop != null); } return true; }()); @@ -677,75 +493,30 @@ class RefreshIndicatorState extends State _status == RefreshIndicatorStatus.refresh || _status == RefreshIndicatorStatus.done; - return Stack( + child = Stack( clipBehavior: Clip.none, children: [ child, if (_status != null) Positioned( - top: _isIndicatorAtTop! ? widget.edgeOffset : null, - bottom: !_isIndicatorAtTop! ? widget.edgeOffset : null, + top: widget.edgeOffset, left: 0.0, right: 0.0, child: SizeTransition( - axisAlignment: _isIndicatorAtTop! ? 1.0 : -1.0, + axisAlignment: 1.0, sizeFactor: _positionFactor, // This is what brings it down. child: Padding( - padding: _isIndicatorAtTop! - ? EdgeInsets.only(top: widget.displacement) - : EdgeInsets.only(bottom: widget.displacement), + padding: EdgeInsets.only(top: widget.displacement), child: Align( - alignment: _isIndicatorAtTop! - ? Alignment.topCenter - : Alignment.bottomCenter, - child: ScaleTransition( - scale: _scaleFactor, - child: AnimatedBuilder( - animation: _positionController, - builder: (BuildContext context, Widget? child) { - final Widget materialIndicator = - RefreshProgressIndicator( - semanticsLabel: - widget.semanticsLabel ?? - MaterialLocalizations.of( - context, - ).refreshIndicatorSemanticLabel, - semanticsValue: widget.semanticsValue, - value: showIndeterminateIndicator - ? null - : _value.value, - valueColor: _valueColor, - backgroundColor: widget.backgroundColor, - strokeWidth: widget.strokeWidth, - elevation: widget.elevation, - ); - - final Widget cupertinoIndicator = - CupertinoActivityIndicator( - color: widget.color, - ); - - switch (widget._indicatorType) { - case _IndicatorType.material: - return materialIndicator; - - case _IndicatorType.adaptive: - final ThemeData theme = Theme.of(context); - switch (theme.platform) { - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - return materialIndicator; - case TargetPlatform.iOS: - case TargetPlatform.macOS: - return cupertinoIndicator; - } - - case _IndicatorType.noSpinner: - return const SizedBox.shrink(); - } - }, + alignment: Alignment.topCenter, + child: AnimatedBuilder( + animation: _positionController, + builder: (context, child) => RefreshProgressIndicator( + value: showIndeterminateIndicator ? null : _value.value, + valueColor: _valueColor, + backgroundColor: widget.backgroundColor, + strokeWidth: widget.strokeWidth, + elevation: widget.elevation, ), ), ), @@ -754,16 +525,81 @@ class RefreshIndicatorState extends State ), ], ); + if (!widget.isClampingScrollPhysics && + (Platform.isIOS || Platform.isMacOS)) { + return child; + } + return ScrollConfiguration( + behavior: RefreshScrollBehavior( + desktopDragDevices, + scrollPhysics: RefreshScrollPhysics(onDrag: _onDrag), + ), + child: child, + ); + } + + bool _onDrag(double offset) { + if (_positionController.value > 0.0 && + _status == RefreshIndicatorStatus.drag && + _viewportDimension != null) { + _dragOffset = _dragOffset! + offset; + _checkDragOffset(_viewportDimension!); + return true; + } + return false; } } Widget refreshIndicator({ required RefreshCallback onRefresh, required Widget child, + bool isClampingScrollPhysics = false, }) { return RefreshIndicator( displacement: displacement, onRefresh: onRefresh, + isClampingScrollPhysics: isClampingScrollPhysics, child: child, ); } + +class RefreshScrollBehavior extends CustomScrollBehavior { + const RefreshScrollBehavior( + super.dragDevices, { + required this.scrollPhysics, + }); + + final RefreshScrollPhysics scrollPhysics; + + @override + ScrollPhysics getScrollPhysics(BuildContext context) { + return scrollPhysics; + } +} + +typedef OnDrag = bool Function(double offset); + +class RefreshScrollPhysics extends ClampingScrollPhysics { + const RefreshScrollPhysics({ + super.parent, + required this.onDrag, + }); + + final OnDrag onDrag; + + @override + RefreshScrollPhysics applyTo(ScrollPhysics? ancestor) { + return RefreshScrollPhysics( + parent: buildParent(ancestor), + onDrag: onDrag, + ); + } + + @override + double applyPhysicsToUserOffset(ScrollMetrics position, double offset) { + if (offset < 0.0 && onDrag(offset)) { + return 0.0; + } + return parent?.applyPhysicsToUserOffset(position, offset) ?? offset; + } +} diff --git a/lib/pages/audio/view.dart b/lib/pages/audio/view.dart index 9377c087b..d3cb0d1f1 100644 --- a/lib/pages/audio/view.dart +++ b/lib/pages/audio/view.dart @@ -236,6 +236,7 @@ class _AudioPageState extends State { ), child: refreshIndicator( onRefresh: () => _controller.loadPrev(context), + isClampingScrollPhysics: true, child: CustomScrollView( controller: scrollController, physics: _controller.reachStart diff --git a/lib/pages/video/medialist/view.dart b/lib/pages/video/medialist/view.dart index 25b66c51b..3a0b097ba 100644 --- a/lib/pages/video/medialist/view.dart +++ b/lib/pages/video/medialist/view.dart @@ -12,7 +12,7 @@ import 'package:PiliPlus/models_new/video/video_detail/episode.dart'; import 'package:PiliPlus/pages/common/slide/common_slide_page.dart'; import 'package:PiliPlus/utils/duration_utils.dart'; import 'package:PiliPlus/utils/platform_utils.dart'; -import 'package:flutter/material.dart' hide RefreshCallback; +import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; diff --git a/lib/pages/video/reply/view.dart b/lib/pages/video/reply/view.dart index 61ee1fb3a..67c775014 100644 --- a/lib/pages/video/reply/view.dart +++ b/lib/pages/video/reply/view.dart @@ -73,6 +73,7 @@ class _VideoReplyPanelState extends State }, child: refreshIndicator( onRefresh: _videoReplyController.onRefresh, + isClampingScrollPhysics: widget.isNested, child: Stack( clipBehavior: Clip.none, children: [ diff --git a/lib/pages/video/reply_reply/view.dart b/lib/pages/video/reply_reply/view.dart index 100a6de97..500ad6da7 100644 --- a/lib/pages/video/reply_reply/view.dart +++ b/lib/pages/video/reply_reply/view.dart @@ -181,6 +181,7 @@ class _VideoReplyReplyPanelState extends State Widget buildList(ThemeData theme) { final child = refreshIndicator( onRefresh: _controller.onRefresh, + isClampingScrollPhysics: widget.isNested, child: CustomScrollView( key: ValueKey(scrollController.hashCode), controller: scrollController,