Android 图像视图缩放

我使用的代码示例来自于“ Make Sense of Multitouch” 用于缩放图像视图。在 ScaleListener 上,我为内容添加了 ScaleGestureDetector.getFocusX() and getFocusY()for 来缩放手势的焦点。它工作得很好。

问题是,在第一次多点触摸时,整个图像绘制的位置改变为当前的触摸点,并从那里放大。你能帮我解决这个问题吗?

以下是我的 TouchImageView 代码示例。

public class TouchImageViewSample extends ImageView {


private Paint borderPaint = null;
private Paint backgroundPaint = null;


private float mPosX = 0f;
private float mPosY = 0f;


private float mLastTouchX;
private float mLastTouchY;
private static final int INVALID_POINTER_ID = -1;
private static final String LOG_TAG = "TouchImageView";


// The ‘active pointer’ is the one currently moving our object.
private int mActivePointerId = INVALID_POINTER_ID;


public TouchImageViewSample(Context context) {
this(context, null, 0);
}


public TouchImageViewSample(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}


private ScaleGestureDetector mScaleDetector;
private float mScaleFactor = 1.f;


// Existing code ...
public TouchImageViewSample(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
// Create our ScaleGestureDetector
mScaleDetector = new ScaleGestureDetector(context, new ScaleListener());


borderPaint = new Paint();
borderPaint.setARGB(255, 255, 128, 0);
borderPaint.setStyle(Paint.Style.STROKE);
borderPaint.setStrokeWidth(4);


backgroundPaint = new Paint();
backgroundPaint.setARGB(32, 255, 255, 255);
backgroundPaint.setStyle(Paint.Style.FILL);


}


@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: {
final float x = ev.getX();
final float y = ev.getY();


mLastTouchX = x;
mLastTouchY = y;


mActivePointerId = ev.getPointerId(0);
break;
}


case MotionEvent.ACTION_MOVE: {
final int pointerIndex = ev.findPointerIndex(mActivePointerId);
final float x = ev.getX(pointerIndex);
final float y = ev.getY(pointerIndex);


// Only move if the ScaleGestureDetector isn't processing a gesture.
if (!mScaleDetector.isInProgress()) {
final float dx = x - mLastTouchX;
final float dy = y - mLastTouchY;


mPosX += dx;
mPosY += dy;


invalidate();
}


mLastTouchX = x;
mLastTouchY = y;
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);
}
break;
}
}


return true;
}


/*
* (non-Javadoc)
*
* @see android.view.View#draw(android.graphics.Canvas)
*/
@Override
public void draw(Canvas canvas) {
super.draw(canvas);
canvas.drawRect(0, 0, getWidth() - 1, getHeight() - 1, borderPaint);
}


@Override
public void onDraw(Canvas canvas) {
canvas.drawRect(0, 0, getWidth() - 1, getHeight() - 1, backgroundPaint);
if (this.getDrawable() != null) {
canvas.save();
canvas.translate(mPosX, mPosY);


Matrix matrix = new Matrix();
matrix.postScale(mScaleFactor, mScaleFactor, pivotPointX,
pivotPointY);
// canvas.setMatrix(matrix);


canvas.drawBitmap(
((BitmapDrawable) this.getDrawable()).getBitmap(), matrix,
null);


// this.getDrawable().draw(canvas);
canvas.restore();
}
}


/*
* (non-Javadoc)
*
* @see
* android.widget.ImageView#setImageDrawable(android.graphics.drawable.Drawable
* )
*/
@Override
public void setImageDrawable(Drawable drawable) {
// Constrain to given size but keep aspect ratio
int width = drawable.getIntrinsicWidth();
int height = drawable.getIntrinsicHeight();
mLastTouchX = mPosX = 0;
mLastTouchY = mPosY = 0;


int borderWidth = (int) borderPaint.getStrokeWidth();
mScaleFactor = Math.min(((float) getLayoutParams().width - borderWidth)
/ width, ((float) getLayoutParams().height - borderWidth)
/ height);
pivotPointX = (((float) getLayoutParams().width - borderWidth) - (int) (width * mScaleFactor)) / 2;
pivotPointY = (((float) getLayoutParams().height - borderWidth) - (int) (height * mScaleFactor)) / 2;
super.setImageDrawable(drawable);
}


float pivotPointX = 0f;
float pivotPointY = 0f;


private class ScaleListener extends
ScaleGestureDetector.SimpleOnScaleGestureListener {


@Override
public boolean onScale(ScaleGestureDetector detector) {
mScaleFactor *= detector.getScaleFactor();


pivotPointX = detector.getFocusX();
pivotPointY = detector.getFocusY();


Log.d(LOG_TAG, "mScaleFactor " + mScaleFactor);
Log.d(LOG_TAG, "pivotPointY " + pivotPointY + ", pivotPointX= "
+ pivotPointX);
mScaleFactor = Math.max(0.05f, mScaleFactor);


invalidate();
return true;
}
}

这是我如何在活动中使用它。

ImageView imageView = (ImageView) findViewById(R.id.imgView);


int hMargin = (int) (displayMetrics.widthPixels * .10);
int vMargin = (int) (displayMetrics.heightPixels * .10);


RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(displayMetrics.widthPixels - (hMargin * 2), (int)(displayMetrics.heightPixels - btnCamera.getHeight()) - (vMargin * 2));
params.leftMargin = hMargin;
params.topMargin =  vMargin;
imageView.setLayoutParams(params);
imageView.setImageDrawable(drawable);
171671 次浏览

我制作了自己的自定义图像视图与捏缩放。Chirag Raval代码没有限制/边界,因此用户可以将图像从屏幕上拖出。这个能修好它。

