自定義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(),還有接口回調設置監聽,整體邏輯不復雜,實用性強,適合入門學習?;旧喜皇翘珡碗s的自定義控件就是這些內容了。

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>
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容