自定义View
- 1,简介
- 2,创建
- 2.1,构造方法
- 2.2,onFinishInflate()
- 4.1,onMeasure(int, int)
- 4.2,onLayout(boolean, int, int, int, int)
- 4.3,onSizeChanged(int, int, int, int)
- 5.1,onDraw(android.graphics.Canvas)
- 6.1,onKeyDown(int, KeyEvent)
- 6.2,onKeyUp(int, KeyEvent)
- 6.3,onTrackballEvent(MotionEvent)
- 6.4,onTouchEvent(MotionEvent)
- 7.1,onFocusChanged(boolean, int, android.graphics.Rect)
- 7.2,onWindowFocusChanged(boolean)
- 8.1,onAttachedToWindow()
- 8.2,onDetachedFromWindow()
- 8.3,onWindowVisibilityChanged(int)
- 9.1,视图的单线程化
- 9.2,主题
- 9.3,动画
1,简介
View
是所有用户界面组件的基本构建块。每一个视图在屏幕上都占据一个矩形区域并负责绘制和事件处理。一个
window
中的所有
View
都被安排在一个树中。我们可以通过代码添加
View
,或在
XML
布局中指定视图树。作为控件的
View
有许多专门化的子类能够显示文本(
TextView
)、图像(
ImageView
)或其他内容。Android框架负责测量(
measure
)、布局(
layout
)和绘制(
draw
)视图。谷歌为我们封装了一些常用的组件UI或容器。主要分为两大类:
- widgets:
View
是widgets的基类,它们是用于创建交互式UI的组件(按钮、文本框等)。
- layouts:
View
的子类
ViewGroup
是layouts的基类,是保存其他
View
(或其他
ViewGroup
)的容器。
这些组件能满足大部分应用场景,但有时候我们实际的项目需求更为复杂。这时候就需要我们实现自己的自定义
View
或
ViewGroup
(
custom
view)。
要实现自定义视图,通常首先要实现一个
View
的子类,并根据需要选择性的重写框架在所有
View
上调用的一些标准方法。
自定义View需要关注的点很多,几乎每一个都能专门写一篇长文去讲,本文只从宏观上概括这些点,并不从细处做详解。
2,创建
2.1,构造方法
View
的源码SDK中为我们提供了四种不同的构造方法。当从java代码中创建视图时,要调用构造函数的一种形式:
public View(Context context) {}
当从布局文件中
inflated
视图时,框架会调用构造函数的一种形式:
public View(Context context, @Nullable AttributeSet attrs) {}public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {}public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {}
我们的组件包含自定义属性时,使用第二种形式的时候应该解析和应用布局文件中定义的所有属性。
2.2,onFinishInflate()
Inflate(膨胀):是一个视图由
XML
文件到
View
的过程。
onFinishInflate
在视图及其所有子视图都从
XML
中Inflate后调用。是Inflate的最后一个阶段。
@CallSuperprotected void onFinishInflate() {}
3,属性
View或ViewGroup以及他们的子类都有根据自身需要定义的视图属性。例如:
android.R.styleable#View_padding
,
android.R.styleable#View_background
,
android.R.styleable#View_alpha
,… 等等。同样的,当我们实现自己的自定义View的时候,可能也需要实现我们自己的属性。举个例子。
我想实现一个带文字的开关按钮,可根据开关状态自动显示不同的文字,而不需要通过setText来改变。
首先,在
res/values
文件夹下创建一个名为
attrs.xml
的文件。申明如下属性:
<declare-styleable name=\"ToggleView\"><attr name=\"textOn\" format=\"string\" /><attr name=\"checked\" format=\"boolean\" /><attr name=\"textOff\" format=\"string\" /><attr name=\"button\" format=\"reference\" /></declare-styleable>
然后,创建继承自CheckBox的类ToggleView:
public ToggleView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {super(context, attrs, defStyleAttr);TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ToggleView);mTextOn = a.getString(R.styleable.ToggleView_textOn);mTextOff = a.getString(R.styleable.ToggleView_textOff);setButtonDrawable(a.getDrawable(R.styleable.ToggleView_button));setChecked(a.getBoolean(R.styleable.ToggleView_checked, false));a.recycle();}
《Android中自定义控件属性TypedArray》
4,布局
布局(
Layout
)包含两个过程:测量流程 和 布局流程 。
- 测量流程 是在
measure(int, int)
中实现的,沿着视图树自顶向下遍历测量。在递归期间,每个视图都将尺寸规范下推到树中。在测量结束时,每个视图都存储了它的测量值。
- 布局流程 是在
layout(int,int,int,int)
中进行的,也是自上而下的。在此传递期间,每个父节点负责使用尺寸传递中计算的大小定位其所有子节点。
当视图的
measure()
方法返回时,必须设置它的
getMeasuredWidth()
和
getMeasuredHeight()
值,以及该视图所有子视图的值。
View
宽度和高度的测量值必须遵守其父视图施加的约束。这保证了在测量流程的最后,所有的父视图都能接受他们的子视图的所有测量值。父视图可以在其子视图上多次调用
measure()
。例如,父视图可以使用未指定的尺寸对每个子视图进行一次测量,以确定它们想要的大小,然后如果所有子视图未约束大小的总和过大或过小,则再次使用实际数值对它们调用
measure()
。
measure
使用两个类来表达尺寸。
MeasureSpec
类用来告诉它们的父视图它们想要如何被测量和定位。
LayoutParams
描述视图的宽度和高度。对于每个尺寸,它可以指定一个准确的数字或者使用系统预设的两个值:
-
MATCH_PARENT
,表示视图要和它的父视图一样大(减去父视图的padding
)
-
WRAP_CONTENT
,表示视图需要包含其内容的大小(包括自身的padding
)。
对于ViewGroup的不同子类,会有不同的LayoutParams子类。例如,AbsoluteLayout有自己的LayoutParams子类,其中增加了X和Y值。
MeasureSpecs
用于将需求量从父级推到子级。其测量可以包含三种模式:
-
UNSPECIFIED
: 父视图使用它来确定子视图的期望尺寸。例如,LinearLayout
可能会对高度设为
UNSPECIFIED
宽度为
EXACTLY
240的子视图调用
measure()
,以确定宽度为240像素的子视图的高。
-
EXACTLY
: 父视图使用它来给子视图施加精确的尺寸。子视图必须使用这个尺寸,并保证它的所有后代都适应这个尺寸。 -
AT_MOST
: 父视图使用它来给子视图设置最大尺寸。子视图必须保证它和它的所有后代都适合这个尺寸。
在测量的过程中,系统会调用
onMeasure(int, int)
以确定此视图及其所有子视图需要的大小。
4.1,onMeasure(int, int)
measure
的基类实现默认为背景大小,除非
MeasureSpec
允许更大的尺寸。如果我们要提供对其内容准确有效的尺寸,那么我们应该重写
onMeasure(int, int)
方法。
/*** @param widthMeasureSpec 由父视图施加的水平空间需求量。需要被编码为android.view.View.MeasureSpec。* @param heightMeasureSpec 由父视图施加的竖直空间需求量。需要被编码为android.view.View.MeasureSpec。** @see #getMeasuredWidth()* @see #getMeasuredHeight()* @see #setMeasuredDimension(int, int)* @see #getSuggestedMinimumHeight()* @see #getSuggestedMinimumWidth()* @see android.view.View.MeasureSpec#getMode(int)* @see android.view.View.MeasureSpec#getSize(int)*/protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));}
这个方法的作用是测量视图及其内容,以确定测量的宽度和测量的高度,由
measure(int, int)
调用。
重写此方法时,必须调用setMeasuredDimension(int, int)来存储此视图的测量过的宽度和高度。 如果不这样做,
measure(int, int)
将抛出一个
IllegalStateException
。通常是在我们的
onMeasure(int, int)
方法中调用超类的
onMeasure(int, int)
方法。
如果这个方法被重写,那么子类负责确保测量的高度和宽度至少应是视图的最小高度和宽度
getSuggestedMinimumHeight()
和
getSuggestedMinimumWidth()
。
当测量流程完毕后,需要初始化布局为视图排布位置,这时需要调用
requestLayout
。
4.2,onLayout(boolean, int, int, int, int)
当某些改变使视图的布局无效时调用
requestLayout
函数,他会安排视图树的布局。当视图层次结构正处于布局传递中(
isInLayout()
)时,不应该调用此函数。如果正在进行布局,请求可能在当前布局传递结束时(然后布局将再次运行)或在绘制当前帧并发生下一个布局之后才会得到响应。
重写了
requestLayout方法的子类应该调用超类方法来正确处理可能的请求期间布局错误。
调用了
requestLayout
会导致
View
执行
layout
方法,
layout
为视图及其所有后代指定大小和位置。这是布局机制的第二阶段(第一个阶段是测量)。在这个阶段中,每个父节点调用其所有子节点的
layout
来为它们定位。子类不应重写
requestLayout
方法。带有子类的派生类应该重写
onLayout
。在这个方法中对每个子节点调用
layout
。
/*** 当视图应该为每个子视图分配大小和位置时,从layout中调用。* 带有子类的派生类应该重写此方法,并对其每个子类调用layout。** @param changed 这个视图新的尺寸或大小相比之前是否发生了变化* @param left Left position, relative to parent* @param top Top position, relative to parent* @param right Right position, relative to parent* @param bottom Bottom position, relative to parent*/protected void onLayout(boolean changed, int left, int top, int right, int bottom) {}
4.3,onSizeChanged(int, int, int, int)
在布局的时候可能会触发布局大小的改变,当视图的大小发生变化时调用
onSizeChanged
。此时视图的测量和布局流程已经完成。我们可以根据需要重写这个类。
/*** 当视图的大小发生变化时,在布局过程中调用。如果是刚被添加到视图层次结构(第一次调用),old值为0** @param w 此视图的当前宽度。* @param h 此视图的当前高度。* @param oldw 此视图的改变之前的宽度。* @param oldh 此视图的改变之前的高度。*/protected void onSizeChanged(int w, int h, int oldw, int oldh) {}
5,绘制
绘制是通过遍历树并记录任何需要更新的视图的绘制命令来处理的。在此之后,整个树的绘制命令会被发送到屏幕以显示。
视图树主要是按顺序记录和绘制的。在绘制之前先绘制父视图,在绘制之后绘制他们的子视图。子视图与其兄弟视图之间按照他们在树中出现的顺序绘制。如果视图设置了背景,那么视图将在调用其
onDraw()
方法之前先绘制背景。可以使用
ViewGroup#setChildrenDrawingOrderEnabled(boolean)
和
View#setZ(float)
自定义子视图的绘制顺序。要强制视图绘制就调用
invalidate()
。
5.1,onDraw(android.graphics.Canvas)
当视图应该呈现其内容时会调用这个方法。实现它可以用来做我们自己的绘制。
/*** @param canvas the canvas on which the background will be drawn*/protected void onDraw(Canvas canvas) {}
《canvas专题》
6,事件处理
当用户通过方向键(如D-pad)在用户界面中导航时(TV通过遥控器操作),有必要将焦点放在可操作的按钮上,这样用户就可以看到需要输入的内容。但是,如果设备具有触摸功能,并且用户通过触摸开始与界面交互,那么就不再需要总是突出显示(获取焦点)某个特定的视图。这时会发了一种名为“触摸模式”的交互模式。
对于支持触摸的设备,一旦用户触摸屏幕,设备将进入触摸模式。对于
isFocusableInTouchMode
为真的视图(
EditText
)是可定焦的。其他可触摸的视图,比如按钮,在被触摸时不会聚焦,它们只会触发
onClickListener
。
任何时候用户点击方向键,视图设备将退出触摸模式,并找到一个视图进行聚焦,这样用户无需再次触摸屏幕即可恢复与用户界面的交互。触摸模式状态在
Activity
中维护。调用
isInTouchMode
可以查看设备当前是否处于触摸模式。
6.1,onKeyDown(int, KeyEvent)
当发生新的硬件按键事件时调用
KeyEvent.Callback#onKeyDown(int, KeyEvent)
。其默认实现 :
/*** 如果视图是enabled并可单击的,则在`KeyEvent#KEYCODE_DPAD_CENTER`或`KeyEvent#KEYCODE_ENTER`被释放时执行按下视图。** @param keyCode 代表按下按钮的键代码,来自android.view.KeyEvent* @param event 定义按钮操作的KeyEvent对象*/public boolean onKeyDown(int keyCode, KeyEvent event) {>>>return false;}
这个方法主要是针对硬件按键(鼠标、键盘、遥控器)事件的,软件键盘上的按键通常不会触发该侦听器,尽管在某些情况下可能会选择这样做。但是不要依靠这个方法来捕捉软件按键事件。
6.2,onKeyUp(int, KeyEvent)
当硬件按键启动事件发生时调用
KeyEvent.Callback#onKeyUp(int, KeyEvent)
。其默认实现:
/*** 当`KeyEvent#KEYCODE_DPAD_CENTER`, `KeyEvent#KEYCODE_ENTER`或`KeyEvent#KEYCODE_SPACE`被释放时,执行视图的点击。** @param keyCode A key code that represents the button pressed, from* {@link android.view.KeyEvent}.* @param event The KeyEvent object that defines the button action.*/public boolean onKeyUp(int keyCode, KeyEvent event) {>>>return false;}
这个方法主要是针对硬件按键(鼠标、键盘、遥控器)事件的,软件键盘上的按键通常不会触发该侦听器,尽管在某些情况下可能会选择这样做。但是不要依靠这个方法来捕捉软件按键事件。
6.3,onTrackballEvent(MotionEvent)
当轨迹球(也是一种硬件设备)运动事件发生时调用。
public boolean onTrackballEvent(MotionEvent event) {return false;}
实现此方法来处理轨迹球运动事件。自上一个事件以来轨迹球的相对移动可以通过
MotionEvent#getX
和
MotionEvent#getY
来检索。这些被规范化了,因此移动1对应于用户按下一个DPAD键(它们通常是小数值,表示从轨迹球获得的更细粒度的移动信息)。
6.4,onTouchEvent(MotionEvent)
当触摸屏运动事件发生时调用。
/*** 实现此方法来处理触摸屏运动事件。* <p>* 如果使用此方法检测单击操作,建议通过实现和调用performClick()来执行。这将确保一致的系统行为,包括:* <ul>* 1,服从点击声音偏好* 2,OnClickListener的调用* 3,当可访问性特性被启用时处理AccessibilityNodeInfo#ACTION_CLICK ACTION_CLICK** @param event The motion event.* @return 如果处理了事件,则为True,否则为false。*/public boolean onTouchEvent(MotionEvent event) {>>> 代码省略return false;}
关于触摸事件更详细的解释可以看《Android 从源码探索Touch事件的分发机制》
7,焦点
框架会处理常规的焦点移动以响应用户输入。在视图被删除、隐藏,或新视图可用时更改焦点。
isFocusable
方法表明视图是否能够获得焦点,可通过调用
setFocusable(Boolean)
来改变这个状态。当在触摸模式下,视图通过
isFocusableInTouchMode
来表明他们是否仍然可以获得焦点,并可以通过
setFocusableInTouchMode(boolean)
改变。
焦点移动(点击输入法键盘上的下一步)是基于在给定方向上找到最近的邻居的算法。在极少数情况下,默认算法可能与开发人员的预期行为不匹配。在这些情况下,可以通过在布局文件中使用这些
XML
属性来提供显式覆盖:
nextFocusDown、nextFocusLeft、nextFocusRight、nextFocusUp
。
View要获得焦点可调用
requestFocus()
。
7.1,onFocusChanged(boolean, int, android.graphics.Rect)
当视图的焦点状态发生变化时,由视图系统调用
onFocusChanged
。当
onFocusChanged
事件是由方向性导航引起时,从方向性和
previouslyFocusedRect
可知道焦点从哪里来的(焦点所处的上一个区域)。当重写时,一定要调用超类,以便标准的焦点处理能够被执行。
/**** @param gainFocus 如果视图有焦点为true;否则false。* @param direction 当调用requestFocus()来给视图提供焦点时,焦点的方向已经移动。* 值是FOCUS_UP,FOCUS_DOWN,FOCUS_LEFT,#FOCUS_RIGHT,FOCUS_FORWARD,FOCUS_BACKWARD。*/@CallSuperprotected void onFocusChanged(boolean gainFocus, @FocusDirection int direction,@Nullable Rect previouslyFocusedRect) {>>> 代码省略}
previouslyFocusedRect:
在这个视图的坐标系统中,先前聚焦的视图的矩形区域。如果可以,这个值将作为焦点从哪里来(除了方向)的更细粒度的信息传递进来。否则为
null
。
7.2,onWindowFocusChanged(boolean)
当包含视图的窗口获得或失去焦点时调用。
/*** Called when the window containing this view gains or loses focus. Note* that this is separate from view focus: to receive key events, both* your view and its window must have focus. If a window is displayed* on top of yours that takes input focus, then your own window will lose* focus but the view focus will remain unchanged.** @param hasWindowFocus True if the window containing this view now has* focus, false otherwise.*/public void onWindowFocusChanged(boolean hasWindowFocus) {>>> 代码省略}
8,附加窗口
8.1,onAttachedToWindow()
当视图附加到窗口时调用
onAttachedToWindow
。这个函数肯定在
onDraw(android.graphics.Canvas)
之前被调用,但是它可以在第一次调用
onDraw
之前的任何时候被调用,包括在
onMeasure(int, int)
之前或之后。重写此方法必须调用超类方法。
@CallSuperprotected void onAttachedToWindow() {>>>}
8.2,onDetachedFromWindow()
当视图从其窗口分离时调用
onDetachedFromWindow
。
@CallSuperprotected void onDetachedFromWindow() {}
8.3,onWindowVisibilityChanged(int)
当包含视图的窗口的可见性发生更改时调用。更改包括
GONE
,
INVISIBLE
和
VISIBLE
之间之间的变化。
visibility
只会告诉我们窗口是否对窗口管理器可见,但并不能告诉我们窗口是否被屏幕上的其他窗口遮挡,即使它本身是可见的。
/*** @param visibility 窗口的新可见性。*/protected void onWindowVisibilityChanged(@Visibility int visibility) {if (visibility == VISIBLE) {initialAwakenScrollBars();}}
9,其他
9.1,视图的单线程化
视图的基本循环如下:
- 如果在处理事件的过程中,视图的边界可能需要更改,视图将调用
requestLayout()
。
- 类似地,如果在处理事件的过程中视图的外观可能需要更改,视图将调用
invalidate()
。
- 如果调用了
requestLayout()
或
invalidate()
,框架将负责测量、布局和绘制合适的树。
整个视图树是单线程的。在调用任何视图上的任何方法时,必须始终处于UI线程上。 如果在其他线程上工作,并且想要从那个线程更新视图的状态,应该使用
Handler
。关于Handler:
《Android 从源码了解Handler(Looper 、Message、MessageQueue)的实现原理》
9.2,主题
默认情况下,视图使用提供给其构造函数的上下文对象的主题创建。但是,可以使用
android.R.styleable#View_theme
指定不同的主题。在布局XML中使用
android:theme
属性,或者通过从代码中传递
ContextThemeWrapper
到构造方法。当
android.R.styleable#View_theme
属性在XML中使用时,指定的主题会覆盖
Context
的主题,并用于视图本身以及任何子元素。
《Android Theme 常见主题风格详解》
9.3,动画
从
Android 3.0
开始,动画视图的首选方法是使用
android.animation
包的api。这些以
android.animation.Animator
为基类的类改变视图对象的实际属性,例如
setAlpha(float)
和
setTranslationX(float)
。这个行为与3.0之前的
android.view.animation
形成了对比。基于
Animation
的类只对视图在显示器上的绘制方式进行动画处理。尤其是
ViewPropertyAnimator
类,它使视图属性动画实现变得特别容易和有效。
当然,我们还是可以使用3.0之前的动画类来实现动画视图的渲染。可以使用
setAnimation(Animation)
或
startAnimation(Animation)
将
Animation
对象附加到视图中。动画可以随时间改变视图的比例、旋转、平移和透明度。如果动画附加到具有子节点的视图,则动画将影响以该节点为根的整个子树。当动画开始时,框架将负责重新绘制适当的视图,直到动画完成。
《Android 动画总结》