以下是 CustomImageView 类:

    public class CustomImageVIew extends ImageView implements OnTouchListener {




private Matrix matrix = new Matrix();
private Matrix savedMatrix = new Matrix();


static final int NONE = 0;
static final int DRAG = 1;
static final int ZOOM = 2;


private int mode = NONE;


private PointF mStartPoint = new PointF();
private PointF mMiddlePoint = new PointF();
private Point mBitmapMiddlePoint = new Point();


private float oldDist = 1f;
private float matrixValues[] = {0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f};
private float scale;
private float oldEventX = 0;
private float oldEventY = 0;
private float oldStartPointX = 0;
private float oldStartPointY = 0;
private int mViewWidth = -1;
private int mViewHeight = -1;
private int mBitmapWidth = -1;
private int mBitmapHeight = -1;
private boolean mDraggable = false;




public CustomImageVIew(Context context) {
this(context, null, 0);
}


public CustomImageVIew(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}


public CustomImageVIew(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
this.setOnTouchListener(this);
}


@Override
public void onSizeChanged (int w, int h, int oldw, int oldh){
super.onSizeChanged(w, h, oldw, oldh);
mViewWidth = w;
mViewHeight = h;
}


public void setBitmap(Bitmap bitmap){
if(bitmap != null){
setImageBitmap(bitmap);


mBitmapWidth = bitmap.getWidth();
mBitmapHeight = bitmap.getHeight();
mBitmapMiddlePoint.x = (mViewWidth / 2) - (mBitmapWidth /  2);
mBitmapMiddlePoint.y = (mViewHeight / 2) - (mBitmapHeight / 2);


matrix.postTranslate(mBitmapMiddlePoint.x, mBitmapMiddlePoint.y);
this.setImageMatrix(matrix);
}
}


@Override
public boolean onTouch(View v, MotionEvent event){
switch (event.getAction() & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN:
savedMatrix.set(matrix);
mStartPoint.set(event.getX(), event.getY());
mode = DRAG;
break;
case MotionEvent.ACTION_POINTER_DOWN:
oldDist = spacing(event);
if(oldDist > 10f){
savedMatrix.set(matrix);
midPoint(mMiddlePoint, event);
mode = ZOOM;
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_POINTER_UP:
mode = NONE;
break;
case MotionEvent.ACTION_MOVE:
if(mode == DRAG){
drag(event);
} else if(mode == ZOOM){
zoom(event);
}
break;
}


return true;
}






public void drag(MotionEvent event){
matrix.getValues(matrixValues);


float left = matrixValues[2];
float top = matrixValues[5];
float bottom = (top + (matrixValues[0] * mBitmapHeight)) - mViewHeight;
float right = (left + (matrixValues[0] * mBitmapWidth)) -mViewWidth;


float eventX = event.getX();
float eventY = event.getY();
float spacingX = eventX - mStartPoint.x;
float spacingY = eventY - mStartPoint.y;
float newPositionLeft = (left  < 0 ? spacingX : spacingX * -1) + left;
float newPositionRight = (spacingX) + right;
float newPositionTop = (top  < 0 ? spacingY : spacingY * -1) + top;
float newPositionBottom = (spacingY) + bottom;
boolean x = true;
boolean y = true;


if(newPositionRight < 0.0f || newPositionLeft > 0.0f){
if(newPositionRight < 0.0f && newPositionLeft > 0.0f){
x = false;
} else{
eventX = oldEventX;
mStartPoint.x = oldStartPointX;
}
}
if(newPositionBottom < 0.0f || newPositionTop > 0.0f){
if(newPositionBottom < 0.0f && newPositionTop > 0.0f){
y = false;
} else{
eventY = oldEventY;
mStartPoint.y = oldStartPointY;
}
}


if(mDraggable){
matrix.set(savedMatrix);
matrix.postTranslate(x? eventX - mStartPoint.x : 0, y? eventY - mStartPoint.y : 0);
this.setImageMatrix(matrix);
if(x)oldEventX = eventX;
if(y)oldEventY = eventY;
if(x)oldStartPointX = mStartPoint.x;
if(y)oldStartPointY = mStartPoint.y;
}


}


public void zoom(MotionEvent event){
matrix.getValues(matrixValues);


float newDist = spacing(event);
float bitmapWidth = matrixValues[0] * mBitmapWidth;
float bimtapHeight = matrixValues[0] * mBitmapHeight;
boolean in = newDist > oldDist;


if(!in && matrixValues[0] < 1){
return;
}
if(bitmapWidth > mViewWidth || bimtapHeight > mViewHeight){
mDraggable = true;
} else{
mDraggable = false;
}


float midX = (mViewWidth / 2);
float midY = (mViewHeight / 2);


matrix.set(savedMatrix);
scale = newDist / oldDist;
matrix.postScale(scale, scale, bitmapWidth > mViewWidth ? mMiddlePoint.x : midX, bimtapHeight > mViewHeight ? mMiddlePoint.y : midY);


this.setImageMatrix(matrix);




}










/** Determine the space between the first two fingers */
private float spacing(MotionEvent event) {
float x = event.getX(0) - event.getX(1);
float y = event.getY(0) - event.getY(1);


return (float)Math.sqrt(x * x + y * y);
}


/** Calculate the mid point of the first two fingers */
private void midPoint(PointF point, MotionEvent event) {
float x = event.getX(0) + event.getX(1);
float y = event.getY(0) + event.getY(1);
point.set(x / 2, y / 2);
}




}

你可以这样在你的活动中使用它:

CustomImageVIew mImageView = (CustomImageVIew)findViewById(R.id.customImageVIew1);
mImage.setBitmap(your bitmap);

布局:

<your.package.name.CustomImageVIew
android:id="@+id/customImageVIew1"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:layout_marginBottom="15dp"
android:layout_marginLeft="15dp"
android:layout_marginRight="15dp"
android:layout_marginTop="15dp"
android:layout_centerHorizontal="true"
android:layout_centerVertical="true"
android:scaleType="matrix"/> // important

您可以使用这个类: TouchImageView

在 build.gradle 中添加波纹管:

compile 'com.commit451:PhotoView:1.2.4'

或者

compile 'com.github.chrisbanes:PhotoView:1.3.0'

在 Java 文件中:

PhotoViewAttacher photoAttacher;
photoAttacher= new PhotoViewAttacher(Your_Image_View);
photoAttacher.update();

使用 比例手势检测器

学习新概念时,我不喜欢使用库或代码转储。我发现了一个很好的描述 给你和在 文件的如何调整大小的图像捏。这个答案是一个稍作修改的总结。您可能希望稍后添加更多功能,但它将帮助您开始工作。

Animated gif: Scale image example

布局

ImageView只是使用应用程序的标志,因为它已经可用。不过,您可以用任何您喜欢的图像替换它。

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">


<ImageView
android:id="@+id/imageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:src="@mipmap/ic_launcher"
android:layout_centerInParent="true"/>


</RelativeLayout>

活动

我们在活动上使用 ScaleGestureDetector来听触摸事件。当一个缩放(即捏)姿态被检测到,然后缩放因子被用来调整 ImageView的大小。

public class MainActivity extends AppCompatActivity {


private ScaleGestureDetector mScaleGestureDetector;
private float mScaleFactor = 1.0f;
private ImageView mImageView;


@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);


// initialize the view and the gesture detector
mImageView = findViewById(R.id.imageView);
mScaleGestureDetector = new ScaleGestureDetector(this, new ScaleListener());
}


