自定义操作杆教程
自定义操作杆教程
同学们都玩过王者荣耀吧,里面有控制人物走动的操作杆,但是大家不要误以为这是Android里面自带的控件,需要我们开发者自己实现。实现一个操作杆的步骤较为麻烦,但是我在这里大概地将其分为以下四个步骤:
- 画出基本的view
- 实现根据手指触摸的位置改变view的位置
- 写出不同方向上面的回调函数
- 限定触摸的区域
可能大家目前看上面的四个步骤会很懵,但是别着急,我们慢慢道来。
画出基本的view
看到这里之前,大家需要对自定义View有一定的了解,起码要知道 paint 和 canvas 这两个自定义view必须要用到的东西,在这里我向大家推荐学习自定义
view 的教程,大神 GcsSloop 的博客,讲的十分详细,如果没有相应的基础,大家看完前两章就可以把这篇博客大部分看懂了。
我们暂把我们的自定义操作杆叫做 StickView ,这样我们就可以,我们先写出一个自定义View的框架。
public class StickView extends ControlView {
private Paint paint = new Paint();
public StickControlView(Context context) {
this(context, null);
}
public StickControlView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public StickControlView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initialData();
}
private void initialData() {
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
}
@Override
protected void onDraw(Canvas canvas) {
}
}
自定义 View 最重要的就是重写 View 的 onDraw 这个方法,但是在此之前我们要获取其定义 View 的宽和高,我们需要在 onSizeChanged 这个方法里面获取到这个 View 的宽和高,接着我们就只看这两个方法。
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
width = w;
height = h;
centerPoint.x = w / 2;
centerPoint.y = h / 2;
stickRadius = (w > h ? h : w) / 8;
edgeRadius = (w > h ? h : w) * 2 / 5;
resetStick();
initialRegion();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
drawEdge(canvas);
drawStick(canvas);
}
在上面的代码 onSizeChanged 中我分别定义了操作杆本身的半径和边境的半径,接着在 onDraw 当中我们分别写出了如何画出边界和操作杆的方法,这两个方法都是我自定义的,代码如下:
private void drawEdge(Canvas canvas) {
paint.reset();
paint.setStyle(Paint.Style.STROKE);
paint.setColor(getResources().getColor(R.color.edge_color, null));
paint.setStrokeWidth(1);
paint.setAntiAlias(true);
canvas.drawCircle(centerPoint.x, centerPoint.y, edgeRadius, paint);
}
private void drawStick(Canvas canvas) {
paint.reset();
paint.setColor(getResources().getColor(R.color.stick_color, null));
paint.setStyle(Paint.Style.FILL);
paint.setAntiAlias(true);
canvas.drawCircle(stickX, stickY, stickRadius, paint);
}
到这一步,我们就可以看到操作杆大概的样子了!

