From 48c6d0703fa3bbf4fb47c9a66b1e49253d14fbc3 Mon Sep 17 00:00:00 2001 From: yim Date: Fri, 6 Dec 2024 09:39:06 +0800 Subject: [PATCH] Added boundary feature to ReorderableList. (#146182) Fixes: #146112
Code sample ```dart import 'package:flutter/material.dart'; void main() { runApp(MaterialApp( home: Scaffold( body: Center( child: Container( width: 200, color: Colors.green, child: DragBoundary( child: CustomScrollView( shrinkWrap: true, slivers: [ SliverReorderableList( itemBuilder: (BuildContext context, int index) { return ReorderableDragStartListener( key: ValueKey(index), index: index, child: Text('$index'), ); }, itemCount: 5, onReorder: (int fromIndex, int toIndex) {}, ), ], ), ), ), ), ), )); } ```
| without `DragBoundary` | with the `DragBoundary` | | ------- | ------- | ![Screen Recording 2024-04-03 at 13 01 19](https://github.com/flutter/flutter/assets/12887926/b092e229-5fc6-40a4-acf0-1197ef04c0fb) | ![Screen Recording 2024-04-03 at 13 01 19](https://github.com/flutter/flutter/assets/12887926/561da112-c1ef-48f1-b043-3947f1f0012e) | ## 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]. [Contributor Guide]: https://github.com/flutter/flutter/wiki/Tree-hygiene#overview [Tree Hygiene]: https://github.com/flutter/flutter/wiki/Tree-hygiene [test-exempt]: https://github.com/flutter/flutter/wiki/Tree-hygiene#tests [Flutter Style Guide]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo [Features we expect every widget to implement]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#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/wiki/Tree-hygiene#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/wiki/Chat [Data Driven Fixes]: https://github.com/flutter/flutter/wiki/Data-driven-Fixes --- .../lib/src/material/reorderable_list.dart | 6 ++ .../lib/src/widgets/reorderable_list.dart | 69 ++++++++++++++++++- .../test/material/reorderable_list_test.dart | 40 +++++++++++ .../test/widgets/reorderable_list_test.dart | 45 ++++++++++++ 4 files changed, 158 insertions(+), 2 deletions(-) diff --git a/packages/flutter/lib/src/material/reorderable_list.dart b/packages/flutter/lib/src/material/reorderable_list.dart index 9c851ee581..7c6f57920e 100644 --- a/packages/flutter/lib/src/material/reorderable_list.dart +++ b/packages/flutter/lib/src/material/reorderable_list.dart @@ -102,6 +102,7 @@ class ReorderableListView extends StatefulWidget { this.restorationId, this.clipBehavior = Clip.hardEdge, this.autoScrollerVelocityScalar, + this.dragBoundaryProvider, }) : assert( (itemExtent == null && prototypeItem == null) || (itemExtent == null && itemExtentBuilder == null) || @@ -171,6 +172,7 @@ class ReorderableListView extends StatefulWidget { this.restorationId, this.clipBehavior = Clip.hardEdge, this.autoScrollerVelocityScalar, + this.dragBoundaryProvider, }) : assert(itemCount >= 0), assert( (itemExtent == null && prototypeItem == null) || @@ -292,6 +294,9 @@ class ReorderableListView extends StatefulWidget { /// {@macro flutter.widgets.SliverReorderableList.autoScrollerVelocityScalar.default} final double? autoScrollerVelocityScalar; + /// {@macro flutter.widgets.reorderable_list.dragBoundaryProvider} + final ReorderDragBoundaryProvider? dragBoundaryProvider; + @override State createState() => _ReorderableListViewState(); } @@ -446,6 +451,7 @@ class _ReorderableListViewState extends State { onReorderEnd: widget.onReorderEnd, proxyDecorator: widget.proxyDecorator ?? _proxyDecorator, autoScrollerVelocityScalar: widget.autoScrollerVelocityScalar, + dragBoundaryProvider: widget.dragBoundaryProvider, ), ), if (widget.footer != null) diff --git a/packages/flutter/lib/src/widgets/reorderable_list.dart b/packages/flutter/lib/src/widgets/reorderable_list.dart index 96ea0c0bc9..570735302a 100644 --- a/packages/flutter/lib/src/widgets/reorderable_list.dart +++ b/packages/flutter/lib/src/widgets/reorderable_list.dart @@ -12,6 +12,7 @@ import 'package:flutter/scheduler.dart'; import 'basic.dart'; import 'debug.dart'; +import 'drag_boundary.dart'; import 'framework.dart'; import 'inherited_theme.dart'; import 'localizations.dart'; @@ -83,6 +84,37 @@ typedef ReorderCallback = void Function(int oldIndex, int newIndex); /// The returned value will typically be the [child] wrapped in other widgets. typedef ReorderItemProxyDecorator = Widget Function(Widget child, int index, Animation animation); +/// Used to provide drag boundaries during drag-and-drop reordering. +/// +/// {@tool snippet} +/// ```dart +/// DragBoundary( +/// child: CustomScrollView( +/// slivers: [ +/// SliverReorderableList( +/// itemBuilder: (BuildContext context, int index) { +/// return ReorderableDragStartListener( +/// key: ValueKey(index), +/// index: index, +/// child: Text('$index'), +/// ); +/// }, +/// dragBoundaryProvider: (BuildContext context) { +/// return DragBoundary.forRectOf(context); +/// }, +/// itemCount: 5, +/// onReorder: (int fromIndex, int toIndex) {}, +/// ), +/// ], +/// ) +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// * [DragBoundary], a widget that provides drag boundaries. +typedef ReorderDragBoundaryProvider = DragBoundaryDelegate? Function(BuildContext context); + /// A scrolling container that allows the user to interactively reorder the /// list items. /// @@ -139,6 +171,7 @@ class ReorderableList extends StatefulWidget { this.restorationId, this.clipBehavior = Clip.hardEdge, this.autoScrollerVelocityScalar, + this.dragBoundaryProvider, }) : assert(itemCount >= 0), assert( (itemExtent == null && prototypeItem == null) || @@ -271,6 +304,14 @@ class ReorderableList extends StatefulWidget { /// {@macro flutter.widgets.SliverReorderableList.autoScrollerVelocityScalar.default} final double? autoScrollerVelocityScalar; + /// {@template flutter.widgets.reorderable_list.dragBoundaryProvider} + /// A callback used to provide drag boundaries during drag-and-drop reordering. + /// + /// If null, the delegate returned by `DragBoundary.forRectMaybeOf` will be used. + /// Defaults to null. + /// {@endtemplate} + final ReorderDragBoundaryProvider? dragBoundaryProvider; + /// The state from the closest instance of this class that encloses the given /// context. /// @@ -419,6 +460,7 @@ class ReorderableListState extends State { onReorderEnd: widget.onReorderEnd, proxyDecorator: widget.proxyDecorator, autoScrollerVelocityScalar: widget.autoScrollerVelocityScalar, + dragBoundaryProvider: widget.dragBoundaryProvider, ), ), ], @@ -465,6 +507,7 @@ class SliverReorderableList extends StatefulWidget { this.itemExtentBuilder, this.prototypeItem, this.proxyDecorator, + this.dragBoundaryProvider, double? autoScrollerVelocityScalar, }) : autoScrollerVelocityScalar = autoScrollerVelocityScalar ?? _kDefaultAutoScrollVelocityScalar, assert(itemCount >= 0), @@ -515,6 +558,9 @@ class SliverReorderableList extends StatefulWidget { /// {@endtemplate} final double autoScrollerVelocityScalar; + /// {@macro flutter.widgets.reorderable_list.dragBoundaryProvider} + final ReorderDragBoundaryProvider? dragBoundaryProvider; + @override SliverReorderableListState createState() => SliverReorderableListState(); @@ -1348,9 +1394,15 @@ class _DragInfo extends Drag { index = item.index; child = item.widget.child; capturedThemes = item.widget.capturedThemes; - dragPosition = initialPosition; dragOffset = itemRenderBox.globalToLocal(initialPosition); itemSize = item.context.size!; + _rawDragPosition = initialPosition; + if (listState.widget.dragBoundaryProvider != null) { + boundary = listState.widget.dragBoundaryProvider!.call(listState.context); + } else { + boundary = DragBoundary.forRectMaybeOf(listState.context); + } + dragPosition = _adjustedDragOffset(initialPosition); itemExtent = _sizeExtent(itemSize, scrollDirection); itemLayoutConstraints = itemRenderBox.constraints; scrollable = Scrollable.of(item.context); @@ -1364,6 +1416,7 @@ class _DragInfo extends Drag { final ReorderItemProxyDecorator? proxyDecorator; final TickerProvider tickerProvider; + late DragBoundaryDelegate? boundary; late SliverReorderableListState listState; late int index; late Widget child; @@ -1375,6 +1428,7 @@ class _DragInfo extends Drag { late CapturedThemes capturedThemes; ScrollableState? scrollable; AnimationController? _proxyAnimation; + late Offset _rawDragPosition; void dispose() { if (kFlutterMemoryAllocationsEnabled) { @@ -1399,7 +1453,8 @@ class _DragInfo extends Drag { @override void update(DragUpdateDetails details) { final Offset delta = _restrictAxis(details.delta, scrollDirection); - dragPosition += delta; + _rawDragPosition += delta; + dragPosition = _adjustedDragOffset(_rawDragPosition); onUpdate?.call(this, dragPosition, details.delta); } @@ -1416,6 +1471,16 @@ class _DragInfo extends Drag { onCancel?.call(this); } + Offset _adjustedDragOffset(Offset offset) { + if (boundary == null) { + return offset; + } + final Offset adjOffset = boundary!.nearestPositionWithinBoundary( + (offset - dragOffset) & itemSize, + ).shift(dragOffset).topLeft; + return adjOffset; + } + void _dropCompleted() { _proxyAnimation?.dispose(); _proxyAnimation = null; diff --git a/packages/flutter/test/material/reorderable_list_test.dart b/packages/flutter/test/material/reorderable_list_test.dart index a15264e9bf..4d034f1089 100644 --- a/packages/flutter/test/material/reorderable_list_test.dart +++ b/packages/flutter/test/material/reorderable_list_test.dart @@ -1925,6 +1925,46 @@ void main() { expect(offsetForFastScroller / offsetForSlowScroller, fastVelocityScalar / slowVelocityScalar); }); + testWidgets('DragBoundary defines the boundary for ReorderableListView.', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Container( + margin: const EdgeInsets.only(top: 100), + height: 300, + child: DragBoundary( + child: ReorderableListView.builder( + itemBuilder: (BuildContext context, int index) { + return ReorderableDragStartListener( + key: ValueKey(index), + index: index, + child: Text('$index'), + ); + }, + itemCount: 5, + onReorder: (int fromIndex, int toIndex) {}, + ), + ), + ), + ), + ), + ); + TestGesture drag = await tester.startGesture(tester.getCenter(find.text('0'))); + await tester.pump(kLongPressTimeout); + await drag.moveBy(const Offset(0, -400)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('0')), const Offset(0, 100)); + await drag.up(); + await tester.pumpAndSettle(); + + drag = await tester.startGesture(tester.getCenter(find.text('0'))); + await tester.pump(kLongPressTimeout); + await drag.moveBy(const Offset(0, 800)); + await tester.pumpAndSettle(); + expect(tester.getBottomLeft(find.text('0')), const Offset(0, 400)); + await drag.up(); + await tester.pumpAndSettle(); + }); } Future longPressDrag(WidgetTester tester, Offset start, Offset end) async { diff --git a/packages/flutter/test/widgets/reorderable_list_test.dart b/packages/flutter/test/widgets/reorderable_list_test.dart index b3d1ab90d4..cce29834b9 100644 --- a/packages/flutter/test/widgets/reorderable_list_test.dart +++ b/packages/flutter/test/widgets/reorderable_list_test.dart @@ -1677,6 +1677,51 @@ void main() { await drag.up(); await tester.pumpAndSettle(); }); + + testWidgets('DragBoundary defines the boundary for ReorderableList.', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Container( + margin: const EdgeInsets.only(top: 100), + height: 300, + child: DragBoundary( + child: CustomScrollView( + slivers: [ + SliverReorderableList( + itemBuilder: (BuildContext context, int index) { + return ReorderableDragStartListener( + key: ValueKey(index), + index: index, + child: Text('$index'), + ); + }, + itemCount: 5, + onReorder: (int fromIndex, int toIndex) {}, + ), + ], + ) + ), + ), + ), + ), + ); + TestGesture drag = await tester.startGesture(tester.getCenter(find.text('0'))); + await tester.pump(kLongPressTimeout); + await drag.moveBy(const Offset(0, -400)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('0')), const Offset(0, 100)); + await drag.up(); + await tester.pumpAndSettle(); + + drag = await tester.startGesture(tester.getCenter(find.text('0'))); + await tester.pump(kLongPressTimeout); + await drag.moveBy(const Offset(0, 800)); + await tester.pumpAndSettle(); + expect(tester.getBottomLeft(find.text('0')), const Offset(0, 400)); + await drag.up(); + await tester.pumpAndSettle(); + }); } class TestList extends StatelessWidget {