import 'package:flutter/material.dart'; enum _RefreshEdge { top, bottom } class BidirectionalRefresh extends StatefulWidget { static const double defaultTriggerDistance = 90; final Widget child; final Future Function() onRefresh; final double triggerDistance; const BidirectionalRefresh({ super.key, required this.child, required this.onRefresh, this.triggerDistance = defaultTriggerDistance, }); @override State createState() => _BidirectionalRefreshState(); } class _BidirectionalRefreshState extends State { double _topPullDistance = 0; double _bottomPullDistance = 0; bool _isRefreshing = false; bool _handleScrollNotification(ScrollNotification notification) { if (_isRefreshing) { return false; } if (notification is ScrollStartNotification) { _resetPullDistance(); return false; } if (notification is OverscrollNotification) { final metrics = notification.metrics; final isAtTop = metrics.extentBefore == 0; final isAtBottom = metrics.extentAfter == 0; if (!isAtTop && !isAtBottom) { return false; } var shouldRefresh = false; setState(() { if (isAtTop) { _topPullDistance += notification.overscroll.abs(); shouldRefresh = _topPullDistance >= widget.triggerDistance; } if (isAtBottom) { _bottomPullDistance += notification.overscroll.abs(); shouldRefresh = shouldRefresh || _bottomPullDistance >= widget.triggerDistance; } }); if (shouldRefresh) { _runRefresh(); } return false; } if (notification is ScrollEndNotification) { _resetPullDistance(); } return false; } Future _runRefresh() async { if (_isRefreshing) { return; } setState(() { _isRefreshing = true; }); try { await widget.onRefresh(); } finally { if (mounted) { setState(() { _isRefreshing = false; _topPullDistance = 0; _bottomPullDistance = 0; }); } } } void _resetPullDistance() { if (_topPullDistance == 0 && _bottomPullDistance == 0) { return; } setState(() { _topPullDistance = 0; _bottomPullDistance = 0; }); } @override Widget build(BuildContext context) { final topProgress = (_topPullDistance / widget.triggerDistance).clamp( 0.0, 1.0, ); final bottomProgress = (_bottomPullDistance / widget.triggerDistance).clamp( 0.0, 1.0, ); return NotificationListener( onNotification: _handleScrollNotification, child: Stack( children: [ widget.child, _RefreshIndicatorOverlay( alignment: Alignment.topCenter, progress: _isRefreshing ? 1 : topProgress, visible: _isRefreshing || _topPullDistance > 0, edge: _RefreshEdge.top, ), _RefreshIndicatorOverlay( alignment: Alignment.bottomCenter, progress: _isRefreshing ? 1 : bottomProgress, visible: _isRefreshing || _bottomPullDistance > 0, edge: _RefreshEdge.bottom, ), ], ), ); } } class _RefreshIndicatorOverlay extends StatelessWidget { final Alignment alignment; final double progress; final bool visible; final _RefreshEdge edge; const _RefreshIndicatorOverlay({ required this.alignment, required this.progress, required this.visible, required this.edge, }); @override Widget build(BuildContext context) { if (!visible) { return const SizedBox.shrink(); } final indicator = RefreshProgressIndicator(value: progress); final padding = edge == _RefreshEdge.top ? const EdgeInsets.only(top: 12) : const EdgeInsets.only(bottom: 12); return Positioned.fill( child: IgnorePointer( child: Align( alignment: alignment, child: Padding( padding: padding, child: SizedBox.square(dimension: 28, child: indicator), ), ), ), ); } }