// this redirects all touch events in the activity to the gesture detector
@Override
public boolean onTouchEvent(MotionEvent event) {
return mScaleGestureDetector.onTouchEvent(event);
}


private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {


// when a scale gesture is detected, use it to resize the image
@Override
public boolean onScale(ScaleGestureDetector scaleGestureDetector){
mScaleFactor *= scaleGestureDetector.getScaleFactor();
mImageView.setScaleX(mScaleFactor);
mImageView.setScaleY(mScaleFactor);
return true;
}
}
}

笔记

  • 虽然在上面的例子中,该活动具有手势检测器,但是它也可以设置在图像视图本身上。
  • 您可以使用以下命令来限制缩放的大小

    mScaleFactor = Math.max(0.1f, Math.min(mScaleFactor, 5.0f));
    
  • Thanks again to Pinch-to-zoom with multi-touch gestures In Android

  • Documentation
  • Use Ctrl + mouse drag to simulate a pinch gesture in the emulator.

Going on

You will probably want to do other things like panning and scaling to some focus point. You can develop these things yourself, but if you would like to use a pre-made custom view, copy TouchImageView.java into your project and use it like a normal ImageView. It worked well for me and I only ran into one bug. I plan to further edit the code to remove the warning and the parts that I don't need. You can do the same.

我为图像视图编码夹缩放使用 zomageview。因此用户可以拖动屏幕上的图像,并放大,缩小图像。

您可以按照这个 link获得 Step By Step代码,也可以给出输出截图。

Https://stackoverflow.com/a/58074642/11613683

Kotlin 的自定义缩放视图

 import android.content.Context
import android.graphics.Matrix
import android.graphics.PointF
import android.util.AttributeSet
import android.util.Log
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.view.ScaleGestureDetector.SimpleOnScaleGestureListener
import androidx.appcompat.widget.AppCompatImageView


class ZoomImageview : AppCompatImageView {
var matri: Matrix? = null
var mode = NONE


// Remember some things for zooming
var last = PointF()
var start = PointF()
var minScale = 1f
var maxScale = 3f
lateinit var m: FloatArray
var viewWidth = 0
var viewHeight = 0
var saveScale = 1f
protected var origWidth = 0f
protected var origHeight = 0f
var oldMeasuredWidth = 0
var oldMeasuredHeight = 0
var mScaleDetector: ScaleGestureDetector? = null
var contex: Context? = null


constructor(context: Context) : super(context) {
sharedConstructing(context)
}


constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
sharedConstructing(context)
}


private fun sharedConstructing(context: Context) {
super.setClickable(true)
this.contex= context
mScaleDetector = ScaleGestureDetector(context, ScaleListener())
matri = Matrix()
m = FloatArray(9)
imageMatrix = matri
scaleType = ScaleType.MATRIX
setOnTouchListener { v, event ->
mScaleDetector!!.onTouchEvent(event)
val curr = PointF(event.x, event.y)
when (event.action) {
MotionEvent.ACTION_DOWN -> {
last.set(curr)
start.set(last)
mode = DRAG
}
MotionEvent.ACTION_MOVE -> if (mode == DRAG) {
val deltaX = curr.x - last.x
val deltaY = curr.y - last.y
val fixTransX = getFixDragTrans(deltaX, viewWidth.toFloat(), origWidth * saveScale)
val fixTransY = getFixDragTrans(deltaY, viewHeight.toFloat(), origHeight * saveScale)
matri!!.postTranslate(fixTransX, fixTransY)
fixTrans()
last[curr.x] = curr.y
}
MotionEvent.ACTION_UP -> {
mode = NONE
val xDiff = Math.abs(curr.x - start.x).toInt()
val yDiff = Math.abs(curr.y - start.y).toInt()
if (xDiff < CLICK && yDiff < CLICK) performClick()
}
MotionEvent.ACTION_POINTER_UP -> mode = NONE
}
imageMatrix = matri
invalidate()
true // indicate event was handled
}
}


fun setMaxZoom(x: Float) {
maxScale = x
}


