onScrollNotification method

void onScrollNotification(
  1. ScrollNotification notification,
  2. Future<void> onRefresh()
)

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);
    }
  }
}