From cd0082f04f37f2c7e1ea38ac18c235a2184492fa Mon Sep 17 00:00:00 2001
From: Chikamatsu Kazuya <43089218+chika3742@users.noreply.github.com>
Date: Tue, 8 Apr 2025 13:23:34 +0900
Subject: [PATCH] Fix: DraggableScrollableSheet may not close if snapping is
 enabled (#165557)

<!--
Thanks for filing a pull request!
Reviewers are typically assigned within a week of filing a request.
To learn more about code review, see our documentation on Tree Hygiene:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
-->

fixes #140701

This PR fixes an issue where a DraggableScrollableSheet with `snap` set
to true and `shouldCloseOnMinExtent` set to true may not close when
dragged downward.

The issue was caused by round-off errors accumulated by `addPixelDelta`
method, which could lead to `extent.currentSize` not matching the
`extent.minSize` exactly when the bottom is reached. I added logic to
correct it when the snapping ballistic animation is complete.

<details>
<summary>Sample code</summary>

```Dart
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: HomePage(),
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("DraggableScrollableSheet Test"),
      ),
      body: Center(
        child: ElevatedButton(
          child: Text("Open"),
          onPressed: () {
            showModalBottomSheet(
              context: context,
              showDragHandle: true,
              isScrollControlled: true,
              builder: (context) => _buildBottomSheet(),
            );
          },
        ),
      ),
    );
  }

  Widget _buildBottomSheet() {
    return NotificationListener<DraggableScrollableNotification>(
      onNotification: (notification) {
        print(notification.extent);
        return false;
      },
      child: DraggableScrollableSheet(
        expand: false,
        snap: true,
        shouldCloseOnMinExtent: true,
        maxChildSize: 0.9,
        minChildSize: 0.25,
        builder: (context, scrollController) {
          return ListView.builder(
            controller: scrollController,
            itemCount: 100,
            itemBuilder: (context, index) {
              return ListTile(
                title: Text("Item $index"),
              );
            },
          );
        },
      ),
    );
  }
}
```

</details>

| Before applying fix | After |
| --- | --- |
| * Occurs with probability <video
src="https://github.com/user-attachments/assets/ffd2d097-3ed5-4775-90d5-950092d49591">
| <video
src="https://github.com/user-attachments/assets/0f20cb81-3444-40a3-a84d-ed4bff15887e">
|


## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
---
 .../widgets/draggable_scrollable_sheet.dart   | 20 +++++++++++---
 .../draggable_scrollable_sheet_test.dart      | 26 +++++++++++++++++++
 2 files changed, 42 insertions(+), 4 deletions(-)

diff --git a/packages/flutter/lib/src/widgets/draggable_scrollable_sheet.dart b/packages/flutter/lib/src/widgets/draggable_scrollable_sheet.dart
index 4110266db99..6d0dcc67e97 100644
--- a/packages/flutter/lib/src/widgets/draggable_scrollable_sheet.dart
+++ b/packages/flutter/lib/src/widgets/draggable_scrollable_sheet.dart
@@ -15,6 +15,7 @@ library;
 
 import 'dart:math' as math;
 
+import 'package:collection/collection.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/gestures.dart';
 
@@ -909,14 +910,18 @@ class _DraggableScrollableSheetScrollPosition extends ScrollPositionWithSingleCo
     }
   }
 
-  bool get _isAtSnapSize {
-    return extent.snapSizes.any((double snapSize) {
+  // Checks if the sheet's current size is close to a snap size, returning the
+  // snap size if so; returns null otherwise.
+  double? _getCurrentSnapSize() {
+    return extent.snapSizes.firstWhereOrNull((double snapSize) {
       return (extent.currentSize - snapSize).abs() <=
           extent.pixelsToSize(physics.toleranceFor(this).distance);
     });
   }
 
-  bool get _shouldSnap => extent.snap && extent.hasDragged && !_isAtSnapSize;
+  bool _isAtSnapSize() => _getCurrentSnapSize() != null;
+
+  bool _shouldSnap() => extent.snap && extent.hasDragged && !_isAtSnapSize();
 
   @override
   void dispose() {
@@ -929,7 +934,7 @@ class _DraggableScrollableSheetScrollPosition extends ScrollPositionWithSingleCo
 
   @override
   void goBallistic(double velocity) {
-    if ((velocity == 0.0 && !_shouldSnap) ||
+    if ((velocity == 0.0 && !_shouldSnap()) ||
         (velocity < 0.0 && listShouldScroll) ||
         (velocity > 0.0 && extent.isAtMax)) {
       super.goBallistic(velocity);
@@ -981,6 +986,13 @@ class _DraggableScrollableSheetScrollPosition extends ScrollPositionWithSingleCo
         super.goBallistic(velocity);
         ballisticController.stop();
       } else if (ballisticController.isCompleted) {
+        // Update the extent value after the snap animation completes to
+        // avoid rounding errors that could prevent the sheet from closing when
+        // it reaches minSize.
+        final double? snapSize = _getCurrentSnapSize();
+        if (snapSize != null) {
+          extent.updateSize(snapSize, context.notificationContext!);
+        }
         super.goBallistic(0);
       }
     }
diff --git a/packages/flutter/test/widgets/draggable_scrollable_sheet_test.dart b/packages/flutter/test/widgets/draggable_scrollable_sheet_test.dart
index 4f44965fba0..47a2c295fb8 100644
--- a/packages/flutter/test/widgets/draggable_scrollable_sheet_test.dart
+++ b/packages/flutter/test/widgets/draggable_scrollable_sheet_test.dart
@@ -1892,4 +1892,30 @@ void main() {
     expect(receivedNotification!.shouldCloseOnMinExtent, isFalse);
     controller.dispose();
   });
+
+  // Regression test for https://github.com/flutter/flutter/issues/140701
+  testWidgets('DraggableScrollableSheet snaps exactly to minChildSize', (
+    WidgetTester tester,
+  ) async {
+    double? lastExtent;
+
+    await tester.pumpWidget(
+      boilerplateWidget(
+        null,
+        snap: true,
+        onDraggableScrollableNotification: (DraggableScrollableNotification notification) {
+          lastExtent = notification.extent;
+          return false;
+        },
+      ),
+    );
+
+    // One of the conditions for reproducing the round-off error.
+    await tester.fling(find.text('Item 1'), const Offset(0, 100), 2000);
+    await tester.pumpFrames(
+      tester.widget(find.byType(Directionality)),
+      const Duration(milliseconds: 500),
+    );
+    expect(lastExtent, .25);
+  });
 }
-- 
GitLab