Android自定義View全解

目錄

目錄.png

1. 自定義View基礎

1.1 分類

自定義View的實現方式有以下幾種

類型 定義
自定義組合控件 多個控件組合成爲一個新的控件,方便多處複用
繼承系統View控件 繼承自TextView等系統控件,在系統控件的基礎功能上進行擴展
繼承View 不復用系統控件邏輯,繼承View進行功能定義
繼承系統ViewGroup 繼承自LinearLayout等系統控件,在系統控件的基礎功能上進行擴展
繼承ViewViewGroup 不復用系統控件邏輯,繼承ViewGroup進行功能定義

1.2 View繪製流程

View的繪製基本由measure()、layout()、draw()這個三個函數完成

函數 作用 相關方法
measure() 測量View的寬高 measure(),setMeasuredDimension(),onMeasure()
layout() 計算當前View以及子View的位置 layout(),onLayout(),setFrame()
draw() 視圖的繪製工作 draw(),onDraw()

1.3 座標系

在Android座標系中,以屏幕左上角作爲原點,這個原點向右是X軸的正軸,向下是Y軸正軸。如下所示:

 

Android座標系.png

 

除了Android座標系,還存在View座標系,View座標系內部關係如圖所示。

 

視圖座標系.png

View獲取自身高度

由上圖可算出View的高度:

  • width = getRight() - getLeft();
  • height = getBottom() - getTop();

View的源碼當中提供了getWidth()和getHeight()方法用來獲取View的寬度和高度,其內部方法和上文所示是相同的,我們可以直接調用來獲取View得寬高。

View自身的座標

通過如下方法可以獲取View到其父控件的距離。

  • getTop();獲取View到其父佈局頂邊的距離。
  • getLeft();獲取View到其父佈局左邊的距離。
  • getBottom();獲取View到其父佈局頂邊的距離。
  • getRight();獲取View到其父佈局左邊的距離。

1.4 構造函數

無論是我們繼承系統View還是直接繼承View,都需要對構造函數進行重寫,構造函數有多個,至少要重寫其中一個才行。如我們新建TestView

 

public class TestView extends View {
    /**
     * 在java代碼裏new的時候會用到
     * @param context
     */
    public TestView(Context context) {
        super(context);
    }

    /**
     * 在xml佈局文件中使用時自動調用
     * @param context
     */
    public TestView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    /**
     * 不會自動調用,如果有默認style時,在第二個構造函數中調用
     * @param context
     * @param attrs
     * @param defStyleAttr
     */
    public TestView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }


    /**
     * 只有在API版本>21時纔會用到
     * 不會自動調用,如果有默認style時,在第二個構造函數中調用
     * @param context
     * @param attrs
     * @param defStyleAttr
     * @param defStyleRes
     */
    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public TestView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    }
}

1.5 自定義屬性

Android系統的控件以android開頭的都是系統自帶的屬性。爲了方便配置自定義View的屬性,我們也可以自定義屬性值。
Android自定義屬性可分爲以下幾步:

  1. 自定義一個View
  2. 編寫values/attrs.xml,在其中編寫styleable和item等標籤元素
  3. 在佈局文件中View使用自定義的屬性(注意namespace)
  4. 在View的構造方法中通過TypedArray獲取

實例說明

  • 自定義屬性的聲明文件

 

    <?xml version="1.0" encoding="utf-8"?>
    <resources>
        <declare-styleable name="test">
            <attr name="text" format="string" />
            <attr name="testAttr" format="integer" />
        </declare-styleable>
    </resources>
  • 自定義View類

 

public class MyTextView extends View {
    private static final String TAG = MyTextView.class.getSimpleName();

    //在View的構造方法中通過TypedArray獲取
    public MyTextView(Context context, AttributeSet attrs) {
        super(context, attrs);
        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.test);
        String text = ta.getString(R.styleable.test_testAttr);
        int textAttr = ta.getInteger(R.styleable.test_text, -1);
        Log.e(TAG, "text = " + text + " , textAttr = " + textAttr);
        ta.recycle();
    }
}
  • 佈局文件中使用

 

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res/com.example.test"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >

    <com.example.test.MyTextView
        android:layout_width="100dp"
        android:layout_height="200dp"
        app:testAttr="520"
        app:text="helloworld" />

</RelativeLayout>

屬性值的類型format

(1). reference:參考某一資源ID

  • 屬性定義:

 

<declare-styleable name = "名稱">
     <attr name = "background" format = "reference" />
</declare-styleable>
  • 屬性使用:

 

<ImageView android:background = "@drawable/圖片ID"/>

(2). color:顏色值

  • 屬性定義:

 

<attr name = "textColor" format = "color" />
  • 屬性使用:

 

<TextView android:textColor = "#00FF00" />

(3). boolean:布爾值

  • 屬性定義:

 

<attr name = "focusable" format = "boolean" />
  • 屬性使用:

 

<Button android:focusable = "true"/>

(4). dimension:尺寸值

  • 屬性定義:

 

<attr name = "layout_width" format = "dimension" />
  • 屬性使用:

 

<Button android:layout_width = "42dip"/>

