Android自定义控件之虚拟摇杆(遥控飞机)

2,274 阅读10分钟

前言

之前在开发项目中,有一个功能是,设计一个虚拟摇杆,操作大疆无人机飞行,在实现过程中感觉比较锻炼自定义View的能力,在此记录一下,本文中摇杆代码从项目中抽取出来重新实现,如下是程序运行图: 虚拟摇杆.gif

功能分析

本次自定义View功能需求如下:
1.摇杆绘制
自定义View绘制摇杆大小圆,手指移动时只改变小圆位置,当手指触摸点在大圆外时,小圆圆心在大圆边缘上,并且绘制一条蓝色弧线,绘制度数为小圆圆心位置向两侧延伸45度(一般UI设计的时候,会给特定的圆弧形图片,如果显示图片就需要将图片移动到小圆圆心位置,之后根据手指触摸点与大圆圆心夹角来旋转图片,目前没有找到类似的圆弧图片,后期看能不能找到类似的)。
2.摇杆移动数据返回
返回摇杆移动产生的数据,根据这些数据控制飞行图片移动。在这里我返回的是飞机图片x,y坐标应该改变的值。这个值具体如何获得,在下面代码实现中讲解。
3.飞机图片移动
飞机图片移动相对简单,只需要在接收到摇杆数据的时候,修改飞机图片绘制位置,并重绘即可,需要注意的地方是摇杆移动飞机超出View边界该怎么处理。

代码实现

摇杆绘制和摇杆移动数据返回,通过自定义的RockerView内实现,飞机图片移动,通过自定义的FlyView实现,上述功能在RockerView和FlyView代码实现里面介绍。

摇杆(RockerView)

我们可以先从摇杆如何绘制开始。

首先从RockerView开头声明一些绘制需要一些变量,比如画笔,圆心坐标,手指触摸点坐标,圆半径等变量。

在init()方法内对画笔样式,颜色,View默认宽高等数据进行设置。

在onMeasure()方法内获取View的宽高模式,该方法简单可以概况为,宽高有具体值或者为match_parent。宽高设置为MeasureSpec.getSize()方法获取的数据,之后宽高值取两者中最小值,当宽高值在xml设置为wrap_content时,宽高取默认值,之后在方法末尾通过setMeasuredDimension()设置宽高。

在onLayout()方法内,对绘制圆等图像用到的变量进行赋值,例如,大圆圆心xy值,小圆圆心xy值,大小圆半径,绘制蓝色圆弧矩形,RockerView宽高等数据。

之后是onDraw()方法,在该方法内绘制大小圆,蓝色圆弧等图案。只不过蓝色圆弧需要加上判断条件来控制是否绘制。

手指触摸时绘制小圆位置改变,则需要重写onTouchEvent()方法,当手指按下或移动时,需要更新手指触摸点坐标,并判断手指触摸点是否超出大圆,超出大圆时,需要计算小圆圆心位置,并且还需要计算手指触摸点与圆心连线和x正半轴形成的夹角。并且通过接口返回摇杆移动的数据,飞机图片根据这些数据来移动。

绘制代码简单介绍如上,下面对View内一些需要注意地方进行介绍。如果看到完整代码,里面有一个自定义方法是initAngle(),该方法代码如下:

/** 计算夹角度数,并实现小圆圆心最多至大圆边上 */
private void initAngle() {
    radian = Math.atan2((touchY - bigCenterY), (touchX - bigCenterX));
    angle = (float) (radian * (180 / Math.PI));//范围-180-180
    isBigCircleOut = false;
    if (bigCenterX != -1 && bigCenterY != -1) {//大圆中心xy已赋值
        double rxr = (double) Math.pow(touchX - bigCenterX, 2) + Math.pow(touchY - bigCenterY, 2);
        distance = Math.sqrt(rxr);//手点击点距离大圆圆心距离
        smallCenterX = touchX;
        smallCenterY = touchY;
        if (distance > bigRadius) {//距离大于半圆半径时,固定小圆圆心在大圆边缘上
            smallCenterX = (int) (bigRadius / distance * (touchX - bigCenterX)) + bigCenterX;
            smallCenterY = (int) (bigRadius / distance * (touchY - bigCenterY)) + bigCenterX;
            isBigCircleOut = true;
        }
    }
}

