InteractiveViewer pan axis locking (#61019)

This commit is contained in:
Justin McCandless 2020-07-08 09:51:02 -07:00 committed by GitHub
parent df64d1b303
commit a82005a9dc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 221 additions and 3 deletions

View file

@ -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<InteractiveViewer> with TickerProvid
final GlobalKey _parentKey = GlobalKey();
Animation<Offset> _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<InteractiveViewer> 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<InteractiveViewer> with TickerProvid
}
_gestureType = null;
_panAxis = null;
_scaleStart = _transformationController.value.getMaxScaleOnAxis();
_referenceFocalPoint = _transformationController.toScene(
details.localFocalPoint,
@ -710,6 +727,9 @@ class _InteractiveViewerState extends State<InteractiveViewer> 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<InteractiveViewer> 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<InteractiveViewer> 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;
}

View file

@ -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(