diff --git a/examples/api/lib/widgets/interactive_viewer/interactive_viewer.constrained.0.dart b/examples/api/lib/widgets/interactive_viewer/interactive_viewer.constrained.0.dart index c576f4523ef..68c5c9c7692 100644 --- a/examples/api/lib/widgets/interactive_viewer/interactive_viewer.constrained.0.dart +++ b/examples/api/lib/widgets/interactive_viewer/interactive_viewer.constrained.0.dart @@ -34,7 +34,7 @@ class MyStatelessWidget extends StatelessWidget { const int columnCount = 6; return InteractiveViewer( - alignPanAxis: true, + panAxis: PanAxis.aligned, constrained: false, scaleEnabled: false, child: Table( diff --git a/packages/flutter/lib/src/widgets/interactive_viewer.dart b/packages/flutter/lib/src/widgets/interactive_viewer.dart index 86d825aa6e1..5319fa8382e 100644 --- a/packages/flutter/lib/src/widgets/interactive_viewer.dart +++ b/packages/flutter/lib/src/widgets/interactive_viewer.dart @@ -67,7 +67,12 @@ class InteractiveViewer extends StatefulWidget { InteractiveViewer({ super.key, this.clipBehavior = Clip.hardEdge, + @Deprecated( + 'Use panAxis instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) this.alignPanAxis = false, + this.panAxis = PanAxis.free, this.boundaryMargin = EdgeInsets.zero, this.constrained = true, // These default scale values were eyeballed as reasonable limits for common @@ -83,6 +88,7 @@ class InteractiveViewer extends StatefulWidget { this.transformationController, required Widget this.child, }) : assert(alignPanAxis != null), + assert(panAxis != null), assert(child != null), assert(constrained != null), assert(minScale != null), @@ -114,7 +120,12 @@ class InteractiveViewer extends StatefulWidget { InteractiveViewer.builder({ super.key, this.clipBehavior = Clip.hardEdge, + @Deprecated( + 'Use panAxis instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) this.alignPanAxis = false, + this.panAxis = PanAxis.free, this.boundaryMargin = EdgeInsets.zero, // These default scale values were eyeballed as reasonable limits for common // use cases. @@ -128,7 +139,7 @@ class InteractiveViewer extends StatefulWidget { this.scaleFactor = 200.0, this.transformationController, required InteractiveViewerWidgetBuilder this.builder, - }) : assert(alignPanAxis != null), + }) : assert(panAxis != null), assert(builder != null), assert(minScale != null), assert(minScale > 0), @@ -158,6 +169,8 @@ class InteractiveViewer extends StatefulWidget { /// Defaults to [Clip.hardEdge]. final Clip clipBehavior; + /// This property is deprecated, please use [panAxis] instead. + /// /// If true, panning is only allowed in the direction of the horizontal axis /// or the vertical axis. /// @@ -169,8 +182,25 @@ class InteractiveViewer extends StatefulWidget { /// See also: /// * [constrained], which has an example of creating a table that uses /// alignPanAxis. + @Deprecated( + 'Use panAxis instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) final bool alignPanAxis; + /// When set to [PanAxis.aligned], panning is only allowed in the horizontal + /// axis or the vertical axis, diagonal panning is not allowed. + /// + /// When set to [PanAxis.vertical] or [PanAxis.horizontal] panning is only + /// allowed in the specified axis. For example, if set to [PanAxis.vertical], + /// panning will only be allowed in the vertical axis. And if set to [PanAxis.horizontal], + /// panning will only be allowed in the horizontal axis. + /// + /// When set to [PanAxis.free] panning is allowed in all directions. + /// + /// Defaults to [PanAxis.free]. + final PanAxis panAxis; + /// A margin for the visible boundaries of the child. /// /// Any transformation that results in the viewport being able to view outside @@ -507,7 +537,7 @@ class _InteractiveViewerState extends State with TickerProvid final GlobalKey _parentKey = GlobalKey(); Animation? _animation; late AnimationController _controller; - Axis? _panAxis; // Used with alignPanAxis. + Axis? _currentAxis; // Used with panAxis. 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. @@ -566,9 +596,26 @@ class _InteractiveViewerState extends State with TickerProvid return matrix.clone(); } - final Offset alignedTranslation = widget.alignPanAxis && _panAxis != null - ? _alignAxis(translation, _panAxis!) - : translation; + late final Offset alignedTranslation; + + if (_currentAxis != null) { + switch(widget.panAxis){ + case PanAxis.horizontal: + alignedTranslation = _alignAxis(translation, Axis.horizontal); + break; + case PanAxis.vertical: + alignedTranslation = _alignAxis(translation, Axis.vertical); + break; + case PanAxis.aligned: + alignedTranslation = _alignAxis(translation, _currentAxis!); + break; + case PanAxis.free: + alignedTranslation = translation; + break; + } + } else { + alignedTranslation = translation; + } final Matrix4 nextMatrix = matrix.clone()..translate( alignedTranslation.dx, @@ -734,7 +781,7 @@ class _InteractiveViewerState extends State with TickerProvid } _gestureType = null; - _panAxis = null; + _currentAxis = null; _scaleStart = _transformationController!.value.getMaxScaleOnAxis(); _referenceFocalPoint = _transformationController!.toScene( details.localFocalPoint, @@ -825,7 +872,7 @@ class _InteractiveViewerState extends State with TickerProvid widget.onInteractionUpdate?.call(details); return; } - _panAxis ??= _getPanAxis(_referenceFocalPoint!, focalPointScene); + _currentAxis ??= _getPanAxis(_referenceFocalPoint!, focalPointScene); // Translate so that the same point in the scene is underneath the // focal point before and after the movement. final Offset translationChange = focalPointScene - _referenceFocalPoint!; @@ -853,13 +900,13 @@ class _InteractiveViewerState extends State with TickerProvid _controller.reset(); if (!_gestureIsSupported(_gestureType)) { - _panAxis = null; + _currentAxis = null; return; } // If the scale ended with enough velocity, animate inertial movement. if (_gestureType != _GestureType.pan || details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) { - _panAxis = null; + _currentAxis = null; return; } @@ -947,7 +994,7 @@ class _InteractiveViewerState extends State with TickerProvid // Handle inertia drag animation. void _onAnimate() { if (!_controller.isAnimating) { - _panAxis = null; + _currentAxis = null; _animation?.removeListener(_onAnimate); _animation = null; _controller.reset(); @@ -1296,3 +1343,20 @@ Axis? _getPanAxis(Offset point1, Offset point2) { final double y = point2.dy - point1.dy; return x.abs() > y.abs() ? Axis.horizontal : Axis.vertical; } + +/// This enum is used to specify the behavior of the [InteractiveViewer] when +/// the user drags the viewport. +enum PanAxis{ + /// The user can only pan the viewport along the horizontal axis. + horizontal, + + /// The user can only pan the viewport along the vertical axis. + vertical, + + /// The user can pan the viewport along the horizontal and vertical axes + /// but not diagonally. + aligned, + + /// The user can pan the viewport freely in any direction. + free, +} diff --git a/packages/flutter/test/widgets/interactive_viewer_test.dart b/packages/flutter/test/widgets/interactive_viewer_test.dart index a1081504752..2f776671f74 100644 --- a/packages/flutter/test/widgets/interactive_viewer_test.dart +++ b/packages/flutter/test/widgets/interactive_viewer_test.dart @@ -288,14 +288,52 @@ void main() { expect(transformationController.value.getMaxScaleOnAxis(), minScale); }); - testWidgets('alignPanAxis allows panning in one direction only for diagonal gesture', (WidgetTester tester) async { + testWidgets('PanAxis.free allows panning in all directions 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: const SizedBox(width: 200.0, height: 200.0), + ), + ), + ), + ), + ); + + expect(transformationController.value, equals(Matrix4.identity())); + + // Perform a diagonal drag gesture. + final Offset childOffset = tester.getTopLeft(find.byType(SizedBox)); + final Offset childInterior = Offset( + childOffset.dx + 20.0, + childOffset.dy + 20.0, + ); + final TestGesture gesture = await tester.startGesture(childInterior); + 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, childOffset.dx - childInterior.dx); + expect(translation.y, childOffset.dy - childInterior.dy); + }); + + testWidgets('PanAxis.aligned 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( + panAxis: PanAxis.aligned, boundaryMargin: const EdgeInsets.all(double.infinity), transformationController: transformationController, child: const SizedBox(width: 200.0, height: 200.0), @@ -327,14 +365,14 @@ void main() { expect(translation.y, childOffset.dy - childInterior.dy); }); - testWidgets('alignPanAxis allows panning in one direction only for horizontal leaning gesture', (WidgetTester tester) async { + testWidgets('PanAxis.aligned 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, + panAxis: PanAxis.aligned, boundaryMargin: const EdgeInsets.all(double.infinity), transformationController: transformationController, child: const SizedBox(width: 200.0, height: 200.0), @@ -366,6 +404,240 @@ void main() { expect(translation.y, 0.0); }); + testWidgets('PanAxis.horizontal allows panning in the horizontal direction only for diagonal gesture', (WidgetTester tester) async { + final TransformationController transformationController = TransformationController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: InteractiveViewer( + panAxis: PanAxis.horizontal, + boundaryMargin: const EdgeInsets.all(double.infinity), + transformationController: transformationController, + child: const SizedBox(width: 200.0, height: 200.0), + ), + ), + ), + ), + ); + + expect(transformationController.value, equals(Matrix4.identity())); + + // Perform a diagonal drag gesture. + final Offset childOffset = tester.getTopLeft(find.byType(SizedBox)); + final Offset childInterior = Offset( + childOffset.dx + 20.0, + childOffset.dy + 20.0, + ); + final TestGesture gesture = await tester.startGesture(childInterior); + await tester.pump(); + await gesture.moveTo(childOffset); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + // Translation has only happened along the x axis (the default axis when + // a gesture is perfectly at 45 degrees to the axes). + final Vector3 translation = transformationController.value.getTranslation(); + expect(translation.x, childOffset.dx - childInterior.dx); + expect(translation.y, 0.0); + }); + + testWidgets('PanAxis.horizontal allows panning in the horizontal direction only for horizontal leaning gesture', (WidgetTester tester) async { + final TransformationController transformationController = TransformationController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: InteractiveViewer( + panAxis: PanAxis.horizontal, + boundaryMargin: const EdgeInsets.all(double.infinity), + transformationController: transformationController, + child: const SizedBox(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(SizedBox)); + final Offset childInterior = Offset( + childOffset.dx + 20.0, + childOffset.dy + 10.0, + ); + final TestGesture gesture = await tester.startGesture(childInterior); + 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 been set to the panningDirection parameter. + final Vector3 translation = transformationController.value.getTranslation(); + expect(translation.x, childOffset.dx - childInterior.dx); + expect(translation.y, 0.0); + }); + + testWidgets('PanAxis.horizontal does not allow panning in vertical direction on vertical gesture', (WidgetTester tester) async { + final TransformationController transformationController = TransformationController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: InteractiveViewer( + panAxis: PanAxis.horizontal, + boundaryMargin: const EdgeInsets.all(double.infinity), + transformationController: transformationController, + child: const SizedBox(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(SizedBox)); + final Offset childInterior = Offset( + childOffset.dx + 0.0, + childOffset.dy + 10.0, + ); + final TestGesture gesture = await tester.startGesture(childInterior); + await tester.pump(); + await gesture.moveTo(childOffset); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + // Translation didn't happen because the only axis allowed to do panning + // is the horizontal. + final Vector3 translation = transformationController.value.getTranslation(); + expect(translation.x, 0.0); + expect(translation.y, 0.0); + }); + + testWidgets('PanAxis.vertical allows panning in the vertical direction only for diagonal gesture', (WidgetTester tester) async { + final TransformationController transformationController = TransformationController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: InteractiveViewer( + panAxis: PanAxis.vertical, + boundaryMargin: const EdgeInsets.all(double.infinity), + transformationController: transformationController, + child: const SizedBox(width: 200.0, height: 200.0), + ), + ), + ), + ), + ); + + expect(transformationController.value, equals(Matrix4.identity())); + + // Perform a diagonal drag gesture. + final Offset childOffset = tester.getTopLeft(find.byType(SizedBox)); + final Offset childInterior = Offset( + childOffset.dx + 20.0, + childOffset.dy + 20.0, + ); + final TestGesture gesture = await tester.startGesture(childInterior); + await tester.pump(); + await gesture.moveTo(childOffset); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + // Translation has only happened along the x axis (the default axis when + // a gesture is perfectly at 45 degrees to the axes). + final Vector3 translation = transformationController.value.getTranslation(); + expect(translation.y, childOffset.dy - childInterior.dy); + expect(translation.x, 0.0); + }); + + testWidgets('PanAxis.vertical allows panning in the vertical direction only for vertical leaning gesture', (WidgetTester tester) async { + final TransformationController transformationController = TransformationController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: InteractiveViewer( + panAxis: PanAxis.vertical, + boundaryMargin: const EdgeInsets.all(double.infinity), + transformationController: transformationController, + child: const SizedBox(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(SizedBox)); + final Offset childInterior = Offset( + childOffset.dx + 20.0, + childOffset.dy + 10.0, + ); + final TestGesture gesture = await tester.startGesture(childInterior); + 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 been set to the panningDirection parameter. + final Vector3 translation = transformationController.value.getTranslation(); + expect(translation.y, childOffset.dy - childInterior.dy); + expect(translation.x, 0.0); + }); + + testWidgets('PanAxis.vertical does not allow panning in horizontal direction on vertical gesture', (WidgetTester tester) async { + final TransformationController transformationController = TransformationController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: InteractiveViewer( + panAxis: PanAxis.vertical, + boundaryMargin: const EdgeInsets.all(double.infinity), + transformationController: transformationController, + child: const SizedBox(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(SizedBox)); + final Offset childInterior = Offset( + childOffset.dx + 10.0, + childOffset.dy + 0.0, + ); + final TestGesture gesture = await tester.startGesture(childInterior); + await tester.pump(); + await gesture.moveTo(childOffset); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + // Translation didn't happen because the only axis allowed to do panning + // is the horizontal. + final Vector3 translation = transformationController.value.getTranslation(); + expect(translation.x, 0.0); + expect(translation.y, 0.0); + }); + testWidgets('inertia fling and boundary sliding', (WidgetTester tester) async { final TransformationController transformationController = TransformationController(); const double boundaryMargin = 50.0; @@ -519,7 +791,7 @@ void main() { home: Scaffold( body: Center( child: InteractiveViewer( - alignPanAxis: true, + panAxis: PanAxis.aligned, boundaryMargin: const EdgeInsets.all(boundaryMargin), minScale: minScale, transformationController: transformationController,