这个方法用在onTouchEvent()方法的手指按下与移动事件中应用,这个方法前两行代码是计算手指触摸点与圆心连线和x正半轴形成的夹角取值,夹角取值范围如下图所示。 图片.png 代码先通过Math.atan2(y,x)方法获取手指触摸点与圆心连线和x正半轴之间的弧度制,获取弧度后通过(float) (radian * (180 / Math.PI))获取对应的度数,这里特别注意下Math.atan2(y,x)方法是y值在前,x在后。
此外这个方法还计算了手指触摸点与大圆圆心距离,以及判断手指触摸点是否在大圆外,以及在大圆外时,获取在大圆边缘上的小圆圆心的xy值。

在计算小圆圆心的坐标需要了解一个地方是,view实现过程中使用的坐标系是屏幕坐标系,屏幕坐标系是以View左上角为原点,原点左边是x的正半轴,原点下面是y正半轴,屏幕坐标系和数学坐标系是不一样。小圆圆心坐标获取原理,是根据三角形的相似原理获取,小圆圆心的坐标获取原理如下图所示:

图片.png 在上图中可以看到小圆y坐标的获取,小圆x坐标获取与y获取类似。可以直接把公式套进去。关于摇杆绘制的内容,至此差不多完成了,下面来处理返回摇杆移动数据的功能。

返回摇杆移动数据是通过自定义接口实现的。在触摸事件返回摇杆移动数据的事件有手指按下与移动。我们代码可以写为下面的形式(下面代码是伪代码)。

@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
        case MotionEvent.ACTION_MOVE:
            //返回摇杆移动数据的方法
            break;
        case MotionEvent.ACTION_UP:
            ...
            break;
    }
    postInvalidate();
    return true;
}

如果按照上面代码写法我们会发现,当我们手指按下不动的时候或者手指按下移动一会后手指不动,是不会触发ACTION_MOVE事件的,不触发这个事件,我们就无法返回摇杆移动的数据,进而无法控制飞机改变位置。效果图如下

虚拟摇杆_按下不移动的问题.gif 解决这个问题,需要使用Handler和Runnable,在Runnable的run方法内,实现接口方法,并调用自身。getFlyOffset()是传递摇杆移动数据的方法,代码如下:

private Handler mHandler = new Handler();
private Runnable mRunnable = new Runnable() {
    @Override
    public void run() {
        if (isStart){
            getFlyOffset();
            mHandler.postDelayed(this,drawTime);
        }
    }
};

之后在手指按下与点击事件里面,先判断Handler有没有开始,若isStart为true,则isStart改为false,并移除mRunnable,之后isStart改为true,延迟16ms执行mRunnable,当手指抬起时,若Handler状态为开始,则修改状态为false并移除mRunnable,这样就解决了手指按下不移动时,传递摇杆数据,相关代码如下:

@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
        case MotionEvent.ACTION_MOVE:
            ...
            initAngle();
            getFlyOffset();
            if (isStart) {
                isStart = false;
                mHandler.removeCallbacks(mRunnable);
            }
            isStart = true;
            mHandler.postDelayed(mRunnable,drawTime);
            break;
        case MotionEvent.ACTION_UP:
            ...
            if (isStart) {
                mHandler.removeCallbacks(mRunnable);//有问题
                isStart = false;
            }
            break;
    }
    postInvalidate();
    return true;
}

至此摇杆相关功能介绍完毕,RockerView完整代码如下:

public class RockerView extends View {
    private final int VELOCITY = 40;//飞机速度

