onScrollNotification method
Implementation
void onScrollNotification(
ScrollNotification notification, Future<void> Function() onRefresh) {
if (isRefreshing.value || isCollapsing.value) return;
final metrics = notification.metrics;
if (notification is ScrollUpdateNotification) {
// [dragDetails] is non-null only while the user's finger is actively
// driving the scroll. When it is null, the update is physics-driven
// (the ballistic bounce-back after release). We must NOT let that
// bounce-back shrink the pull or cancel a committed refresh, otherwise
// the loader vanishes and the list snaps to the top instantly.
final bool isUserDrag = notification.dragDetails != null;
if (metrics.pixels < 0) {
// Bouncing overscroll (iOS style or bouncing physics).
if (!isUserDrag) {
// Physics settling the overscroll — leave the pull/threshold alone.
return;
}
final rawOffset = -metrics.pixels;
final dampedOffset = rawOffset * 0.85;
pullOffset.value = dampedOffset.clamp(0.0, triggerDistance * 1.5);
if (pullOffset.value >= triggerDistance) {
hasThresholdBeenReached.value = true;
if (enableHaptics && !hasSelectionHapticFired.value) {
HapticFeedback.selectionClick();
hasSelectionHapticFired.value = true;
}
} else if (pullOffset.value < triggerDistance * 0.5) {
// Only reset the threshold flag if they drag all the way back up (cancel gesture)
hasThresholdBeenReached.value = false;
hasSelectionHapticFired.value = false;
}
} else {
// Scroll position is >= 0 (normal list scrolling or clamping top)
if (pullOffset.value > 0) {
if (isUserDrag) {
// The user is actively dragging the content back up to cancel.
final delta = notification.scrollDelta ?? 0.0;
if (delta > 0) {
pullOffset.value =
(pullOffset.value - delta).clamp(0.0, triggerDistance * 1.5);
if (pullOffset.value < triggerDistance * 0.5) {
hasThresholdBeenReached.value = false;
hasSelectionHapticFired.value = false;
}
} else if (metrics.pixels > 0) {
pullOffset.value = 0.0;
hasThresholdBeenReached.value = false;
hasSelectionHapticFired.value = false;
}
} else if (!hasThresholdBeenReached.value) {
// Physics settling without a committed refresh — release the header.
pullOffset.value = 0.0;
}
} else if (pullOffset.value != 0.0) {
pullOffset.value = 0.0;
}
}
} else if (notification is OverscrollNotification) {
// Clamping overscroll physics (Android defaults).
final bool isUserDrag = notification.dragDetails != null;
if (notification.overscroll < 0 && isUserDrag) {
final double delta = -notification.overscroll * 0.85;
pullOffset.value =
(pullOffset.value + delta).clamp(0.0, triggerDistance * 1.5);
if (pullOffset.value >= triggerDistance) {
hasThresholdBeenReached.value = true;
if (enableHaptics && !hasSelectionHapticFired.value) {
HapticFeedback.selectionClick();
hasSelectionHapticFired.value = true;
}
}
}
} else if (notification is ScrollEndNotification ||
(notification is UserScrollNotification &&
notification.direction == ScrollDirection.idle)) {
// Release gesture — decide based on the committed threshold.
if (hasThresholdBeenReached.value && pullOffset.value > 0) {
triggerRefresh(onRefresh);
} else if (pullOffset.value > 0) {
snapBack(0.0);
}
}
}