From a82005a9dc71f72fbdbf412a97f911b60f53b648 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Wed, 8 Jul 2020 09:51:02 -0700 Subject: [PATCH] InteractiveViewer pan axis locking (#61019) --- .../lib/src/widgets/interactive_viewer.dart | 52 +++++- .../test/widgets/interactive_viewer_test.dart | 172 ++++++++++++++++++ 2 files changed, 221 insertions(+), 3 deletions(-) diff --git a/packages/flutter/lib/src/widgets/interactive_viewer.dart b/packages/flutter/lib/src/widgets/interactive_viewer.dart index 28b318efac2..a6c784ef493 100644 --- a/packages/flutter/lib/src/widgets/interactive_viewer.dart +++ b/packages/flutter/lib/src/widgets/interactive_viewer.dart @@ -53,6 +53,7 @@ class InteractiveViewer extends StatefulWidget { /// The [child] parameter must not be null. InteractiveViewer({ Key key, + this.alignPanAxis = false, this.boundaryMargin = EdgeInsets.zero, this.constrained = true, // These default scale values were eyeballed as reasonable limits for common @@ -66,7 +67,8 @@ class InteractiveViewer extends StatefulWidget { this.scaleEnabled = true, this.transformationController, @required this.child, - }) : assert(child != null), + }) : assert(alignPanAxis != null), + assert(child != null), assert(constrained != null), assert(minScale != null), assert(minScale > 0), @@ -85,6 +87,15 @@ class InteractiveViewer extends StatefulWidget { && boundaryMargin.left.isFinite)), super(key: key); + /// If true, panning is only allowed in the direction of the horizontal axis + /// or the vertical axis. + /// + /// In other words, when this is true, diagonal panning is not allowed. A + /// single gesture begun along one axis cannot also cause panning along the + /// other axis without stopping and beginning a new gesture. This is a common + /// pattern in tables where data is displayed in columns and rows. + final bool alignPanAxis; + /// A margin for the visible boundaries of the child. /// /// Any transformation that results in the viewport being able to view outside @@ -477,6 +488,7 @@ class _InteractiveViewerState extends State with TickerProvid final GlobalKey _parentKey = GlobalKey(); Animation _animation; AnimationController _controller; + Axis _panAxis; // Used with alignPanAxis. Offset _referenceFocalPoint; // Point where the current gesture began. double _scaleStart; // Scale value at start of scaling gesture. double _rotationStart = 0.0; // Rotation at start of rotation gesture. @@ -528,9 +540,13 @@ class _InteractiveViewerState extends State with TickerProvid return matrix.clone(); } + final Offset alignedTranslation = widget.alignPanAxis && _panAxis != null + ? _alignAxis(translation, _panAxis) + : translation; + final Matrix4 nextMatrix = matrix.clone()..translate( - translation.dx, - translation.dy, + alignedTranslation.dx, + alignedTranslation.dy, ); // Transform the viewport to determine where its four corners will be after @@ -683,6 +699,7 @@ class _InteractiveViewerState extends State with TickerProvid } _gestureType = null; + _panAxis = null; _scaleStart = _transformationController.value.getMaxScaleOnAxis(); _referenceFocalPoint = _transformationController.toScene( details.localFocalPoint, @@ -710,6 +727,9 @@ class _InteractiveViewerState extends State with TickerProvid !widget.scaleEnabled ? 1.0 : details.scale, !_rotateEnabled ? 0.0 : details.rotation, ); + if (_gestureType == _GestureType.pan) { + _panAxis ??= _getPanAxis(_referenceFocalPoint, focalPointScene); + } if (!_gestureIsSupported(_gestureType)) { return; @@ -800,11 +820,13 @@ class _InteractiveViewerState extends State with TickerProvid _controller.reset(); if (!_gestureIsSupported(_gestureType)) { + _panAxis = null; return; } // If the scale ended with enough velocity, animate inertial movement. if (_gestureType != _GestureType.pan || details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) { + _panAxis = null; return; } @@ -871,6 +893,7 @@ class _InteractiveViewerState extends State with TickerProvid // Handle inertia drag animation. void _onAnimate() { if (!_controller.isAnimating) { + _panAxis = null; _animation?.removeListener(_onAnimate); _animation = null; _controller.reset(); @@ -1158,3 +1181,26 @@ Offset _round(Offset offset) { double.parse(offset.dy.toStringAsFixed(9)), ); } + +// Align the given offset to the given axis by allowing movement only in the +// axis direction. +Offset _alignAxis(Offset offset, Axis axis) { + switch (axis) { + case Axis.horizontal: + return Offset(offset.dx, 0.0); + case Axis.vertical: + default: + return Offset(0.0, offset.dy); + } +} + +// Given two points, return the axis where the distance between the points is +// greatest. If they are equal, return null. +Axis _getPanAxis(Offset point1, Offset point2) { + if (point1 == point2) { + return null; + } + final double x = point2.dx - point1.dx; + final double y = point2.dy - point1.dy; + return x.abs() > y.abs() ? Axis.horizontal : Axis.vertical; +} diff --git a/packages/flutter/test/widgets/interactive_viewer_test.dart b/packages/flutter/test/widgets/interactive_viewer_test.dart index 1597bf7b6bf..b5d88ca7ebc 100644 --- a/packages/flutter/test/widgets/interactive_viewer_test.dart +++ b/packages/flutter/test/widgets/interactive_viewer_test.dart @@ -260,6 +260,86 @@ void main() { expect(transformationController.value.getMaxScaleOnAxis(), minScale); }); + testWidgets('alignPanAxis allows panning in one direction only for diagonal gesture', (WidgetTester tester) async { + final TransformationController transformationController = TransformationController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: InteractiveViewer( + alignPanAxis: true, + boundaryMargin: const EdgeInsets.all(double.infinity), + transformationController: transformationController, + child: Container(width: 200.0, height: 200.0), + ), + ), + ), + ), + ); + + expect(transformationController.value, equals(Matrix4.identity())); + + // Perform a diagonal drag gesture. + final Offset childOffset = tester.getTopLeft(find.byType(Container)); + final Offset childInterior = Offset( + childOffset.dx + 20.0, + childOffset.dy + 20.0, + ); + final TestGesture gesture = await tester.startGesture(childInterior); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(childOffset); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + // Translation has only happened along the y axis (the default axis when + // a gesture is perfectly at 45 degrees to the axes). + final Vector3 translation = transformationController.value.getTranslation(); + expect(translation.x, 0.0); + expect(translation.y, childOffset.dy - childInterior.dy); + }); + + testWidgets('alignPanAxis allows panning in one direction only for horizontal leaning gesture', (WidgetTester tester) async { + final TransformationController transformationController = TransformationController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: InteractiveViewer( + alignPanAxis: true, + boundaryMargin: const EdgeInsets.all(double.infinity), + transformationController: transformationController, + child: Container(width: 200.0, height: 200.0), + ), + ), + ), + ), + ); + + expect(transformationController.value, equals(Matrix4.identity())); + + // Perform a horizontally leaning diagonal drag gesture. + final Offset childOffset = tester.getTopLeft(find.byType(Container)); + final Offset childInterior = Offset( + childOffset.dx + 20.0, + childOffset.dy + 10.0, + ); + final TestGesture gesture = await tester.startGesture(childInterior); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(childOffset); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + // Translation happened only along the x axis because that's the axis that + // had the greatest movement. + final Vector3 translation = transformationController.value.getTranslation(); + expect(translation.x, childOffset.dx - childInterior.dx); + expect(translation.y, 0.0); + }); + testWidgets('inertia fling and boundary sliding', (WidgetTester tester) async { final TransformationController transformationController = TransformationController(); const double boundaryMargin = 50.0; @@ -406,6 +486,98 @@ void main() { expect(newSceneFocalPoint.dy, closeTo(sceneFocalPoint.dy, 1.0)); }); + testWidgets('Scaling automatically causes a centering translation even when alignPanAxis is set', (WidgetTester tester) async { + final TransformationController transformationController = TransformationController(); + const double boundaryMargin = 50.0; + const double minScale = 0.1; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: InteractiveViewer( + alignPanAxis: true, + boundaryMargin: const EdgeInsets.all(boundaryMargin), + minScale: minScale, + transformationController: transformationController, + child: Container(width: 200.0, height: 200.0), + ), + ), + ), + ), + ); + + Vector3 translation = transformationController.value.getTranslation(); + expect(translation.x, 0.0); + expect(translation.y, 0.0); + + // Pan into the corner of the boundaries in two gestures, since + // alignPanAxis prevents diagonal panning. + final Offset childOffset1 = tester.getTopLeft(find.byType(Container)); + const Offset flingEnd1 = Offset(20.0, 0.0); + await tester.flingFrom(childOffset1, flingEnd1, 1000.0); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 5)); + final Offset childOffset2 = tester.getTopLeft(find.byType(Container)); + const Offset flingEnd2 = Offset(0.0, 15.0); + await tester.flingFrom(childOffset2, flingEnd2, 1000.0); + await tester.pumpAndSettle(); + translation = transformationController.value.getTranslation(); + expect(translation.x, closeTo(boundaryMargin, .000000001)); + expect(translation.y, closeTo(boundaryMargin, .000000001)); + + // Zoom out so the entire child is visible. The child will also be + // translated in order to keep it inside the boundaries. + final Offset childCenter = tester.getCenter(find.byType(Container)); + Offset scaleStart1 = Offset(childCenter.dx - 40.0, childCenter.dy); + Offset scaleStart2 = Offset(childCenter.dx + 40.0, childCenter.dy); + Offset scaleEnd1 = Offset(childCenter.dx - 10.0, childCenter.dy); + Offset scaleEnd2 = Offset(childCenter.dx + 10.0, childCenter.dy); + TestGesture gesture = await tester.createGesture(); + TestGesture gesture2 = await tester.createGesture(); + await gesture.down(scaleStart1); + await gesture2.down(scaleStart2); + await tester.pump(); + await gesture.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + expect(transformationController.value.getMaxScaleOnAxis(), lessThan(1.0)); + translation = transformationController.value.getTranslation(); + expect(translation.x, lessThan(boundaryMargin)); + expect(translation.y, lessThan(boundaryMargin)); + expect(translation.x, greaterThan(0.0)); + expect(translation.y, greaterThan(0.0)); + expect(translation.x, closeTo(translation.y, .000000001)); + + // Zoom in on a point that's not the center, and see that it remains at + // roughly the same location in the viewport after the zoom. + scaleStart1 = Offset(childCenter.dx - 50.0, childCenter.dy); + scaleStart2 = Offset(childCenter.dx - 30.0, childCenter.dy); + scaleEnd1 = Offset(childCenter.dx - 51.0, childCenter.dy); + scaleEnd2 = Offset(childCenter.dx - 29.0, childCenter.dy); + final Offset viewportFocalPoint = Offset( + childCenter.dx - 40.0 - childOffset1.dx, + childCenter.dy - childOffset1.dy, + ); + final Offset sceneFocalPoint = transformationController.toScene(viewportFocalPoint); + gesture = await tester.createGesture(); + gesture2 = await tester.createGesture(); + await gesture.down(scaleStart1); + await gesture2.down(scaleStart2); + await tester.pump(); + await gesture.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + final Offset newSceneFocalPoint = transformationController.toScene(viewportFocalPoint); + expect(newSceneFocalPoint.dx, closeTo(sceneFocalPoint.dx, 1.0)); + expect(newSceneFocalPoint.dy, closeTo(sceneFocalPoint.dy, 1.0)); + }); + testWidgets('Can scale with mouse', (WidgetTester tester) async { final TransformationController transformationController = TransformationController(); await tester.pumpWidget(