    private Paint smallCirclePaint;//小圆画笔
    private Paint bigCirclePaint;//大圆画笔
    private Paint sideCirclePaint;//大圆边框画笔
    private Paint arcPaint;//圆弧画布
    private int smallCenterX = -1, smallCenterY = -1;//绘制小圆圆心 x,y坐标
    private int bigCenterX = -1,bigCenterY = -1;//绘制大圆圆心 x,y坐标
    private int touchX = -1, touchY = -1;//触摸点 x,y坐标
    private float bigRadiusProportion = 69F / 110F;//大圆半径占view一半宽度的比例 用于获取大圆半径
    private float smallRadiusProportion = 4F / 11F;//小圆半径占view一半宽度的比例
    private float bigRadius = -1;//大圆半径
    private float smallRadius = -1;//小圆半径
    private double distance = -1; //手指按压点与大圆圆心的距离
    private double radian = -1;//弧度
    private float angle = -1;//度数 -180~180
    private int viewHeight,viewWidth;
    private int defaultViewHeight, defaultViewWidth;
    private RectF arcRect = new RectF();//绘制蓝色圆弧用到矩形
    private int drawArcAngle = 90;//圆弧绘制度数
    private int arcOffsetAngle = -45;//圆弧偏移度数
    private int drawTime = 16;//告诉flyView重绘的时间间隔 这里是16ms一次
    private boolean isBigCircleOut = false;//触摸点在大圆外

    private boolean isStart = false;
    private Handler mHandler = new Handler();
    private Runnable mRunnable = new Runnable() {
        @Override
        public void run() {
            if (isStart){
                getFlyOffset();
                mHandler.postDelayed(this,drawTime);
            }
        }
    };

    public RockerView(Context context) {
        super(context);
        init(context);
    }

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

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


    private void init(Context context) {
        defaultViewWidth = DensityUtil.dp2px(context,220);
        defaultViewHeight = DensityUtil.dp2px(context,220);

        bigCirclePaint = new Paint();
        bigCirclePaint.setStyle(Paint.Style.FILL);
        bigCirclePaint.setStrokeWidth(5);
        bigCirclePaint.setColor(Color.parseColor("#1AFFFFFF"));
        bigCirclePaint.setAntiAlias(true);

        smallCirclePaint = new Paint();
        smallCirclePaint.setStyle(Paint.Style.FILL);
        smallCirclePaint.setStrokeWidth(5);
        smallCirclePaint.setColor(Color.parseColor("#4DFFFFFF"));
        smallCirclePaint.setAntiAlias(true);

        sideCirclePaint = new Paint();
        sideCirclePaint.setStyle(Paint.Style.STROKE);
        sideCirclePaint.setStrokeWidth(DensityUtil.dp2px(context, 1));
        sideCirclePaint.setColor(Color.parseColor("#33FFFFFF"));
        sideCirclePaint.setAntiAlias(true);

        arcPaint = new Paint();
        arcPaint.setColor(Color.parseColor("#FF5DA9FF"));
        arcPaint.setStyle(Paint.Style.STROKE);
        arcPaint.setStrokeWidth(5);
        arcPaint.setAntiAlias(true);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //获取视图的宽高的测量模式
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int width,height;
        if (widthMode == MeasureSpec.EXACTLY){
            width = widthSize;
        }else {
            width = defaultViewWidth;
        }

        if (heightMode == MeasureSpec.EXACTLY){
            height = heightSize;
        }else {
            height = defaultViewHeight;
        }
        width = Math.min(width,height);
        height = width;
        //设置视图的宽度和高度
        setMeasuredDimension(width,height);
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        bigCenterX = getWidth() / 2;
        bigCenterY = getHeight() / 2;
        smallCenterX = bigCenterX;
        smallCenterY = bigCenterY;

        bigRadius = bigRadiusProportion * Math.min(bigCenterX, bigCenterY);
        smallRadius = smallRadiusProportion * Math.min(bigCenterX, bigCenterY);

        arcRect.set(bigCenterX-bigRadius,bigCenterY-bigRadius,bigCenterX+bigRadius,bigCenterY+bigRadius);
        viewHeight = getHeight();
        viewWidth = getWidth();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawCircle(bigCenterX, bigCenterY, bigRadius, bigCirclePaint);
        canvas.drawCircle(smallCenterX, smallCenterY, smallRadius, smallCirclePaint);
        canvas.drawCircle(bigCenterX, bigCenterY, bigRadius, sideCirclePaint);

        if (isBigCircleOut) {
            canvas.drawArc(arcRect,angle+arcOffsetAngle,drawArcAngle,false,arcPaint);
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
            case MotionEvent.ACTION_MOVE:
                touchX = (int) event.getX();
                touchY = (int) event.getY();
                initAngle();
                getFlyOffset();
                if (isStart) {
                    isStart = false;
                    mHandler.removeCallbacks(mRunnable);
                }
                isStart = true;
                mHandler.postDelayed(mRunnable,drawTime);
                break;
            case MotionEvent.ACTION_UP:
                smallCenterX = bigCenterX;
                smallCenterY = bigCenterY;
                isBigCircleOut = false;
                if (isStart) {
                    mHandler.removeCallbacks(mRunnable);//有问题
                    isStart = false;
                }
                break;
        }
        postInvalidate();
        return true;
    }

