前言:个人认为安卓开发的两个难点是多线程异步和自定义UI控件!前者决定了APP的性能,后者决定了APP交互体验。要做出屌炸天的优秀的自定义UI控件UI交互,必然要先深入理解安卓View对象,然后才能做出优秀的自定义UI控件。本文包括以下几个部分的内容:View对象是什么?如何灵活加载View对象?View的绘制流程,View状态及重绘流程,简单自定义View的实现。本文包括一个demo,实现了View对象动态加载和自定义UI控件。
一、View对象
View类表示了用户界面的基本构建模块。一个View占用了屏幕上的一个矩形区域并且负责界面绘制和事件处理。View是用来构建用户界面组件(Button,TextView,EditText等)的基类。ViewGroup子类是各种布局的基类,用作包含其他View(或其他ViewGroups)和定义这些View布局参数(Layout属性)的容器。
特别注意的是 ViewGroup类由View类派生,是被称为“Layouts(布局)”的子类的基类。
二、LayoutInflater加载View对象(代码实现ViewActivity1.java)
安卓开发中,通常在Activity的onCreate()方法中使用setContentView()方法加载该Activity的.xml布局文件,这样我们在布局文件里面设计的UI界面就可以显示在Activity页面上。实际上,setContentView()方法内部是使用LayoutInflater加载布局的。
实现步骤:
1、两种实例化LayoutInflater对象的方式
a、LayoutInflater layoutInflater = LayoutInflater.from(context);
b、LayoutInflater layoutInflater = (LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
a方式是b方式的封装形式。
2、使用实例化的LayoutInflater对象的inflate方法加载布局
layoutInflater.inflate(resourceId, root, attachToRoott);
id;第二个参数是指给该布局的外部再嵌套一层父布局,如果不需要就直接传null;第三个参数不是必选,具体作用参考下列a、b、c、d四点说明。
a、如果root参数为null,attachToRoot将失去作用,设置任何值都没有意义,函数返回resource对应的View对象。
b、如果root参数不为null,attachToRoot设为true,则会给加载的布局文件的指定一个父布局,即root。函数会建立一个resouce对应的View对象和root父布局的绑定关系,并返回root父布局对象。
c、如果root参数不为null,attachToRoot设为false,函数会建立一个resouce对应的View对象和root父布局的绑定关系,并返回resouce对应的View对象。
d、在不设置attachToRoot参数的情况下,如果root不为null,attachToRoot参数默认为true。
3、使用addView()方法可以添加该View对象到指定的布局中。需要注意的是,inflate方法加载xml布局文件并用addView方法添加到界面中这种控件动态加载方式是不能指定控件加载布局的位置。
LayoutInflater工作原理:
从LayoutInflater源码来看,不管是使用该对象的哪一个inflate方法,最终的实现都是下面一段的代码。
<span style="font-family:SimHei;font-size:18px;">1. public View inflate(XmlPullParser parser, ViewGroup root, boolean attachToRoot) {
2. synchronized (mConstructorArgs) {
3. final AttributeSet attrs = Xml.asAttributeSet(parser);
4. mConstructorArgs[0] = mContext;
5. View result = root;
6. try {
7. int type;
8. while ((type = parser.next()) != XmlPullParser.START_TAG &&
9. type != XmlPullParser.END_DOCUMENT) {
10. }
11. if (type != XmlPullParser.START_TAG) {
12. throw new InflateException(parser.getPositionDescription()
13. + ": No start tag found!");
14. }
15. final String name = parser.getName();
16. if (TAG_MERGE.equals(name)) {
17. if (root == null || !attachToRoot) {
18. throw new InflateException("merge can be used only with a valid "
19. + "ViewGroup root and attachToRoot=true");
20. }
21. rInflate(parser, root, attrs);
22. } else {
23. View temp = createViewFromTag(name, attrs);
24. ViewGroup.LayoutParams params = null;
25. if (root != null) {
26. params = root.generateLayoutParams(attrs);
27. if (!attachToRoot) {
28. temp.setLayoutParams(params);
29. }
30. }
31. rInflate(parser, temp, attrs);
32. if (root != null && attachToRoot) {
33. root.addView(temp, params);
34. }
35. if (root == null || !attachToRoot) {
36. result = temp;
37. }
38. }
39. } catch (XmlPullParserException e) {
40. InflateException ex = new InflateException(e.getMessage());
41. ex.initCause(e);
42. throw ex;
43. } catch (IOException e) {
44. InflateException ex = new InflateException(
45. parser.getPositionDescription()
46. + ": " + e.getMessage());
47. ex.initCause(e);
48. throw ex;
49. }
50. return result;
51. }
52. }</span>
1、从传入的参数XmlPullParser可以看出,LayoutInflater内部使用了android提供的pull方法解析.xml布局文件。
2、在代码的第16行有一个if语句,判断当前是否以传入的root参数作为根节点,否则需要创建根节点View对象;23行调用createViewFromTag(name,attrs)方法创建返回一个根节点View对象(在createViewFromTag方法中调用了createView(),更多具体细节就不去纠结);
3、我们知道DOM布局都是树状结构,所以在确定了根节点View对象后,在代码的21行和31行调用rInflate方法循环遍历这个根布局下的子元素。参考rInflate方法的源码:
1. private void rInflate(XmlPullParser parser, View parent, final AttributeSet attrs)
2. throws XmlPullParserException, IOException {
3. final int depth = parser.getDepth();
4. int type;
5. while (((type = parser.next()) != XmlPullParser.END_TAG ||
6. parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) {
7. if (type != XmlPullParser.START_TAG) {
8. continue;
9. }
10. final String name = parser.getName();
11. if (TAG_REQUEST_FOCUS.equals(name)) {
12. parseRequestFocus(parser, parent);
13. } else if (TAG_INCLUDE.equals(name)) {
14. if (parser.getDepth() == 0) {
15. throw new InflateException("<include /> cannot be the root element");
16. }
17. parseInclude(parser, parent, attrs);
18. } else if (TAG_MERGE.equals(name)) {
19. throw new InflateException("<merge /> must be the root element");
20. } else {
21. final View view = createViewFromTag(name, attrs);
22. final ViewGroup viewGroup = (ViewGroup) parent;
23. final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
24. rInflate(parser, view, attrs);
25. viewGroup.addView(view, params);
26. }
27. }
28. parent.onFinishInflate();
29. }
View对象并添加到ViewGroup对象中。这个过程就形成了一个完整的DOM结构,返回一个包含根节点View对象和其子元素View对象的ViewGroup对象。
4、组件的Layout开头属性
需要注意的是,View对象作为基类派生的组件的Layout开头属性值定义的都是该组件在父布局中的布局特性。(即不能独立于父布局生效)所以,为了确保我们加载的所有组件的Layout属性生效,必须确保组件具有父布局。事实上,我们加载Activity布局调用的方法setContentView()也默认为该布局添加了一个FrameLayout父布局。
Activity布局示意图
三、View的绘制流程
Measure->Layout->Draw,我们重点来关注一下这三个流程的实现。
1、Measure
View系统的绘制流程会从ViewRoot的performTraversals()方法中开始,在其内部调用View的measure()方法。measure()方法接收两个参数,widthMeasureSpec和heightMeasureSpec,这两个值由父视图经过计算后传递给子视图的,说明父视图会在一定程度上决定子视图的大小。而对于一个布局的最外层根布局来说,它的widthMeasureSpec和heightMeasureSpec值是默认为充满屏幕。MeasureSpec的值由specSize和specMode共同组成的,其中specSize记录的是大小,specMode记录的是规格。
方法中调用的onMeasure()方法才会真正的确定View大小的地方,函数内部会综合父布局传递的MeasureSpec值和View本身设置的大小返回并设置最终View大小,父布局传递的MeasureSpec值优先级更高。当然,一个界面的展示可能会涉及到很多次的measure,因为一个布局中一般都会包含多个子视图,每个视图都需要经历一次measure过程。ViewGroup中定义了一个measureChildren()方法来去测量子视图的大小。
2、Layout
过程结束后,视图的大小就已经确定好了,接下来就是视图布局流程,可以理解为确定视图的位置。执行这一流程的layout方法也是在ViewRoot的performTraversals()方法中调用。有些类似的,layout()方法中是调用的onLayout方法,但是onLayout方法是父布局去确定子视图的显示位置,所以是在视图的父布局中去实现这个方法!从逻辑上说,每一个视图measure方法的实现不仅仅确定了自身大小,也确定了子视图大小,然后layout方法确定了子视图的布局。
3、Draw
中的代码会继续执行并创建出一个Canvas对象,然后调用View的draw方法来执行具体的绘制工作,包括视图的背景、内容以及子视图的绘制。draw方法中通过调用View对象的onDraw方法绘制视图内容,调用View对象的dispatchDraw方法去绘制子视图(这实际上是一个递归的封装)。
四、View状态及重绘流程
我们在使用View对象的时候,都有通过背景选择器设置控件在不同的状态下的显示效果,比如默认、聚焦、选中等。前文已经讲解了所有控件对象都是由View对象派生出来的,那么弄清楚了View对象的状态及重绘流程自然也就清楚了这种控件动态效果的原理。
1、视图状态
我们只关注最常用的几种视图状态。
A、enabled
setEnable()方法来改变视图的可用状态,传入true表示可用,传入false表示不可用。它们之间最大的区别在于,不可用的视图是无法响应onTouch事件的。
B、Focused
手机只可以使用requestFocus()这个办法来让视图获得焦点了。而requestFocus()方法也不能保证一定可以让视图获得焦点,它会有一个布尔值的返回值,如果返回true说明获得焦点成功,返回false说明获得焦点失败。一般只有视图在focusable和focusable in touch mode同时成立的情况下才能成功获取焦点。
C、window_focused
表示当前视图是否处于正在交互的窗口中,这个值由系统自动决定,应用程序不能进行改变。
D、Selected
setSelected()方法能够改变视图的选中状态,传入true表示选中,传入false表示未选中。
E、Pressed
表示当前视图是否处于按下状态。可以调用setPressed()方法来对这一状态进行改变,传入true表示按下,传入false表示未按下。通常情况下这个状态都是由系统自动赋值的,但开发者也可以自己调用这个方法来进行改变。
2、视图重绘
Button控件在pressed状态为true或false下的不同背景。
源码分析:
a、当视图状态出现变化时,View对象就调用其drawableStateChanged()方法。
方法源代码:
<span style="font-family:SimHei;font-size:18px;"> protected void drawableStateChanged() {
Drawable d = mBGDrawable;
if (d !=null && d.isStateful()) {
d.setState(getDrawableState());
}
}</span>
mBGDrawable实际上是布局文件中该控件的background对象,可以是属性值也可以是指定drawable文件。
第四行代码中getDrawableState()方法来获取视图状态,注意因为View对象具有很多种状态,所以方法返回的是一个记录所有状态属性的数组。
同样是在第四行代码中调用Drawable对象的setState()方法来对状态进行更新。
setState方法源代码:
public boolean setState(final int[] stateSet) {
if (!Arrays.equals(mStateSet, stateSet)) {
mStateSet = stateSet;
return onStateChange(stateSet);
}
return false;
}
onStateChange方法。注意这个方法是Drawable对象的,而不同的Drawable实例化对象中的onStateChange方法是不同的,该方法实现了根据不同状态重绘控件的业务。
b、视图重绘
onStateChange方法完成View的重绘,在第三段中我们了解了视图绘制的流程:measure->layout->draw。那么视图重绘是不是也要走这三个流程?
·视图的重绘是由于视图状态改变这个事件触发的,所以setVisibility、setEnabled、setSelected等方法都会导致视图重绘。
·归根结底视图重绘最后都是通过调用invalidate方法实现的。
· invalidate方法最终也是调用绘制视图的performTraversals方法,但是这时measure和layout流程不会执行,只是执行了draw流程。
五、简单自定义View的实现
自定义View的实现方式分为三种:自绘控件、组合控件、继承控件,掌握实现这三种自定义控件的方法就可以做出期望的UI控件。
1、自绘控件(代码实现ViewActivity1.java)
自绘控件的概念是一个View上面所展示的内容通过onDraw方法绘制完成,这里实现一个响应点击事件动态变化的自绘View控件。
第一步、自定义一个自绘控件对象继承View并实现onClickListener接口
第二步、在activity的布局文件中使用自绘控件的方式和button、textview这些普通控件是一样的
我们实现自绘控件的方式是重写View对象的onDraw方法,理论上说,onDraw方法中重写的内容决定了自绘控件的内容。自绘控件的重绘和其它普通控件一样,通过invalidate方法实现而不是直接调用onDraw方法,当然前面我们提到了invalidate方法内部是有调用onDraw方法的。
代码示例:
<span style="font-family:SimHei;font-size:18px;">public class View1 extends View implements OnClickListener {
//定义画笔对象
private Paint mPaint;
//定义Rect对象用来存储矩形区域坐标
private Rect mRect;
//计数参数
private int clickNum=0;
//构造函数,初始化类对象mPaint和mRect,并为View1自定义控件绑定点击监听器
public View1(Context context, AttributeSet attrs) {
super(context, attrs);
// TODO Auto-generated constructor stub
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);//实例化画笔对象
mRect = new Rect();//实例化Rect对象
setOnClickListener(this);//绑定点击监听器
}
@Override
protected void onDraw(Canvas canvas) {
// TODO Auto-generated method stub
super.onDraw(canvas);
mPaint.setColor(Color.GRAY);//设置画笔颜色为grey
canvas.drawRect(0, 0, getWidth(), getHeight(), mPaint);//画出一个灰色矩形
mPaint.setColor(Color.WHITE);//设置画笔颜色为white
mPaint.setTextSize(30); //设置画笔字体大小
String text = String.valueOf(clickNum);
mPaint.getTextBounds(text, 0, text.length(), mRect);//测量文字内容占用矩形局域范围,存储在mRect对象中
float textWidth = mRect.width();
float textHeight = mRect.height();
//在灰色矩形正中写出文字内容
canvas.drawText(text, getWidth() / 2 - textWidth / 2, getHeight() / 2 + textHeight / 2, mPaint);
}
@Override
public void onClick(View v) {
// TODO Auto-generated method stub
clickNum++;//记录点击次数
invalidate();//注意这里不能直接调用onDraw方法完成视图会话,而是通过调用invalidate方法。
}
}</span>
2、组合控件(代码实现ViewActivity1.java)
对于组合控件,我们不需要重写View对象的onDraw方法来绘制出想要展现的内容。组合控件是使用系统原生的控件组合在一起,达到我们需要的展现效果。我们实现一个RelativeLayout布局下使用Button和TextView控件组合而成的顶部导航栏控件。
第一步、生成组合控件的布局文件
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#f0f0f0" >
<Button
android:id="@+id/btn2"
android:layout_width="60dp"
android:layout_height="40dp"
android:layout_centerVertical="true"
android:layout_marginRight="5dp"
android:background="#DEDEDE"
android:text="@string/menu"
android:textColor="#fff" />
<TextView
android:id="@+id/tv1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="@string/hello_world"
android:textColor="#fff"
android:textSize="20sp" />
</RelativeLayout>
第二步、定义一个组合控件对象View2继承自FrameLayout,前文已经指出所有Layout布局对象的基类是ViewGroup对象,ViewGroup对象又是由View对象派生。概念上来说,组合控件就是在一个layout布局中组合系统原生的控件。
<span style="font-family:SimHei;font-size:18px;">public class View2 extends FrameLayout {
//定义组合控件中的button控件
private Button btn_menu;
//构造函数
public View2(Context context, AttributeSet attrs) {
super(context, attrs);
final Context mContext = context;
// TODO Auto-generated constructor stub
LayoutInflater.from(context).inflate(R.layout.title_bar_view,this);
btn_menu = (Button) findViewById(R.id.btn2);
//添加点击功能效果
btn_menu.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
// TODO Auto-generated method stub
Toast toast=Toast.makeText(mContext, "逗你玩!", Toast.LENGTH_SHORT);
toast.show();
}
});
}
}</span>
第三步、像使用其它系统控件一样在xml布局文件中使用生成的组合控件。
3、继承控件(代码实现ViewActivity2.java)
继承控件在继承现有控件的基础上添加新的功能效果,形成新的自定义控件,比如实现一个侧滑显示功能菜单的ListView控件。
第一步、准备一个菜单栏的布局,新建item_menu.xml文件
第二步、定义一个带侧滑显示功能菜单的控件对象,继承自ListView对象,实现一个手势动作监听器onGestureListener处理手势动作,具体的代码逻辑注释写的比较清楚了。
<span style="font-family:SimHei;font-size:18px;">public class View3 extends ListView implements OnTouchListener,OnGestureListener {
//定义GestureDetector对象,用于处理手势动作
private GestureDetector gestureDetector;
//Item菜单的功能选项监听器
private OptionListener listener;
//Item菜单视图对象
private View menu;
//Item布局对象
private ViewGroup itemLayout;
//Item菜单是否显示的标识
private Boolean isMenuShow;
//点击事件选定Item在ListView中的位置
private int selectedItem;
//自定义ListView对象的
public View3(Context context, AttributeSet attrs) {
super(context, attrs);
// TODO Auto-generated constructor stub
//初始化Item菜单显示状态为未显示
isMenuShow=false;
//实例化GestureDetector对象
gestureDetector = new GestureDetector(getContext(), this);
//ListView设置OnTouch事件监听器
setOnTouchListener(this);
}
/*
* 手势动作监听器的一系列内部方法
* onDown-判断点击位置是ListView中的第几条Item,记录这个索引值
* onFling-判断是否有侧移的手势动作,确定是否展现出Item菜单
*/
@Override
public boolean onDown(MotionEvent e) {
// TODO Auto-generated method stub
if (!isMenuShow) {
selectedItem = pointToPosition((int) e.getX(), (int) e.getY());
}
return false;
}
@Override
public void onShowPress(MotionEvent e) {
// TODO Auto-generated method stub
}
@Override
public boolean onSingleTapUp(MotionEvent e) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX,float distanceY) {
// TODO Auto-generated method stub
return false;
}
@Override
public void onLongPress(MotionEvent e) {
// TODO Auto-generated method stub
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX,float velocityY) {
// TODO Auto-generated method stub
//velocityX和velocityY分别表示的是沿X、Y轴每秒的手势移动速度
if (!isMenuShow && Math.abs(velocityX) > Math.abs(velocityY)) {
//获取选中的Item对象
itemLayout = (ViewGroup) getChildAt(selectedItem - getFirstVisiblePosition());
//加载Item菜单布局文件
menu = LayoutInflater.from(getContext()).inflate(R.layout.item_menu,itemLayout,false);
//设置menu视图对象的布局属性
RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
params.addRule(RelativeLayout.ALIGN_PARENT_RIGHT);
params.addRule(RelativeLayout.CENTER_VERTICAL);
//添加menu视图对象
itemLayout.addView(menu, params);
//获取菜单选项对象
TextView tv1 = (TextView) findViewById(R.id.option1);
TextView tv2 = (TextView) findViewById(R.id.option2);
//打印出ViewID
Log.d("View1ID:",String.valueOf(tv1.getId()));
Log.d("View2ID:",String.valueOf(tv2.getId()));
//绑定点击option1事件
tv1.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
itemLayout.removeView(menu);
menu = null;
isMenuShow = false;
listener.showQQ(selectedItem);
}
});
//绑定点击option2事件
tv2.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
itemLayout.removeView(menu);
menu = null;
isMenuShow = false;
listener.showWX(selectedItem);
}
});
//标记Item菜单为已经显示状态
isMenuShow = true;
}
return false;
}
/*
* onTouch事件监听器,监听的是ListView层面的onTouch事件,存在Item的菜单有无显示两种情况
* 一、菜单处于显示状态,判定该动作为ListView复位,即关闭菜单显示
* 二、菜单处于未显示状态,调用gestureDetector对象处理该动作
*/
@Override
public boolean onTouch(View v, MotionEvent event) {
// TODO Auto-generated method stub
if (isMenuShow) {
itemLayout.removeView(menu);
menu = null;
isMenuShow = false;
return false;
} else {
//判断点击的是否是ListView对象的无效区域
if(View3.INVALID_POSITION == pointToPosition((int)event.getX(), (int) event.getY())){
return false;
}else{
return gestureDetector.onTouchEvent(event);
}
}
}
public void setOptionListener(OptionListener l){
listener=l;
}
//Item菜单选项功能监听器接口
public interface OptionListener {
void showQQ(int index);
void showWX(int index);
}
}</span>
第三步、在activity中实例化该自定义控件,并定义功能接口。具体代码请参考demo中的ViewActivity2.java
注:关于View对象的知识点很多,也比较复杂!不可能在一篇文章的篇幅解释的非常清晰,但是希望先大致有个整体的认识,后面在实际应用中可以不断的补充和完善这一块的知识!