private inner class ScaleListener : SimpleOnScaleGestureListener() {
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
mode = ZOOM
return true
}


override fun onScale(detector: ScaleGestureDetector): Boolean {
var mScaleFactor = detector.scaleFactor
val origScale = saveScale
saveScale *= mScaleFactor
if (saveScale > maxScale) {
saveScale = maxScale
mScaleFactor = maxScale / origScale
} else if (saveScale < minScale) {
saveScale = minScale
mScaleFactor = minScale / origScale
}
if (origWidth * saveScale <= viewWidth || origHeight * saveScale <= viewHeight) matri!!.postScale(mScaleFactor, mScaleFactor, viewWidth / 2.toFloat(), viewHeight / 2.toFloat()) else matri!!.postScale(mScaleFactor, mScaleFactor, detector.focusX, detector.focusY)
fixTrans()
return true
}
}


fun fixTrans() {
matri!!.getValues(m)
val transX = m[Matrix.MTRANS_X]
val transY = m[Matrix.MTRANS_Y]
val fixTransX = getFixTrans(transX, viewWidth.toFloat(), origWidth * saveScale)
val fixTransY = getFixTrans(transY, viewHeight.toFloat(), origHeight * saveScale)
if (fixTransX != 0f || fixTransY != 0f) matri!!.postTranslate(fixTransX, fixTransY)
}


fun getFixTrans(trans: Float, viewSize: Float, contentSize: Float): Float {
val minTrans: Float
val maxTrans: Float
if (contentSize <= viewSize) {
minTrans = 0f
maxTrans = viewSize - contentSize
} else {
minTrans = viewSize - contentSize
maxTrans = 0f
}
if (trans < minTrans) return -trans + minTrans
if (trans > maxTrans) return -trans + maxTrans
return 0f
}


fun getFixDragTrans(delta: Float, viewSize: Float, contentSize: Float): Float {
if (contentSize <= viewSize) {
return 0f
} else {
return delta
}
}


override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
viewWidth = MeasureSpec.getSize(widthMeasureSpec)
viewHeight = MeasureSpec.getSize(heightMeasureSpec)
//
// Rescales image on rotation
//
if (oldMeasuredHeight == viewWidth && oldMeasuredHeight == viewHeight || viewWidth == 0 || viewHeight == 0) return
oldMeasuredHeight = viewHeight
oldMeasuredWidth = viewWidth
if (saveScale == 1f) {
//Fit to screen.
val scale: Float
val drawable = drawable
if (drawable == null || drawable.intrinsicWidth == 0 || drawable.intrinsicHeight == 0) return
val bmWidth = drawable.intrinsicWidth
val bmHeight = drawable.intrinsicHeight
Log.d("bmSize", "bmWidth: $bmWidth bmHeight : $bmHeight")
val scaleX = viewWidth.toFloat() / bmWidth.toFloat()
val scaleY = viewHeight.toFloat() / bmHeight.toFloat()
scale = Math.min(scaleX, scaleY)
matri!!.setScale(scale, scale)
// Center the image
var redundantYSpace = viewHeight.toFloat() - scale * bmHeight.toFloat()
var redundantXSpace = viewWidth.toFloat() - scale * bmWidth.toFloat()
redundantYSpace /= 2.toFloat()
redundantXSpace /= 2.toFloat()
matri!!.postTranslate(redundantXSpace, redundantYSpace)
origWidth = viewWidth - 2 * redundantXSpace
origHeight = viewHeight - 2 * redundantYSpace
imageMatrix = matri
}
fixTrans()
}


companion object {
// We can be in one of these 3 states
const val NONE = 0
const val DRAG = 1
const val ZOOM = 2
const val CLICK = 3
}
}

TouchImageViewSample类中,缩放发生在枢轴点附近。属于枢轴点的像素不受图像缩放的影响。当您更改枢轴点时,视图将重新绘制,并且扩展将围绕新的枢轴点进行。这会改变上一个枢轴点的位置,当你每次触摸图像时,图像就会发生位移。你必须通过翻译图像来补偿这种移位误差。看看在我的 ZoomGestureDetector.updatePivotPoint()方法中是如何做到这一点的。

放大手势检测器

我创建了一个自定义缩放手势检测器类。它可以同时进行缩放、平移和旋转。它还支持抛出动画。