实现根据手指触摸的位置改变view的位置
这里的改变 View 的位置大家想想也知道,就是改变操作杆的位置,想要改变操作杆的位置其实很简单,其实这里关键的就是有调用 View 的一个叫做 invalidate 的方法,这个方法就是重新调用 onDraw 这个方法,也就是我们总是说的重绘,所以我们的思路是定义操作杆中心的 X 坐标和 Y 坐标,让这两个坐标等于我们手指所在的坐标,但是关键的是我们要还要重写 View 另一个方法,就是控制 View 的触摸事件,需要 onTouchEvent 这个方法。
public boolean onTouchEvent(MotionEvent event) {
int x = (int)event.getX();
int y = (int)event.getY();
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
if (stickRegion.contains(x, y)) {
return true;
}
break;
case MotionEvent.ACTION_MOVE:
if (edgeRegion.contains(x, y)) {
updateStick(x, y);
} else {
resetStick();
}
break;
case MotionEvent.ACTION_UP:
resetStick();
break;
}
return super.onTouchEvent(event);
}
我们注意到需要在 ACTION_DOWN 事件要返回 true ,这是因为想要消费一个事件序列,我们必须要消费 ACTION_DOWN ,返回 true 就是消费,否则就是不消费,我们关键就是要修改 ACTION_MOVE 这个方法,先不管edgeRegion.contains(x, y),我们过会会讲的,最重要的就是 updateStick(x, y) 这个方法是我们想要的修改操作杆的位置的方法,resetStick 这个方法就是将操作杆恢复到中间,接下来就直接上代码吧。
private void updateStick(int x, int y) {
int dx = x - centerPoint.x;
int dy = y - centerPoint.y;
stickX = x - dx / 4;
stickY = y - dy / 4;
double degree = calculateAngle(dx, dy);
invalidate();
Log.d(TAG, "" + degree);
}
private void resetStick() {
stickX = width / 2;
stickY = height / 2;
invalidate();
}
写出不同方向上面的回调函数
所谓的方向的回调方法并不是必须得,每个人都有自己的理解,在这里我就简单的分成四个,也就是上下左右方向。我们定义一个 enum 类,定义了四个枚举变量,分别是 UP, LEFT, DOWN, RIGHT ,接着又写了一些监听的函数,最后我们就可以在想要获取方向的地方获取到回调,具体的代码如下。
public enum Direction {
UP, LEFT, DOWN, RIGHT
}
public interface OnDirectionListener {
void onDirection(Direction direction);
}
public void setOnDirectionListener(OnDirectionListener onDirectionListener) {
this.onDirectionListener = onDirectionListener;
}
接着是在 updateStick 里面增加一段代码,具体代码如下:
if (onDirectionListener != null) {
if (degree > 0 && degree < 45 || degree > 325 && degree < 360) {
onDirectionListener.onDirection(Direction.RIGHT);
} else if (degree > 45 && degree < 135){
onDirectionListener.onDirection(Direction.DOWN);
} else if (degree > 135 && degree < 225) {
onDirectionListener.onDirection(Direction.LEFT);
} else if (degree > 225 && degree < 325){
onDirectionListener.onDirection(Direction.UP);
}
}
当然至于这些判断的角度是怎么计算出来的,其实很简单,先计算 tan 的函数,然后将这个 tan 的值转换成角度,这些方法 Java 的 Math 包都有提供,这里把那部分重点的列出来。
private double calculateAngle(float dx, float dy) {
double degrees = Math.toDegrees(Math.atan2(dy, dx));
return degrees < 0 ? Math.floor(degrees + 360) : Math.floor(degrees);
}
最后比如说我们在Activity 和 Fragment 中使用就可以像一个正常的 View 那样监听它的角度的变化,可以参考 OnClickListener 。
StickView stickView = (StickView) findViewById(R.id.sv_my);
stickView.setOnDirectionListener(new StickView.OnDirectionListener() {
@Override
public void onDirection(StickView.Direction direction) {
switch (direction) {
case UP:
Log.d(TAG, "调用了 UP");
break;
case DOWN:
Log.d(TAG, "调用了 DOWN");
break;
case LEFT:
Log.d(TAG, "调用了 LEFT");
break;
case RIGHT:
Log.d(TAG, "调用了 RIGHT");
break;
}
}
});
调用的 Log 如下 :
2019-01-27 19:56:46.636 18539-18539/com.example.yuanyuanlai.uav D/MainActivity: 调用了 UP
2019-01-27 19:56:46.653 18539-18539/com.example.yuanyuanlai.uav D/MainActivity: 调用了 UP
2019-01-27 19:56:46.669 18539-18539/com.example.yuanyuanlai.uav D/MainActivity: 调用了 UP
2019-01-27 19:56:46.686 18539-18539/com.example.yuanyuanlai.uav D/MainActivity: 调用了 UP
2019-01-27 19:56:46.702 18539-18539/com.example.yuanyuanlai.uav D/MainActivity: 调用了 UP
2019-01-27 19:56:46.719 18539-18539/com.example.yuanyuanlai.uav D/MainActivity: 调用了 UP
2019-01-27 19:56:46.736 18539-18539/com.example.yuanyuanlai.uav D/MainActivity: 调用了 UP
2019-01-27 19:56:46.752 18539-18539/com.example.yuanyuanlai.uav D/MainActivity: 调用了 UP
2019-01-27 19:56:46.770 18539-18539/com.example.yuanyuanlai.uav D/MainActivity: 调用了 UP
2019-01-27 19:56:46.774 18539-18539/com.example.yuanyuanlai.uav D/ViewRootImpl@18d04f6[MainActivity]: ViewPostIme pointer 1
2019-01-27 19:56:48.088 18539-18539/com.example.yuanyuanlai.uav D/ViewRootImpl@18d04f6[MainActivity]: ViewPostIme pointer 0
2019-01-27 19:56:48.139 18539-18539/com.example.yuanyuanlai.uav D/MainActivity: 调用了 LEFT
2019-01-27 19:56:48.155 18539-18539/com.example.yuanyuanlai.uav D/MainActivity: 调用了 LEFT
2019-01-27 19:56:48.172 18539-18539/com.example.yuanyuanlai.uav D/MainActivity: 调用了 LEFT
2019-01-27 19:56:48.188 18539-18539/com.example.yuanyuanlai.uav D/MainActivity: 调用了 LEFT
2019-01-27 19:56:48.205 18539-18539/com.example.yuanyuanlai.uav D/MainActivity: 调用了 LEFT
2019-01-27 19:56:48.216 18539-18539/com.example.yuanyuanlai.uav D/MainActivity: 调用了 LEFT
......
限定触摸的区域
最后一个正常的操作杆,还是得限定下它的触摸区域,首先不能没触摸到操作杆,那个杆子就动了;也不能触摸的时候,手已经离操作杆很远了,还是可以改变距离,这样显得不是很合理,我们就分别规范边界区域和操作杆的边界。首先为什么规范这两个,我们来分析下,之前说了不能没有触摸到操作杆,所以我们操作杆的边界只在 ACTION_DOWN 的时候判断是否消费事件,接着为什么为边界区域定义一个边界呢,因为我们在移动操作杆的时候不行要操作杆移动的太远。好了,直接上代码吧。
private void initialRegion() {
Path stickPath = new Path();
stickPath.addCircle(centerPoint.x, centerPoint.y, stickRadius, Path.Direction.CW);
Region globalRegion = new Region(centerPoint.x - stickRadius, centerPoint.y - stickRadius, centerPoint.x + stickRadius, centerPoint.y + stickRadius);
stickRegion.setPath(stickPath, globalRegion);
Path edgePath = new Path();
edgePath.addCircle(centerPoint.x, centerPoint.y, edgeRadius * 2, Path.Direction.CW);
globalRegion = new Region(centerPoint.x - edgeRadius * 2, centerPoint.y - edgeRadius * 2, centerPoint.x + edgeRadius * 2, centerPoint.y + edgeRadius * 2);
edgeRegion.setPath(edgePath, globalRegion);
}
上面的代码是我们定义两个边界,注意因为这里我们用到了长度,所以必须放在我们获取到长度地方的后面,这里我们把它放到 onSizeChanged 那里,大家可以在上面看到。也许大家会感到奇怪,为什么会有一个 globalRegion ,这里因为我们的边界是一个不规矩的区域,所以我们先要定义一个区域,和我们的path取交集,所以尽量要比 path 大一点。
具体怎么调用这个 region 其实也很简单,我们就是用它来判断 x 和 y 是否在规定的区域范围里面。上例子!!
stickRegion.contains(x, y)
总结
自定义 View 涉及的点还是很多的,想要完成一个别人看似很简单的效果,就要花很多心思,并且这里实现的是一个简单的操作杆,想要实现那种市场上面可以看见的操作杆,大家需要花很多的心思,去完成一些细节,学习 View 方面的知识还是要系统一点,因为它真的对 Android 来说很重要。
最后贴出所有代码
public class StickView extends View {
private OnDirectionListener onDirectionListener;
public enum Direction {
UP, LEFT, DOWN, RIGHT
}
public interface OnDirectionListener {
void onDirection(Direction direction);
}
public void setOnDirectionListener(OnDirectionListener onDirectionListener) {
this.onDirectionListener = onDirectionListener;
}
private int width, height;
private final static String TAG = "StickView";
private int edgeRadius;
private int stickRadius;
private Region stickRegion = new Region();
private Region edgeRegion = new Region();
private int stickX, stickY;
private Point centerPoint = new Point();
private Paint paint = new Paint();
public StickView(Context context) {
super(context);
}
public StickView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public StickView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
drawEdge(canvas);
drawStick(canvas);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
width = w;
height = h;
centerPoint.x = w / 2;
centerPoint.y = h / 2;
stickRadius = (w > h ? h : w) / 8;
edgeRadius = (w > h ? h : w) * 2 / 5;
resetStick();
initialRegion();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int)event.getX();
int y = (int)event.getY();
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
if (stickRegion.contains(x, y)) {
return true;
}
break;
case MotionEvent.ACTION_MOVE:
if (edgeRegion.contains(x, y)) {
updateStick(x, y);
} else {
resetStick();
}
break;
case MotionEvent.ACTION_UP:
resetStick();
break;
}
return super.onTouchEvent(event);
}
private void drawEdge(Canvas canvas) {
paint.reset();
paint.setStyle(Paint.Style.STROKE);
paint.setColor(getResources().getColor(R.color.edge_color, null));
paint.setStrokeWidth(1);
paint.setAntiAlias(true);
canvas.drawCircle(centerPoint.x, centerPoint.y, edgeRadius, paint);
}
private void drawStick(Canvas canvas) {
paint.reset();
paint.setColor(getResources().getColor(R.color.stick_color, null));
paint.setStyle(Paint.Style.FILL);
paint.setAntiAlias(true);
canvas.drawCircle(stickX, stickY, stickRadius, paint);
}
private void resetStick() {
stickX = width / 2;
stickY = height / 2;
invalidate();
}
private void initialRegion() {
Path stickPath = new Path();
stickPath.addCircle(centerPoint.x, centerPoint.y, stickRadius, Path.Direction.CW);
Region globalRegion = new Region(centerPoint.x - stickRadius, centerPoint.y - stickRadius, centerPoint.x + stickRadius, centerPoint.y + stickRadius);
stickRegion.setPath(stickPath, globalRegion);
Path edgePath = new Path();
edgePath.addCircle(centerPoint.x, centerPoint.y, edgeRadius * 2, Path.Direction.CW);
globalRegion = new Region(centerPoint.x - edgeRadius * 2, centerPoint.y - edgeRadius * 2, centerPoint.x + edgeRadius * 2, centerPoint.y + edgeRadius * 2);
edgeRegion.setPath(edgePath, globalRegion);
}
private void updateStick(int x, int y) {
int dx = x - centerPoint.x;
int dy = y - centerPoint.y;
stickX = x - dx / 4;
stickY = y - dy / 4;
double degree = calculateAngle(dx, dy);
invalidate();
if (onDirectionListener != null) {
if (degree > 0 && degree < 45 || degree > 325 && degree < 360) {
onDirectionListener.onDirection(Direction.RIGHT);
} else if (degree > 45 && degree < 135){
onDirectionListener.onDirection(Direction.DOWN);
} else if (degree > 135 && degree < 225) {
onDirectionListener.onDirection(Direction.LEFT);
} else if (degree > 225 && degree < 325){
onDirectionListener.onDirection(Direction.UP);
}
}
Log.d(TAG, "" + degree);
}
private double calculateAngle(float dx, float dy) {
double degrees = Math.toDegrees(Math.atan2(dy, dx));
return degrees < 0 ? Math.floor(degrees + 360) : Math.floor(degrees);
}
}