前言

之前在开发项目中,有一个功用是,规划一个虚拟摇杆,操作大疆无人机飞行,在完成过程中感觉比较训练自定义View的能力,在此记载一下,本文中摇杆代码从项目中抽取出来从头完成,如下是程序运行图:

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

功用分析

本次自定义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正半轴构成的夹角取值,夹角取值规模如下图所示。

Android自定义控件之虚拟摇杆(遥控飞机)
代码先经过Math.atan2(y,x)办法获取手指接触点与圆心连线和x正半轴之间的弧度制,获取弧度后经过(float) (radian * (180 / Math.PI))获取对应的度数,这里特别留意下Math.atan2(y,x)办法是y值在前,x在后。
此外这个办法还核算了手指接触点与大圆圆心间隔,以及判别手指接触点是否在大圆外,以及在大圆外时,获取在大圆边际上的小圆圆心的xy值。

在核算小圆圆心的坐标需求了解一个当地是,view完成过程中运用的坐标系是屏幕坐标系,屏幕坐标系是以View左上角为原点,原点左边是x的正半轴,原点下面是y正半轴,屏幕坐标系和数学坐标系是不一样。小圆圆心坐标获取原理,是依据三角形的类似原理获取,小圆圆心的坐标获取原理如下图所示:

Android自定义控件之虚拟摇杆(遥控飞机)
在上图中能够看到小圆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事情的,不触发这个事情,咱们就无法回来摇杆移动的数据,从而无法操控飞机改动方位。效果图如下

Android自定义控件之虚拟摇杆(遥控飞机)
解决这个问题,需求运用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)
}

飞机图片如下:

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

总结

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

项目地址: GitHub