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
This commit is contained in:
Chikamatsu Kazuya 2025-04-08 13:23:34 +09:00 committed by GitHub
parent cc556aecae
commit cd0082f04f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 42 additions and 4 deletions

View File

@ -15,6 +15,7 @@ library;
import 'dart:math' as math; import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
@ -909,14 +910,18 @@ class _DraggableScrollableSheetScrollPosition extends ScrollPositionWithSingleCo
} }
} }
bool get _isAtSnapSize { // Checks if the sheet's current size is close to a snap size, returning the
return extent.snapSizes.any((double snapSize) { // snap size if so; returns null otherwise.
double? _getCurrentSnapSize() {
return extent.snapSizes.firstWhereOrNull((double snapSize) {
return (extent.currentSize - snapSize).abs() <= return (extent.currentSize - snapSize).abs() <=
extent.pixelsToSize(physics.toleranceFor(this).distance); 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 @override
void dispose() { void dispose() {
@ -929,7 +934,7 @@ class _DraggableScrollableSheetScrollPosition extends ScrollPositionWithSingleCo
@override @override
void goBallistic(double velocity) { void goBallistic(double velocity) {
if ((velocity == 0.0 && !_shouldSnap) || if ((velocity == 0.0 && !_shouldSnap()) ||
(velocity < 0.0 && listShouldScroll) || (velocity < 0.0 && listShouldScroll) ||
(velocity > 0.0 && extent.isAtMax)) { (velocity > 0.0 && extent.isAtMax)) {
super.goBallistic(velocity); super.goBallistic(velocity);
@ -981,6 +986,13 @@ class _DraggableScrollableSheetScrollPosition extends ScrollPositionWithSingleCo
super.goBallistic(velocity); super.goBallistic(velocity);
ballisticController.stop(); ballisticController.stop();
} else if (ballisticController.isCompleted) { } 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); super.goBallistic(0);
} }
} }

View File

@ -1892,4 +1892,30 @@ void main() {
expect(receivedNotification!.shouldCloseOnMinExtent, isFalse); expect(receivedNotification!.shouldCloseOnMinExtent, isFalse);
controller.dispose(); 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);
});
} }