(5). float:浮點值

  • 屬性定義:

 

<attr name = "fromAlpha" format = "float" />
  • 屬性使用:

 

<alpha android:fromAlpha = "1.0"/>

(6). integer:整型值

  • 屬性定義:

 

<attr name = "framesCount" format="integer" />
  • 屬性使用:

 

<animated-rotate android:framesCount = "12"/>

(7). string:字符串

  • 屬性定義:

 

<attr name = "text" format = "string" />
  • 屬性使用:

 

<TextView android:text = "我是文本"/>

(8). fraction:百分數

  • 屬性定義:

 

<attr name = "pivotX" format = "fraction" />
  • 屬性使用:

 

<rotate android:pivotX = "200%"/>

(9). enum:枚舉值

  • 屬性定義:

 

<declare-styleable name="名稱">
    <attr name="orientation">
        <enum name="horizontal" value="0" />
        <enum name="vertical" value="1" />
    </attr>
</declare-styleable>
  • 屬性使用:

 

<LinearLayout  
    android:orientation = "vertical">
</LinearLayout>

注意:枚舉類型的屬性在使用的過程中只能同時使用其中一個,不能 android:orientation = “horizontal|vertical"

(10). flag:位或運算

  • 屬性定義:

 

<declare-styleable name="名稱">
    <attr name="gravity">
            <flag name="top" value="0x01" />
            <flag name="bottom" value="0x02" />
            <flag name="left" value="0x04" />
            <flag name="right" value="0x08" />
            <flag name="center_vertical" value="0x16" />
            ...
    </attr>
</declare-styleable>
  • 屬性使用:

 

<TextView android:gravity="bottom|left"/>

注意:位運算類型的屬性在使用的過程中可以使用多個值

(11). 混合類型:屬性定義時可以指定多種類型值

  • 屬性定義:

 

<declare-styleable name = "名稱">
     <attr name = "background" format = "reference|color" />
</declare-styleable>
  • 屬性使用:

 

<ImageView
android:background = "@drawable/圖片ID" />
或者:
<ImageView
android:background = "#00FF00" />

2. View繪製流程

這一章節偏向於解釋View繪製的源碼實現,可以更好地幫助我們掌握整個繪製過程。

View的繪製基本由measure()、layout()、draw()這個三個函數完成

函數 作用 相關方法
measure() 測量View的寬高 measure(),setMeasuredDimension(),onMeasure()
layout() 計算當前View以及子View的位置 layout(),onLayout(),setFrame()
draw() 視圖的繪製工作 draw(),onDraw()

2.1 Measure()

MeasureSpec

MeasureSpec是View的內部類,它封裝了一個View的尺寸,在onMeasure()當中會根據這個MeasureSpec的值來確定View的寬高。

MeasureSpec的值保存在一個int值當中。一個int值有32位,前兩位表示模式mode後30位表示大小size。即MeasureSpec = mode + size

MeasureSpec當中一共存在三種modeUNSPECIFIEDEXACTLY
AT_MOST

對於View來說,MeasureSpec的mode和Size有如下意義

模式 意義 對應
EXACTLY 精準模式,View需要一個精確值,這個值即爲MeasureSpec當中的Size match_parent
AT_MOST 最大模式,View的尺寸有一個最大值,View不可以超過MeasureSpec當中的Size值 wrap_content
UNSPECIFIED 無限制,View對尺寸沒有任何限制,View設置爲多大就應當爲多大 一般系統內部使用

使用方式

 

    // 獲取測量模式(Mode)
    int specMode = MeasureSpec.getMode(measureSpec)

    // 獲取測量大小(Size)
    int specSize = MeasureSpec.getSize(measureSpec)

    // 通過Mode 和 Size 生成新的SpecMode
    int measureSpec=MeasureSpec.makeMeasureSpec(size, mode);

在View當中,MeasureSpace的測量代碼如下:

 

public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
        int specMode = MeasureSpec.getMode(spec);
        int specSize = MeasureSpec.getSize(spec);

        int size = Math.max(0, specSize - padding);

        int resultSize = 0;
        int resultMode = 0;

        switch (specMode) {
        //當父View要求一個精確值時,爲子View賦值
        case MeasureSpec.EXACTLY:
            //如果子view有自己的尺寸,則使用自己的尺寸
            if (childDimension >= 0) {
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
                //當子View是match_parent,將父View的大小賦值給子View
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                resultSize = size;
                resultMode = MeasureSpec.EXACTLY;
                //如果子View是wrap_content,設置子View的最大尺寸爲父View
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            }
            break;

        // 父佈局給子View了一個最大界限
        case MeasureSpec.AT_MOST:
            if (childDimension >= 0) {
                //如果子view有自己的尺寸,則使用自己的尺寸
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                // 父View的尺寸爲子View的最大尺寸
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                //父View的尺寸爲子View的最大尺寸
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            }
            break;

        // 父佈局對子View沒有做任何限制
        case MeasureSpec.UNSPECIFIED:
            if (childDimension >= 0) {
            //如果子view有自己的尺寸,則使用自己的尺寸
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                //因父佈局沒有對子View做出限制,當子View爲MATCH_PARENT時則大小爲0
                resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
                resultMode = MeasureSpec.UNSPECIFIED;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                //因父佈局沒有對子View做出限制,當子View爲WRAP_CONTENT時則大小爲0
                resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
                resultMode = MeasureSpec.UNSPECIFIED;
            }
            break;
        }
    
        return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
    }

