I have created a zoom and pan class for an ImageView.
Features I am trying to create.
- It pans on single finger touch and movement
- It zooms and pans on two finger touch and movement
For the most part this works very well.
It has a slight bug when I do the following:
- I pan around with one finger (Status: No problem)
- I put down a second finger, zoom and pan (Status: No problem)
- I release my second finger (Status: The image jumps a little)
Was hoping someone could help me solve this.
I am thinking that it must have to do with resetting the mLastTouchX and mLastTouchY in "case MotionEvent.ACTION_POINTER_UP"
Any help would greatly be appreciated!
import android.content.Context;
import android.graphics.Canvas;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.widget.ImageView;
public class MyImageView extends ImageView {
private static final int INVALID_POINTER_ID = -1;
private float mPosX;
private float mPosY;
private float mLastTouchX;
private float mLastTouchY;
private float mLastGestureX;
private float mLastGestureY;
private int mActivePointerId = INVALID_POINTER_ID;
private ScaleGestureDetector mScaleDetector;
private float mScaleFactor = 1.f;
public MyImageView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
mScaleDetector = new ScaleGestureDetector(getContext(), new ScaleListener());
}
public MyImageView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
mScaleDetector = new ScaleGestureDetector(context, new ScaleListener());
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
// Let the ScaleGestureDetector inspect all events.
mScaleDetector.onTouchEvent(ev);
final int action = ev.getAction();
switch (action & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN: {
if (!mScaleDetector.isInProgress()) {
final float x = ev.getX();
final float y = ev.getY();
mLastTouchX = x;
mLastTouchY = y;
mActivePointerId = ev.getPointerId(0);
}
break;
}
case MotionEvent.ACTION_POINTER_1_DOWN: {
if (mScaleDetector.isInProgress()) {
final float gx = mScaleDetector.getFocusX();
final float gy = mScaleDetector.getFocusY();
mLastGestureX = gx;
mLastGestureY = gy;
}
break;
}
case MotionEvent.ACTION_MOVE: {
// Only move if the ScaleGestureDetector isn't processing a gesture.
if (!mScaleDetector.isInProgress()) {
final int pointerIndex = ev.findPointerIndex(mActivePointerId);
final float x = ev.getX(pointerIndex);
final float y = ev.getY(pointerIndex);
final float dx = x - mLastTouchX;
final float dy = y - mLastTouchY;
mPosX += dx;
mPosY += dy;
invalidate();
mLastTouchX = x;
mLastTouchY = y;
}
else{
final float gx = mScaleDetector.getFocusX();
final float gy = mScaleDetector.getFocusY();
final float gdx = gx - mLastGestureX;
final float gdy = gy - mLastGestureY;
mPosX += gdx;
mPosY += gdy;
invalidate();
mLastGestureX = gx;
mLastGestureY = gy;
}
break;
}
case MotionEvent.ACTION_UP: {
mActivePointerId = INVALID_POINTER_ID;
break;
}
case MotionEvent.ACTION_CANCEL: {
mActivePointerId = INVALID_POINTER_ID;
break;
}
case MotionEvent.ACTION_POINTER_UP: {
final int pointerIndex = (ev.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK)
>> MotionEvent.ACTION_POINTER_INDEX_SHIFT;
final int pointerId = ev.getPointerId(pointerIndex);
if (pointerId == mActivePointerId) {
Log.d("DEBUG", "mActivePointerId");
// This was our active pointer going up. Choose a new
// active pointer and adjust accordingly.
final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
mLastTouchX = ev.getX(newPointerIndex);
mLastTouchY = ev.getY(newPointerIndex);
mActivePointerId = ev.getPointerId(newPointerIndex);
}
break;
}
}
return true;
}
@Override
public void onDraw(Canvas canvas) {
canvas.save();
canvas.translate(mPosX, mPosY);
if (mScaleDetector.isInProgress()) {
canvas.scale(mScaleFactor, mScaleFactor, mScaleDetector.getFocusX(), mScaleDetector.getFocusY());
}
else{
canvas.scale(mScaleFactor, mScaleFactor);
}
super.onDraw(canvas);
canvas.restore();
}
private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
@Override
public boolean onScale(ScaleGestureDetector detector) {
mScaleFactor *= detector.getScaleFactor();
// Don't let the object get too small or too large.
mScaleFactor = Math.max(0.1f, Math.min(mScaleFactor, 10.0f));
invalidate();
return true;
}
}
}
Seems the canvas.scale() in the 'else' statement of the 'onDraw' method required the mLastGestureX and mLastGestureY to stop the jumping.
I also refresh mLastTouchX and mLastTouchY when going back to single finger panning in the 'case MotionEvent.ACTION_POINTER_UP'
Here's the final, may not suit everyone causes it does not restrict panning past image bounds but that should be easy to accomplish, there are alot of discussions out there on that topic.
import android.content.Context;
import android.graphics.Canvas;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.widget.ImageView;
public class MyImageView extends ImageView {
private static final int INVALID_POINTER_ID = -1;
private float mPosX;
private float mPosY;
private float mLastTouchX;
private float mLastTouchY;
private float mLastGestureX;
private float mLastGestureY;
private int mActivePointerId = INVALID_POINTER_ID;
private ScaleGestureDetector mScaleDetector;
private float mScaleFactor = 1.f;
public MyImageView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
mScaleDetector = new ScaleGestureDetector(getContext(), new ScaleListener());
}
public MyImageView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
mScaleDetector = new ScaleGestureDetector(context, new ScaleListener());
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
// Let the ScaleGestureDetector inspect all events.
mScaleDetector.onTouchEvent(ev);
final int action = ev.getAction();
switch (action & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN: {
if (!mScaleDetector.isInProgress()) {
final float x = ev.getX();
final float y = ev.getY();
mLastTouchX = x;
mLastTouchY = y;
mActivePointerId = ev.getPointerId(0);
}
break;
}
case MotionEvent.ACTION_POINTER_1_DOWN: {
if (mScaleDetector.isInProgress()) {
final float gx = mScaleDetector.getFocusX();
final float gy = mScaleDetector.getFocusY();
mLastGestureX = gx;
mLastGestureY = gy;
}
break;
}
case MotionEvent.ACTION_MOVE: {
// Only move if the ScaleGestureDetector isn't processing a gesture.
if (!mScaleDetector.isInProgress()) {
final int pointerIndex = ev.findPointerIndex(mActivePointerId);
final float x = ev.getX(pointerIndex);
final float y = ev.getY(pointerIndex);
final float dx = x - mLastTouchX;
final float dy = y - mLastTouchY;
mPosX += dx;
mPosY += dy;
invalidate();
mLastTouchX = x;
mLastTouchY = y;
}
else{
final float gx = mScaleDetector.getFocusX();
final float gy = mScaleDetector.getFocusY();
final float gdx = gx - mLastGestureX;
final float gdy = gy - mLastGestureY;
mPosX += gdx;
mPosY += gdy;
invalidate();
mLastGestureX = gx;
mLastGestureY = gy;
}
break;
}
case MotionEvent.ACTION_UP: {
mActivePointerId = INVALID_POINTER_ID;
break;
}
case MotionEvent.ACTION_CANCEL: {
mActivePointerId = INVALID_POINTER_ID;
break;
}
case MotionEvent.ACTION_POINTER_UP: {
final int pointerIndex = (ev.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK)
>> MotionEvent.ACTION_POINTER_INDEX_SHIFT;
final int pointerId = ev.getPointerId(pointerIndex);
if (pointerId == mActivePointerId) {
// This was our active pointer going up. Choose a new
// active pointer and adjust accordingly.
final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
mLastTouchX = ev.getX(newPointerIndex);
mLastTouchY = ev.getY(newPointerIndex);
mActivePointerId = ev.getPointerId(newPointerIndex);
}
else{
final int tempPointerIndex = ev.findPointerIndex(mActivePointerId);
mLastTouchX = ev.getX(tempPointerIndex);
mLastTouchY = ev.getY(tempPointerIndex);
}
break;
}
}
return true;
}
@Override
public void onDraw(Canvas canvas) {
canvas.save();
canvas.translate(mPosX, mPosY);
if (mScaleDetector.isInProgress()) {
canvas.scale(mScaleFactor, mScaleFactor, mScaleDetector.getFocusX(), mScaleDetector.getFocusY());
}
else{
canvas.scale(mScaleFactor, mScaleFactor, mLastGestureX, mLastGestureY);
}
super.onDraw(canvas);
canvas.restore();
}
private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
@Override
public boolean onScale(ScaleGestureDetector detector) {
mScaleFactor *= detector.getScaleFactor();
// Don't let the object get too small or too large.
mScaleFactor = Math.max(0.1f, Math.min(mScaleFactor, 10.0f));
invalidate();
return true;
}
}
}
I have been toying with a solution to this problem for over a week, and it's giving me a ton of problems. I've narrowed the issue down significantly, though. Your solution above did NOT work for me, but my solution below is close. The issue is that it jumps whenever a second finger is pressed down or lifted. I've found that this happens because mPosX and mPosY are not always truly what the variables are supposed to represent. Here's what I mean:
When ACTION_MOVE is called and the code enters the "else" statement (to deal with zoom events), mPosX and mPosY are only changing according to the change in focus, not the change in zoom. This means that panning with two fingers works, and zooming with two fingers works, but mPosX and mPosY are not changing appropriately with respect to the changing zoom.
I've been trying to figure out ways to fix this using the differential changes in zoom (mScaleDetector.getScaleFactor()) and the differential changes in focus, but I can't seem to work through the logic well enough to find something that works.
Another solution is to move all zoom operations into OnTouchListener, and completely getting rid of the ScaleListener. This means a lot more math, but it would definitely be a solution.
Here is onDraw:
@Override
public void onDraw(Canvas c) {
c.save();
if (mScaleDetector.isInProgress()) {
c.scale(mScaleFactor, mScaleFactor, mLastGestureX - mPosX,
mLastGestureY - mPosY);
} else {
c.scale(mScaleFactor, mScaleFactor, mLastGestureX, mLastGestureY);
}
c.translate(mPosX / mScaleFactor, mPosY / mScaleFactor);
// drawing instruction here
c.restore();
}
Here is how the code reacts to finger presses:
@Override
public boolean onTouchEvent(MotionEvent ev) {
mScaleDetector.onTouchEvent(ev);
final int action = ev.getAction();
switch (action & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN: {
if (!mScaleDetector.isInProgress()) {
final float x = ev.getX();
final float y = ev.getY();
mLastTouchX = x;
mLastTouchY = y;
mActivePointerId = ev.getPointerId(0);
}
break;
}
case MotionEvent.ACTION_POINTER_DOWN: {
if (!mScaleDetector.isInProgress()) {
final float gx = mScaleDetector.getFocusX();
final float gy = mScaleDetector.getFocusY();
mLastGestureX = gx;
mLastGestureY = gy;
}
break;
}
case MotionEvent.ACTION_MOVE: {
if (!mScaleDetector.isInProgress()) {
Log.i("hi", "SD not in progress");
final int pointerIndex = ev.findPointerIndex(mActivePointerId);
final float x = ev.getX(pointerIndex);
final float y = ev.getY(pointerIndex);
final float dx = x - mLastTouchX;
final float dy = y - mLastTouchY;
mPosX += dx;
mPosY += dy;
invalidate();
mLastTouchX = x;
mLastTouchY = y;
} else {
Log.i("hi", "SD in progress");
final float gx = mScaleDetector.getFocusX();
final float gy = mScaleDetector.getFocusY();
final float gdx = gx - mLastGestureX;
final float gdy = gy - mLastGestureY;
mPosX += gdx;
mPosY += gdy;
// SOMETHING NEEDS TO HAPPEN RIGHT HERE.
invalidate();
mLastGestureX = gx;
mLastGestureY = gy;
}
break;
}
case MotionEvent.ACTION_POINTER_UP: {
final int pointerIndex = (ev.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) >> MotionEvent.ACTION_POINTER_INDEX_SHIFT;
final int pointerId = ev.getPointerId(pointerIndex);
if (pointerId == mActivePointerId) {
// This was our active pointer going up. Choose a new
// active pointer and adjust accordingly.
final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
mLastTouchX = ev.getX(newPointerIndex);
mLastTouchY = ev.getY(newPointerIndex);
mActivePointerId = ev.getPointerId(newPointerIndex);
} else {
final int tempPointerIndex = ev.findPointerIndex(mActivePointerId);
mLastTouchX = ev.getX(tempPointerIndex);
mLastTouchY = ev.getY(tempPointerIndex);
}
break;
}
}
return true;
}
And although it's mostly unrelated, here's the ScaleListener:
private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
@Override
public boolean onScale(ScaleGestureDetector detector) {
mScaleFactor *= detector.getScaleFactor();
invalidate();
return true;
}
}
Again, this code is NOT working perfectly, but it's very close. I've explained the exact issue above and am still having trouble getting it working. I don't know if this will pop up in your notifications, Hank, but hopefully someone will see it and help me out.
Hank's solution works for me. I added a reset function so subsequent images can be displayed normally.
public void ResetView() {
mScaleFactor = 1.f;
mPosX = 0.f;
mPosY = 0.f;
}
W/o looking at the code I'd assume you're doing a position calculation based on 2 fingers when there are 2 fingers. You'll always get a jump in that case.