diff --git a/docs/adr/0075-fix-load-more-not-triggered-on-ios-fast-scroll.md b/docs/adr/0075-fix-load-more-not-triggered-on-ios-fast-scroll.md new file mode 100644 index 000000000..bb0abb9d6 --- /dev/null +++ b/docs/adr/0075-fix-load-more-not-triggered-on-ios-fast-scroll.md @@ -0,0 +1,43 @@ +# 0075. Fix load more not triggered on iOS when fast scrolling to bottom + +Date: 2026-03-31 + +## Status + +- Issues: + - [TF-4425 Bug: load more is not working](https://github.com/linagora/tmail-flutter/issues/TF-4425) + +## Context + +On iOS, `handleLoadMoreEmailsRequest()` was never triggered when users scrolled to the bottom of the email list. The original condition used exact equality: + +```dart +scrollInfo.metrics.pixels == scrollInfo.metrics.maxScrollExtent +``` + +**Root cause:** iOS uses `BouncingScrollPhysics` (spring-based simulation), which causes `pixels` to either overshoot or settle slightly short of `maxScrollExtent` depending on scroll speed and device performance. In both cases, `ScrollEndNotification` fires when `pixels != maxScrollExtent`, so the condition is never satisfied. + +This behavior is device-dependent — newer/faster devices (iPhone 13 Pro Max, iOS 18) are less affected, while older/slower devices (iPhone 7, iOS 15) exhibit larger deviations. Android is unaffected because `ClampingScrollPhysics` clamps `pixels` exactly within `[0, maxScrollExtent]`. + +## Decision + +Apply `ClampingScrollPhysics` on iOS only, and replace `==` with `>=`: + +```dart +// physics +physics: PlatformInfo.isIOS + ? const ClampingScrollPhysics() + : const AlwaysScrollableScrollPhysics(), + +// condition +scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent +``` + +`ClampingScrollPhysics` ensures `pixels` is clamped exactly to `maxScrollExtent` at the boundary, making the condition deterministic. The `>=` adds a defensive guard for any residual edge cases. + +## Consequences + +- Load more works correctly across all iOS versions and device generations. +- No behavior change on Android or Web. +- iOS loses the native bounce feel at list boundaries. Acceptable for an email list UI. +- No risk of duplicate requests due to the existing `isRunning` guard. diff --git a/lib/features/search/email/presentation/search_email_view.dart b/lib/features/search/email/presentation/search_email_view.dart index c42b62c2d..8032c6f42 100644 --- a/lib/features/search/email/presentation/search_email_view.dart +++ b/lib/features/search/email/presentation/search_email_view.dart @@ -693,7 +693,7 @@ class SearchEmailView extends GetWidget onNotification: (ScrollNotification scrollInfo) { if (scrollInfo is ScrollEndNotification && controller.searchMoreState != SearchMoreState.waiting - && scrollInfo.metrics.pixels == scrollInfo.metrics.maxScrollExtent + && scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent && scrollInfo.metrics.axisDirection == AxisDirection.down) { controller.searchMoreEmailsAction(); } @@ -701,7 +701,9 @@ class SearchEmailView extends GetWidget }, child: ListView.separated( controller: controller.resultSearchScrollController, - physics: const AlwaysScrollableScrollPhysics(), + physics: PlatformInfo.isIOS + ? const ClampingScrollPhysics() + : const AlwaysScrollableScrollPhysics(), key: const PageStorageKey('list_presentation_email_in_search_view'), itemCount: listPresentationEmail.length, itemBuilder: (context, index) => _buildEmailItem( diff --git a/lib/features/thread/presentation/thread_view.dart b/lib/features/thread/presentation/thread_view.dart index 274cef9b3..dfc106a18 100644 --- a/lib/features/thread/presentation/thread_view.dart +++ b/lib/features/thread/presentation/thread_view.dart @@ -366,7 +366,9 @@ class ThreadView extends GetWidget Widget listView = ListView.separated( key: const PageStorageKey('list_presentation_email_in_threads'), controller: controller.listEmailController, - physics: const AlwaysScrollableScrollPhysics(), + physics: PlatformInfo.isIOS + ? const ClampingScrollPhysics() + : const AlwaysScrollableScrollPhysics(), itemCount: listPresentationEmail.length + 2, itemBuilder: (context, index) => Obx(() { if (index == listPresentationEmail.length) { @@ -440,7 +442,7 @@ class ThreadView extends GetWidget bool _handleScrollNotificationListener(ScrollNotification scrollInfo) { if (scrollInfo is ScrollEndNotification && - scrollInfo.metrics.pixels == scrollInfo.metrics.maxScrollExtent && + scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent && !controller.loadingMoreStatus.value.isRunning && scrollInfo.metrics.axisDirection == AxisDirection.down ) {