Android 实现一个可以拖动大小,移动区域的矩形框

这个标题好难取啊,真的不太好用文字表达这个意思。还是来张图吧。

就像这样的一个矩形框

就像这样的一个矩形框,是可以通过拖动边角来改变大小的,同时,拖动非边角的区域,还可以移动这个矩形框

嗯,想实现的就是这样的一个矩形框。(当然是不包括这只猫的,蟹蟹~)


对了,图中的矩形框的4个顶角旁边的线条加粗了,这个也不去实现了。


先放出我的实现效果,图片基本看不出来,反正你要想象它是可以拖动改变大小,并且可以移动改变位置的就可以了。(防止线太细,导致截图之后看不到,估计把线弄粗了~)

这里写图片描述
正文开始,以下是我的实现。

主要是通过自定义view然后调用 canvas.drawRect()来实现的,这一句就是核心代码了。

然后,主要是判断手指触摸的区域是不是在顶角附近。(求两点之间的直线距离)

然后,无论是改变大小,还是移动矩形,都不应该让矩形的任何一边超出view本身的区域。

然后,就是要注意,如果是移动,那么rect的大小是不能被改变的,如果是拖动顶角来改变大小,也要区分好拖动不同的顶角,要分别怎么改变rectleft,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同步进行缩放了。这个似乎….

猜你喜欢

转载自blog.csdn.net/DucklikeJAVA/article/details/80947956