import android.graphics.Canvas
import android.graphics.Matrix
import android.view.MotionEvent
import android.view.VelocityTracker
import androidx.core.math.MathUtils
import androidx.dynamicanimation.animation.FlingAnimation
import androidx.dynamicanimation.animation.FloatValueHolder
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2


class ZoomGestureDetector(private val listener: Listener) {


companion object {
const val MIN_SCALE = 0.01f
const val MAX_SCALE = 100f
const val MIN_FLING_VELOCITY = 50f
const val MAX_FLING_VELOCITY = 8000f
}


// public
var isZoomEnabled: Boolean = true
var isScaleEnabled: Boolean = true
var isRotationEnabled: Boolean = true
var isTranslationEnabled: Boolean = true
var isFlingEnabled: Boolean = true


// local
private val mDrawMatrix: Matrix = Matrix()
private val mTouchMatrix: Matrix = Matrix()
private val mPointerMap: HashMap<Int, Position> = HashMap()
private val mTouchPoint: FloatArray = floatArrayOf(0f, 0f)
private val mPivotPoint: FloatArray = floatArrayOf(0f, 0f)


// transformations
private var mTranslationX: Float = 0f
private var mTranslationY: Float = 0f
private var mScaling: Float = 1f
private var mPivotX: Float = 0f
private var mPivotY: Float = 0f
private var mRotation: Float = 0f


// previous values
private var mPreviousFocusX: Float = 0f
private var mPreviousFocusY: Float = 0f
private var mPreviousTouchSpan: Float = 1f


// fling related
private var mVelocityTracker: VelocityTracker? = null
private var mFlingAnimX: FlingAnimation? = null
private var mFlingAnimY: FlingAnimation? = null


fun updateTouchLocation(event: MotionEvent) {
mTouchPoint[0] = event.x
mTouchPoint[1] = event.y
mTouchMatrix.mapPoints(mTouchPoint)
event.setLocation(mTouchPoint[0], mTouchPoint[1])
}


fun updateCanvasMatrix(canvas: Canvas) {
canvas.setMatrix(mDrawMatrix)
}


fun onTouchEvent(event: MotionEvent): Boolean {
if (isZoomEnabled) {
// update velocity tracker
if (isFlingEnabled) {
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain()
}
mVelocityTracker?.addMovement(event)
}
// handle touch events
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
// update focus point
mPreviousFocusX = event.x
mPreviousFocusY = event.y
event.savePointers()
// cancel ongoing fling animations
if (isFlingEnabled) {
mFlingAnimX?.cancel()
mFlingAnimY?.cancel()
}
}
MotionEvent.ACTION_POINTER_DOWN -> {
updateTouchParameters(event)
}
MotionEvent.ACTION_POINTER_UP -> {
// Check the dot product of current velocities.
// If the pointer that left was opposing another velocity vector, clear.
if (isFlingEnabled) {
mVelocityTracker?.let { tracker ->
tracker.computeCurrentVelocity(1000, MAX_FLING_VELOCITY)
val upIndex: Int = event.actionIndex
val id1: Int = event.getPointerId(upIndex)
val x1 = tracker.getXVelocity(id1)
val y1 = tracker.getYVelocity(id1)
for (i in 0 until event.pointerCount) {
if (i == upIndex) continue
val id2: Int = event.getPointerId(i)
val x = x1 * tracker.getXVelocity(id2)
val y = y1 * tracker.getYVelocity(id2)
val dot = x + y
if (dot < 0) {
tracker.clear()
break
}
}
}
}
updateTouchParameters(event)
}
MotionEvent.ACTION_UP -> {
// do fling animation
if (isFlingEnabled) {
mVelocityTracker?.let { tracker ->
val pointerId: Int = event.getPointerId(0)
tracker.computeCurrentVelocity(1000, MAX_FLING_VELOCITY)
val velocityY: Float = tracker.getYVelocity(pointerId)
val velocityX: Float = tracker.getXVelocity(pointerId)
if (abs(velocityY) > MIN_FLING_VELOCITY || abs(velocityX) > MIN_FLING_VELOCITY) {
val translateX = mTranslationX
val translateY = mTranslationY
val valueHolder = FloatValueHolder()
mFlingAnimX = FlingAnimation(valueHolder).apply {
setStartVelocity(velocityX)
setStartValue(0f)
addUpdateListener { _, value, _ ->
mTranslationX = translateX + value
updateDrawMatrix()
listener.onZoom(mScaling, mRotation, mTranslationX to mTranslationY, mPivotX to mPivotY)
}
addEndListener { _, _, _, _ ->
updateTouchMatrix()
}
start()
}
mFlingAnimY = FlingAnimation(valueHolder).apply {
setStartVelocity(velocityY)
setStartValue(0f)
addUpdateListener { _, value, _ ->
mTranslationY = translateY + value
updateDrawMatrix()
listener.onZoom(mScaling, mRotation, mTranslationX to mTranslationY, mPivotX to mPivotY)
}
addEndListener { _, _, _, _ ->
updateTouchMatrix()
}
start()
}
}
tracker.recycle()
mVelocityTracker = null
}
}
}
MotionEvent.ACTION_MOVE -> {
val (focusX, focusY) = event.focalPoint()
if (event.pointerCount > 1) {
if (isScaleEnabled) {
val touchSpan = event.touchSpan(focusX, focusY)
mScaling *= scaling(touchSpan)
mScaling = MathUtils.clamp(mScaling, MIN_SCALE, MAX_SCALE)
mPreviousTouchSpan = touchSpan
}
if (isRotationEnabled) {
mRotation += event.rotation(focusX, focusY)
}
if (isTranslationEnabled) {
val (translationX, translationY) = translation(focusX, focusY)
mTranslationX += translationX
mTranslationY += translationY
}
} else {
if (isTranslationEnabled) {
val (translationX, translationY) = translation(focusX, focusY)
mTranslationX += translationX
mTranslationY += translationY
}
}
mPreviousFocusX = focusX
mPreviousFocusY = focusY
updateTouchMatrix()
updateDrawMatrix()
event.savePointers()
listener.onZoom(mScaling, mRotation, mTranslationX to mTranslationY, mPivotX to mPivotY)
}
}
return true
}
return false
}