這裏需要注意,這段代碼只是在爲子View設置MeasureSpec參數而不是實際的設置子View的大小。子View的最終大小需要在View中具體設置。

從源碼可以看出來,子View的測量模式是由自身LayoutParam和父View的MeasureSpec來決定的。

在測量子View大小時:

父View mode 子View
UNSPECIFIED 父佈局沒有做出限制,子View有自己的尺寸,則使用,如果沒有則爲0
EXACTLY 父佈局採用精準模式,有確切的大小,如果有大小則直接使用,如果子View沒有大小,子View不得超出父view的大小範圍
AT_MOST 父佈局採用最大模式,存在確切的大小,如果有大小則直接使用,如果子View沒有大小,子View不得超出父view的大小範圍

onMeasure()

整個測量過程的入口位於Viewmeasure方法當中,該方法做了一些參數的初始化之後調用了onMeasure方法,這裏我們主要分析onMeasure

onMeasure方法的源碼如下:

 

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

        setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
                getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
    }

很簡單這裏只有一行代碼,涉及到了三個方法我們挨個分析。

  • setMeasuredDimension(int measuredWidth, int measuredHeight) :該方法用來設置View的寬高,在我們自定義View時也會經常用到。
  • getDefaultSize(int size, int measureSpec):該方法用來獲取View默認的寬高,結合源碼來看。

 

/**
*   有兩個參數size和measureSpec
*   1、size表示View的默認大小,它的值是通過`getSuggestedMinimumWidth()方法來獲取的,之後我們再分析。
*   2、measureSpec則是我們之前分析的MeasureSpec,裏面存儲了View的測量值以及測量模式
*/
public static int getDefaultSize(int size, int measureSpec) {
        int result = size;
        int specMode = MeasureSpec.getMode(measureSpec);
        int specSize = MeasureSpec.getSize(measureSpec);

        //從這裏我們看出,對於AT_MOST和EXACTLY在View當中的處理是完全相同的。所以在我們自定義View時要對這兩種模式做出處理。
        switch (specMode) {
        case MeasureSpec.UNSPECIFIED:
            result = size;
            break;
        case MeasureSpec.AT_MOST:
        case MeasureSpec.EXACTLY:
            result = specSize;
            break;
        }
        return result;
    }
  • getSuggestedMinimumWidth():getHeight和該方法原理是一樣的,這裏只分析這一個。

 

//當View沒有設置背景時,默認大小就是mMinWidth,這個值對應Android:minWidth屬性,如果沒有設置時默認爲0.
//如果有設置背景,則默認大小爲mMinWidth和mBackground.getMinimumWidth()當中的較大值。
protected int getSuggestedMinimumWidth() {
        return (mBackground == null) ? mMinWidth : max(mMinWidth, mBackground.getMinimumWidth());
    }

ViewGroup的測量過程與View有一點點區別,其本身是繼承自View,它沒有對Viewmeasure方法以及onMeasure方法進行重寫。

爲什麼沒有重寫onMeasure呢?ViewGroup除了要測量自身寬高外還需要測量各個子View的大小,而不同的佈局測量方式也都不同(可參考LinearLayout以及FrameLayout),所以沒有辦法統一設置。因此它提供了測量子View的方法measureChildren()以及measureChild()幫助我們對子View進行測量。

measureChildren()以及measureChild()的源碼這裏不再分析,大致流程就是遍歷所有的子View,然後調用Viewmeasure()方法,讓子View測量自身大小。具體測量流程上面也以及介紹過了


measure過程會因爲佈局的不同或者需求的不同而呈現不同的形式,使用時還是要根據業務場景來具體分析,如果想再深入研究可以看一下LinearLayoutonMeasure方法。

2.2 Layout()

要計算位置首先要對Android座標系有所瞭解,前面的內容我們也有介紹過。

layout()過程,對於View來說用來計算View的位置參數,對於ViewGroup來說,除了要測量自身位置,還需要測量子View的位置。

layout()方法是整個Layout()流程的入口,看一下這部分源碼

 

/**
*  這裏的四個參數l、t、r、b分別代表View的左、上、右、下四個邊界相對於其父View的距離。
*
*/
public void layout(int l, int t, int r, int b) {
        if ((mPrivateFlags3 & PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT) != 0) {
            onMeasure(mOldWidthMeasureSpec, mOldHeightMeasureSpec);
            mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
        }

        int oldL = mLeft;
        int oldT = mTop;
        int oldB = mBottom;
        int oldR = mRight;

        //這裏通過setFrame或setOpticalFrame方法確定View在父容器當中的位置。
        boolean changed = isLayoutModeOptical(mParent) ?
                setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);

        //調用onLayout方法。onLayout方法是一個空實現,不同的佈局會有不同的實現。
        if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
            onLayout(changed, l, t, r, b);

        }

    }