    /** 计算夹角度数,并实现小圆圆心最多至大圆边上 */
    private void initAngle() {
        radian = Math.atan2((touchY - bigCenterY), (touchX - bigCenterX));
        angle = (float) (radian * (180 / Math.PI));//范围-180-180
        isBigCircleOut = false;
        if (bigCenterX != -1 && bigCenterY != -1) {//大圆中心xy已赋值
            double rxr = (double) Math.pow(touchX - bigCenterX, 2) + Math.pow(touchY - bigCenterY, 2);
            distance = Math.sqrt(rxr);//手点击点距离大圆圆心距离
            smallCenterX = touchX;
            smallCenterY = touchY;
            if (distance > bigRadius) {//距离大于半圆半径时,固定小圆圆心在大圆边缘上
                smallCenterX = (int) (bigRadius / distance * (touchX - bigCenterX)) + bigCenterX;
                smallCenterY = (int) (bigRadius / distance * (touchY - bigCenterY)) + bigCenterX;
                isBigCircleOut = true;
            }
        }
    }

    /** 获取飞行偏移量 */
    private void getFlyOffset() {
        float x = (smallCenterX - bigCenterX) * 1.0f / viewWidth * VELOCITY;
        float y = (smallCenterY - bigCenterY) * 1.0f / viewHeight * VELOCITY;
        onRockerListener.getDate(this, x, y);
    }

    /**
     * pX,pY为手指按点坐标减view的坐标
     */
    public interface OnRockerListener {
        public void getDate(RockerView rocker, final float pX, final float pY);
    }
    private OnRockerListener onRockerListener;
    public void getDate(final OnRockerListener onRockerListener) {
        this.onRockerListener = onRockerListener;
    }
}

飞机(FlyView)

飞机图片移动相对简单,实现原理是在自定义View里面,通过改变绘制图片方法(drawBitmap()方法)里的left,top值来模拟飞机移动。FlyView实现代码如下:

public class FlyView extends View {
    private Paint mPaint;
    private Bitmap mBitmap;
    private int viewHeight, viewWidth;
    private int imgHeight, imgWidth;
    private int left, top;

    public FlyView(Context context) {
        super(context);
        init(context);
    }

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

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

    void init(Context context) {
        mPaint = new Paint();
        mBitmap = BitmapFactory.decodeResource(context.getResources(), R.mipmap.fly);
        imgHeight = mBitmap.getHeight();
        imgWidth = mBitmap.getWidth();
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        viewHeight = h;
        viewWidth = w;
        left = w / 2 - imgHeight / 2;
        top = h / 2 - imgWidth / 2;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawBitmap(mBitmap, left, top, mPaint);
    }
    
    /** 移动图片 */
    public void move(float x, float y) {
        left += x;
        top += y;
        if (left < 0) {
            left = 0;
        }else if (left > viewWidth - imgWidth) {
            left = viewWidth - imgWidth;
        }

        if (top < 0) {
            top = 0;
        } else if (top > viewHeight - imgHeight) {
            top = viewHeight - imgHeight;
        }
        postInvalidate();
    }
}

在Activity或者Fragment里面对View设置代码(kotlin)如下:

binding.viewRocker.getDate { _, pX, pY ->
    binding.viewFly.move(pX, pY)
}

飞机图片如下:

fly.png

总结

摇杆整体实现没有太复杂的逻辑,比较容易混的地方,可能是屏幕坐标系和数学坐标系能不能转过弯来。印象中好像可以通过Matrix将坐标变换,但一时间想不起来怎么实现,后面了解下Matrix相关内容。
关于虚拟摇杆实现有很多方式,我写的这个不是最优的方式,虚拟摇杆有些需求没有接触到,在代码实现中可能比较简单,小伙伴们看到文章不足的地方,可以留言告诉我,一起学习交流下。

项目地址: GitHub