// update focus point, touch span and pivot point
private fun updateTouchParameters(event: MotionEvent) {
val (focusX, focusY) = event.focalPoint()
mPreviousFocusX = focusX
mPreviousFocusY = focusY
mPreviousTouchSpan = event.touchSpan(focusX, focusY)
updatePivotPoint(focusX, focusY)
updateTouchMatrix()
updateDrawMatrix()
event.savePointers()
listener.onZoom(mScaling, mRotation, mTranslationX to mTranslationY, mPivotX to mPivotY)
}


// touch matrix is used to transform touch points
// on the child view and to find pivot point
private fun updateTouchMatrix() {
mTouchMatrix.reset()
mTouchMatrix.preTranslate(-mTranslationX, -mTranslationY)
mTouchMatrix.postRotate(-mRotation, mPivotX, mPivotY)
mTouchMatrix.postScale(1f / mScaling, 1f / mScaling, mPivotX, mPivotY)
}


// draw matrix is used to transform child view when drawing on the canvas
private fun updateDrawMatrix() {
mDrawMatrix.reset()
mDrawMatrix.preScale(mScaling, mScaling, mPivotX, mPivotY)
mDrawMatrix.preRotate(mRotation, mPivotX, mPivotY)
mDrawMatrix.postTranslate(mTranslationX, mTranslationY)
}


// this updates the pivot point and translation error caused by changing the pivot point
private fun updatePivotPoint(focusX: Float, focusY: Float) {
// update point
mPivotPoint[0] = focusX
mPivotPoint[1] = focusY
mTouchMatrix.mapPoints(mPivotPoint)
mPivotX = mPivotPoint[0]
mPivotY = mPivotPoint[1]
// correct pivot error
mDrawMatrix.mapPoints(mPivotPoint)
mTranslationX -= mTranslationX + mPivotX - mPivotPoint[0]
mTranslationY -= mTranslationY + mPivotY - mPivotPoint[1]
}


private fun MotionEvent.focalPoint(): Pair<Float, Float> {
val upIndex = if (actionMasked == MotionEvent.ACTION_POINTER_UP) actionIndex else -1
var sumX = 0f
var sumY = 0f
var sumCount = 0
for (pointerIndex in 0 until pointerCount) {
if (pointerIndex == upIndex) continue
sumX += getX(pointerIndex)
sumY += getY(pointerIndex)
sumCount++
}
val focusX = sumX / sumCount
val focusY = sumY / sumCount
return focusX to focusY
}