從源碼我們知道,在layout()方法中已經通過setOpticalFrame(l, t, r, b)setFrame(l, t, r, b)方法對View自身的位置進行了設置,所以onLayout(changed, l, t, r, b)方法主要是ViewGroup對子View的位置進行計算。

有興趣的可以看一下LinearLayoutonLayout源碼,可以幫助加深理解。

2.3 Draw()

draw流程也就是的View繪製到屏幕上的過程,整個流程的入口在Viewdraw()方法之中,而源碼註釋也寫的很明白,整個過程可以分爲6個步驟。

  1. 如果需要,繪製背景。
  2. 有過有必要,保存當前canvas。
  3. 繪製View的內容。
  4. 繪製子View。
  5. 如果有必要,繪製邊緣、陰影等效果。
  6. 繪製裝飾,如滾動條等等。

通過各個步驟的源碼再做分析:

 

    public void draw(Canvas canvas) {

       
        int saveCount;
        // 1. 如果需要,繪製背景
        if (!dirtyOpaque) {
            drawBackground(canvas);
        }

        // 2. 有過有必要,保存當前canvas。
        final int viewFlags = mViewFlags;
      
        if (!verticalEdges && !horizontalEdges) {
            // 3. 繪製View的內容。
            if (!dirtyOpaque) onDraw(canvas);

            // 4. 繪製子View。
            dispatchDraw(canvas);

            drawAutofilledHighlight(canvas);

            // Overlay is part of the content and draws beneath Foreground
            if (mOverlay != null && !mOverlay.isEmpty()) {
                mOverlay.getOverlayView().dispatchDraw(canvas);
            }

            // 6. 繪製裝飾,如滾動條等等。
            onDrawForeground(canvas);

            // we're done...
            return;
        }
    }
    
    /**
    *  1.繪製View背景
    */
    private void drawBackground(Canvas canvas) {
        //獲取背景
        final Drawable background = mBackground;
        if (background == null) {
            return;
        }

        setBackgroundBounds();

        //獲取便宜值scrollX和scrollY,如果scrollX和scrollY都不等於0,則會在平移後的canvas上面繪製背景。
        final int scrollX = mScrollX;
        final int scrollY = mScrollY;
        if ((scrollX | scrollY) == 0) {
            background.draw(canvas);
        } else {
            canvas.translate(scrollX, scrollY);
            background.draw(canvas);
            canvas.translate(-scrollX, -scrollY);
        }
    }
    
    /**
    * 3.繪製View的內容,該方法是一個空的實現,在各個業務當中自行處理。
    */
    protected void onDraw(Canvas canvas) {
    }
    
    /**
    * 4. 繪製子View。該方法在View當中是一個空的實現,在各個業務當中自行處理。
    *  在ViewGroup當中對dispatchDraw方法做了實現,主要是遍歷子View,並調用子類的draw方法,一般我們不需要自己重寫該方法。
    */
    protected void dispatchDraw(Canvas canvas) {

    }
        

3. 自定義組合控件

自定義組合控件就是將多個控件組合成爲一個新的控件,主要解決多次重複使用同一類型的佈局。如我們頂部的HeaderView以及dailog等,我們都可以把他們組合成一個新的控件。

我們通過一個自定義HeaderView實例來了解自定義組合控件的用法。

1. 編寫佈局文件

 

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:id="@+id/header_root_layout"
    android:layout_height="45dp"
    android:background="#827192">

    <ImageView
        android:id="@+id/header_left_img"
        android:layout_width="45dp"
        android:layout_height="45dp"
        android:layout_alignParentLeft="true"
        android:paddingLeft="12dp"
        android:paddingRight="12dp"
        android:src="@drawable/back"
        android:scaleType="fitCenter"/>

    <TextView
        android:id="@+id/header_center_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:lines="1"
        android:maxLines="11"
        android:ellipsize="end"
        android:text="title"
        android:textStyle="bold"
        android:textColor="#ffffff"/>
    
    <ImageView
        android:id="@+id/header_right_img"
        android:layout_width="45dp"
        android:layout_height="45dp"
        android:layout_alignParentRight="true"
        android:src="@drawable/add"
        android:scaleType="fitCenter"
        android:paddingRight="12dp"
        android:paddingLeft="12dp"/>

</RelativeLayout>

佈局很簡單,中間是title的文字,左邊是返回按鈕,右邊是一個添加按鈕。

2. 實現構造方法

 

//因爲我們的佈局採用RelativeLayout,所以這裏繼承RelativeLayout。
//關於各個構造方法的介紹可以參考前面的內容
public class YFHeaderView extends RelativeLayout {

    public YFHeaderView(Context context) {
        super(context);
    }

    public YFHeaderView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public YFHeaderView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

}

3. 初始化UI

 

    //初始化UI,可根據業務需求設置默認值。
    private void initView(Context context) {
        LayoutInflater.from(context).inflate(R.layout.view_header, this, true);
        img_left = (ImageView) findViewById(R.id.header_left_img);
        img_right = (ImageView) findViewById(R.id.header_right_img);
        text_center = (TextView) findViewById(R.id.header_center_text);
        layout_root = (RelativeLayout) findViewById(R.id.header_root_layout);
        layout_root.setBackgroundColor(Color.BLACK);
        text_center.setTextColor(Color.WHITE);

    }
    

