Skip to content

Commit b4777c3

Browse files
authored
Make DraggableScrollableController a ChangeNotifier (#96089)
1 parent f01556a commit b4777c3

File tree

2 files changed

+167
-1
lines changed

2 files changed

+167
-1
lines changed

packages/flutter/lib/src/widgets/draggable_scrollable_sheet.dart

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,14 @@ typedef ScrollableWidgetBuilder = Widget Function(
4444
///
4545
/// The controller's methods cannot be used until after the controller has been
4646
/// passed into a [DraggableScrollableSheet] and the sheet has run initState.
47-
class DraggableScrollableController {
47+
///
48+
/// A [DraggableScrollableController] is a [Listenable]. It notifies its
49+
/// listeners whenever an attached sheet changes sizes. It does not notify its
50+
/// listeners when a sheet is first attached or when an attached sheet's
51+
/// parameters change without affecting the sheet's current size. It does not
52+
/// fire when [pixels] changes without [size] changing. For example, if the
53+
/// constraints provided to an attached sheet change.
54+
class DraggableScrollableController extends ChangeNotifier {
4855
_DraggableScrollableSheetScrollController? _attachedController;
4956

5057
/// Get the current size (as a fraction of the parent height) of the attached sheet.
@@ -160,9 +167,23 @@ class DraggableScrollableController {
160167
void _attach(_DraggableScrollableSheetScrollController scrollController) {
161168
assert(_attachedController == null, 'Draggable scrollable controller is already attached to a sheet.');
162169
_attachedController = scrollController;
170+
_attachedController!.extent._currentSize.addListener(notifyListeners);
171+
}
172+
173+
void _onExtentReplaced(_DraggableSheetExtent previousExtent) {
174+
// When the extent has been replaced, the old extent is already disposed and
175+
// the controller will point to a new extent. We have to add our listener to
176+
// the new extent.
177+
_attachedController!.extent._currentSize.addListener(notifyListeners);
178+
if (previousExtent.currentSize != _attachedController!.extent.currentSize) {
179+
// The listener won't fire for a change in size between two extent
180+
// objects so we have to fire it manually here.
181+
notifyListeners();
182+
}
163183
}
164184

165185
void _detach() {
186+
_attachedController?.extent._currentSize.removeListener(notifyListeners);
166187
_attachedController = null;
167188
}
168189
}
@@ -635,6 +656,7 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
635656
}
636657

637658
void _replaceExtent() {
659+
final _DraggableSheetExtent previousExtent = _extent;
638660
_extent.dispose();
639661
_extent = _extent.copyWith(
640662
minSize: widget.minChildSize,
@@ -647,6 +669,9 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
647669
// Modify the existing scroll controller instead of replacing it so that
648670
// developers listening to the controller do not have to rebuild their listeners.
649671
_scrollController.extent = _extent;
672+
// If an external facing controller was provided, let it know that the
673+
// extent has been replaced.
674+
widget.controller?._onExtentReplaced(previousExtent);
650675
if (widget.snap) {
651676
// Trigger a snap in case snap or snapSizes has changed. We put this in a
652677
// post frame callback so that `build` can update `_extent.availablePixels`

packages/flutter/test/widgets/draggable_scrollable_sheet_test.dart

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -976,6 +976,147 @@ void main() {
976976
expect(tester.takeException(), isAssertionError);
977977
});
978978

979+
testWidgets('Can listen for changes in sheet size', (WidgetTester tester) async {
980+
const Key stackKey = ValueKey<String>('stack');
981+
const Key containerKey = ValueKey<String>('container');
982+
final List<double> loggedSizes = <double>[];
983+
final DraggableScrollableController controller = DraggableScrollableController();
984+
controller.addListener(() {
985+
loggedSizes.add(controller.size);
986+
});
987+
await tester.pumpWidget(_boilerplate(
988+
null,
989+
controller: controller,
990+
stackKey: stackKey,
991+
containerKey: containerKey,
992+
));
993+
await tester.pumpAndSettle();
994+
final double screenHeight = tester
995+
.getSize(find.byKey(stackKey))
996+
.height;
997+
998+
// The initial size shouldn't be logged because no change has occurred yet.
999+
expect(loggedSizes.isEmpty, true);
1000+
1001+
await tester.drag(find.text('Item 1'), Offset(0, .1 * screenHeight), touchSlopY: 0);
1002+
await tester.pumpAndSettle();
1003+
expect(loggedSizes, <double>[.4].map((double v) => closeTo(v, precisionErrorTolerance)));
1004+
loggedSizes.clear();
1005+
1006+
await tester.timedDrag(find.text('Item 1'), Offset(0, -.1 * screenHeight), const Duration(seconds: 1), frequency: 2);
1007+
await tester.pumpAndSettle();
1008+
expect(loggedSizes, <double>[.45, .5].map((double v) => closeTo(v, precisionErrorTolerance)));
1009+
loggedSizes.clear();
1010+
1011+
controller.jumpTo(.6);
1012+
await tester.pumpAndSettle();
1013+
expect(loggedSizes, <double>[.6].map((double v) => closeTo(v, precisionErrorTolerance)));
1014+
loggedSizes.clear();
1015+
1016+
controller.animateTo(1, duration: const Duration(milliseconds: 400), curve: Curves.linear);
1017+
await tester.pumpAndSettle();
1018+
expect(loggedSizes, <double>[.7, .8, .9, 1].map((double v) => closeTo(v, precisionErrorTolerance)));
1019+
loggedSizes.clear();
1020+
1021+
DraggableScrollableActuator.reset(tester.element(find.byKey(containerKey)));
1022+
await tester.pumpAndSettle();
1023+
expect(loggedSizes, <double>[.5].map((double v) => closeTo(v, precisionErrorTolerance)));
1024+
loggedSizes.clear();
1025+
});
1026+
1027+
testWidgets('Listener does not fire on parameter change and persists after change', (WidgetTester tester) async {
1028+
const Key stackKey = ValueKey<String>('stack');
1029+
const Key containerKey = ValueKey<String>('container');
1030+
final List<double> loggedSizes = <double>[];
1031+
final DraggableScrollableController controller = DraggableScrollableController();
1032+
controller.addListener(() {
1033+
loggedSizes.add(controller.size);
1034+
});
1035+
await tester.pumpWidget(_boilerplate(
1036+
null,
1037+
controller: controller,
1038+
stackKey: stackKey,
1039+
containerKey: containerKey,
1040+
));
1041+
await tester.pumpAndSettle();
1042+
final double screenHeight = tester
1043+
.getSize(find.byKey(stackKey))
1044+
.height;
1045+
1046+
expect(loggedSizes.isEmpty, true);
1047+
1048+
await tester.drag(find.text('Item 1'), Offset(0, .1 * screenHeight), touchSlopY: 0);
1049+
await tester.pumpAndSettle();
1050+
expect(loggedSizes, <double>[.4].map((double v) => closeTo(v, precisionErrorTolerance)));
1051+
loggedSizes.clear();
1052+
1053+
// Update a parameter without forcing a change in the current size.
1054+
await tester.pumpWidget(_boilerplate(
1055+
null,
1056+
minChildSize: .1,
1057+
controller: controller,
1058+
stackKey: stackKey,
1059+
containerKey: containerKey,
1060+
));
1061+
expect(loggedSizes.isEmpty, true);
1062+
1063+
await tester.drag(find.text('Item 1'), Offset(0, .1 * screenHeight), touchSlopY: 0);
1064+
await tester.pumpAndSettle();
1065+
expect(loggedSizes, <double>[.3].map((double v) => closeTo(v, precisionErrorTolerance)));
1066+
loggedSizes.clear();
1067+
});
1068+
1069+
testWidgets('Listener fires if a parameter change forces a change in size', (WidgetTester tester) async {
1070+
const Key stackKey = ValueKey<String>('stack');
1071+
const Key containerKey = ValueKey<String>('container');
1072+
final List<double> loggedSizes = <double>[];
1073+
final DraggableScrollableController controller = DraggableScrollableController();
1074+
controller.addListener(() {
1075+
loggedSizes.add(controller.size);
1076+
});
1077+
await tester.pumpWidget(_boilerplate(
1078+
null,
1079+
controller: controller,
1080+
stackKey: stackKey,
1081+
containerKey: containerKey,
1082+
));
1083+
await tester.pumpAndSettle();
1084+
final double screenHeight = tester
1085+
.getSize(find.byKey(stackKey))
1086+
.height;
1087+
1088+
expect(loggedSizes.isEmpty, true);
1089+
1090+
// Set a new `initialChildSize` which will trigger a size change because we
1091+
// haven't moved away initial size yet.
1092+
await tester.pumpWidget(_boilerplate(
1093+
null,
1094+
initialChildSize: .6,
1095+
controller: controller,
1096+
stackKey: stackKey,
1097+
containerKey: containerKey,
1098+
));
1099+
expect(loggedSizes, <double>[.6].map((double v) => closeTo(v, precisionErrorTolerance)));
1100+
loggedSizes.clear();
1101+
1102+
// Move away from initial child size.
1103+
await tester.drag(find.text('Item 1'), Offset(0, .3 * screenHeight), touchSlopY: 0);
1104+
await tester.pumpAndSettle();
1105+
expect(loggedSizes, <double>[.3].map((double v) => closeTo(v, precisionErrorTolerance)));
1106+
loggedSizes.clear();
1107+
1108+
// Set a `minChildSize` greater than the current size.
1109+
await tester.pumpWidget(_boilerplate(
1110+
null,
1111+
minChildSize: .4,
1112+
controller: controller,
1113+
stackKey: stackKey,
1114+
containerKey: containerKey,
1115+
));
1116+
expect(loggedSizes, <double>[.4].map((double v) => closeTo(v, precisionErrorTolerance)));
1117+
loggedSizes.clear();
1118+
});
1119+
9791120
testWidgets('Invalid controller interactions throw assertion errors', (WidgetTester tester) async {
9801121
final DraggableScrollableController controller = DraggableScrollableController();
9811122
// Can't use a controller before attaching it.

0 commit comments

Comments
 (0)