Add boundary feature to the drag gesture. (#147521)

Inspired by the review on #146182.

This PR adds boundary feature to the drag gestures, including `MultiDragGestureRecognizer` and `DragGestureRecognizer`. The `GestureDetector` widget will also benefit from this.
This commit is contained in:
yim 2024-10-30 11:01:18 +08:00 committed by GitHub
parent 7ee7fff210
commit c051b69e2a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 289 additions and 0 deletions

View File

@ -0,0 +1,69 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
void main() {
runApp(const DragBoundaryExampleApp());
}
class DragBoundaryExampleApp extends StatefulWidget {
const DragBoundaryExampleApp({super.key});
@override
State<StatefulWidget> createState() => DragBoundaryExampleAppState();
}
class DragBoundaryExampleAppState extends State<DragBoundaryExampleApp> {
Offset _currentPosition = Offset.zero;
Offset _initialPosition = Offset.zero;
final Size _boxSize = const Size(100, 100);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Padding(
padding: const EdgeInsets.all(100),
child: DragBoundary(
child: Builder(
builder: (BuildContext context) {
return Stack(
children: <Widget>[
Container(
color: Colors.green,
),
Positioned(
top: _currentPosition.dy,
left: _currentPosition.dx,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onPanStart: (DragStartDetails details) {
_initialPosition = details.localPosition - _currentPosition;
},
onPanUpdate: (DragUpdateDetails details) {
_currentPosition = details.localPosition - _initialPosition;
final Rect withinBoundary = DragBoundary.forRectOf(context, useGlobalPosition: false).nearestPositionWithinBoundary(
_currentPosition & _boxSize,
);
setState(() {
_currentPosition = withinBoundary.topLeft;
});
},
child: Container(
width: _boxSize.width,
height: _boxSize.height,
color: Colors.red,
),
),
),
],
);
},
),
),
),
),
);
}
}

View File

@ -0,0 +1,29 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_api_samples/widgets/gesture_detector/gesture_detector.3.dart'
as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('The red box always moves inside the green box', (WidgetTester tester) async {
await tester.pumpWidget(
const example.DragBoundaryExampleApp(),
);
final Finder greenFinder = find.byType(Container).first;
final Finder redFinder = find.byType(Container).last;
final TestGesture drag = await tester.startGesture(tester.getCenter(redFinder));
await tester.pump(kLongPressTimeout);
await drag.moveBy(const Offset(1000, 1000));
await tester.pumpAndSettle();
expect(tester.getBottomRight(redFinder), tester.getBottomRight(greenFinder));
await drag.moveBy(const Offset(-2000, -2000));
await tester.pumpAndSettle();
expect(tester.getTopLeft(redFinder), tester.getTopLeft(greenFinder));
await drag.up();
await tester.pumpAndSettle();
});
}

View File

@ -0,0 +1,113 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'framework.dart';
/// The interface for defining the algorithm for a boundary that a specified shape is dragged within.
///
/// See also:
/// * [DragBoundary], an [InheritedWidget] that provides a [DragBoundaryDelegate] to its descendants.
///
/// `T` is a data class that defines the shape being dragged. For example, when dragging a rectangle within the boundary,
/// `T` should be a `Rect`.
abstract class DragBoundaryDelegate<T> {
/// Returns whether the specified dragged object is within the boundary.
bool isWithinBoundary(T draggedObject);
/// Returns the given dragged object after moving it fully inside
/// the boundary with the shortest distance.
///
/// If the bounds cannot contain the dragged object, an exception is thrown.
T nearestPositionWithinBoundary(T draggedObject);
}
class _DragBoundaryDelegateForRect extends DragBoundaryDelegate<Rect> {
_DragBoundaryDelegateForRect(this.boundary);
final Rect? boundary;
@override
bool isWithinBoundary(Rect draggedObject) {
if (boundary == null) {
return true;
}
return boundary!.contains(draggedObject.topLeft) && boundary!.contains(draggedObject.bottomRight);
}
@override
Rect nearestPositionWithinBoundary(Rect draggedObject) {
if (boundary == null) {
return draggedObject;
}
if (boundary!.right - draggedObject.width < boundary!.left ||
boundary!.bottom - draggedObject.height < boundary!.top) {
throw FlutterError(
'The rect is larger than the boundary. '
'The rect width must be less than the boundary width, and the rect height must be less than the boundary height.',
);
}
final double left = clampDouble(
draggedObject.left,
boundary!.left,
boundary!.right - draggedObject.width,
);
final double top = clampDouble(
draggedObject.top,
boundary!.top,
boundary!.bottom - draggedObject.height,
);
return Rect.fromLTWH(left, top, draggedObject.width, draggedObject.height);
}
}
/// Provides a [DragBoundaryDelegate] for its descendants whose bounds are those defined by this widget.
///
/// [forRectOf] and [forRectMaybeOf] returns a delegate for a drag object of type [Rect].
///
/// {@tool dartpad}
/// This example demonstrates dragging a red box, constrained within the bounds
/// of a green box.
///
/// ** See code in examples/api/lib/widgets/gesture_detector/gesture_detector.3.dart **
/// {@end-tool}
class DragBoundary extends InheritedWidget {
/// Creates a widget that provides a boundary to its descendants.
const DragBoundary({required super.child, super.key});
/// {@template flutter.widgets.DragBoundary.forRectOf}
/// Retrieve the [DragBoundary] from the nearest ancestor to
/// get its [DragBoundaryDelegate] of [Rect].
///
/// The [useGlobalPosition] specifies whether to retrieve the [DragBoundaryDelegate] of type
/// [Rect] in global coordinates. If false, the local coordinates of the boundary are used. Defaults to true.
/// {@endtemplate}
///
/// If no [DragBoundary] ancestor is found, the delegate will return a delegate that allows the drag object to move freely.
static DragBoundaryDelegate<Rect> forRectOf(BuildContext context, {bool useGlobalPosition = true}) {
return forRectMaybeOf(context, useGlobalPosition: useGlobalPosition)
?? _DragBoundaryDelegateForRect(null);
}
/// {@macro flutter.widgets.DragBoundary.forRectOf}
///
/// returns null if not ancestor is found.
static DragBoundaryDelegate<Rect>? forRectMaybeOf(BuildContext context, {bool useGlobalPosition = true}) {
final InheritedElement? element =
context.getElementForInheritedWidgetOfExactType<DragBoundary>();
if (element == null) {
return null;
}
final RenderBox? rb = element.findRenderObject() as RenderBox?;
assert(rb != null && rb.hasSize, 'DragBoundary is not available');
final Rect boundary = useGlobalPosition
? Rect.fromPoints(rb!.localToGlobal(Offset.zero), rb.localToGlobal(rb.size.bottomRight(Offset.zero)))
: Offset.zero & rb!.size;
return _DragBoundaryDelegateForRect(boundary);
}
@override
bool updateShouldNotify(covariant InheritedWidget oldWidget) {
return true;
}
}

