这个标题好难取啊,真的不太好用文字表达这个意思。还是来张图吧。
就像这样的一个矩形框,是可以通过拖动边角来改变大小的,同时,拖动非边角的区域,还可以移动这个矩形框。
嗯,想实现的就是这样的一个矩形框。(当然是不包括这只猫的,蟹蟹~)
对了,图中的矩形框的4个顶角旁边的线条加粗了,这个也不去实现了。
先放出我的实现效果,图片基本看不出来,反正你要想象它是可以拖动改变大小,并且可以移动改变位置的就可以了。(防止线太细,导致截图之后看不到,估计把线弄粗了~)
正文开始,以下是我的实现。
主要是通过自定义view
然后调用 canvas.drawRect()
来实现的,这一句就是核心代码了。
然后,主要是判断手指触摸的区域是不是在顶角附近。(求两点之间的直线距离)
然后,无论是改变大小,还是移动矩形,都不应该让矩形的任何一边超出view
本身的区域。
然后,就是要注意,如果是移动,那么rect
的大小是不能被改变的,如果是拖动顶角来改变大小,也要区分好拖动不同的顶角,要分别怎么改变rect
的left,top,right,bottom
。
好了,以上是注意事项。下面是代码实现:
package com.python.cat.studyview.view;
import android.annotation.TargetApi;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.PointF;
import android.graphics.RectF;
import android.os.Build;
import android.support.annotation.IntDef;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.MotionEvent;
import com.apkfuns.logutils.LogUtils;
import com.python.cat.studyview.base.BaseView;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
public class RectView extends BaseView {
public static final int NONE_POINT = 0;
public static final int LEFT_TOP = 1;
public static final int RIGHT_TOP = 1 + 1;
public static final int RIGHT_BOTTOM = 1 + 1 + 1;
public static final int LEFT_BOTTOM = 1 + 1 + 1 + 1;
private float currentX;
private float currentY;
private float downX;
private float downY;
@IntDef({LEFT_BOTTOM, LEFT_TOP, RIGHT_BOTTOM, RIGHT_TOP})
@Retention(RetentionPolicy.SOURCE)
@interface TouchNear {
}
public static final int MOVE_ERROR = -1024;
public static final int MOVE_H = 90;
public static final int MOVE_V = 90 + 1;
public static final int MOVE_VH = 90 + 1 + 1;
@IntDef({MOVE_ERROR, MOVE_H, MOVE_V, MOVE_VH})
@Retention(RetentionPolicy.SOURCE)
@interface MoveDirection {
}
@TouchNear
int currentNEAR = NONE_POINT;
private Paint paint;
private RectF oval;
private float NEAR = 0;
public RectView(Context context) {
super(context);
}
public RectView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public RectView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@SuppressWarnings("unused")
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public RectView(Context context, @Nullable AttributeSet attrs,
int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
NEAR = Math.min(mWidth, mHeight) / 10;
paint = new Paint();
paint.setAntiAlias(true);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(10);
oval = new RectF();
oval.set(0, 0, mWidth, mHeight); // first ui
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawRect(oval, paint);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
this.currentX = event.getX();
this.currentY = event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
this.downX = event.getX();
this.downY = event.getY();
currentNEAR = checkNear();
LogUtils.w("currentNEAR===> " + currentNEAR);
break;
case MotionEvent.ACTION_MOVE:
if (currentNEAR == NONE_POINT) {
// do move...
int canMove = canMove();
LogUtils.e("canMove? " + canMove);
float dx = currentX - downX;
float dy = currentY - downY;
LogUtils.w("dx=" + dx + " , dy=" + dy);
float newL = roundLength(oval.left + dx, mWidth);
float newR = roundLength(oval.right + dx, mWidth);
float newT = roundLength(oval.top + dy, mHeight);
float newB = roundLength(oval.bottom + dy, mHeight);
switch (canMove) {
case MOVE_H:
if (!distortionInMove(oval, newL, oval.top, newR, oval.bottom)) {
oval.set(newL, oval.top, newR, oval.bottom);
}
downX = currentX;
downY = currentY;
break;
case MOVE_V:
if (!distortionInMove(oval, oval.left, newT, oval.right, newB)) {
oval.set(oval.left, newT, oval.right, newB);
}
downX = currentX;
downY = currentY;
break;
case MOVE_VH:
// oval.inset(dx, dy);
if (!distortionInMove(oval, newL, newT, newR, newB)) {
oval.set(newL, newT, newR, newB);
}
downX = currentX;
downY = currentY;
break;
case MOVE_ERROR:
break;
}
} else {
// do drag crop
currentX = roundLength(currentX, mWidth);
currentY = roundLength(currentY, mHeight);
switch (currentNEAR) {
case LEFT_TOP:
oval.set(currentX, currentY, oval.right, oval.bottom);
break;
case LEFT_BOTTOM:
oval.set(currentX, oval.top, oval.right, currentY);
break;
case RIGHT_TOP:
oval.set(oval.left, currentY, currentX, oval.bottom);
break;
case RIGHT_BOTTOM:
oval.set(oval.left, oval.top, currentX, currentY);
break;
}
}
postInvalidate(); // update ui
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
break;
}
return true;
}
/**
* 移动的时候是否变形了
*/
private boolean distortionInMove(RectF oval, float cL, float cT, float cR, float cB) {
return Math.abs((cR - cL) - (oval.right - oval.left)) > 0.001
|| Math.abs((cB - cT) - (oval.bottom - oval.top)) > 0.001;
}
private float roundLength(float w, float max) {
if (w < 0) {
return 0;
} else if (w > max) {
return max;
} else {
return w;
}
}
@TouchNear
private int checkNear() {
boolean nearLT = near(currentX, currentY, oval.left, oval.top);
if (nearLT) {
return LEFT_TOP;
}
boolean nearLB = near(currentX, currentY, oval.left, oval.bottom);
if (nearLB) {
return LEFT_BOTTOM;
}
boolean nearRT = near(currentX, currentY, oval.right, oval.top);
if (nearRT) {
return RIGHT_TOP;
}
boolean nearRB = near(currentX, currentY, oval.right, oval.bottom);
if (nearRB) {
return RIGHT_BOTTOM;
}
return NONE_POINT;
}
/**
* when can move?
* if the oval is not the max,then can move
*
* @return
*/
@MoveDirection
int canMove() {
if (touchEdge()) {
return MOVE_ERROR;
}
if (!oval.contains(currentX, currentY)) {
return MOVE_ERROR;
}
if (oval.right - oval.left == mWidth
&& oval.bottom - oval.top == mHeight) {
return MOVE_ERROR;
} else if (oval.right - oval.left == mWidth
&& oval.bottom - oval.top != mHeight) {
return MOVE_V;
} else if (oval.right - oval.left != mWidth
&& oval.bottom - oval.top == mHeight) {
return MOVE_H;
} else {
return MOVE_VH;
}
}
/**
* 超出边界
*
* @return true, false
*/
boolean touchEdge() {
return oval.left < 0 || oval.right > mWidth
|| oval.top < 0 || oval.bottom > mHeight;
}
boolean near(PointF one, PointF other) {
float dx = Math.abs(one.x - other.x);
float dy = Math.abs(one.y - other.y);
return Math.pow(dx * dx + dy * dy, 0.5) <= NEAR;
}
boolean near(float x1, float y1, float x2, float y2) {
float dx = Math.abs(x1 - x2);
float dy = Math.abs(y1 - y2);
return Math.pow(dx * dx + dy * dy, 0.5) <= NEAR;
}
}
还有一个小点应该注意一下,onTouch , onDraw
方法都可能被频繁调用,尽量不要在里面创建对象。之前准备通过Point
对象封装各个点的,后面放弃了,直接弄成x1,y1
这样的基本类型的变量了。就是为了避免对象的过多创建。
嗯,本机测试无论是拖动改变大小,还是移动改变位置,并没有卡顿的现象。蟹蟹。
如果想导入到 as
里面运行一下,那么就戳我吧。
为了实现矩形边框的4个顶角的加粗线条,我天真地使用了 canvas.drawLine()
方法,画了8条线,看起来的确是那么回事了,但是看起来有点别扭的样子。
是的,就是粗心不是在矩形框的内部,而是在线上了。这不好,不是原图中的效果,准备使用 path
来改进这个。
当前这种效果的修改代码如下:(只是修改了 onDraw()
)
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
paint.setStrokeWidth(1);
paint.setStyle(Paint.Style.STROKE);
canvas.drawRect(oval, paint);
paint.setStrokeWidth(10);
paint.setStyle(Paint.Style.FILL_AND_STROKE);
// 8 lines
// canvas.drawLines();
// lt h
canvas.drawLine(oval.left, oval.top, oval.left + lineLen, oval.top, paint);
// lt v
canvas.drawLine(oval.left, oval.top, oval.left, oval.top + lineLen, paint);
// rt h
canvas.drawLine(oval.right - lineLen, oval.top, oval.right, oval.top, paint);
// rt v
canvas.drawLine(oval.right, oval.top, oval.right, oval.top + lineLen, paint);
// lb h
canvas.drawLine(oval.left, oval.bottom, oval.left + lineLen, oval.bottom, paint);
// lb v
canvas.drawLine(oval.left, oval.bottom - lineLen, oval.left, oval.bottom, paint);
// rb h
canvas.drawLine(oval.right - lineLen, oval.bottom, oval.right, oval.bottom, paint);
// rb v
canvas.drawLine(oval.right, oval.bottom - lineLen, oval.right, oval.bottom, paint);
}
对的,就是加了 // draw 8 lines 后面的8行代码。
======> 不完美才完美
目前为止,顶角的粗线效果并不好,因为粗线覆盖在细线上面了,而效果图上面,粗线应该是被包含在细线的内部的。这样要怎么弄呢?实现方案大概有很多,我这里是使用了path
去实现的。
但是要注意一下,path
通过moveTo/lineTo
肯定可以实现一个不规则的封闭图形的,不过这里的效果,每个顶角的粗线,其实是两个部分重合的矩形区域。这样,就可以通过Path.OP.UNION
去把两个包含矩形的path
的交集取到。
关于Path.OP
,可以参考这里别人的博客 。
Path.Op.DIFFERENCE 减去path1中path1与path2都存在的部分;
path1 = (path1 - path1 ∩ path2)
Path.Op.INTERSECT 保留path1与path2共同的部分;
path1 = path1 ∩ path2
Path.Op.UNION 取path1与path2的并集;
path1 = path1 ∪ path2
Path.Op.REVERSE_DIFFERENCE 与DIFFERENCE刚好相反;
path1 = path2 - (path1 ∩ path2)
Path.Op.XOR 与INTERSECT刚好相反;
path1 = (path1 ∪ path2) - (path1 ∩ path2)作者:zhaoyubetter
链接:https://www.jianshu.com/p/40abd770d05c
來源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。以上内容已注明出处。
好了,回到这个效果的实现。先看下实现效果。
可以看到,通过path
确实实现了这个效果。
看一下代码:
/**
* 画出左上角的path 路径
*
* @param canvas 画布
*/
private void drawLeftTopPath(Canvas canvas) {
hCrop.set(oval.left, oval.top, oval.left + lineLen, oval.top + lineWidth);
vCrop.set(oval.left, oval.top, oval.left + lineWidth, oval.top + lineLen);
hPath.rewind();
vPath.rewind();
path.rewind();
hPath.addRect(hCrop, Path.Direction.CCW);
vPath.addRect(vCrop, Path.Direction.CCW);
path.op(hPath, vPath, Path.Op.UNION);
canvas.drawPath(path, paint);
}
一共写4个类似的方法,就可以实现了。
这里还是要注意一点,尽量少的创建对象,尽量复用对象。比如这里的
path
,我可以为4个顶角的区域,各创建1个,但是既然可以使用一个path
去实现,我就指使用了一个。
当然,如果你希望看到完整的,可以运行的代码,可以戳我。
但是,效果图上面,被选中区域的外部是有一个半透明的蒙层的,这个又要怎么弄?
我的做法是,在外部再搞一个大的矩形,这个矩形的大小就是当前view
的大小。然后依然是通过Path.OP
来弄,这次使用的是Path.Op.DIFFERENCE
,这样就画出外部矩形与内部矩形的不同区域,效果图如下:
对的,实现的效果就是这样子的。【为什么下面有一张图片?因为是在布局里面加了一个ImageView
,这个 ImageView
刚好被当前的View
覆盖。】
代码也是不多的,就加了几行,不过是在onDraw()
最前面的的。
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// draw outer area
paint.setStyle(Paint.Style.FILL);
paint.setColor(getResources().getColor(R.color.white_overlay));
path.rewind();
innerPath.rewind();
outerPath.rewind();
innerPath.addRect(oval, Path.Direction.CCW);
outerPath.addRect(outer, Path.Direction.CCW);
path.op(outerPath, innerPath, Path.Op.DIFFERENCE);
canvas.drawPath(path, paint);
// draw oval
paint.setStyle(Paint.Style.STROKE);
paint.setColor(Color.BLUE);
paint.setStrokeCap(Paint.Cap.ROUND);
paint.setStrokeWidth(1);
canvas.drawRect(oval, paint);
// draw bold line path
if (3 == 3) {
paint.setStrokeWidth(10);
paint.setStyle(Paint.Style.FILL);
// lt
drawLeftTopPath(canvas);
// rt
drawRightTopPath(canvas);
// rb
drawRightBottomPath(canvas);
// lb
drawLeftBottomPath(canvas);
}
如果你希望看到完整的,可以运行的代码,可以戳我。
===> 有的手机的效果是,如果是移动,并不是移动这个选择区域,而是移动底部的图片,这个就有点麻烦了。难道要把图片也绘制出来,然后移动?
—— 对于这个问题,肯定也是有多种解决方法,我这里的解决方法就是,下面放一个ImageView
,里面重写onTouch
,让 ImageView
自己可以移动。
先看一下效果图:
看到看吗,效果图中,后面的图片明显下移了。要的就是这个效果:移动的时候,移动后面的图片,而不是选择区域矩形。
不过要注意一点,既然是让下面的View
移动,那么自己的onTouch
方法就要返回false
了。
这里应该在什么时候返回 false? 我之前就弄错了,因为我是要在 move
的时候,移动下面的view
,于是我在 move
的时候return false
了。但是这样没有效果。应该在down
的时候就return false
。至于为什么,可以看看大神关于触摸反馈的讲解。
那么,我的代码就是需要修改一下onTouch
了,代码如下:
case MotionEvent.ACTION_DOWN:
this.downX = event.getX();
this.downY = event.getY();
currentNEAR = checkNear();
LogUtils.w("currentNEAR===> " + currentNEAR);
if (currentNEAR == NONE_POINT) {
get().setFocusable(false);
get().setClickable(false);
get().setEnabled(false);
return false; // --> 这种情况下,让下面的 view 去处理
}
break;
然后下面的ImageView
也是要重写 onTouch
的,里面的代码如下:
// from: https://blog.csdn.net/androidv/article/details/53028473
// 视图坐标方式
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// 记录触摸点坐标
lastX = x;
lastY = y;
break;
case MotionEvent.ACTION_MOVE:
// 计算偏移量
int offsetX = x - lastX;
int offsetY = y - lastY;
// 在当前left、top、right、bottom的基础上加上偏移量
layout(getLeft() + offsetX,
getTop() + offsetY,
getRight() + offsetX,
getBottom() + offsetY);
// offsetLeftAndRight(offsetX);
// offsetTopAndBottom(offsetY);
break;
}
return true;
}
【这段代码是在网上抄的,不过很管用。~】
嗯,这样就实现了。
如果你希望看到完整的,可以运行的代码,可以戳我。
但是这种方案,我觉得还有一个问题不能解决,就是实际上,如果移动图片超出了矩形区域了,也就是矩形区域并没有完全显示图片的内容,而是显示了一部分空白的部分,在手指抬起之后,会把图片自动移动直到矩形区域完全显示的是图片的内容。
有了以上的实现,移动图片应该不是主要问题了,主要问题是,怎么让下面的view
知道上面的view
的矩形区域的位置?涉及到两个对象之间的数据交互了。不清楚这样的实现,是不是一个好的实现。
这个还是可以实现的,不过代码就不太好了。我这里是通过给RectView
提供一个getOval()
方法,然后让ImageView
去获取这个值。
代码如下:
// add in RectView.java
public RectF getOval() {
return oval;
}
// add in ImageView
/**
* 耦合性太重,可复用性太低
*
* @return oval
*/
private RectF getOval() {
ViewParent parent = getParent();
ViewGroup vg = (ViewGroup) parent;
int count = vg.getChildCount();
for (int x = 0; x < count; x++) {
View child = vg.getChildAt(x);
if (child instanceof RectView) {
RectView rv = (RectView) child;
return rv.getOval();
}
}
return null;
}
这个方法其实是找到第一个
RectView
,如果这个布局里面有多个,这个方法就得修改了。
然后是一个计算偏移的方法:
private int[] autoMove() {
RectF oval = getOval();
if (oval == null) {
LogUtils.e("不能检测到上层 view 的矩形区域,请检查代码逻辑!");
return new int[]{0, 0};
} else {
int dx = 0, dy = 0;
float left = getLeft() - oval.left;
float right = oval.right - getRight();
if (left > 0) {
dx = -Math.round(left); // 这里为什么加- ,因为是要左移
} else if (right > 0) {
dx = Math.round(right); // 右移
}
float top = getTop() - oval.top;
float bottom = oval.bottom - getBottom();
if (top > 0) {
dy = -Math.round(top); // 上移
} else if (bottom > 0) {
dy = Math.round(bottom); // 下移
}
return new int[]{dx, dy};
}
}
然后是重新Action_Up
事件:
case MotionEvent.ACTION_CANCEL:
int[] xy = autoMove();
int dx = xy[0];
int dy = xy[1];
LogUtils.w("up===> " + Arrays.toString(xy));
// 在当前 left、top、right、bottom 的基础上 + 偏移量
layout(getLeft() + dx,
getTop() + dy,
getRight() + dx,
getBottom() + dy);
break;
这样,这个需求就实现了。
看一下效果:
其实看不到什么效果,这个要去触摸移动然后抬起手指才能看到的。
不过功能的确实现了。
如果你希望看到完整的,可以运行的代码,可以戳我。
有的手机还有一种效果,在这之后,会自动把选中的区域放大到整个view
,包括矩形框以及下面的图片,这又得让两个view
同步进行缩放了。这个似乎….
为了实现这种同步缩放图片及矩形框的效果,我还是决定把图片和矩形框放在一个 view
里面去实现。这样确实会方便一点。
不过目前并没有实现同步缩放,而是先实现了up 之后的位移。这种位移是做什么的?就是矩形框选择选中区域之后,然后松手,但是此时,矩形框不一定在屏幕中间,于是就进行位移,把矩形框位移到屏幕中间;同时,图片也要进行同步的位移,否则位移之后,被选中的区域就变了。
这里做的,就是在 up 之后的逻辑了:
// up 事件的最下面添加
{
// 完成这次位移之后,需要将矩形移动到 view 中间,图片也是做相应的位移
float[] floatXY = move2center();
LogUtils.w("移动中间位置:" + Arrays.toString(floatXY));
float fdx = floatXY[0];
float fdy = floatXY[1];
// move oval
oval.set(oval.left + fdx, oval.top + fdy,
oval.right + fdx, oval.bottom + fdy);
// move bitmap
matrix.postTranslate(fdx, fdy);
bmpLeft += fdx;
bmpRight += fdx;
bmpTop += fdy;
bmpBottom += fdy;
}
然后里面用到的方法是:
/**
* * 将图片与矩形区域全部位移到 view 的中心(先不缩放,仅仅位移)
*
* @return 需要位移的距离: dx, dy
*/
private float[] move2center() {
float ovalCenterX = oval.left + (oval.right - oval.left) / 2;
float ovalCenterY = oval.top + (oval.bottom - oval.top) / 2;
LogUtils.e("oval center==(" + ovalCenterX + "," + ovalCenterY + ")"
+ " ####CENTER(" + center.x + "," + center.y + ")");
return new float[]
{
center.x - ovalCenterX, // x center
center.y - ovalCenterY // y center
};
}
嗯,逻辑就是这个逻辑了。
后面想实现同步缩放的效果,这时候要考虑一个问题,缩放的极限(最大/最小缩放尺寸。),然后是缩放后怎么恢复?怎么去捕捉缩放之后的图片坐标。