private fun MotionEvent.touchSpan(
currentFocusX: Float,
currentFocusY: Float
): Float {
var spanSumX = 0f
var spanSumY = 0f
var sumCount = 0
val ignoreIndex = if (actionMasked == MotionEvent.ACTION_POINTER_UP) actionIndex else -1
for (pointerIndex in 0 until pointerCount) {
if (pointerIndex == ignoreIndex) continue
spanSumX += abs(currentFocusX - getX(pointerIndex))
spanSumY += abs(currentFocusY - getY(pointerIndex))
sumCount++
}
if (sumCount > 1) {
val spanX = spanSumX / sumCount
val spanY = spanSumY / sumCount
return spanX + spanY
}
return mPreviousTouchSpan
}


private fun scaling(currentTouchSpan: Float): Float {
return currentTouchSpan / mPreviousTouchSpan
}


private fun MotionEvent.rotation(
currentFocusX: Float,
currentFocusY: Float
): Float {
var rotationSum = 0f
var weightSum = 0f
for (pointerIndex in 0 until pointerCount) {
val pointerId = getPointerId(pointerIndex)
val x1 = getX(pointerIndex)
val y1 = getY(pointerIndex)
val (x2, y2) = mPointerMap[pointerId] ?: continue
val dx1 = x1 - currentFocusX
val dy1 = y1 - currentFocusY
val dx2 = x2 - currentFocusX
val dy2 = y2 - currentFocusY
// dot product is proportional to the cosine of the angle
// the determinant is proportional to its sine
// sign of the rotation tells if it is clockwise or counter-clockwise
val dot = dx1 * dx2 + dy1 * dy2
val det = dy1 * dx2 - dx1 * dy2
val rotation = atan2(det, dot)
val weight = abs(dx1) + abs(dy1)
rotationSum += rotation * weight
weightSum += weight
}
if (weightSum > 0f) {
val rotation = rotationSum / weightSum
return rotation * 180f / PI.toFloat()
}
return 0f
}


private fun translation(
currentFocusX: Float,
currentFocusY: Float
): Pair<Float, Float> {
return (currentFocusX - mPreviousFocusX) to (currentFocusY - mPreviousFocusY)
}


private fun MotionEvent.savePointers() {
mPointerMap.clear()
for (pointerIndex in 0 until pointerCount) {
val id = getPointerId(pointerIndex)
val x = getX(pointerIndex)
val y = getY(pointerIndex)
mPointerMap[id] = x to y
}
}


interface Listener {
fun onZoom(scaling: Float, rotation: Float, translation: Position, pivot: Position)
}


}


typealias Position = Pair<Float, Float>

我在 FrameLayout中使用了 ZoomGestureDetector,如下所示。

import android.content.Context
import android.graphics.Canvas
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.widget.FrameLayout


class ZoomLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr),
ZoomGestureDetector.Listener {


private val gestureDetector = ZoomGestureDetector(this)


var isZoomEnabled
get() = gestureDetector.isZoomEnabled
set(value) {
gestureDetector.isZoomEnabled = value
}


var isScaleEnabled
get() = gestureDetector.isScaleEnabled
set(value) {
gestureDetector.isScaleEnabled = value
}


var isRotationEnabled
get() = gestureDetector.isRotationEnabled
set(value) {
gestureDetector.isRotationEnabled = value
}


var isTranslationEnabled
get() = gestureDetector.isTranslationEnabled
set(value) {
gestureDetector.isTranslationEnabled = value
}


var isFlingEnabled
get() = gestureDetector.isFlingEnabled
set(value) {
gestureDetector.isFlingEnabled = value
}


override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
if (isZoomEnabled) return true
gestureDetector.updateTouchLocation(event)
return super.onInterceptTouchEvent(event)
}


override fun onTouchEvent(event: MotionEvent): Boolean {
return gestureDetector.onTouchEvent(event)
}


override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean {
gestureDetector.updateCanvasMatrix(canvas)
return super.drawChild(canvas, child, drawingTime)
}


override fun onZoom(scaling: Float, rotation: Float, translation: Position, pivot: Position) {
invalidate()
}


}

更新:

我已经为此在 Github.com/udarawanasinghe/android-transform-layout上发表了一个图书馆。根据变换矩阵的级联特性,采用了不同的算法。