mirror of
https://github.com/flutter/flutter
synced 2024-10-13 19:52:53 +00:00
InteractiveViewer pan axis locking (#61019)
This commit is contained in:
parent
df64d1b303
commit
a82005a9dc
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in a new issue