4. 提供對外的方法

可以根據業務需求對外暴露一些方法。

 

    //設置標題文字的方法
    private void setTitle(String title) {
        if (!TextUtils.isEmpty(title)) {
            text_center.setText(title);
        }
    }
    //對左邊按鈕設置事件的方法
    private void setLeftListener(OnClickListener onClickListener) {
        img_left.setOnClickListener(onClickListener);
    }

    //對右邊按鈕設置事件的方法
    private void setRightListener(OnClickListener onClickListener) {
        img_right.setOnClickListener(onClickListener);
    }

5. 在佈局當中引用該控件

 

<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.example.yf.view.YFHeaderView
        android:layout_width="match_parent"
        android:layout_height="45dp">

    </com.example.yf.view.YFHeaderView>

</LinearLayout>

到這裏基本的功能已經有了。除了這些基礎功能外,我們還可以做一些功能擴展,比如可以在佈局時設置我的View顯示的元素,因爲可能有些需求並不需要右邊的按鈕。這時候就需要用到自定義屬性來解決了。

前面已經簡單介紹過自定義屬性的相關知識,我們之間看代碼

1.首先在values目錄下創建attrs.xml

內容如下:

 

<resources>

    <declare-styleable name="HeaderBar">
        <attr name="title_text_clolor" format="color"></attr>
        <attr name="title_text" format="string"></attr>
        <attr name="show_views">
            <flag name="left_text" value="0x01" />
            <flag name="left_img" value="0x02" />
            <flag name="right_text" value="0x04" />
            <flag name="right_img" value="0x08" />
            <flag name="center_text" value="0x10" />
            <flag name="center_img" value="0x20" />
        </attr>
    </declare-styleable>
</resources>

這裏我們定義了三個屬性,文字內容、顏色以及要顯示的元素。

2.在java代碼中進行設置

 

    private void initAttrs(Context context, AttributeSet attrs) {
        TypedArray mTypedArray = context.obtainStyledAttributes(attrs, R.styleable.HeaderBar);
        //獲取title_text屬性
        String title = mTypedArray.getString(R.styleable.HeaderBar_title_text);
        if (!TextUtils.isEmpty(title)) {
            text_center.setText(title);
        }
        //獲取show_views屬性,如果沒有設置時默認爲0x26
        showView = mTypedArray.getInt(R.styleable.HeaderBar_show_views, 0x26);
        text_center.setTextColor(mTypedArray.getColor(R.styleable.HeaderBar_title_text_clolor, Color.WHITE));
        mTypedArray.recycle();
        showView(showView);

    }
    
    private void showView(int showView) {
        //將showView轉換爲二進制數,根據不同位置上的值設置對應View的顯示或者隱藏。
        Long data = Long.valueOf(Integer.toBinaryString(showView));
        element = String.format("%06d", data);
        for (int i = 0; i < element.length(); i++) {
            if(i == 0) ;
            if(i == 1) text_center.setVisibility(element.substring(i,i+1).equals("1")? View.VISIBLE:View.GONE);
            if(i == 2) img_right.setVisibility(element.substring(i,i+1).equals("1")? View.VISIBLE:View.GONE);
            if(i == 3) ;
            if(i == 4) img_left.setVisibility(element.substring(i,i+1).equals("1")? View.VISIBLE:View.GONE);
            if(i == 5) ;
        }

    }

3.在佈局文件中進行設置

 

    <com.example.yf.view.YFHeaderView
        android:layout_width="match_parent"
        android:layout_height="45dp"
        app:title_text="標題"
        app:show_views="center_text|left_img|right_img">

    </com.example.yf.view.YFHeaderView>

OK,到這裏整個View基本定義完成。整個YFHeaderView的代碼如下

 

public class YFHeaderView extends RelativeLayout {

    private ImageView img_left;
    private TextView text_center;
    private ImageView img_right;
    private RelativeLayout layout_root;
    private Context context;
    String element;

    private int showView;

    public YFHeaderView(Context context) {
        super(context);
        this.context = context;
        initView(context);
    }

    public YFHeaderView(Context context, AttributeSet attrs) {
        super(context, attrs);
        this.context = context;
        initView(context);
        initAttrs(context, attrs);
    }

