Android自定义控件之算法标注框(图片上标注)

1,815 阅读12分钟

前言

在开发项目过程中,有一个功能是获取算法同事给我们一张图片,以及两个点,让我们在图片上绘制矩形,框出算法标注的区域,感觉后面可能会有另一个需求是,给一张图片,用户给图片框出标注的区域,之后将坐标点上传后台。根据这想法尝试实现算法标注框。View的运行效果如下:

算法标注框.gif

功能分析

功能可以简单说为:手指按下并划动时候绘制标注框
但具体到功能实现有些细节地方需要注意
1.标注框伸缩:手指拖拽标注框不同区域,可以实现标注框不同方向伸缩,例如左上角可以上下左右伸缩,左侧则只能左右伸缩。
2.标注框拖拽:拖拽框不会超出View边缘,例如拖拽标注框超出图片的右下侧,则标准框右侧和下侧不动,左侧和上侧移动。
3.标注框点坐标:获取标注框点坐标,一般算法需要框的左上点与右下点坐标。
4.其他功能:根据需求的扩展,例如标注框装饰样式,颜色,响应伸缩标注框的区域宽度等。

代码实现

(为了看起来方便,代码实现部分讲解的代码都是截取的,View完整代码可以在文章末尾查看,也可以在文章末尾的Github地址下载源码运行)
标注框绘制比较简单,在onTouchEvent()方法内记录下手指按下与移动的坐标点,根据坐标点,设置矩形的坐标值,之后在onDraw()方法内绘制矩形即可,代码可以简写为下面形式。

@Override
public boolean onTouchEvent(MotionEvent event) {
    float x = event.getX();
    float y = event.getY();

    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            downX = x;
            downY = y;
            downRectF.set(downX, downY, downX, downY);
            //... 其他功能代码
            lastX = x;
            lastY = y;
            break;
        case MotionEvent.ACTION_MOVE:
            float offsetX = x - lastX;
            float offsetY = y - lastY;
            if (!boxRectConfirm) {//标注框还未确认
                boxRectF.set(Math.min(downX, x), Math.min(downY, y), Math.max(downX, x), Math.max(downY, y));
            }

            //... 其他功能省略代码
            lastX = x;
            lastY = y;
            break;
        case MotionEvent.ACTION_UP:
            //... 其他功能省略代码
            break;
    }
    postInvalidate();
    return true;
}
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    if (boxRectF != null) {
        canvas.drawRect(boxRectF, boxPaint);
        //... 其他功能省略代码
    }
}

根据上面代码,一个根据手指移动进行绘制算法标注框,就简单的完成了,下面开始补充功能。

标注框伸缩

根据标注框的使用,可以将算法框的拖拽区域分为八个部分,上,下,左,右,左上,右上,右下,左下。具体可以看下图所示: image.png 上图中显示了八个有颜色的矩形,当手指按下点在这八个矩形内的时候,才可以对矩形进行伸缩。当手指移动的时候,可以计算xy坐标的偏移量来移动算法框矩形,例如x轴方向偏移量的计算规则为:
float offsetX = x - lastX;
偏移量x = 当前手指的x轴左边 - 手指上一次的x坐标
获取偏移量后,需要判断一下是否将偏移量作用于算法标注框的矩形上,如果满足条件将偏移量作用于算法标注框矩形,代码如下:

case MotionEvent.ACTION_MOVE:
//...其他功能代码
if (changeBoxSide) {//算法框可伸缩
    if (leftRectF.contains(downRectF)) {//左区域
        boxRectF.left += offsetX;
    }else if (leftTopRectF.contains(downRectF) && !dragRectF.contains(downRectF)) {//左上区域 特殊在,点击位置不位于上一次的标注框内
        boxRectF.left += offsetX;
        boxRectF.top += offsetY;
    }//... 其他区域
}
//...其他功能代码
break;

上面代码中boxRectF是算法标注框矩形,当算法框可伸缩的时候,判断手指按下矩形是否包含在左侧区域中,如果包含的话,则将x轴偏移量作用于算法标注框的左侧。
当手指按下区域包含在左上区域的时候,则需要将x轴与y轴的偏移量作用于算法标注框的左侧和上侧, 手指点击其他方向区域时候处理逻辑与上面类似。 上面代码中changeBoxSide获取则是在手指按下的时候判断,当按下手指区域包含在响应伸缩的区域且算法标注框已经存在的时候,changeBoxSide = true,及算法框可伸缩,对应代码如下:

float x = event.getX();
float y = event.getY();
switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
        downX = x;
        downY = y;
        downRectF.set(downX, downY, downX, downY);
        if (sideBoxRectF.contains(downRectF) && !dragRectF.contains(downRectF) && boxRectConfirm) {
            changeBoxSide = true;
        }
        lastX = x;
        lastY = y;
        break;
}

