I'm writing an app for android (although I think this is a generic question) and I need to display a large image (in an ImageView) that can be scrolled and zoomed. I've managed to get scrolling to work by capturing touch events and performing matrix translations, and i'm now working on zooming.
If I simply apply a scale transformation to the image, it zooms in at the origin, which is the top left-hand corner of the screen. I would like to zoom in at the center of the screen.
From what i've read, this means I need a transformation to make the origin the center of the screen. I think what is required is something like the following- assume the center of the screen is (5, 5) for simplicity...
-Translate by (-5, -5)
-Scale by the zoom factor
-Translate by (+5, +5)*zoomfactor
Unfortunatly, this doesnt seem to work- the zoom seems to go anywhere BUT the center...can someone help me out here?
EDIT: This is the code that now works
Matrix zoommatrix = new Matrix();
float[] centerpoint = {targetimageview.getWidth()/2.0f, targetimageview.getHeight()/2.0f};
zoommatrix.postScale(zoomfactor, zoomfactor, centerpoint[0], centerpoint[1]);
zoommatrix.preConcat(targetimageview.getImageMatrix());
targetimageview.setImageMatrix(zoommatrix);
targetimageview.invalidate();
Check ImageViewTouchBase in the Android source code's Camera app; its "zoomTo" method does this:
protected void zoomTo(float scale, float centerX, float centerY) {
if (scale > mMaxZoom) {
scale = mMaxZoom;
}
float oldScale = getScale();
float deltaScale = scale / oldScale;
mSuppMatrix.postScale(deltaScale, deltaScale, centerX, centerY);
setImageMatrix(getImageViewMatrix());
center(true, true);
}
That center method is probably the bit you'll really care about:
protected void center(boolean horizontal, boolean vertical) {
if (mBitmapDisplayed.getBitmap() == null) {
return;
}
Matrix m = getImageViewMatrix();
RectF rect = new RectF(0, 0,
mBitmapDisplayed.getBitmap().getWidth(),
mBitmapDisplayed.getBitmap().getHeight());
m.mapRect(rect);
float height = rect.height();
float width = rect.width();
float deltaX = 0, deltaY = 0;
if (vertical) {
int viewHeight = getHeight();
if (height < viewHeight) {
deltaY = (viewHeight - height) / 2 - rect.top;
} else if (rect.top > 0) {
deltaY = -rect.top;
} else if (rect.bottom < viewHeight) {
deltaY = getHeight() - rect.bottom;
}
}
if (horizontal) {
int viewWidth = getWidth();
if (width < viewWidth) {
deltaX = (viewWidth - width) / 2 - rect.left;
} else if (rect.left > 0) {
deltaX = -rect.left;
} else if (rect.right < viewWidth) {
deltaX = viewWidth - rect.right;
}
}
postTranslate(deltaX, deltaY);
setImageMatrix(getImageViewMatrix());
}
Related
On scroll, I want to rotate and translate these list items in a way that they still are around in a circular manner. I have made a custom view and over ride these methods with this rotation and translation as used Here. But rotations are a bit weird and non circular.
This is what I want to achieve
#Override
protected void dispatchDraw(Canvas canvas) {
canvas.save();
int top = getTop();
float rotate = calculateAngle(top, recyclerViewHeight);
Matrix m = canvas.getMatrix();
m.preTranslate(-2 / getWidth(), -2 / getHeight());
m.postRotate(rotate);
m.postTranslate(2 / getWidth(), 2 / getHeight());
canvas.concat(m);
super.dispatchDraw(canvas);
canvas.restore();
}
private float calculateAngle(int top, float height) {
float result = 0f;
float fullAngleFactor= 60f;
if (top < height / 2f) {
result = (top - (height / 2f)) / (height / 2f) * fullAngleFactor;
} else if (top > height / 2f) {
result = (top - (height / 2f)) / (height / 2f) * fullAngleFactor;
}
return result;
}
I used this library to achieve same behavior. No need to create any custom view and override onDraw() or onDispatchDraw(). I used this layout manager configuration:
layoutManager = new CircleLayoutManager.Builder(this)
.setRadius(900)
.setAngleInterval(30)
.setDistanceToBottom(-350)
.setGravity(CircleLayoutManager.LEFT)
.build();
Currently I am making a camera player with a textureview to render my camera. Because the preview can have any dimension I have created some custom code to alter the textureview when OnSurfaceTextureUpdated is called:
void updateTextureMatrix(int width, int height) {
Display display = WindowManager.DefaultDisplay;
var isPortrait = (display.Rotation == SurfaceOrientation.Rotation0 || display.Rotation == SurfaceOrientation.Rotation180);
int previewWidth = orgPreviewWidth;
int previewHeight = orgPreviewHeight;
if(isPortrait) {
previewWidth = orgPreviewHeight;
previewHeight = orgPreviewWidth;
}
// determine which part to crop
float widthRatio = (float)width / previewWidth;
float heightRatio = (float)height / previewHeight;
float scaleX;
float scaleY;
if(widthRatio > heightRatio) {
// height must be cropped
scaleX = 1;
scaleY = widthRatio * ((float)previewHeight / height);
} else {
// width must be cropped
scaleX = heightRatio * ((float)previewWidth / width);
scaleY = 1;
}
Android.Graphics.Matrix matrix = new Android.Graphics.Matrix();
matrix.SetScale(scaleX, scaleY);
_textureView.SetTransform(matrix);
float scaledWidth = width * scaleX;
float scaledHeight = height * scaleY;
float dx = (width - scaledWidth) * 0.5f;
float dy = (height - scaledHeight) * 0.5f;
_textureView.TranslationX = dx;
_textureView.TranslationY = dy;
}
The scaling & calculation of dx & dy work perfectly fine on older android devices but the devices I have at my disposal with API level 23 throw unexpected behaviour.
The galaxy S3 displays it correctly:
But on the S7:
The phone cuts off a lot of the image, despite positioning it correctly. This makes me believe the bottom part is not being rendered where on older devices it is. Can anyone confirm this and point me in the correct position to fix this issue?
After long testing I figured out the issue was due to the SetTransform method. I was setting my scale using the matrix but this somehow rendered my texture & ignored the TranslationX & TranslationY. Removing the matrix & replacing it by
float scaledWidth = width * scaleX;
float scaledHeight = height * scaleY;
float dx = (width - scaledWidth) * 0.5f;
float dy = (height - scaledHeight) * 0.5f;
_textureView.ScaleX = scaleX;
_textureView.ScaleY = scaleY;
_textureView.TranslationX = dx;
_textureView.TranslationY = dy;
Fixed my issue of rendering wrongly on certain android devices.
I have a circle drawn on canvas. On top on this circle I draw an angle, which starts from part of the circle equal to current time.
Then onTouchEvent, the angle should be redrawn from start point (current time), to end point, based on a point on the circle.
The problem is with calculation dynamically sweep angle in method onTouchEvent.
I've tried different calculations, taken from different Stackoverflow posts / suggestions, and none did not work according to my expectation. The reaction of the angle, onTouchEvent, was always a bit unpredicted.
My code snippet:
#Override
public void draw(Canvas canvas) {
super.draw(canvas);
int radius = getRadius();
int startAngle = getStartAngle();
canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius, this.bkgPaint);
this.arcRect = new RectF((getWidth() / 2) - radius, (getHeight() / 2) - radius, (getWidth() / 2) + radius, (getHeight() / 2) + radius);
canvas.drawArc(this.arcRect, startAngle, sweepAngle, true, arcPaint);
}
#Override
public boolean onTouchEvent(MotionEvent e) {
if (e.getAction() == MotionEvent.ACTION_MOVE) {
sweepAngle = (int) ((360.0D + Math.toDegrees(Math.atan2(e.getX() - 360.0D, 360.0D - e.getY()))) % 360.0D);
invalidate();
}
return true;
}
private int getStartAngle() {
Calendar cal = Calendar.getInstance();
int minutes = cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE);
if (minutes > 720) {
minutes -= 720;
}
int angle = minutes / 2;
return (angle += 270) % 360;
}
private int getRadius() {
return ((80 * getWidth()) / 100) / 2;
}
I've used similar calculations in my color picker. It's open source, you can find sources here.
In your case I would start with calculating end angle, like this:
#Override
public boolean onTouchEvent(MotionEvent e) {
int action = e.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_MOVE:
int x = (int) e.getX();
int y = (int) e.getY();
int cx = x - getWidth() / 2;
int cy = y - getHeight() / 2;
endAngle = (float) (Math.toDegrees(Math.atan2(cy, cx)) + 360f) % 360f;
invalidate();
return true;
}
return super.onTouchEvent(e);
}
Adding 360 degrees with modulo is required. After this operation you'll have 0 degrees on the right side of the circle, 90 at the bottom, 180 on the left and 270 at the top.
Now your sweep angle will be endAngle - startAngle. And that's it!
I've been stuck on this problem for eight hours, so I figured it was time to get some help.
Before I begin my problem, I'll just let it be known that I've been through this site and Google, and none of the answers I've found have helped. (This is one, another, and another.)
Here's the deal: I have a class that extends SurfaceView (let's call it MySurface) and overrides many methods in it. Normally, it draws several squares and text boxes, which is all fine. As soon as a user starts touching, it converts to a Bitmap, then draws each frame that until the user releases.
Here's the rub: I want to implement such a functionality that the user can place two fingers on the screen, pinch to zoom, and also pan around (but ONLY with two fingers down).
I found a few implementations of pinch-to-zoom and adapted them to my Canvas object in MySurface via the following:
public void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.save();
canvas.scale(mScaleVector.z, mScaleVector.z); // This is the scale factor as seen below
canvas.translate(mScaleVector.x, mScaleVector.y); // These are offset values from 0,0, both working fine
// Start draw code
// ...
// End draw code
canvas.restore();
}
private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
#Override
public boolean onScale(ScaleGestureDetector detector) {
float factor = detector.getScaleFactor();
if (Math.abs(factor - 1.0f) >= 0.0075f) {
mScaleVector.z *= factor;
mScaleVector.z = Math.max(MIN_ZOOM, Math.min(mScaleVector.z, MAX_ZOOM));
}
// ...
invalidate();
return true;
}
}
public boolean onTouchEvent(MotionEvent event) {
int action = event.getAction() & MotionEvent.ACTION_MASK;
int pointerIndex = (event.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) >> MotionEvent.ACTION_POINTER_INDEX_SHIFT;
if (event.getPointerCount() == 2) {
if (action == MotionEvent.ACTION_POINTER_DOWN && pointerIndex == 1) {
// The various pivot coordinate codes would belong here
}
}
detector.onTouchEvent(event); // Calls the Scale Gesture Detector
return true;
}
While both elements work fine--the scrolling back and forth and the pinch-to-zoom--there is one large problem. The pinch-to-zoom, when used, zooms into the point 0,0, instead of zooming into the finger point.
I've tried a lot of ways to fix this:
Using canvas.scale(mScaleVector.z, mScaleVector.z, mScaleVector.x, mScaleVector.y);; obviously, this produces unwanted results as the mScaleVector x and y values are 0-offsets.
Managing a "pivot" coordinate that uses the same offset as the translate() method, but this produces either the same 0,0 issue, or jumping around when the view is touched.
Numerous other things... I've done a lot with the aforementioned pivot coordinate, trying to base its location on the user's first touch, and moving it relative to that touch each successive gesture.
Additionally, this canvas must be bounded, so the user cannot scroll forever. However, when I use the .scale(sx, sy, px, py) method, it pushes things beyond any bounds I set in .translate().
I'm... pretty much open to anything at this point. I know this functionality can be added, as it is seen in the Android 4.0 gallery (when viewing a single image). I've tried to track down the source code that handles this, to no avail.
Here is the code I use to implement pinch zoom in an ImageView using ScaleGestureDetector. With little or no modification you should be able to use it too, since you can use transformation marices too, to draw on a Canvas.
#Override
public boolean onScale(ScaleGestureDetector detector) {
float mScaleFactor = (float) Math.min(
Math.max(.8f, detector.getScaleFactor()), 1.2);
float origScale = saveScale;
saveScale *= mScaleFactor;
if (saveScale > maxScale) {
saveScale = maxScale;
mScaleFactor = maxScale / origScale;
} else if (saveScale < minScale) {
saveScale = minScale;
mScaleFactor = minScale / origScale;
}
right = width * saveScale - width
- (2 * redundantXSpace * saveScale);
bottom = height * saveScale - height
- (2 * redundantYSpace * saveScale);
if (origWidth * saveScale <= width
|| origHeight * saveScale <= height) {
matrix.postScale(mScaleFactor, mScaleFactor, width / 2, height / 2);
if (mScaleFactor < 1) {
matrix.getValues(m);
float x = m[Matrix.MTRANS_X];
float y = m[Matrix.MTRANS_Y];
if (mScaleFactor < 1) {
if (Math.round(origWidth * saveScale) < width) {
if (y < -bottom)
matrix.postTranslate(0, -(y + bottom));
else if (y > 0)
matrix.postTranslate(0, -y);
} else {
if (x < -right)
matrix.postTranslate(-(x + right), 0);
else if (x > 0)
matrix.postTranslate(-x, 0);
}
}
}
} else {
matrix.postScale(mScaleFactor, mScaleFactor, detector.getFocusX(), detector.getFocusY());
matrix.getValues(m);
float x = m[Matrix.MTRANS_X];
float y = m[Matrix.MTRANS_Y];
if (mScaleFactor < 1) {
if (x < -right)
matrix.postTranslate(-(x + right), 0);
else if (x > 0)
matrix.postTranslate(-x, 0);
if (y < -bottom)
matrix.postTranslate(0, -(y + bottom));
else if (y > 0)
matrix.postTranslate(0, -y);
}
}
return true;
}
In my case, I computed the neccesary values in the onMeasure() method of the View, you might want to do this somewhere else in your SurfaceView
width = MeasureSpec.getSize(widthMeasureSpec); // Change this according to your screen size
height = MeasureSpec.getSize(heightMeasureSpec); // Change this according to your screen size
// Fit to screen.
float scale;
float scaleX = (float) width / (float) bmWidth;
float scaleY = (float) height / (float) bmHeight;
scale = Math.min(scaleX, scaleY);
matrix.setScale(scale, scale);
setImageMatrix(matrix);
saveScale = 1f;
scaleMappingRatio = saveScale / scale;
// Center the image
redundantYSpace = (float) height - (scale * (float) bmHeight);
redundantXSpace = (float) width - (scale * (float) bmWidth);
redundantYSpace /= (float) 2;
redundantXSpace /= (float) 2;
matrix.postTranslate(redundantXSpace, redundantYSpace);
origWidth = width - 2 * redundantXSpace;
origHeight = height - 2 * redundantYSpace;
right = width * saveScale - width - (2 * redundantXSpace * saveScale);
bottom = height * saveScale - height - (2 * redundantYSpace * saveScale);
setImageMatrix(matrix);
A little explanation:
saveScale is the current scale ratio of the Bitmap
mScaleFactor is the factor you have to multiply your scale ratio with.
maxScale and minScale can be constant values.
width and height are the dimensions of the screen.
redundantXSpace and redundantYSpace are the empty between the image borders and screen borders since the image is centered when in it is smaller then the screen
origHeight and origWidth are the sizes of the bitmap
matrix is the current transformation matrix used to draw the bitmap
The trick is, that when I first scaled and centered the image on initialization, I picked that scale ratio to be 1 and with scaleMappingRatio I mapped the actual scale values of the image relative to that.
protected override void OnDraw(Canvas canvas)
{
canvas.Save();
int x = (int)(canvas.Width * mScaleFactor - canvas.Width) / 2;
int y = (int)(canvas.Height * mScaleFactor - canvas.Height) / 2;
if (mPosX > (canvas.Width + x - 50))
{
mPosX = canvas.Width + x - 50;
}
if (mPosX < (50 - canvas.Width - x))
{
mPosX = 50 - canvas.Width - x;
}
if (mPosY > (canvas.Height + y - 50))
{
mPosY = canvas.Height + y - 50;
}
if (mPosY < (50 - canvas.Height - y))
{
mPosY = 50 - canvas.Height - y;
}
canvas.Translate(mPosX, mPosY);
canvas.Scale(mScaleFactor, mScaleFactor, canvas.Width/2, canvas.Height/2);
base.OnDraw(canvas);
canvas.Restore();
}
This is monodroid code, but can be easily converted to JAVA.
You may choose whatever values for your bound (here is 50 pixels margin)
I have a SurfaceView that is resposible for drawing a Bitmap as a background and another one that will be used as an overlay. So I've decided to do all transformations using a Matrix that can be used for both bitmaps as it is (I think) one of the fastest ways to do it without using OpenGL.
I've been able to implement panning around and zooming but I have some problems with what I've came with:
I wasn't able to find a way how to focus on the center of the two
fingers while zooming, the image always resets to its initial state
(that is, without panning nor scalling) before the new scale being
applied. Besides looking wrong, that doesn't allow the user to zoom
out to see the whole image and then zoom in on the part that is
important.
After the scalling operation the image won't be at the
same place after the new draw pass because the translation value will
be different.
Is there a way to achieve that using a Matrix or is there another solution?
Code is below (I use a SurfaceHolder in a separate thread do lock the SurfaceView canvas and call its doDraw method):
public class MapSurfaceView extends SurfaceView implements SurfaceHolder.Callback {
public void doDraw(Canvas canvas) {
canvas.drawColor(Color.BLACK);
canvas.drawBitmap(mBitmap, mTransformationMatrix, mPaintAA);
}
#Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction() & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_POINTER_DOWN: {
if (event.getPointerCount() == 2) {
mOriginalDistance = MathUtils.distanceBetween(event.getX(0), event.getX(1), event.getY(0), event.getY(1));
mScreenMidpoint = MathUtils.midpoint(event.getX(0), event.getX(1), event.getY(0), event.getY(1));
mImageMidpoint = MathUtils.midpoint((mXPosition+event.getX(0))/mScale, (mXPosition+event.getX(1))/mScale, (mYPosition+event.getY(0))/mScale, (mYPosition+event.getY(1))/mScale);
mOriginalScale = mScale;
}
}
case MotionEvent.ACTION_DOWN: {
mOriginalTouchPoint = new Point((int)event.getX(), (int)event.getY());
mOriginalPosition = new Point(mXPosition, mYPosition);
break;
}
case MotionEvent.ACTION_MOVE: {
if (event.getPointerCount() == 2) {
final double currentDistance = MathUtils.distanceBetween(event.getX(0), event.getX(1), event.getY(0), event.getY(1));
if (mIsZooming || currentDistance - mOriginalDistance > mPinchToZoomTolerance || mOriginalDistance - currentDistance > mPinchToZoomTolerance) {
final float distanceRatio = (float) (currentDistance / mOriginalDistance);
float tempZoom = mOriginalScale * distanceRatio;
mScale = Math.min(10, Math.max(Math.min((float)getHeight()/(float)mBitmap.getHeight(), (float)getWidth()/(float)mBitmap.getWidth()), tempZoom));
mScale = (float) MathUtils.roundToDecimals(mScale, 1);
mIsZooming = true;
mTransformationMatrix = new Matrix();
mTransformationMatrix.setScale(mScale, mScale);//, mImageMidpoint.x, mImageMidpoint.y);
} else {
System.out.println("Dragging");
mIsZooming = false;
final int deltaX = (int) ((int) (mOriginalTouchPoint.x - event.getX()));
final int deltaY = (int) ((int) (mOriginalTouchPoint.y - event.getY()));
mXPosition = mOriginalPosition.x + deltaX;
mYPosition = mOriginalPosition.y + deltaY;
validatePositions();
mTransformationMatrix = new Matrix();
mTransformationMatrix.setScale(mScale, mScale);
mTransformationMatrix.postTranslate(-mXPosition, -mYPosition);
}
}
break;
}
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_POINTER_UP: {
mIsZooming = false;
validatePositions();
mTransformationMatrix = new Matrix();
mTransformationMatrix.setScale(mScale, mScale);
mTransformationMatrix.postTranslate(-mXPosition, -mYPosition);
}
}
return true;
}
private void validatePositions() {
// Lower right corner
mXPosition = Math.min(mXPosition, (int)((mBitmap.getWidth() * mScale)-getWidth()));
mYPosition = Math.min(mYPosition, (int)((mBitmap.getHeight() * mScale)-getHeight()));
// Upper left corner
mXPosition = Math.max(mXPosition, 0);
mYPosition = Math.max(mYPosition, 0);
// Image smaller than the container, should center it
if (mBitmap.getWidth() * mScale <= getWidth()) {
mXPosition = (int) -((getWidth() - (mBitmap.getWidth() * mScale))/2);
}
if (mBitmap.getHeight() * mScale <= getHeight()) {
mYPosition = (int) -((getHeight() - (mBitmap.getHeight() * mScale))/2);
}
}
}
Instead of resetting the transformation matrix every time using new Matrix(), try updating it using post*(). This way, you do only operations relative to the screen. It is easier to think in terms: "zoom to this point on the screen".
Now some code. Having calculated mScale in zooming part:
...
mScale = (float) MathUtils.roundToDecimals(mScale, 1);
float ratio = mScale / mOriginalScale;
mTransformationMatrix.postScale(ratio, ratio, mScreenMidpoint.x, mScreenMidpoint.y);
It might be even better to recalculate mScreenMidpoint on each zooming touch event. This would allow user to change the focus point a bit while zooming. For me, it is more natural than having the focus point frozen after first two finger touch.
During dragging, you translate using deltaX and deltaY instead of absolute points:
mTransformationMatrix.postTranslate(-deltaX, -deltaY);
Of course now you have to change your validatePositions() method to:
ensure deltaX and deltaY do not make image move too much, or
use transformation matrix to check if image is off screen and then move it to counter that
I will describe the second method, as it is more flexible and allows to validate zooming as well.
We calculate how much image is off screen and then move it using those values:
void validate() {
mTransformationMatrix.mapRect(new RectF(0, 0, mBitmap.getWidth(), mBitmap.getHeight()));
float height = rect.height();
float width = rect.width();
float deltaX = 0, deltaY = 0;
// Vertical delta
if (height < mScreenHeight) {
deltaY = (mScreenHeight - height) / 2 - rect.top;
} else if (rect.top > 0) {
deltaY = -rect.top;
} else if (rect.bottom < mScreenHeight) {
deltaY = mScreenHeight - rect.bottom;
}
// Horziontal delta
if (width < mScreenWidth) {
deltaX = (mScreenWidth - width) / 2 - rect.left;
} else if (rect.left > 0) {
deltaX = -rect.left;
} else if (rect.right < mScreenWidth) {
deltaX = mScreenWidth - rect.right;
}
mTransformationMatrix.postTranslate(deltaX, deltaY)
}