    public YFHeaderView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        this.context = context;
        initView(context);
        initAttrs(context, attrs);
    }

    private void initAttrs(Context context, AttributeSet attrs) {
        TypedArray mTypedArray = context.obtainStyledAttributes(attrs, R.styleable.HeaderBar);
        String title = mTypedArray.getString(R.styleable.HeaderBar_title_text);
        if (!TextUtils.isEmpty(title)) {
            text_center.setText(title);
        }
        showView = mTypedArray.getInt(R.styleable.HeaderBar_show_views, 0x26);
        text_center.setTextColor(mTypedArray.getColor(R.styleable.HeaderBar_title_text_clolor, Color.WHITE));
        mTypedArray.recycle();
        showView(showView);

    }

    private void showView(int showView) {
        Long data = Long.valueOf(Integer.toBinaryString(showView));
        element = String.format("%06d", data);
        for (int i = 0; i < element.length(); i++) {
            if(i == 0) ;
            if(i == 1) text_center.setVisibility(element.substring(i,i+1).equals("1")? View.VISIBLE:View.GONE);
            if(i == 2) img_right.setVisibility(element.substring(i,i+1).equals("1")? View.VISIBLE:View.GONE);
            if(i == 3) ;
            if(i == 4) img_left.setVisibility(element.substring(i,i+1).equals("1")? View.VISIBLE:View.GONE);
            if(i == 5) ;
        }

    }

    private void initView(final Context context) {
        LayoutInflater.from(context).inflate(R.layout.view_header, this, true);
        img_left = (ImageView) findViewById(R.id.header_left_img);
        img_right = (ImageView) findViewById(R.id.header_right_img);
        text_center = (TextView) findViewById(R.id.header_center_text);
        layout_root = (RelativeLayout) findViewById(R.id.header_root_layout);
        layout_root.setBackgroundColor(Color.BLACK);
        text_center.setTextColor(Color.WHITE);

        img_left.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View view) {
                Toast.makeText(context, element + "", Toast.LENGTH_SHORT).show();
            }
        });
    }

    private void setTitle(String title) {
        if (!TextUtils.isEmpty(title)) {
            text_center.setText(title);
        }
    }


    private void setLeftListener(OnClickListener onClickListener) {
        img_left.setOnClickListener(onClickListener);
    }

    private void setRightListener(OnClickListener onClickListener) {
        img_right.setOnClickListener(onClickListener);
    }

}

4. 繼承系統控件

繼承系統的控件可以分爲繼承View子類(如TextVIew等)和繼承ViewGroup子類(如LinearLayout等),根據業務需求的不同,實現的方式也會有比較大的差異。這裏介紹一個比較簡單的,繼承自View的實現方式。

業務需求:爲文字設置背景,並在佈局中間添加一條橫線。

因爲這種實現方式會複用系統的邏輯,大多數情況下我們希望複用系統的onMeaseuronLayout流程,所以我們只需要重寫onDraw方法 。實現非常簡單,話不多說,直接上代碼。

 

public class LineTextView extends TextView {

    //定義畫筆,用來繪製中心曲線
    private Paint mPaint;
    
    /**
     * 創建構造方法
     * @param context
     */
    public LineTextView(Context context) {
        super(context);
        init();
    }

    public LineTextView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public LineTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        mPaint = new Paint();
        mPaint.setColor(Color.BLACK);
    }

    //重寫draw方法,繪製我們需要的中間線以及背景
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int width = getWidth();
        int height = getHeight();
        mPaint.setColor(Color.BLUE);
        //繪製方形背景
        RectF rectF = new RectF(0,0,width,height);
        canvas.drawRect(rectF,mPaint);
        mPaint.setColor(Color.BLACK);
        //繪製中心曲線,起點座標(0,height/2),終點座標(width,height/2)
        canvas.drawLine(0,height/2,width,height/2,mPaint);
    }
}

對於View的繪製還需要對Paint()canvas以及Path的使用有所瞭解,不清楚的可以稍微瞭解一下。

這裏的實現比較簡單,因爲具體實現會與業務環境密切相關,這裏只是做一個參考。

5. 直接繼承View

直接繼承View會比上一種實現方複雜一些,這種方法的使用情景下,完全不需要複用系統控件的邏輯,除了要重寫onDraw外還需要對onMeasure方法進行重寫。

我們用自定義View來繪製一個正方形。

  • 首先定義構造方法,以及做一些初始化操作

 

ublic class RectView extends View{
    //定義畫筆
    private Paint mPaint = new Paint();

    /**
     * 實現構造方法
     * @param context
     */
    public RectView(Context context) {
        super(context);
        init();
    }

    public RectView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public RectView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        mPaint.setColor(Color.BLUE);

    }

}
  • 重寫draw方法,繪製正方形,注意對padding屬性進行設置

 

/**
     * 重寫draw方法
     * @param canvas
     */
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //獲取各個編劇的padding值
        int paddingLeft = getPaddingLeft();
        int paddingRight = getPaddingRight();
        int paddingTop = getPaddingTop();
        int paddingBottom = getPaddingBottom();
        //獲取繪製的View的寬度
        int width = getWidth()-paddingLeft-paddingRight;
        //獲取繪製的View的高度
        int height = getHeight()-paddingTop-paddingBottom;
        //繪製View,左上角座標(0+paddingLeft,0+paddingTop),右下角座標(width+paddingLeft,height+paddingTop)
        canvas.drawRect(0+paddingLeft,0+paddingTop,width+paddingLeft,height+paddingTop,mPaint);
    }