上面代码中if中判断条件可能不好理解,下面给一张图补充下: image.png 上图左一绿色区域为sideBoxRectF
上图左二灰色区域为dragRectF
上图左三红色箭头指的绿色框区域则是sideBoxRectF.contains(downRectF) && !dragRectF.contains(downRectF)
if判断条件里面boxRectConfirm属性是判断算法标注框是否确定存在了,这个确定存在是指,在算法标注框没有的时候,手指按下并松开形成的算法标注框。

标注框拖拽

标注框拖拽实现相对简单,在手指按下时候判断按下区域是否在拖拽区域且算法标注框存在时将变量dragBox赋为true,之后在ACTION_MOVE事件内获取手指移动的偏移量之后作用于算法标注框。代码如下:

case MotionEvent.ACTION_DOWN:
    //...
    if (dragRectF.contains(downRectF) && boxRectConfirm){
        dragBox = true;
    }
    lastX = x;
    lastY = y;
    break;
case MotionEvent.ACTION_MOVE:
    float offsetX = x - lastX;
    float offsetY = y - lastY;
    //...
    if (dragBox) {
        boxRectF.offset(offsetX, offsetY);
    }
    //...
    lastX = x;
    lastY = y;
    break;

标注框初始化,伸缩,拖拽时超出View宽高处理

关于算法标注框超出View时候,将标注框超出的边缘设置为View的边缘部分代码如下:

case MotionEvent.ACTION_MOVE:
    //...
    //算法标注框超出View处理
    if (boxRectF.left < 0){
        boxRectF.left = 0;
    }else if (boxRectF.left > viewWidth){
        boxRectF.left = viewWidth;
    }
    if (boxRectF.top < 0){
        boxRectF.top = 0;
    }else if (boxRectF.top > viewHeight){
        boxRectF.top = viewHeight;
    }
    if (boxRectF.right < 0){
        boxRectF.right = 0;
    }else if (boxRectF.right > viewWidth){
        boxRectF.right = viewWidth;
    }
    if (boxRectF.bottom < 0){
        boxRectF.bottom = 0;
    }else if (boxRectF.bottom > viewHeight){
        boxRectF.bottom = viewHeight;
    }
    lastX = x;
    lastY = y;
    break;

上面的代码我是放在ACTION_MOVE事件里面处理,放在这里面目的是手指移动算法标注框的时候能实时看到效果,为了性能更好的话,可以把上面处理超出View的代码放在手指抬起的事件里面,像下面代码:

case MotionEvent.ACTION_UP:
//算法标注框超出View处理
if (boxRectF.left < 0){
    //...
}
//...其他代码

选择放在ACTION_MOVE效果如下:

算法标注框-手指移动.gif 选择放在ACTION_UP的效果如下: 算法标注框-手指按下时处理.gif

标注框装饰类型

标注框装饰类型,无装饰和圆形是比较好实现的,例如圆形装饰,只需要获取八个方向的点,之后绘制使用canvas.drawCircle()方法就可以了。例如下面代码

//左上
canvas.drawCircle(boxRectF.left, boxRectF.top, decorationLineWidth / 2, decorationPaint);
//...
//左侧
canvas.drawCircle(boxRectF.left, (boxRectF.top + boxRectF.bottom) / 2, decorationLineWidth / 2, decorationPaint);
//...

严格来说上面的左上左侧并不准确,例如在拖动左侧框没有超过右侧时候,是正确的但一旦有下图的操作时候就不准确了。 算法标注框-圆形讲解.gif 可以看到未松手时候,圆形是跟着左侧边框移动而移动的,当手指松开时候圆形恢复对应的位置,及左上,左侧。出现这种情况的原因是,在绘制圆形时候使用的坐标值来源于boxRectF算法标注框矩形,当伸缩左侧边框超过右侧时候,根据ACTION_MOVE事件里面对伸缩的处理代码如下;

boxRectF.left += offsetX;

这个操作会让boxRectF.left 小于 boxRectF.right,代码是在onTouchEvent()方法末尾添加重绘方法,每当手指移动结束后让view重绘,此时绘制左上圆使用的坐标是(boxRectF.left, boxRectF.top),由于手指右移超出原本右侧导致boxRectF.left 小于 boxRectF.right。因此绘制的圆形就是右上的。
而松手会导致圆回到左上角,是因为在ACTION_UP事件内,使用下面的代码,重新对boxRectF的坐标赋值,代码如下:

float left = Math.min(boxRectF.left, boxRectF.right);
float right = Math.max(boxRectF.left, boxRectF.right);
float top = Math.min(boxRectF.top, boxRectF.bottom);
float bottom = Math.max(boxRectF.top, boxRectF.bottom);
boxRectF.set(left, top, right, bottom);

