// 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 Function() onRefresh; final Widget child; final double topInset; @override State createState() => _FeedPullRefreshState(); } class _FeedPullRefreshState extends State { 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 _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, ), ), ), ), ), ), ], ), ); } }