之前我們講到過View的measure過程,再看一下源碼對這一步的處理

 

    public static int getDefaultSize(int size, int measureSpec) {
        int result = size;
        int specMode = MeasureSpec.getMode(measureSpec);
        int specSize = MeasureSpec.getSize(measureSpec);

        switch (specMode) {
        case MeasureSpec.UNSPECIFIED:
            result = size;
            break;
        case MeasureSpec.AT_MOST:
        case MeasureSpec.EXACTLY:
            result = specSize;
            break;
        }
        return result;
    }

在View的源碼當中並沒有對AT_MOSTEXACTLY兩個模式做出區分,也就是說View在wrap_contentmatch_parent兩個模式下是完全相同的,都會是match_parent,顯然這與我們平時用的View不同,所以我們要重寫onMeasure方法。

  • 重寫onMeasure方法

 

    /**
     * 重寫onMeasure方法
     *
     * @param widthMeasureSpec
     * @param heightMeasureSpec
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);

        //處理wrap_contentde情況
        if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(300, 300);
        } else if (widthMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(300, heightSize);
        } else if (heightMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(widthSize, 300);
        }
    }

整個自定義View的代碼如下:

 

public class RectView extends View {
    //定義畫筆
    private Paint mPaint = new Paint();

    /**
     * 實現構造方法
     *
     * @param context
     */
    public RectView(Context context) {
        super(context);
        init();
    }

    public RectView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public RectView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        mPaint.setColor(Color.BLUE);

    }

    /**
     * 重寫onMeasure方法
     *
     * @param widthMeasureSpec
     * @param heightMeasureSpec
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);

        if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(300, 300);
        } else if (widthMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(300, heightSize);
        } else if (heightMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(widthSize, 300);
        }
    }

    /**
     * 重寫draw方法
     *
     * @param canvas
     */
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //獲取各個編劇的padding值
        int paddingLeft = getPaddingLeft();
        int paddingRight = getPaddingRight();
        int paddingTop = getPaddingTop();
        int paddingBottom = getPaddingBottom();
        //獲取繪製的View的寬度
        int width = getWidth() - paddingLeft - paddingRight;
        //獲取繪製的View的高度
        int height = getHeight() - paddingTop - paddingBottom;
        //繪製View,左上角座標(0+paddingLeft,0+paddingTop),右下角座標(width+paddingLeft,height+paddingTop)
        canvas.drawRect(0 + paddingLeft, 0 + paddingTop, width + paddingLeft, height + paddingTop, mPaint);
    }
}

整個過程大致如下,直接繼承View時需要有幾點注意:

1、在onDraw當中對padding屬性進行處理。
2、在onMeasure過程中對wrap_content屬性進行處理。
3、至少要有一個構造方法。

6. 繼承ViewGroup

自定義ViewGroup的過程相對複雜一些,因爲除了要對自身的大小和位置進行測量之外,還需要對子View的測量參數負責。

需求實例

實現一個類似於Viewpager的可左右滑動的佈局。

代碼比較多,我們結合註釋分析。

 

public class HorizontaiView extends ViewGroup {

    private int lastX;
    private int lastY;

    private int currentIndex = 0;
    private int childWidth = 0;
    private Scroller scroller;
    private VelocityTracker tracker;

    
    /**
     * 1.創建View類,實現構造函數
     * 實現構造方法
     * @param context
     */
    public HorizontaiView(Context context) {
        super(context);
        init(context);
    }

