| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249 |
- 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<void> Function() onRefresh;
- final Widget child;
- @override
- State<AppRefreshIndicator> createState() => _AppRefreshIndicatorState();
- }
- enum _PtrState { idle, pulling, ready, loading }
- class _AppRefreshIndicatorState extends State<AppRefreshIndicator>
- with TickerProviderStateMixin {
- static const double _kThreshold = 64.0;
- static const double _kMaxPull = 110.0;
- late final AnimationController _spinCtrl;
- late final AnimationController _collapseCtrl;
- late Animation<double> _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<double>(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<void> _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<void> _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<OverscrollIndicatorNotification>(
- onNotification: (n) {
- if (_ptrState != _PtrState.idle) n.disallowIndicator();
- return true;
- },
- child: NotificationListener<ScrollNotification>(
- 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,
- ),
- ],
- ),
- ),
- );
- }
- }
|