181 lines
5.0 KiB
Dart
181 lines
5.0 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../../core/theme/app_colors.dart';
|
|
import '../../../core/theme/app_dimens.dart';
|
|
|
|
const double _maxPull = 96;
|
|
const double _refreshThreshold = 56;
|
|
const double _firstPageEpsilon = 0.01;
|
|
|
|
/// 第一条已经到顶时,向下的拖动不再翻页。
|
|
class FeedRefreshPhysics extends PageScrollPhysics {
|
|
const FeedRefreshPhysics({super.parent});
|
|
|
|
@override
|
|
FeedRefreshPhysics applyTo(ScrollPhysics? ancestor) {
|
|
return FeedRefreshPhysics(parent: buildParent(ancestor));
|
|
}
|
|
|
|
@override
|
|
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
|
|
if (position.pixels <= position.minScrollExtent && offset > 0) {
|
|
return 0;
|
|
}
|
|
return super.applyPhysicsToUserOffset(position, offset);
|
|
}
|
|
|
|
@override
|
|
Simulation? createBallisticSimulation(
|
|
ScrollMetrics position,
|
|
double velocity,
|
|
) {
|
|
if (position.pixels <= position.minScrollExtent && velocity < 0) {
|
|
return super.createBallisticSimulation(position, 0);
|
|
}
|
|
return super.createBallisticSimulation(position, velocity);
|
|
}
|
|
}
|
|
|
|
/// 竖滑到第一条后继续下拉时刷新,其它页仍交给翻页。
|
|
class FeedPullRefresh extends StatefulWidget {
|
|
const FeedPullRefresh({
|
|
super.key,
|
|
required this.pageController,
|
|
required this.onRefresh,
|
|
required this.child,
|
|
this.topInset = 0,
|
|
});
|
|
|
|
final PageController pageController;
|
|
final Future<void> Function() onRefresh;
|
|
final Widget child;
|
|
final double topInset;
|
|
|
|
@override
|
|
State<FeedPullRefresh> createState() => _FeedPullRefreshState();
|
|
}
|
|
|
|
class _FeedPullRefreshState extends State<FeedPullRefresh> {
|
|
Offset? _origin;
|
|
double _pull = 0;
|
|
bool _tracking = false;
|
|
bool _refreshing = false;
|
|
|
|
bool _atFirstPage() {
|
|
final controller = widget.pageController;
|
|
if (!controller.hasClients) return false;
|
|
return (controller.page ?? 0) <= _firstPageEpsilon;
|
|
}
|
|
|
|
void _setPull(double value) {
|
|
final next = value.clamp(0.0, _maxPull);
|
|
if (next == _pull) return;
|
|
setState(() => _pull = next);
|
|
}
|
|
|
|
void _onPointerDown(PointerDownEvent event) {
|
|
if (_refreshing || !_atFirstPage()) {
|
|
_tracking = false;
|
|
return;
|
|
}
|
|
_origin = event.position;
|
|
_tracking = true;
|
|
}
|
|
|
|
void _onPointerMove(PointerMoveEvent event) {
|
|
final origin = _origin;
|
|
if (!_tracking || origin == null || _refreshing) return;
|
|
final delta = event.position - origin;
|
|
if (delta.dx.abs() > delta.dy.abs() && delta.dy < _refreshThreshold) {
|
|
_tracking = false;
|
|
_setPull(0);
|
|
return;
|
|
}
|
|
if (delta.dy > 0) _holdFirstPage();
|
|
_setPull(delta.dy);
|
|
}
|
|
|
|
void _holdFirstPage() {
|
|
final controller = widget.pageController;
|
|
if (!controller.hasClients) return;
|
|
final position = controller.position;
|
|
if (position.pixels <= position.minScrollExtent) return;
|
|
controller.jumpTo(position.minScrollExtent);
|
|
}
|
|
|
|
void _onPointerEnd() {
|
|
final shouldRefresh = _tracking && _pull >= _refreshThreshold;
|
|
_origin = null;
|
|
_tracking = false;
|
|
if (shouldRefresh) {
|
|
_refresh();
|
|
return;
|
|
}
|
|
_setPull(0);
|
|
}
|
|
|
|
Future<void> _refresh() async {
|
|
if (_refreshing) return;
|
|
_refreshing = true;
|
|
_setPull(_refreshThreshold);
|
|
try {
|
|
await widget.onRefresh();
|
|
} finally {
|
|
_refreshing = false;
|
|
if (mounted) _setPull(0);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Listener(
|
|
onPointerDown: _onPointerDown,
|
|
onPointerMove: _onPointerMove,
|
|
onPointerUp: (_) => _onPointerEnd(),
|
|
onPointerCancel: (_) => _onPointerEnd(),
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
widget.child,
|
|
if (_pull > 0)
|
|
Positioned(
|
|
top: widget.topInset,
|
|
left: 0,
|
|
right: 0,
|
|
child: IgnorePointer(
|
|
child: Align(
|
|
alignment: Alignment.topCenter,
|
|
child: Container(
|
|
key: const Key('pull-refresh-indicator'),
|
|
width: AppDimens.refreshIndicatorSize,
|
|
height: AppDimens.refreshIndicatorSize,
|
|
padding: const EdgeInsets.all(
|
|
(AppDimens.refreshIndicatorSize -
|
|
AppDimens.refreshSpinnerSize) /
|
|
2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.refreshIndicatorSurface,
|
|
borderRadius: BorderRadius.circular(
|
|
AppDimens.refreshIndicatorRadius,
|
|
),
|
|
),
|
|
child: const SizedBox.square(
|
|
dimension: AppDimens.refreshSpinnerSize,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: AppDimens.refreshSpinnerStroke,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|