    public HorizontaiView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init(context);
    }

    public HorizontaiView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }

    private void init(Context context) {
        scroller = new Scroller(context);
        tracker = VelocityTracker.obtain();
    }

    /**
     * 2、根據自定義View的繪製流程,重寫`onMeasure`方法,注意對wrap_content的處理
     * 重寫onMeasure方法
     * @param widthMeasureSpec
     * @param heightMeasureSpec
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //獲取寬高的測量模式以及測量值
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        //測量所有子View
        measureChildren(widthMeasureSpec, heightMeasureSpec);
        //如果沒有子View,則View大小爲0,0
        if (getChildCount() == 0) {
            setMeasuredDimension(0, 0);
        } else if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
            View childOne = getChildAt(0);
            int childWidth = childOne.getMeasuredWidth();
            int childHeight = childOne.getMeasuredHeight();
            //View的寬度=單個子View寬度*子View個數,View的高度=子View高度
            setMeasuredDimension(getChildCount() * childWidth, childHeight);
        } else if (widthMode == MeasureSpec.AT_MOST) {
            View childOne = getChildAt(0);
            int childWidth = childOne.getMeasuredWidth();
            //View的寬度=單個子View寬度*子View個數,View的高度=xml當中設置的高度
            setMeasuredDimension(getChildCount() * childWidth, heightSize);
        } else if (heightMode == MeasureSpec.AT_MOST) {
            View childOne = getChildAt(0);
            int childHeight = childOne.getMeasuredHeight();
            //View的寬度=xml當中設置的寬度,View的高度=子View高度
            setMeasuredDimension(widthSize, childHeight);
        }
    }

    /**
     * 3、接下來重寫`onLayout`方法,對各個子View設置位置。
     * 設置子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) {
        int childCount = getChildCount();
        int left = 0;
        View child;
        for (int i = 0; i < childCount; i++) {
            child = getChildAt(i);
            if (child.getVisibility() != View.GONE) {
                childWidth = child.getMeasuredWidth();
                child.layout(left, 0, left + childWidth, child.getMeasuredHeight());
                left += childWidth;
            }
        }
    }
}

到這裏我們的View佈局就已經基本結束了。但是要實現Viewpager的效果,還需要添加對事件的處理。事件的處理流程之前我們有分析過,在製作自定義View的時候也是會經常用到的,不瞭解的可以參考之前的文章Android Touch事件分發超詳細解析

 

    /**
     * 4、因爲我們定義的是ViewGroup,從onInterceptTouchEvent開始。
     * 重寫onInterceptTouchEvent,對橫向滑動事件進行攔截
     * @param event
     * @return
     */
    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        boolean intercrpt = false;
        //記錄當前點擊的座標
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - lastX;
                int delatY = y - lastY;
                //當X軸移動的絕對值大於Y軸移動的絕對值時,表示用戶進行了橫向滑動,對事件進行攔截
                if (Math.abs(deltaX) > Math.abs(delatY)) {
                    intercrpt = true;
                }
                break;
        }
        lastX = x;
        lastY = y;
        //intercrpt = true表示對事件進行攔截
        return intercrpt;
    }
    
    /**
     * 5、當ViewGroup攔截下用戶的橫向滑動事件以後,後續的Touch事件將交付給`onTouchEvent`進行處理。
     * 重寫onTouchEvent方法
     * @param event
     * @return
     */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        tracker.addMovement(event);
        //獲取事件座標(x,y)
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - lastX;
                int delatY = y - lastY;
                //scrollBy方法將對我們當前View的位置進行偏移
                scrollBy(-deltaX, 0);
                break;
            //當產生ACTION_UP事件時,也就是我們擡起手指
            case MotionEvent.ACTION_UP:
                //getScrollX()爲在X軸方向發生的便宜,childWidth * currentIndex表示當前View在滑動開始之前的X座標
                //distance存儲的就是此次滑動的距離
                int distance = getScrollX() - childWidth * currentIndex;
                //當本次滑動距離>View寬度的1/2時,切換View
                if (Math.abs(distance) > childWidth / 2) {
                    if (distance > 0) {
                        currentIndex++;
                    } else {
                        currentIndex--;
                    }
                } else {
                    //獲取X軸加速度,units爲單位,默認爲像素,這裏爲每秒1000個像素點
                    tracker.computeCurrentVelocity(1000);
                    float xV = tracker.getXVelocity();
                    //當X軸加速度>50時,也就是產生了快速滑動,也會切換View
                    if (Math.abs(xV) > 50) {
                        if (xV < 0) {
                            currentIndex++;
                        } else {
                            currentIndex--;
                        }
                    }
                }
                //對currentIndex做出限制其範圍爲【0,getChildCount() - 1】
                currentIndex = currentIndex < 0 ? 0 : currentIndex > getChildCount() - 1 ? getChildCount() - 1 : currentIndex;
                //滑動到下一個View
                smoothScrollTo(currentIndex * childWidth, 0);
                tracker.clear();
                break;
        }
        lastX = x;
        lastY = y;
        return true;
    }


    private void smoothScrollTo(int destX, int destY) {
        //startScroll方法將產生一系列偏移量,從(getScrollX(), getScrollY()),destX - getScrollX()和destY - getScrollY()爲移動的距離
        scroller.startScroll(getScrollX(), getScrollY(), destX - getScrollX(), destY - getScrollY(), 1000);
        //invalidate方法會重繪View,也就是調用View的onDraw方法,而onDraw又會調用computeScroll()方法
        invalidate();
    }

    //重寫computeScroll方法
    @Override
    public void computeScroll() {
        super.computeScroll();
        //當scroller.computeScrollOffset()=true時表示滑動沒有結束
        if (scroller.computeScrollOffset()) {
            //調用scrollTo方法進行滑動,滑動到scroller當中計算到的滑動位置
            scrollTo(scroller.getCurrX(), scroller.getCurrY());
            //沒有滑動結束,繼續刷新View
            postInvalidate();
        }
    }

這部分代碼比較多,爲了方便閱讀,在代碼當中進行了註釋。
之後就是在XML代碼當中引入自定義View

 

<com.example.yf.view.HorizontaiView
        android:id="@+id/test_layout"
        android:layout_width="match_parent"
        android:layout_height="400dp">
        <ListView
            android:id="@+id/list1"
            android:layout_width="match_parent"
            android:layout_height="match_parent">

        </ListView>

        <ListView
            android:id="@+id/list2"
            android:layout_width="match_parent"
            android:layout_height="match_parent">

        </ListView>

        <ListView
            android:id="@+id/list3"
            android:layout_width="match_parent"
            android:layout_height="match_parent">

        </ListView>

    </com.example.yf.view.HorizontaiView>

好了,可以運行看一下效果了。

總結

本篇文章對常用的自定義View的方式進行了總結,並簡單分析了View的繪製流程。對各種實現方式寫了簡單的實現。


鏈接:https://www.jianshu.com/p/705a6cb6bfee
 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章