前言
自定义View的基础是了解绘制的流程及相关方法(onMeasure()、onLayout()、onDraw()),了解事件分发机制及相关方法,还有Canvas、Paint等与绘制有关的类,详细的学习可看大神的文章
AndroidNote。此篇文章做个梳理,以及如何自定义一个展开收起控件。
下面这张图可以直观看出绘制的流程,非原创。
这是一张从其他文章拷贝过来的图.png
一、自定义View分类
1、自定义组合控件。例如继承LinearLayout,初始化时通过LayoutInflater添加xml布局,只需要得到布局的View做相应处理,不需要考虑测量、定位、绘制等方法。
2、继承系统控件,在基础功能上做拓展,比如继承EditText,在它右侧添加删除按钮。
3、继承View、ViewGroup,这种要复杂得多,需要了解View的绘制流程和关键方法,实现onMeasure()、onLayout()、onDraw(),实现触摸事件onTouchEvent()做相应处理,需要思考整个详细的流程。
二、绘制的流程及相关方法
1、onMeasure()
@Overrideprotected
void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//1、获取系统根据mode测量出来的宽高值,它不一定是最终的宽高值,因为重写onMeasure(),
//一般都是想自己设置宽高,如果要拿最终的测量值,要从onSizeChanged()里面取。
int size = MeasureSpec.getSize(widthMeasureSpec);
//2、获取mode,三种返回值解释如下
int mode = MeasureSpec.getMode(widthMeasureSpec);
switch (mode) {
case MeasureSpec.UNSPECIFIED:
//未指定,在这个模式下父控件不会干涉子 View 想要多大的尺寸,比如可在RecyclerView源码看到它的使用。
//自定义View时可以根据需求定制,比如mode是这个时,给宽高设置一个默认值。
break;
case MeasureSpec.AT_MOST:
//对应 wrap_content
break;
case MeasureSpec.EXACTLY:
//对应确切的值和 match_parent
break;
}
//3、最后别忘了调这个方法设置宽高
setMeasuredDimension(width, height);
}
自定义ViewGroup,除了上述方法,还要注意以下几个方法调用。
1)measureChildren(widthMeasureSpec,heightMeasureSpec)
触发每个子View的onMeasure(),这是必须调用的,写在onMeausre()最前面,不然后面无法得到子View宽高。
2)getChildCount()
获取直接子View的数量,也就是说ViewGroup里有两个子View,两个子View又有自己的子View,那么该ViewGroup 调用这个方法会得到 2。
3)getChildAt(int)
获取子View。
2、onLayout()
定位,确定子View在父View中的位置。这个方法在View的源码里是空实现,在ViewGroup源码是抽象方法,所以自定义View不需要这个方法,自定义ViewGroup时一定要重写这个方法。这是因为子View的定位是由父View决定,在父View的 onLayout() 方法里调用子View的 layout() 来定位子View。
大致流程如下:
/** *
* 遍历循环子View,调用子View的layout(int l, int t, int r, int b)定位
*
* @param changed
* @param l MyViewGroup 的 左坐标
* @param t MyViewGroup 的 顶坐标
* @param r MyViewGroup 的 右坐标
* @param b MyViewGroup 的 底坐标
*/
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int count = getChildCount();
int curHeight = 0;
for(int k = 0;k<count;k++){
View child = getChildAt(k);
int height = child.getMeasuredHeight();
int width = child.getMeasuredWidth();
//子View定位方法,它的参数是相对于父View来说的,也就是说如果要定位在父View的左上角
//那么,l 和 t 应该传0。而不是传onLayout() 这个方法得到的l 和 t。
child.layout(0,curHeight,width,curHeight + height);
curHeight += height;
}
}
3、onDraw()
绘制,涉及到Paint,Canvas,Path等知识,此处不详细展开,注意不要在onDraw() 里 new 对象,例如Paint,应该在View初始化时设置。
4、onSizeChanged()
当View的size有变化时会调用,可以用来取最终宽高。
5、总结
自定义view
重写onMeasure()、onDraw()。
1)onMeasure():MeasureSpec.size()获取Size,MeasureSpec.mode()获取模式,最后记得调用setMeasuredDimension(width,size);设置宽高。
2)onSizeChanged():会得到最终的宽高,当view的size有变化时会调用。
3)onDraw():注意不要在此方法创建新对象,例如Paint不要放在里面new出来,Invalidate()和postInvalidate(),都会调用onDraw()重绘。如果需要重新测量定位,调用requestLayout()。
- TypeArray:获取attrs.xml定义的属性。
自定义ViewGroup
除了onMeasure() 和 onDraw(),还要重写onLayout()。
1)onMeasure():
除了上述相关内容,还要注意以下几点,measureChildren(),会触发每个子View的onMeasure(),注意和measureChild()区分;调用getChildCount()获取子View数量;调用getChildAt(i)获取子View。
2)onLayout():
遍历循环子View,调用子View的layout(int l, int t, int r, int b)定位。
三、事件分发机制及相关方法
1、在ViewGroup 事件分发
image.png image.png2、在View 消费事件
image.pngimage.png
image.png
总结
1)事件分发流程dispatchTouchEvent()、onInterceptTouchEvent()、onTouchEvent(),如果做拦截事件,在ViewGroup 的 onInterceptTouchEvent()返回true即可,View 没有onInterceptTouchEvent()。
2)注意onTouchEvent() 和 onTouch() 的关系,自定义View时,常常需要重写 onTouchEvent()。
3)ACTION_DOWN、ACTION_MOVE、ACTION_UP 传递流程的梳理,自定义View的时候常见。
四、其他知识点以及注意事项(待更新)
1、LayoutInflater
三种方法的理解,详情请看 Android LayoutInflate深度解析 给你带来全新的认识
五、自定义控件学习例子
了解View的绘制和事件分发基本知识后,再去自定义控件还是有难度的。自定义控件难点在于怎么去把效果拆分,协调父View、子View之间的关系,然后一点一点去实现,而不是看到一个完整的效果懵逼。这个可以通过拆分别人的自定义控件去学习,考虑怎么达到这样的效果,下面推荐两个例子学习。
1、SlideView
Android自定义滑动确认控件SlideView
这是一个日常工作中很可能用到的控件。
自定义ViewGroup 和 View,获取自定义属性TypedArray,绘制流程onMeasure()、onLayout()、onDraw(),触摸事件处理onTouchEvent(),还有接口回调设置监听,整体逻辑不复杂,实用性强,适合入门学习。基本上不是太复杂的自定义控件就是这些内容了。
2、StepView
StepView
步骤指示器,可用于快递收件流程、任务完成流程等。
3、SlideShowView
一个下滑展开,上滑收起的View,具体效果如下图
需求分析:
两个View,可拖动的View 叫 sView, 上层View 叫 topView。
1、需要定义一个父View 来装 sView 和 topView,且 sView 是在 topView 的底层。
方案:RelativeLayout、FrameLayout、自定义ViewGroup 选一。
2、一开始只显示topView,sView完全不显示。
方案:重写父View onMeasure(),一开始设置高度为 topView 的宽高。
3、下滑上滑。
方案:重写onTouchEvent(),对三种状态做处理。
4、sView 展开和收起。
方案:动态改变sView高度、父View 的高度,重写onLayout()重新定位 sView。
public class SlideShowView extends ViewGroup {
private String TAG = getClass().getSimpleName();
/**
* 可拖动View的宽高
* */
private int msHeight;
private int msWidth;
/**
* 上层View的宽高
* */
private int mTopHeight;
private int mTopWidth;
/**
* 布局最大宽高
* */
private int maxHeight;
private int maxWidth;
/**
* 按下时的点
* */
private int downY = 0;
/**
* 当前高度
* */
private int curHeight;
/**
* 按下时,父View的高度
* */
private int downHeight;
/**
* 抬起时,父View的目标高度
* */
private int targetHeight;
/**
* 滑动距离
* */
private int slide = 0;
/**
* 属性:滑动有效距离
* */
private int mSlideEffectSize;
/**
* 属性:是否能滑动
* */
private boolean mEnableSlideShow;
public SlideShowView(Context context) {
this(context,null);
}
public SlideShowView(Context context, AttributeSet attrs) {
this(context, attrs,0);
}
public SlideShowView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//获取自定义属性
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.SlideShowView, 0, 0);
mSlideEffectSize = a.getDimensionPixelSize(R.styleable.SlideShowView_slide_effect_size,50);
mEnableSlideShow = a.getBoolean(R.styleable.SlideShowView_enable_slide_show,true);
a.recycle();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//第一测量,需要得到子View宽高
if(curHeight == 0){
//对所有的子View进行测量
measureChildren(widthMeasureSpec,heightMeasureSpec);
//得到直接子View的数量
int childCount = getChildCount();
//子View不是2个的,此控件失效
if(childCount != 2){
setMeasuredDimension(0,0);
}else{
//第一个View的宽高
View child1 = getChildAt(0);
msWidth = child1.getMeasuredWidth();
msHeight = child1.getMeasuredHeight();
//第二个子View的宽高
View child2 = getChildAt(1);
mTopWidth = child2.getMeasuredWidth();
mTopHeight = child2.getMeasuredHeight();
//整个viewGroup最大宽高
maxWidth = Math.max(msWidth,mTopWidth);
maxHeight = msHeight + mTopHeight;
//初始设置高度为 上层View 的高度
setMeasuredDimension(maxWidth,mTopHeight);
}
}else{
//经由上下滑动改变高度测量
setMeasuredDimension(maxWidth,curHeight);
}
}
/**
* 测量后确定的值
* @param w
* @param h
* @param oldw
* @param oldh
*/
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
Log.e(TAG,"onSizeChanged:新宽--" + w + ",新高--" + h);
curHeight = h;
}
/**
* 定位,其实是定子View 相对于父View 的位置信息。
* 此处两个子View。
* topView:顶部和 父View 保持一致,不收滑动影响。
* sView: 底部和 父View 保持一致,收滑动影响。
*
* @param changed
* @param l
* @param t
* @param r
* @param b
*/
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
//第一个子View是可拖动的
View child1 = getChildAt(0);
//layout()里的参数,是指子View 在 父View 里的坐标,因为要和顶部保持一致,所以l和t都是0。
child1.layout(0,curHeight - msHeight,msWidth,curHeight);
//第二个子View是不变的
View child2 = getChildAt(1);
child2.layout(0,0,mTopWidth,mTopHeight);
}
/**
* 触摸事件
* @param event
* @return
*/
@Override
public boolean onTouchEvent(MotionEvent event) {
if(!mEnableSlideShow){
return false;
}
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
downY = (int) event.getY();
Log.e(TAG,"downY:" + downY);
//记录按下时,整个父view的高
downHeight = curHeight;
break;
case MotionEvent.ACTION_MOVE:
/**
* slide < 0,往下滑动。 slide>0,往上滑动
* */
slide = downY - (int)event.getY();
if(slide < 0 && curHeight < maxHeight) {
//下滑操作,且当前高度没达到最大高度
curHeight = downHeight + Math.abs(slide);
requestLayout();
}else if(slide > 0 && curHeight > mTopHeight){
//上滑操作,当前高度没有达到最小高度
curHeight = downHeight - Math.abs(slide);
requestLayout();
}
Log.e(TAG,"slide:" + slide);
break;
case MotionEvent.ACTION_UP:
//滑动决策,滑动距离达到某个值,就进行展开 or 收起
if(Math.abs(slide) > mSlideEffectSize){
if(slide<0){
targetHeight = maxHeight;
}else{
targetHeight = mTopHeight;
}
}else{
//恢复原样
targetHeight = downHeight;
}
showAnim();
Log.e(TAG,"最终高度:" + targetHeight);
//requestLayout();
break;
}
return true;
}
/**
* 属性动画,过渡最终展开收起效果
*/
private void showAnim(){
ValueAnimator animator = ValueAnimator.ofInt(curHeight,targetHeight);
animator.setDuration(300);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
curHeight = (int) animation.getAnimatedValue();
requestLayout();
}
});
animator.setInterpolator(new LinearInterpolator());
animator.start();
}
}
<!--自定义属性-->
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="SlideShowView">
<!--滑动多大距离,才判定是展开 or 收起-->
<attr name="slide_effect_size" format="dimension"/>
<!--是否可以滑动显示-->
<attr name="enable_slide_show" format="boolean"/>
</declare-styleable>
</resources>
<!--在布局中使用-->
<?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"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.sz.dzh.dandroidsummary.widget.custom.SlideShowView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="20dp"
android:orientation="vertical"
app:slide_effect_size = "20dp">
<LinearLayout
android:layout_width="200dp"
android:layout_height="200dp"
android:gravity="center"
android:background="@color/color_53">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="详情" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_drag"
android:layout_width="200dp"
android:layout_height="100dp"
android:gravity="center"
android:background="@color/colorPrimary">
<TextView
android:id="@+id/tv_show"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="显示详情"
android:padding="10dp"
android:textSize="@dimen/text_size20"/>
</LinearLayout>
</com.sz.dzh.dandroidsummary.widget.custom.SlideShowView>
</LinearLayout>
网友评论