View是Android很重要的一部分,常用的View有Button、TextView、EditView、ListView、GridView、各种layout等等,开发者通过对这些View的各种组合以形成丰富多彩的交互界面,一个应用中界面交互的体验往往在应用的受欢迎程度上起了很关键得作用,所以开发者们大多会想方设法的做出一个更加精美的界面,例如:通过自定义View、深入学习View的原理以便更好的对其优化使其在操作起来更加流畅等等,也正因为如此,在面试中View也常常作为面试官重点考察的对象之一。
View是所有控件的基类,包括Button、TextView、EditView等等都直接或间接继承自view,View下面还有ViewGroup子类,即LinearLayout、RelativeLayout等都属于ViewGroup。
我们需要知道的是在Android中,无论是View还是其他界面,右方向代表着x轴的正向,下方向代表着y轴的正向。
在 ActivityThread 中,当Activity被创建后会将 DecorView 添加到 Window 中,同时创建 ViewRootImpl 对象,并将 ViewRootImpl 和 DecorView 建立关联,而 DecorView 就是一个 Activity 的顶级 View,在一个默认的主题中,它分为标题栏,和内容区域,我们所添加的 View 均是添加到了 DecorView 的内容区域,这些被添加进去的 View 的工作流程正式通过 ViewRootImpl 完成的。
ViewRoot、DecorView 及 View 的三大流程简介:
自定义 View 的方式不止一种,可以直接继承 View,重写 onDraw() 方法,也可以直接继承 ViewGroup,还可以继承现有的控件(如:TextView、LinearLayout)等,本篇主要介绍一下直接继承 View 的方式。
直接继承 View 来实现自定义 View 的这种方式比较灵活,可以实现很多复杂的效果,这种方式最关键的步骤就是重写 onDraw() 方法,通过 Paint 画笔等工具在 Canvas 画布上进行各种图案的绘制以达到我们想要的效果。
其实在自定义 View 过程中,难点往往不是怎么使用画笔本身,而是绘制出预期效果的思路,例如:你想通过自定义 View 来做一个折线图控件,传入一组数据怎么确定这些数据在画布上对应点的相对坐标,而确定点的坐标就需要通过相关的数学公式来计算了,推算出合适的公式往往就是解决问题的关键。
接下来就用这种方式来写个圆形的小 demo 来说明一下自定义 View 的流程。
/**
* 自定义 View 简单示例
* Created by liuwei on 17/12/14.
*/
public class MyView extends View {
private final static String TAG = MyView.class.getSimpleName();
private Paint mPaint = new Paint();
private int mColor = Color.parseColor("#ff0000");
public MyView(Context context) {
super(context);
Log.i(TAG, "MyView(Context context):content=" + context);
init();
}
public MyView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
Log.i(TAG, "MyView(Context context, @Nullable AttributeSet attrs):content=" + context + " | attrs=" + attrs);
init();
}
private void init() {
mPaint.setAntiAlias(true); // 消除锯齿
mPaint.setColor(mColor); // 为画笔设置颜色
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// 重写此方法,对自定义控件在 wrap_content 情况下设置默认宽、高
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
int heithtSpecSize = MeasureSpec.getSize(heightMeasureSpec);
if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(200, 200);
} else if (widthSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(200, heithtSpecSize);
} else if (heightSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(widthSpecSize, 200);
}
}
@Override
protected void onDraw(Canvas canvas) {
Log.i(TAG, "onDraw: ");
super.onDraw(canvas);
canvas.drawCircle(100, 100, 100, mPaint);
}
}
运行结果就是一个红色的实心圆,在这个示例中为了使得布局文件中的 wrap_content 正常生效,重写了 onMeasure() 方法,关于这个问题,在这篇博文《Android查缺补漏--自定义 View 中 wrap_content 无效的解决方案》中也介绍过了,这里就不多说了。
private int mPaddingTop;
private int mPaddingBottom;
private int mPaddingLeft;
private int mPaddingRight;
private int mUsableWidth; // 可用宽度(减去padding后的宽度)
private int mUsableHeight;// 可用高度(减去padding后的高度)
private int mUsableStartX = 0; // 画笔起始点的x坐标
private int mUsableStartY = 0; // 画笔其实点的y坐标
private int mCircleX; // 圆心x坐标
private int mCircleY; // 圆心y坐标
private int mCircleRadius;// 圆的半径
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mPaddingTop = getPaddingTop();
mPaddingBottom = getPaddingBottom();
mPaddingLeft = getPaddingLeft();
mPaddingRight = getPaddingRight();
// 可用宽度和宽度要考虑padding
mUsableWidth = getWidth() - mPaddingRight - mPaddingLeft;
mUsableHeight = getHeight() - mPaddingTop - mPaddingBottom;
// 画笔起始点要考虑padding
mUsableStartX = mPaddingLeft;
mUsableStartY = mPaddingTop;
// 确定可用区域的中心为圆心
mCircleX = mUsableStartX + mUsableWidth / 2;
mCircleY = mUsableStartY + mUsableHeight / 2;
// 确定圆的半径,以可用宽度和高度两者较短的一半为圆的半径
if (mUsableWidth <= mUsableHeight) {
mCircleRadius = mUsableWidth / 2;
} else {
mCircleRadius = mUsableHeight / 2;
}
canvas.drawCircle(mCircleX, mCircleY, mCircleRadius, mPaint);
}
在布局文件中设置 paddingLeft 为15dp,paddingRight 为30dp,为了更好看出间距,将控件的背景颜色设为了黑色,查看效果:
<cn.codingblock.view.reset_view.MyView
android:id="@+id/myview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="10dp"
android:paddingLeft="15dp"
android:paddingRight="30dp"
android:background="#000"/>
效果图:
可见,在 onDraw() 方法对padding处理之后,在布局文件中无论怎么设置padding,都能保证圆心在可用区域的中心。
首先在 res/values 路径下创建一个xml文件,添加一个设置圆的颜色的属性:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="MyView">
<attr name="circle_color" format="color"/>
</declare-styleable>
</resources>
在构造方法中解析属性
public MyView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
TypedArray typeArray = context.obtainStyledAttributes(attrs, R.styleable.MyView);
mColor = typeArray.getColor(R.styleable.MyView_circle_color, mColor);
typeArray.recycle();
init();
}
最后在布局文件中这是属性就可以了,要注意的是,在使用自定义属性时要添加 xmlns:app="http://schemas.android.com/apk/res-auto" 才可以。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context="cn.codingblock.view.activity.MyViewActivity">
<cn.codingblock.view.reset_view.MyView
android:id="@+id/myview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="10dp"
android:paddingLeft="15dp"
android:paddingRight="30dp"
app:circle_color="#ad42ce"
android:background="#000"/>
</LinearLayout>
改变颜色后的效果图如下:
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
Log.i(TAG, "onTouchEvent: ACTION_DOWN");
break;
case MotionEvent.ACTION_UP:
Log.i(TAG, "onTouchEvent: ACTION_UP");
break;
case MotionEvent.ACTION_MOVE:
Log.i(TAG, "onTouchEvent: ACTION_MOVE");
break;
}
return super.onTouchEvent(event);
}
在自定义 View 中,重写 onTouchEvent() 方法,获取 MotionEvent,正如上面代码所写,MotionEvent 比较常用的事件有三种 ACTION_DOWN、ACTION_MOVE、ACTION_UP 分别对应手指按下-移动-离开。
接下来对上面的圆形demo添加一个小事件,就是每当手指点击一下屏幕,圆形就随机换一种颜色:
private Random mRandom = new Random(100);
private int[] mColors = new int[] {
Color.parseColor("#ff0000"),
Color.parseColor("#ffffff"),
Color.parseColor("#ff00ff"),
Color.parseColor("#ffff00"),
Color.parseColor("#ff00ff"),
Color.parseColor("#0000ff")
};
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mColor = mColors[mRandom.nextInt(6)];
mPaint.setColor(mColor);
invalidate(); // 通知控件重绘
break;
case MotionEvent.ACTION_UP:
Log.i(TAG, "onTouchEvent: ACTION_UP");
break;
case MotionEvent.ACTION_MOVE:
Log.i(TAG, "onTouchEvent: ACTION_MOVE");
break;
}
return super.onTouchEvent(event);
}
效果如下:
大家也可以在此基础上稍微再扩展一下,例如:通过 event.getX() 和 event.getY() 获取触摸点的坐标,判断出点是否落在了圆形区域内,从而使只有点手指点到圆形区域内才改变颜色,否则不改变。感兴趣的童鞋可自行动手试一试。
在上面代码中通知 View 重绘时使用了 invalidate() 方法,其实 postInvalidate() 也可以通知 View 重绘,那么这两者有什么区别呢?
其实简单来说,invalidate() 只能在 UI 线程中使用,而 postInvalidate() 可以在子线程中使用。
除了上面最普通的 MotionEvent 事件之外,Android 还提供了很多有趣的事件,就想 GestureDetector(手势检测)、VelocityTracker(速度追踪)等等,用起来也都很方便,其实只要你愿意,这些事件也完全可以在 onTouchEvent() 方法中实现,接下来在为上述的圆形 Demo 添加一个缩放的功能,也就是使用 ScaleGestureDetector 实现,效果跟平时在手机查看照片时我们用两根手指来放大/缩小图片一样。
ScaleGestureDetector 在使用起来也很简单,首先需要初始化并为其添加一个放缩手势监听器,并且需要在 onTouchEvent() 方法内,通过 ScaleGestureDetector.onTouchEvent(event) 来让 ScaleGestureDetector 接管触摸事件,其余的事项请注意看代码中的注释。
在上述代码的基础上新增如下代码:
private Context mContext;
private ScaleGestureDetector mScaleGestureDetector; // 缩放手势检测
private float mScaleRate = 1; // 缩放比率
private void init() {
mPaint.setAntiAlias(true); // 消除锯齿
mPaint.setColor(mColor); // 为画笔设置颜色
// 初始化 ScaleGestureDetector 并添加缩放手势监听器
mScaleGestureDetector = new ScaleGestureDetector(mContext, mOnScaleGestureListener);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mPaddingTop = getPaddingTop();
mPaddingBottom = getPaddingBottom();
mPaddingLeft = getPaddingLeft();
mPaddingRight = getPaddingRight();
// 可用宽度和宽度要考虑padding
mUsableWidth = getWidth() - mPaddingRight - mPaddingLeft;
mUsableHeight = getHeight() - mPaddingTop - mPaddingBottom;
// 画笔起始点要考虑padding
mUsableStartX = mPaddingLeft;
mUsableStartY = mPaddingTop;
// 确定可用区域的中心为圆心
mCircleX = mUsableStartX + mUsableWidth / 2;
mCircleY = mUsableStartY + mUsableHeight / 2;
// 确定圆的半径,以可用宽度和高度两者较短的一半为圆的半径
if (mUsableWidth <= mUsableHeight) {
mCircleRadius = mUsableWidth / 2;
} else {
mCircleRadius = mUsableHeight / 2;
}
// 让半径乘以缩放倍率
mCircleRadius *= mScaleRate;
canvas.drawCircle(mCircleX, mCircleY, mCircleRadius, mPaint);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mColor = mColors[mRandom.nextInt(6)];
mPaint.setColor(mColor);
invalidate(); // 通知控件重绘
break;
case MotionEvent.ACTION_UP:
Log.i(TAG, "onTouchEvent: ACTION_UP");
break;
case MotionEvent.ACTION_MOVE:
Log.i(TAG, "onTouchEvent: ACTION_MOVE");
break;
}
// 让缩放手势检测器接管触摸事件
if (mScaleGestureDetector.onTouchEvent(event)) {
return true;
}
return super.onTouchEvent(event);
}
private ScaleGestureDetector.OnScaleGestureListener mOnScaleGestureListener = new ScaleGestureDetector.OnScaleGestureListener() {
@Override
public boolean onScale(ScaleGestureDetector detector) {
Log.i(TAG, "onScale: " + detector.getScaleFactor());
// 获取缩放比例因子并累乘到缩放倍率上
mScaleRate *= detector.getScaleFactor();
postInvalidate();
return true;
}
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
Log.i(TAG, "onScaleBegin: " + detector.getScaleFactor());
return true;
}
@Override
public void onScaleEnd(ScaleGestureDetector detector) {
Log.i(TAG, "onScaleEnd: " + detector.getScaleFactor());
}
};
上面代码需要注意的是,在 ScaleGestureDetector 捕获到事件后要正确的将事件消费掉(注意代码中返回 true 的地方),不然缩放手势无法正常工作。
自定义 View 在 Android 中一直以来都是很重要的一部分,在平时的开发想要做出一个个性炫酷的交互界面是离不开自定义 View,自定义 View 说难不难,说简单也不简单,总之,千里之行,始于足下,只要我们掌握好自定义 View 的基础知识,再复杂的界面也可以一步步完成。
最后想说的是,本系列文章为博主对Android知识进行再次梳理,查缺补漏的学习过程,一方面是对自己遗忘的东西加以复习重新掌握,另一方面相信在重新学习的过程中定会有巨大的新收获,如果你也有跟我同样的想法,不妨关注我一起学习,互相探讨,共同进步!
参考文献: