自定义View知识梳理

前言

自定义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()。

  1. 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.png
2、在View 消费事件
image.png

image.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深度解析 给你带来全新的认识

image.png

五、自定义控件学习例子

了解View的绘制和事件分发基本知识后,再去自定义控件还是有难度的。自定义控件难点在于怎么去把效果拆分,协调父View、子View之间的关系,然后一点一点去实现,而不是看到一个完整的效果懵逼。这个可以通过拆分别人的自定义控件去学习,考虑怎么达到这样的效果,下面推荐两个例子学习。

1、SlideView
Android自定义滑动确认控件SlideView
这是一个日常工作中很可能用到的控件。
自定义ViewGroup 和 View,获取自定义属性TypedArray,绘制流程onMeasure()、onLayout()、onDraw(),触摸事件处理onTouchEvent(),还有接口回调设置监听,整体逻辑不复杂,实用性强,适合入门学习。基本上不是太复杂的自定义控件就是这些内容了。

2、StepView
StepView
步骤指示器,可用于快递收件流程、任务完成流程等。

3、SlideShowView
一个下滑展开,上滑收起的View,具体效果如下图

效果展示.gif

需求分析:
两个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>
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,591评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,448评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,823评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,204评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,228评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,190评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,078评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,923评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,334评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,550评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,727评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,428评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,022评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,672评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,826评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,734评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,619评论 2 354

推荐阅读更多精彩内容