上面代码使得,boxRectF(算法标注框)左上角与右下角的点坐标能准确。那么把上面代码移动到ACTION_MOVE事件内是不是就行了呢,例如放在ACTION_MOVE末尾。但如果尝试后发现效果如下图所示; 算法标注框-移动到move问题.gif 比如从左上移动到右下时候发现,左上点与右下角点重合,且一起向右下角移动。这是因为当在ACTION_MOVE事件内,执行上面代码的时候,每当手指右下角移动后的boxRectF.left小于boxRectF.right时,都会将两者值交换,如果手指一直向右下角移动时候,会一直交换boxRectF.left和boxRectF.right。这样就导致两者都向右下角移动。因此只能将上面代码放到ACTION_UP事件内处理。
由于canvas.drawCircle使用坐标值来源于boxRectF,而boxRectF的坐标会在手指移动时候变化,这样导致了左侧框超出右侧时候圆绘制位置出现问题。只有手指松开时候圆形才能恢复对应的位置。这样显示不会有问题吗,其实如果将八个圆都绘制出来的话,是看不出这个问题的,因为就算left和right换了位置,也不过是将两个点的圆形位置交换罢了,并不会对效果有影响,毕竟圆形是360度都对称的图像,八个点绘制后的运行图如下:

算法标注框-演示.gif 虽然圆形不受影响,但当那些不对称图像在绘制时候使用boxRectF坐标时候就会影响了,比如标注框的装饰类型为矩形时候,左上角形状绘制代码如下。

leftTopPath.reset();
leftTopPath.moveTo(boxRectF.left, boxRectF.top + decorationLineLength);
leftTopPath.lineTo(boxRectF.left, boxRectF.top);
leftTopPath.lineTo(boxRectF.left + decorationLineLength, boxRectF.top);
canvas.drawPath(leftTopPath, decorationPaint);

运行效果如下; 算法标注框-非对称形状.gif 就能很明显看使用boxRectF(算法标注框)坐标绘制矩形对伸缩效果影响了。对于这种非对称形状的装饰就不能直接使用boxRectF(算法标注框)坐标了,需要例外创建变量,在ACTION_MOVE事件内计算左上和右下对应的坐标并记录。例如下面是对非对称形状的装饰代码修改(左上角),使用的坐标由原来的boxRectF.left等坐标,换为了moveBoxRectLeft等:

leftTopPath.reset();
leftTopPath.moveTo(moveBoxRectLeft, moveBoxRectTop + decorationLineLength);
leftTopPath.lineTo(moveBoxRectLeft, moveBoxRectTop);
leftTopPath.lineTo(moveBoxRectLeft + decorationLineLength, moveBoxRectTop);
canvas.drawPath(leftTopPath, decorationPaint);

下面是手指移动与手指抬起时对moveBoxRectLeft等四个值获取逻辑相关代码:

private float moveBoxRectLeft, moveBoxRectRight, moveBoxRectTop, moveBoxRectBottom;
@Override
public boolean onTouchEvent(MotionEvent event) {
    float x = event.getX();
    float y = event.getY();
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            //...
            break;
        case MotionEvent.ACTION_MOVE:
            //...

            if (decorationType == RECT || decorationType == OUT_RECT) {
                moveBoxRectLeft = Math.min(boxRectF.left, boxRectF.right);
                moveBoxRectRight = Math.max(boxRectF.left, boxRectF.right);
                moveBoxRectTop = Math.min(boxRectF.top, boxRectF.bottom);
                moveBoxRectBottom = Math.max(boxRectF.top, boxRectF.bottom);
            }

            lastX = x;
            lastY = y;
            break;
        case MotionEvent.ACTION_UP:
            float left = Math.min(boxRectF.left, boxRectF.right);
            float right = Math.max(boxRectF.left, boxRectF.right);
            float top = Math.min(boxRectF.top, boxRectF.bottom);
            float bottom = Math.max(boxRectF.top, boxRectF.bottom);

            //如果使用矩形样式可以注释下面四行代码
            moveBoxRectLeft = left;
            moveBoxRectRight = right;
            moveBoxRectTop = top;
            moveBoxRectBottom = bottom;
            //...
            break;
    }
    postInvalidate();
    return true;
}

经过上面的修改,可以使非对称的算法标注框装饰能正确显示了,效果图如下: 算法标注框-修改正确后.gif 上面代码中在ACTION_UP事件内,也对moveBoxRectLeft等属性进行赋值,是因为代码在ACTION_MOVE事件内对moveBoxRectLeft赋值条件是标注框的类型为矩形和外矩形时才能赋值,如果把ACTION_UP内对moveBoxRectLeft属性赋值注释掉的话,当非矩形或外矩形装饰的框在绘制完首次切换到矩形或外矩形时,是不会显示矩形与外矩形的,因为ACTION_MOVE内当装饰类型为非矩形或者非外矩形时不会给moveBoxRectLeft属性赋值,而又在ACTION_UP注释掉给moveBoxRectLeft属性赋值的代码,moveBoxRectLeft就一直是初始值了。
如果不使用矩形或者外矩形标注框装饰的话,可以去掉这两个装饰样式。代码也更精简些。

