import 'package:flutter/material.dart'; /// 品牌化下拉刷新组件 /// /// 图标固定在 AppBar 下方 64px 区域内居中; /// 内容区用 Transform.translate 下移(仅 paint-time offset,不触发 layout), /// 与参考视频效果一致。 /// /// 兼容 iOS(BouncingScrollPhysics)和 Android(ClampingScrollPhysics)。 class AppRefreshIndicator extends StatefulWidget { const AppRefreshIndicator({ super.key, required this.onRefresh, required this.child, }); final Future Function() onRefresh; final Widget child; @override State createState() => _AppRefreshIndicatorState(); } enum _PtrState { idle, pulling, ready, loading } class _AppRefreshIndicatorState extends State with TickerProviderStateMixin { static const double _kThreshold = 64.0; static const double _kMaxPull = 110.0; late final AnimationController _spinCtrl; late final AnimationController _collapseCtrl; late Animation _collapseAnim; _PtrState _ptrState = _PtrState.idle; double _pullHeight = 0.0; bool _tracking = false; double _bounceScale = 1.0; bool _bouncePlayed = false; @override void initState() { super.initState(); _spinCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); _collapseCtrl = AnimationController(vsync: this); _collapseAnim = const AlwaysStoppedAnimation(0.0); _collapseCtrl.addListener(() { if (mounted) setState(() {}); }); } @override void dispose() { _spinCtrl.dispose(); _collapseCtrl.dispose(); super.dispose(); } double get _displayHeight { if (_ptrState == _PtrState.loading) return _kThreshold; if (_collapseCtrl.isAnimating) return _collapseAnim.value; return _pullHeight; } void _animateCollapse({required double from}) { _collapseAnim = Tween(begin: from, end: 0).animate( CurvedAnimation( parent: _collapseCtrl, curve: const Cubic(0.34, 1.20, 0.64, 1.00), ), ); _collapseCtrl.duration = const Duration(milliseconds: 360); _collapseCtrl.forward(from: 0); } bool _handleNotification(ScrollNotification notification) { if (_ptrState == _PtrState.loading) return false; if (notification is ScrollStartNotification) { if (notification.metrics.extentBefore == 0.0) { _collapseCtrl.stop(); _tracking = true; } return false; } if (notification is ScrollUpdateNotification && _tracking) { if (notification.metrics.extentBefore > 0.0) { _tracking = false; if (_pullHeight > 0) _collapse(); return false; } final raw = -(notification.scrollDelta ?? 0.0); if (raw > 0) { final resistance = 1.0 - (_pullHeight / _kMaxPull).clamp(0.0, 1.0); _updatePullHeight(_pullHeight + raw * resistance * 0.55); } else if (raw < 0) { _updatePullHeight((_pullHeight + raw).clamp(0.0, _kMaxPull)); } return false; } if (notification is OverscrollNotification && notification.overscroll < 0 && _tracking) { final raw = -notification.overscroll; final resistance = 1.0 - (_pullHeight / _kMaxPull).clamp(0.0, 1.0); _updatePullHeight(_pullHeight + raw * resistance * 0.55); return false; } if (notification is ScrollEndNotification && _tracking) { _tracking = false; if (_ptrState == _PtrState.ready) { _startLoading(); } else if (_pullHeight > 0) { _collapse(); } return false; } return false; } void _updatePullHeight(double h) { final newH = h.clamp(0.0, _kMaxPull); final wasReady = _ptrState == _PtrState.ready; final nowReady = newH >= _kThreshold; setState(() { _pullHeight = newH; _ptrState = nowReady ? _PtrState.ready : _PtrState.pulling; }); if (nowReady && !wasReady && !_bouncePlayed) { _bouncePlayed = true; _playBounce(); } if (!nowReady && wasReady) { _bouncePlayed = false; } } Future _playBounce() async { const steps = 15; for (var i = 0; i <= steps; i++) { await Future.delayed(const Duration(milliseconds: 10)); if (!mounted || _ptrState != _PtrState.ready) return; final t = i / steps; setState(() { _bounceScale = t <= 0.5 ? 1.0 + 0.15 * (t * 2) : 1.15 - 0.15 * ((t - 0.5) * 2); }); } if (mounted) setState(() => _bounceScale = 1.0); } void _collapse() { final from = _pullHeight; setState(() { _ptrState = _PtrState.idle; _pullHeight = 0; _bouncePlayed = false; }); _animateCollapse(from: from); } Future _startLoading() async { setState(() { _ptrState = _PtrState.loading; _pullHeight = 0; _bouncePlayed = false; }); _spinCtrl.repeat(); try { await widget.onRefresh(); } finally { if (mounted) _endLoading(); } } void _endLoading() { _spinCtrl ..stop() ..reset(); setState(() { _ptrState = _PtrState.idle; _bounceScale = 1.0; }); _animateCollapse(from: _kThreshold); } @override Widget build(BuildContext context) { final h = _displayHeight; final opacity = (h / _kThreshold).clamp(0.0, 1.0); final isLoading = _ptrState == _PtrState.loading; return NotificationListener( onNotification: (n) { if (_ptrState != _PtrState.idle) n.disallowIndicator(); return true; }, child: NotificationListener( onNotification: _handleNotification, child: Stack( clipBehavior: Clip.hardEdge, children: [ // ── 图标层(固定在顶部,不随内容移动)───────────── Positioned( top: 0, left: 0, right: 0, height: _kThreshold, child: Center( child: Opacity( opacity: opacity, child: Transform.scale( scale: _bounceScale, child: isLoading ? RotationTransition( turns: _spinCtrl, child: Image.asset( 'assets/images/app_icon.png', width: 32, height: 32, ), ) : Image.asset( 'assets/images/app_icon.png', width: 32, height: 32, ), ), ), ), ), // ── 内容层(Transform.translate 下移,不触发 layout)─ Transform.translate( offset: Offset(0, h), child: widget.child, ), ], ), ), ); } }