注册

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

前言


之前在开发项目中,有一个功能是,设计一个虚拟摇杆,操作大疆无人机飞行,在实现过程中感觉比较锻炼自定义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


作者:卤肉拌面
链接:https://juejin.cn/post/7225079705992822842
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册