标注框点坐标获取

标注框点坐标获取相对简单,程序中使用boxRectF这个矩形保存标注框的坐标,获取标注框的left,top,right,bottom后将这四个属性分别除以对应的viewWidth或viewHeight,就可以获取在水平或高度所占百分比,之后百分比乘以图片的高度,就是坐标值了。简化的代码如下:

private int[] coordinates = new int[4];//保存标注框左上角与右下角点左边,startX,startY,endX,endY
private int imgWidth, imgHeight;
private float viewWidth, viewHeight;

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    super.onLayout(changed, left, top, right, bottom);
    Drawable drawable = getDrawable();
    if (drawable instanceof BitmapDrawable) {
        BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable;
        Bitmap bitmap = bitmapDrawable.getBitmap();
        if (bitmap != null) {
            imgHeight = bitmap.getHeight();
            imgWidth = bitmap.getWidth();
        }
    }
    viewWidth = getWidth();
    viewHeight = getHeight();
}

@Override
public boolean onTouchEvent(MotionEvent event) {
   
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            //...
        case MotionEvent.ACTION_MOVE:
            //...
        case MotionEvent.ACTION_UP:
            //...
            //手指抬起时,计算点百分比位置
            leftPercent = boxRectF.left / viewWidth;
            topPercent = boxRectF.top / viewHeight;
            rightPercent = boxRectF.right / viewWidth;
            bottomPercent = boxRectF.bottom / viewHeight;

            coordinates[0] = (int) (imgWidth * leftPercent);
            coordinates[1] = (int) (imgHeight * topPercent);
            coordinates[2] = (int) (imgWidth * rightPercent);
            coordinates[3] = (int) (imgHeight * bottomPercent);

            boxRectConfirm = true;
            changeBoxSide = false;
            dragBox = false;
            break;
    }
    postInvalidate();
    return true;
}

这里注意的点是获取图片宽高的话,最好是在onLayout()或者其他类似等界面加载好后的方法内获取,如果是在其他地方获取图片宽高,获取的不是图片真实的分辨率。就没法算出算法框在图片上的坐标。如果算法可以接收百分比形式数据的话,也不需要考虑这一点了。

View完整代码

public class BoundingBoxImageView extends androidx.appcompat.widget.AppCompatImageView {
    private final int NONE = 0, CIRCLE = 1, RECT = 2, OUT_RECT = 3;
    private Paint boxPaint, broadsidePaint, cornerPaint, decorationPaint;
    private float downX = -1f, downY = -1f;//手指按下
    private float lastX = -1f, lastY = -1f;//上一个xy
    private RectF boxRectF = new RectF();//算法框
    private RectF lastBoxRect = new RectF();//上次手指抬起时算法框
    private RectF downRectF = new RectF();//手指按下点Rect
    private RectF sideBoxRectF = new RectF();//用于拖拽算法框边缘的矩形
    private RectF leftRectF = new RectF(), leftTopRectF = new RectF(), topRectF = new RectF(), rightTopRectF = new RectF(), rightRectF = new RectF(), rightBottomRectF = new RectF(), bottomRectF = new RectF(), leftBottomRectF = new RectF();
    private RectF dragRectF = new RectF();//拖拽区域Rect
    private boolean boxRectConfirm = false;//标注框确认 当第一次手指松开时候该值为true,确定标注框已经有了
    private boolean changeBoxSide = false;//标注框形状改变
    private boolean showDragWidth = false;//展示拖拽区域
    private boolean dragBox = false;
    private float boxLineWidth = 5;//标注框线宽
    private float dragWidth = 30;//拖拽区域宽度
    private float decorationLineLength = 80;//装饰线长度
    private float decorationLineWidth = 10;//装饰线宽度

    private int imgWidth, imgHeight;
    private float viewWidth, viewHeight;
    private float leftPercent, topPercent, rightPercent, bottomPercent;
    private int[] coordinates = new int[4];//保存标注框左上角与右下角点左边,startX,startY,endX,endY
    private float moveBoxRectLeft, moveBoxRectRight, moveBoxRectTop, moveBoxRectBottom;
    private Point startPoint = new Point(), endPoint = new Point();
    private int boxColor = Color.parseColor("#99129823");
    private int decorationColor = Color.parseColor("#5DA9FF");
    private int decorationType = NONE;


    private Path leftTopPath = new Path(), rightTopPath = new Path(), rightBottomPath = new Path(), leftBottomPath = new Path();