View File

@ -46,6 +46,7 @@ export 'src/widgets/desktop_text_selection_toolbar_layout_delegate.dart';
export 'src/widgets/dismissible.dart';
export 'src/widgets/display_feature_sub_screen.dart';
export 'src/widgets/disposable_build_context.dart';
export 'src/widgets/drag_boundary.dart';
export 'src/widgets/drag_target.dart';
export 'src/widgets/draggable_scrollable_sheet.dart';
export 'src/widgets/dual_transition_builder.dart';

View File

@ -0,0 +1,77 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('test DragBoundary with useGlobalPosition', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
await tester.pumpWidget(
Container(
margin: const EdgeInsets.only(top: 100, left: 100),
alignment: Alignment.topLeft,
child: DragBoundary(
child: SizedBox(
key: key,
width: 100,
height: 100,
),
),
),
);
final DragBoundaryDelegate<Rect> boundary = DragBoundary.forRectOf(key.currentContext!);
expect(boundary, isNotNull);
expect(boundary.isWithinBoundary(const Rect.fromLTWH(50, 50, 20, 20)), isFalse);
expect(boundary.isWithinBoundary(const Rect.fromLTWH(100, 100, 20, 20)), isTrue);
expect(boundary.nearestPositionWithinBoundary(const Rect.fromLTWH(50, 50, 20, 20)), const Rect.fromLTWH(100, 100, 20, 20));
expect(boundary.nearestPositionWithinBoundary(const Rect.fromLTWH(150, 150, 20, 20)), const Rect.fromLTWH(150, 150, 20, 20));
});
testWidgets('test DragBoundary without useGlobalPosition', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
await tester.pumpWidget(
Container(
margin: const EdgeInsets.only(top: 100, left: 100),
alignment: Alignment.topLeft,
child: DragBoundary(
child: SizedBox(
key: key,
width: 100,
height: 100,
),
),
),
);
final DragBoundaryDelegate<Rect> boundary = DragBoundary.forRectOf(key.currentContext!, useGlobalPosition: false);
expect(boundary, isNotNull);
expect(boundary.isWithinBoundary(const Rect.fromLTWH(50, 50, 20, 20)), isTrue);
expect(boundary.isWithinBoundary(const Rect.fromLTWH(90, 90, 20, 20)), isFalse);
expect(boundary.nearestPositionWithinBoundary(const Rect.fromLTWH(50, 50, 20, 20)), const Rect.fromLTWH(50, 50, 20, 20));
expect(boundary.nearestPositionWithinBoundary(const Rect.fromLTWH(90, 90, 20, 20)), const Rect.fromLTWH(80, 80, 20, 20));
});
testWidgets('forRectOf should return a free boundary when no ancestor has a DragBoundary', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
await tester.pumpWidget(
Container(
margin: const EdgeInsets.only(top: 100, left: 100),
alignment: Alignment.topLeft,
child: SizedBox(
key: key,
width: 100,
height: 100,
),
),
);
final DragBoundaryDelegate<Rect> boundary = DragBoundary.forRectOf(key.currentContext!);
expect(boundary, isNotNull);
expect(boundary.isWithinBoundary(const Rect.fromLTWH(50, 50, 20, 20)), isTrue);
expect(boundary.isWithinBoundary(const Rect.fromLTWH(100, 100, 20, 20)), isTrue);
expect(boundary.isWithinBoundary(const Rect.fromLTWH(300, 300, 300, 300)), isTrue);
expect(boundary.nearestPositionWithinBoundary(const Rect.fromLTWH(50, 50, 20, 20)), const Rect.fromLTWH(50, 50, 20, 20));
expect(boundary.nearestPositionWithinBoundary(const Rect.fromLTWH(150, 150, 20, 20)), const Rect.fromLTWH(150, 150, 20, 20));
expect(boundary.nearestPositionWithinBoundary(const Rect.fromLTWH(300, 300, 300, 300)), const Rect.fromLTWH(300, 300, 300, 300));
});
}