app_refresh_indicator.dart 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. import 'package:flutter/material.dart';
  2. /// 品牌化下拉刷新组件
  3. ///
  4. /// 图标固定在 AppBar 下方 64px 区域内居中;
  5. /// 内容区用 Transform.translate 下移(仅 paint-time offset,不触发 layout),
  6. /// 与参考视频效果一致。
  7. ///
  8. /// 兼容 iOS(BouncingScrollPhysics)和 Android(ClampingScrollPhysics)。
  9. class AppRefreshIndicator extends StatefulWidget {
  10. const AppRefreshIndicator({
  11. super.key,
  12. required this.onRefresh,
  13. required this.child,
  14. });
  15. final Future<void> Function() onRefresh;
  16. final Widget child;
  17. @override
  18. State<AppRefreshIndicator> createState() => _AppRefreshIndicatorState();
  19. }
  20. enum _PtrState { idle, pulling, ready, loading }
  21. class _AppRefreshIndicatorState extends State<AppRefreshIndicator>
  22. with TickerProviderStateMixin {
  23. static const double _kThreshold = 64.0;
  24. static const double _kMaxPull = 110.0;
  25. late final AnimationController _spinCtrl;
  26. late final AnimationController _collapseCtrl;
  27. late Animation<double> _collapseAnim;
  28. _PtrState _ptrState = _PtrState.idle;
  29. double _pullHeight = 0.0;
  30. bool _tracking = false;
  31. double _bounceScale = 1.0;
  32. bool _bouncePlayed = false;
  33. @override
  34. void initState() {
  35. super.initState();
  36. _spinCtrl = AnimationController(
  37. vsync: this,
  38. duration: const Duration(milliseconds: 800),
  39. );
  40. _collapseCtrl = AnimationController(vsync: this);
  41. _collapseAnim = const AlwaysStoppedAnimation(0.0);
  42. _collapseCtrl.addListener(() {
  43. if (mounted) setState(() {});
  44. });
  45. }
  46. @override
  47. void dispose() {
  48. _spinCtrl.dispose();
  49. _collapseCtrl.dispose();
  50. super.dispose();
  51. }
  52. double get _displayHeight {
  53. if (_ptrState == _PtrState.loading) return _kThreshold;
  54. if (_collapseCtrl.isAnimating) return _collapseAnim.value;
  55. return _pullHeight;
  56. }
  57. void _animateCollapse({required double from}) {
  58. _collapseAnim = Tween<double>(begin: from, end: 0).animate(
  59. CurvedAnimation(
  60. parent: _collapseCtrl,
  61. curve: const Cubic(0.34, 1.20, 0.64, 1.00),
  62. ),
  63. );
  64. _collapseCtrl.duration = const Duration(milliseconds: 360);
  65. _collapseCtrl.forward(from: 0);
  66. }
  67. bool _handleNotification(ScrollNotification notification) {
  68. if (_ptrState == _PtrState.loading) return false;
  69. if (notification is ScrollStartNotification) {
  70. if (notification.metrics.extentBefore == 0.0) {
  71. _collapseCtrl.stop();
  72. _tracking = true;
  73. }
  74. return false;
  75. }
  76. if (notification is ScrollUpdateNotification && _tracking) {
  77. if (notification.metrics.extentBefore > 0.0) {
  78. _tracking = false;
  79. if (_pullHeight > 0) _collapse();
  80. return false;
  81. }
  82. final raw = -(notification.scrollDelta ?? 0.0);
  83. if (raw > 0) {
  84. final resistance = 1.0 - (_pullHeight / _kMaxPull).clamp(0.0, 1.0);
  85. _updatePullHeight(_pullHeight + raw * resistance * 0.55);
  86. } else if (raw < 0) {
  87. _updatePullHeight((_pullHeight + raw).clamp(0.0, _kMaxPull));
  88. }
  89. return false;
  90. }
  91. if (notification is OverscrollNotification &&
  92. notification.overscroll < 0 &&
  93. _tracking) {
  94. final raw = -notification.overscroll;
  95. final resistance = 1.0 - (_pullHeight / _kMaxPull).clamp(0.0, 1.0);
  96. _updatePullHeight(_pullHeight + raw * resistance * 0.55);
  97. return false;
  98. }
  99. if (notification is ScrollEndNotification && _tracking) {
  100. _tracking = false;
  101. if (_ptrState == _PtrState.ready) {
  102. _startLoading();
  103. } else if (_pullHeight > 0) {
  104. _collapse();
  105. }
  106. return false;
  107. }
  108. return false;
  109. }
  110. void _updatePullHeight(double h) {
  111. final newH = h.clamp(0.0, _kMaxPull);
  112. final wasReady = _ptrState == _PtrState.ready;
  113. final nowReady = newH >= _kThreshold;
  114. setState(() {
  115. _pullHeight = newH;
  116. _ptrState = nowReady ? _PtrState.ready : _PtrState.pulling;
  117. });
  118. if (nowReady && !wasReady && !_bouncePlayed) {
  119. _bouncePlayed = true;
  120. _playBounce();
  121. }
  122. if (!nowReady && wasReady) {
  123. _bouncePlayed = false;
  124. }
  125. }
  126. Future<void> _playBounce() async {
  127. const steps = 15;
  128. for (var i = 0; i <= steps; i++) {
  129. await Future.delayed(const Duration(milliseconds: 10));
  130. if (!mounted || _ptrState != _PtrState.ready) return;
  131. final t = i / steps;
  132. setState(() {
  133. _bounceScale =
  134. t <= 0.5 ? 1.0 + 0.15 * (t * 2) : 1.15 - 0.15 * ((t - 0.5) * 2);
  135. });
  136. }
  137. if (mounted) setState(() => _bounceScale = 1.0);
  138. }
  139. void _collapse() {
  140. final from = _pullHeight;
  141. setState(() {
  142. _ptrState = _PtrState.idle;
  143. _pullHeight = 0;
  144. _bouncePlayed = false;
  145. });
  146. _animateCollapse(from: from);
  147. }
  148. Future<void> _startLoading() async {
  149. setState(() {
  150. _ptrState = _PtrState.loading;
  151. _pullHeight = 0;
  152. _bouncePlayed = false;
  153. });
  154. _spinCtrl.repeat();
  155. try {
  156. await widget.onRefresh();
  157. } finally {
  158. if (mounted) _endLoading();
  159. }
  160. }
  161. void _endLoading() {
  162. _spinCtrl
  163. ..stop()
  164. ..reset();
  165. setState(() {
  166. _ptrState = _PtrState.idle;
  167. _bounceScale = 1.0;
  168. });
  169. _animateCollapse(from: _kThreshold);
  170. }
  171. @override
  172. Widget build(BuildContext context) {
  173. final h = _displayHeight;
  174. final opacity = (h / _kThreshold).clamp(0.0, 1.0);
  175. final isLoading = _ptrState == _PtrState.loading;
  176. return NotificationListener<OverscrollIndicatorNotification>(
  177. onNotification: (n) {
  178. if (_ptrState != _PtrState.idle) n.disallowIndicator();
  179. return true;
  180. },
  181. child: NotificationListener<ScrollNotification>(
  182. onNotification: _handleNotification,
  183. child: Stack(
  184. clipBehavior: Clip.hardEdge,
  185. children: [
  186. // ── 图标层(固定在顶部,不随内容移动)─────────────
  187. Positioned(
  188. top: 0,
  189. left: 0,
  190. right: 0,
  191. height: _kThreshold,
  192. child: Center(
  193. child: Opacity(
  194. opacity: opacity,
  195. child: Transform.scale(
  196. scale: _bounceScale,
  197. child: isLoading
  198. ? RotationTransition(
  199. turns: _spinCtrl,
  200. child: Image.asset(
  201. 'assets/images/app_icon.png',
  202. width: 32,
  203. height: 32,
  204. ),
  205. )
  206. : Image.asset(
  207. 'assets/images/app_icon.png',
  208. width: 32,
  209. height: 32,
  210. ),
  211. ),
  212. ),
  213. ),
  214. ),
  215. // ── 内容层(Transform.translate 下移,不触发 layout)─
  216. Transform.translate(
  217. offset: Offset(0, h),
  218. child: widget.child,
  219. ),
  220. ],
  221. ),
  222. ),
  223. );
  224. }
  225. }