    public BoundingBoxImageView(Context context) {
        super(context);
    }

    public BoundingBoxImageView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init(context, attrs);
    }

    public BoundingBoxImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context, attrs);
    }

    private void init(Context context, AttributeSet attrs) {
        TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.BoundingBoxImageView);
        showDragWidth = typedArray.getBoolean(R.styleable.BoundingBoxImageView_show_drag_width, showDragWidth);
        dragWidth = typedArray.getDimension(R.styleable.BoundingBoxImageView_drag_width, dragWidth);

        boxLineWidth = typedArray.getDimension(R.styleable.BoundingBoxImageView_box_line_width, boxLineWidth);
        boxColor = typedArray.getColor(R.styleable.BoundingBoxImageView_box_color, boxColor);


        decorationLineLength = typedArray.getDimension(R.styleable.BoundingBoxImageView_decoration_line_length, decorationLineLength);
        decorationLineWidth = typedArray.getDimension(R.styleable.BoundingBoxImageView_decoration_line_width, decorationLineWidth);
        decorationColor = typedArray.getColor(R.styleable.BoundingBoxImageView_decoration_color, decorationColor);
        decorationType = typedArray.getInt(R.styleable.BoundingBoxImageView_decoration_type, decorationType);

        typedArray.recycle();//释放,避免内存泄露

        boxPaint = new Paint();
        boxPaint.setColor(boxColor);
        boxPaint.setStyle(Paint.Style.STROKE);
        boxPaint.setStrokeWidth(boxLineWidth);

        decorationPaint = new Paint();
        decorationPaint.setColor(decorationColor);
        decorationPaint.setStyle(Paint.Style.STROKE);
        decorationPaint.setStrokeWidth(decorationLineWidth);
        decorationPaint.setAntiAlias(true);//开启抗锯齿

        broadsidePaint = new Paint();
        broadsidePaint.setColor(Color.parseColor("#99345212"));
        broadsidePaint.setStrokeWidth(dragWidth);

        cornerPaint = new Paint();
        cornerPaint.setColor(Color.parseColor("#99876534"));
        cornerPaint.setStrokeWidth(dragWidth);
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        Drawable drawable = getDrawable();
        if (drawable instanceof BitmapDrawable) {
            BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable;
            Bitmap bitmap = bitmapDrawable.getBitmap();
            if (bitmap != null) {
                imgHeight = bitmap.getHeight();
                imgWidth = bitmap.getWidth();
            }
        }
        viewWidth = getWidth();
        viewHeight = getHeight();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if (boxRectF != null) {
            canvas.drawRect(boxRectF, boxPaint);
            //边框装饰
            drawDecoration(canvas, decorationType);
            //开发测试使用
            if (showDragWidth) {
                canvas.drawRect(leftRectF, broadsidePaint);
                canvas.drawRect(topRectF, broadsidePaint);
                canvas.drawRect(rightRectF, broadsidePaint);
                canvas.drawRect(bottomRectF, broadsidePaint);

                canvas.drawRect(leftTopRectF, cornerPaint);
                canvas.drawRect(rightTopRectF, cornerPaint);
                canvas.drawRect(leftBottomRectF, cornerPaint);
                canvas.drawRect(rightBottomRectF, cornerPaint);
            }
        }
    }

    private void drawDecoration(Canvas canvas, int type) {
        //矩形确认后才绘制装饰,好处是,避免矩形太小时候导致装饰挤在一起
        if (boxRectConfirm) {
            if (type == NONE) {
                return;
            } else if (type == RECT) {
                //左上右下
                canvas.drawLine(moveBoxRectLeft, (moveBoxRectTop + moveBoxRectBottom) / 2 - decorationLineLength / 2, moveBoxRectLeft, (moveBoxRectTop + moveBoxRectBottom) / 2 + decorationLineLength / 2, decorationPaint);
                canvas.drawLine((moveBoxRectLeft + moveBoxRectRight) / 2 - decorationLineLength / 2, moveBoxRectTop, (moveBoxRectLeft + moveBoxRectRight) / 2 + decorationLineLength / 2, moveBoxRectTop, decorationPaint);
                canvas.drawLine(moveBoxRectRight, (moveBoxRectTop + moveBoxRectBottom) / 2 - decorationLineLength / 2, moveBoxRectRight, (moveBoxRectTop + moveBoxRectBottom) / 2 + decorationLineLength / 2, decorationPaint);
                canvas.drawLine((moveBoxRectLeft + moveBoxRectRight) / 2 - decorationLineLength / 2, moveBoxRectBottom, (moveBoxRectLeft + moveBoxRectRight) / 2 + decorationLineLength / 2, moveBoxRectBottom, decorationPaint);

                leftTopPath.reset();
                leftTopPath.moveTo(moveBoxRectLeft, moveBoxRectTop + decorationLineLength);
                leftTopPath.lineTo(moveBoxRectLeft, moveBoxRectTop);
                leftTopPath.lineTo(moveBoxRectLeft + decorationLineLength, moveBoxRectTop);
                canvas.drawPath(leftTopPath, decorationPaint);

                rightTopPath.reset();
                rightTopPath.moveTo(moveBoxRectRight - decorationLineLength, moveBoxRectTop);
                rightTopPath.lineTo(moveBoxRectRight, moveBoxRectTop);
                rightTopPath.lineTo(moveBoxRectRight, moveBoxRectTop + decorationLineLength);
                canvas.drawPath(rightTopPath, decorationPaint);

                rightBottomPath.reset();
                rightBottomPath.moveTo(moveBoxRectRight, moveBoxRectBottom - decorationLineLength);
                rightBottomPath.lineTo(moveBoxRectRight, moveBoxRectBottom);
                rightBottomPath.lineTo(moveBoxRectRight - decorationLineLength, moveBoxRectBottom);
                canvas.drawPath(rightBottomPath, decorationPaint);

                leftBottomPath.reset();
                leftBottomPath.moveTo(moveBoxRectLeft + decorationLineLength, moveBoxRectBottom);
                leftBottomPath.lineTo(moveBoxRectLeft, moveBoxRectBottom);
                leftBottomPath.lineTo(moveBoxRectLeft, moveBoxRectBottom - decorationLineLength);
                canvas.drawPath(leftBottomPath, decorationPaint);
            } else if (type == CIRCLE) {
                //角装饰 左上 右上 右下 左下
                canvas.drawCircle(boxRectF.left, boxRectF.top, decorationLineWidth / 2, decorationPaint);
                canvas.drawCircle(boxRectF.right, boxRectF.top, decorationLineWidth / 2, decorationPaint);
                canvas.drawCircle(boxRectF.right, boxRectF.bottom, decorationLineWidth / 2, decorationPaint);
                canvas.drawCircle(boxRectF.left, boxRectF.bottom, decorationLineWidth / 2, decorationPaint);
                //边装饰 左上右下
                canvas.drawCircle(boxRectF.left, (boxRectF.top + boxRectF.bottom) / 2, decorationLineWidth / 2, decorationPaint);
                canvas.drawCircle((boxRectF.left + boxRectF.right) / 2, boxRectF.top, decorationLineWidth / 2, decorationPaint);
                canvas.drawCircle(boxRectF.right, (boxRectF.top + boxRectF.bottom) / 2, decorationLineWidth / 2, decorationPaint);
                canvas.drawCircle((boxRectF.left + boxRectF.right) / 2, boxRectF.bottom, decorationLineWidth / 2, decorationPaint);
            } else if (type == OUT_RECT) {
                canvas.drawLine(moveBoxRectLeft - decorationLineWidth / 2, (moveBoxRectTop + moveBoxRectBottom) / 2 - decorationLineLength / 2, moveBoxRectLeft - decorationLineWidth / 2, (moveBoxRectTop + moveBoxRectBottom) / 2 + decorationLineLength / 2, decorationPaint);
                canvas.drawLine((moveBoxRectLeft + moveBoxRectRight) / 2 - decorationLineLength / 2, moveBoxRectTop - decorationLineWidth / 2, (moveBoxRectLeft + moveBoxRectRight) / 2 + decorationLineLength / 2, moveBoxRectTop - decorationLineWidth / 2, decorationPaint);
                canvas.drawLine(moveBoxRectRight + decorationLineWidth / 2, (moveBoxRectTop + moveBoxRectBottom) / 2 - decorationLineLength / 2, moveBoxRectRight + decorationLineWidth / 2, (moveBoxRectTop + moveBoxRectBottom) / 2 + decorationLineLength / 2, decorationPaint);
                canvas.drawLine((moveBoxRectLeft + moveBoxRectRight) / 2 - decorationLineLength / 2, moveBoxRectBottom + decorationLineWidth / 2, (moveBoxRectLeft + moveBoxRectRight) / 2 + decorationLineLength / 2, moveBoxRectBottom + decorationLineWidth / 2, decorationPaint);

                leftTopPath.reset();
                leftTopPath.moveTo(moveBoxRectLeft - decorationLineWidth / 2, moveBoxRectTop + decorationLineLength);
                leftTopPath.lineTo(moveBoxRectLeft - decorationLineWidth / 2, moveBoxRectTop - decorationLineWidth / 2);
                leftTopPath.lineTo(moveBoxRectLeft + decorationLineLength, moveBoxRectTop - decorationLineWidth / 2);
                canvas.drawPath(leftTopPath, decorationPaint);

                rightTopPath.reset();
                rightTopPath.moveTo(moveBoxRectRight - decorationLineLength, moveBoxRectTop - decorationLineWidth / 2);
                rightTopPath.lineTo(moveBoxRectRight + decorationLineWidth / 2, moveBoxRectTop - decorationLineWidth / 2);
                rightTopPath.lineTo(moveBoxRectRight + decorationLineWidth / 2, moveBoxRectTop + decorationLineLength);
                canvas.drawPath(rightTopPath, decorationPaint);

                rightBottomPath.reset();
                rightBottomPath.moveTo(moveBoxRectRight + decorationLineWidth / 2, moveBoxRectBottom - decorationLineLength);
                rightBottomPath.lineTo(moveBoxRectRight + decorationLineWidth / 2, moveBoxRectBottom + decorationLineWidth / 2);
                rightBottomPath.lineTo(moveBoxRectRight - decorationLineLength, moveBoxRectBottom + decorationLineWidth / 2);
                canvas.drawPath(rightBottomPath, decorationPaint);

                leftBottomPath.reset();
                leftBottomPath.moveTo(moveBoxRectLeft + decorationLineLength, moveBoxRectBottom + decorationLineWidth / 2);
                leftBottomPath.lineTo(moveBoxRectLeft - decorationLineWidth / 2, moveBoxRectBottom + decorationLineWidth / 2);
                leftBottomPath.lineTo(moveBoxRectLeft - decorationLineWidth / 2, moveBoxRectBottom - decorationLineLength);
                canvas.drawPath(leftBottomPath, decorationPaint);
            }
        }
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                //内部拦截 解决滑动冲突
                getParent().requestDisallowInterceptTouchEvent(true);
                break;
        }
        return super.dispatchTouchEvent(event);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        float x = event.getX();
        float y = event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                downX = x;
                downY = y;
                downRectF.set(downX, downY, downX, downY);
                //表示可以改变矩形形状 得是矩形确认形状后
                if (sideBoxRectF.contains(downRectF) && !dragRectF.contains(downRectF) && boxRectConfirm) {
                    changeBoxSide = true;
                }
                if (dragRectF.contains(downRectF) && boxRectConfirm) {
                    dragBox = true;
                }
                lastX = x;
                lastY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                float offsetX = x - lastX;
                float offsetY = y - lastY;
                if (!boxRectConfirm) {//矩形确定 第一次松手后 确认矩形 因为new Rect()的点都是0,必须记录下downX和后面手指移动的X
                    boxRectF.set(Math.min(downX, x), Math.min(downY, y), Math.max(downX, x), Math.max(downY, y));
                }

                //拖拽标注框 范围超出View时处理  改变框时候不触发下面条件
                if (dragBox) {//手指按下点,在上一个lastBoxRectF矩形内,才考虑执行下面的代码   因为boxRectF会变的,有可能导致变着变着boxRectF包含了downRectF
                    boxRectF.offset(offsetX, offsetY);
                }

                //拖动边缘改变View大小和形状
                //当左边框小到一定程度就不响应了
                if (changeBoxSide) {
                    //感觉需要对 矩形上下左右区域做下区分
                    if (leftRectF.contains(downRectF)) {//左区域
                        boxRectF.left += offsetX;
                    } else if (leftTopRectF.contains(downRectF) && !dragRectF.contains(downRectF)) {//左上区域 特殊在,点击位置不位于上一次的标注框内
                        boxRectF.left += offsetX;
                        boxRectF.top += offsetY;
                    } else if (topRectF.contains(downRectF)) {//上区域
                        boxRectF.top += offsetY;
                    } else if (rightTopRectF.contains(downRectF) && !dragRectF.contains(downRectF)) {
                        boxRectF.top += offsetY;
                        boxRectF.right += offsetX;
                    } else if (rightRectF.contains(downRectF)) {//右区域
                        boxRectF.right += offsetX;
                    } else if (rightBottomRectF.contains(downRectF) && !dragRectF.contains(downRectF)) {
                        boxRectF.right += offsetX;
                        boxRectF.bottom += offsetY;
                    } else if (bottomRectF.contains(downRectF)) {//下区域
                        boxRectF.bottom += offsetY;
                    } else if (leftBottomRectF.contains(downRectF) && !dragRectF.contains(downRectF)) {
                        boxRectF.left += offsetX;
                        boxRectF.bottom += offsetY;
                    }
                }

                //确认矩形 与 改变矩形大小都需要判断修改后的矩形是否超出View
                if (boxRectF.left < 0) {
                    boxRectF.left = 0;
                } else if (boxRectF.left > viewWidth) {
                    boxRectF.left = viewWidth;
                }
                if (boxRectF.top < 0) {
                    boxRectF.top = 0;
                } else if (boxRectF.top > viewHeight) {
                    boxRectF.top = viewHeight;
                }
                if (boxRectF.right < 0) {
                    boxRectF.right = 0;
                } else if (boxRectF.right > viewWidth) {
                    boxRectF.right = viewWidth;
                }
                if (boxRectF.bottom < 0) {
                    boxRectF.bottom = 0;
                } else if (boxRectF.bottom > viewHeight) {
                    boxRectF.bottom = viewHeight;
                }

                if (decorationType == RECT || decorationType == OUT_RECT) {
                    moveBoxRectLeft = Math.min(boxRectF.left, boxRectF.right);
                    moveBoxRectRight = Math.max(boxRectF.left, boxRectF.right);
                    moveBoxRectTop = Math.min(boxRectF.top, boxRectF.bottom);
                    moveBoxRectBottom = Math.max(boxRectF.top, boxRectF.bottom);
                }

                lastX = x;
                lastY = y;
                break;
            case MotionEvent.ACTION_UP:
                float left = Math.min(boxRectF.left, boxRectF.right);
                float right = Math.max(boxRectF.left, boxRectF.right);
                float top = Math.min(boxRectF.top, boxRectF.bottom);
                float bottom = Math.max(boxRectF.top, boxRectF.bottom);

                //如果使用矩形样式可以注释下面四行代码
                moveBoxRectLeft = left;
                moveBoxRectRight = right;
                moveBoxRectTop = top;
                moveBoxRectBottom = bottom;

                boxRectF.set(left, top, right, bottom);
                lastBoxRect.set(boxRectF);

                dragRectF.set(boxRectF.left + dragWidth, boxRectF.top + dragWidth, boxRectF.right - dragWidth, boxRectF.bottom - dragWidth);

                sideBoxRectF.set(boxRectF.left - dragWidth, boxRectF.top - dragWidth, boxRectF.right + dragWidth, boxRectF.bottom + dragWidth);

                leftRectF.set(boxRectF.left - dragWidth, boxRectF.top + dragWidth, boxRectF.left + dragWidth, boxRectF.bottom - dragWidth);

                leftTopRectF.set(boxRectF.left - dragWidth, boxRectF.top - dragWidth, boxRectF.left + dragWidth, boxRectF.top + dragWidth);

                topRectF.set(boxRectF.left + dragWidth, boxRectF.top - dragWidth, boxRectF.right - dragWidth, boxRectF.top + dragWidth);

                rightTopRectF.set(boxRectF.right - dragWidth, boxRectF.top - dragWidth, boxRectF.right + dragWidth, boxRectF.top + dragWidth);

                rightRectF.set(boxRectF.right - dragWidth, boxRectF.top + dragWidth, boxRectF.right + dragWidth, boxRectF.bottom - dragWidth);

                rightBottomRectF.set(boxRectF.right - dragWidth, boxRectF.bottom - dragWidth, boxRectF.right + dragWidth, boxRectF.bottom + dragWidth);

                bottomRectF.set(boxRectF.left + dragWidth, boxRectF.bottom - dragWidth, boxRectF.right - dragWidth, boxRectF.bottom + dragWidth);

                leftBottomRectF.set(boxRectF.left - dragWidth, boxRectF.bottom - dragWidth, boxRectF.left + dragWidth, boxRectF.bottom + dragWidth);

                //手指抬起时,计算点百分比位置
                leftPercent = boxRectF.left / viewWidth;
                topPercent = boxRectF.top / viewHeight;
                rightPercent = boxRectF.right / viewWidth;
                bottomPercent = boxRectF.bottom / viewHeight;

                coordinates[0] = (int) (imgWidth * leftPercent);
                coordinates[1] = (int) (imgHeight * topPercent);
                coordinates[2] = (int) (imgWidth * rightPercent);
                coordinates[3] = (int) (imgHeight * bottomPercent);

                boxRectConfirm = true;
                changeBoxSide = false;
                dragBox = false;
                break;
        }
        postInvalidate();
        return true;
    }

    public int[] getCoordinates() {
        return coordinates;
    }

    public void clearBox() {
        boxRectF.set(0, 0, 0, 0);
        Arrays.fill(coordinates, 0);
        boxRectConfirm = false;
        postInvalidate();
    }

    public void setDecorationType(int decorationType) {
        this.decorationType = decorationType;
        postInvalidate();
    }
}

总结

实际开发的时候发现,标注框装饰使用圆形是最省事的,因为标注框伸缩是会使左侧超过右侧的,使用对称的图像装饰,在移动的时候看不出问题。
而框装饰为矩形的一般用于截图使用,因为截图一般不会让框左侧伸缩超过右侧这种情况,并且框的大小有时还会规定死。就不需要考虑超出后导致装饰形状显示错误的问题了。
GitHub地址:github.com/SmallCrispy…