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 {