From c051b69e2a2224300e20d93dbd15f4b91e8844d1 Mon Sep 17 00:00:00 2001 From: yim Date: Wed, 30 Oct 2024 11:01:18 +0800 Subject: [PATCH] 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. --- .../gesture_detector/gesture_detector.3.dart | 69 +++++++++++ .../gesture_detector.3_test.dart | 29 +++++ .../lib/src/widgets/drag_boundary.dart | 113 ++++++++++++++++++ packages/flutter/lib/widgets.dart | 1 + .../test/widgets/drag_boundary_test.dart | 77 ++++++++++++ 5 files changed, 289 insertions(+) create mode 100644 examples/api/lib/widgets/gesture_detector/gesture_detector.3.dart create mode 100644 examples/api/test/widgets/gesture_detector/gesture_detector.3_test.dart create mode 100644 packages/flutter/lib/src/widgets/drag_boundary.dart create mode 100644 packages/flutter/test/widgets/drag_boundary_test.dart diff --git a/examples/api/lib/widgets/gesture_detector/gesture_detector.3.dart b/examples/api/lib/widgets/gesture_detector/gesture_detector.3.dart new file mode 100644 index 00000000000..5fa79b39910 --- /dev/null +++ b/examples/api/lib/widgets/gesture_detector/gesture_detector.3.dart @@ -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 createState() => DragBoundaryExampleAppState(); +} + +class DragBoundaryExampleAppState extends State { + 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: [ + 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, + ), + ), + ), + ], + ); + }, + ), + ), + ), + ), + ); + } +} diff --git a/examples/api/test/widgets/gesture_detector/gesture_detector.3_test.dart b/examples/api/test/widgets/gesture_detector/gesture_detector.3_test.dart new file mode 100644 index 00000000000..b63b7f04a72 --- /dev/null +++ b/examples/api/test/widgets/gesture_detector/gesture_detector.3_test.dart @@ -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(); + }); +} diff --git a/packages/flutter/lib/src/widgets/drag_boundary.dart b/packages/flutter/lib/src/widgets/drag_boundary.dart new file mode 100644 index 00000000000..7cd0ab14522 --- /dev/null +++ b/packages/flutter/lib/src/widgets/drag_boundary.dart @@ -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 { + /// 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 { + _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 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? forRectMaybeOf(BuildContext context, {bool useGlobalPosition = true}) { + final InheritedElement? element = + context.getElementForInheritedWidgetOfExactType(); + 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; + } +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index d39d7ea3a16..7652640d9e7 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -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'; diff --git a/packages/flutter/test/widgets/drag_boundary_test.dart b/packages/flutter/test/widgets/drag_boundary_test.dart new file mode 100644 index 00000000000..a3f83404e10 --- /dev/null +++ b/packages/flutter/test/widgets/drag_boundary_test.dart @@ -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 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 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 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)); + }); +}