1. 程式人生 > >Android 自定義 View 詳解

Android 自定義 View 詳解

View 的繪製系列文章:

  • Android View 繪製流程之 DecorView 與 ViewRootImpl

  • Android View 的繪製流程之 Measure 過程詳解 (一)

  • Android View 的繪製流程之 Layout 和 Draw 過程詳解 (二)

  • Android View 的事件分發原理解析

對於 Android 開發者來說,原生控制元件往往無法滿足要求,需要開發者自定義一些控制元件,因此,需要去了解自定義 view 的實現原理。這樣即使碰到需要自定義控制元件的時候,也可以遊刃有餘。

基礎知識

自定義 View 分類

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

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

從上到下越來越難,需要的瞭解的知識也是越來越多的。

建構函式

當我們在自定義 View 的時候,建構函式都是不可缺少,需要對建構函式進行重寫,建構函式有多個,至少要重寫其中一個才行。例如我們新建 MyTextView:

   /**
     * 在java程式碼裡new的時候會用到
     * @param context
     */
    public MyTextView(Context context) {
        super(context);
    }

    /**
     * 在xml佈局檔案中使用時自動呼叫
     * @param context
     */
    public MyTextView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    /**
     * 不會自動呼叫,如果有預設style時,在第二個建構函式中呼叫
     * @param context
     * @param attrs
     * @param defStyleAttr
     */
    public MyTextView(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 MyTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    }

對於每一種建構函式的作用,都已經再程式碼裡面寫出來了。

自定義屬性

寫過佈局的同學都知道,系統控制元件的屬性在 xml 中都是以 android 開頭的。對於自定義 View,也可以自定義屬性,在 xml 中使用。

Android 自定義屬性可分為以下幾步:

  1. 自定義一個 View

  2. 編寫 values/attrs.xml,在其中編寫 styleable 和 item 等標籤元素

  3. 在佈局檔案中 View 使用自定義的屬性(注意 namespace)

  4. 在 View 的構造方法中通過 TypedArray 獲取

e.g  還是以上面的 MyTextView 做演示:

首先我在 activity_main.xml 中引入了 MyTextView:
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

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

</android.support.constraint.ConstraintLayout>

然後我在 values/attrs.xml 中新增自定義屬性:

<?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>

記得在建構函式裡面說過,xml 佈局會呼叫第二個建構函式,因此在這個建構函式裡面獲取屬性和解析:

   /**
     * 在xml佈局檔案中使用時自動呼叫
     * @param context
     */
    public MyTextView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.test);
        int textAttr = ta.getInteger(R.styleable.test_testAttr, -1);
        String text = ta.getString(R.styleable.test_text);
        Log.d(TAG, " text = " + text + ", textAttr = " + textAttr);
     // toast 顯示獲取的屬性值 Toast.makeText(context, text + " " + textAttr, Toast.LENGTH_LONG).show(); ta.recycle(); }

注意當你在引用自定義屬性的時候,記得加上 name 字首,否則會引用不到。

這裡本想截圖 log 的,奈何就是不顯示,就搞成 toast 了。

當然,你還可以自定義很多其他屬性,包括 color, string, integer, boolean, flag,甚至是混合等。

自定義組合控制元件

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

我們通過一個自定義 MyView1 例項來了解自定義組合控制元件的用法。

xml 佈局 

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content">
    
    <TextView
        android:id="@+id/feed_item_com_cont_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:ellipsize="end"
        android:includeFontPadding="false"
        android:maxLines="2"
        android:text="title" />

    <TextView
        android:id="@+id/feed_item_com_cont_desc"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@id/feed_item_com_cont_title"
        android:ellipsize="end"
        android:includeFontPadding="false"
        android:maxLines="2"
        android:text="desc" />

</merge>

 自定義 View 程式碼 :

package com.example.myapplication;

import android.content.Context;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.RelativeLayout;
import android.widget.TextView;

public class MyView1 extends RelativeLayout {

    /** 標題 */
    private TextView mTitle;
    /** 描述 */
    private TextView mDesc;

    public MyView1(Context context) {
        this(context, null);
    }

    public MyView1(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

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

    /**
     * 初使化介面檢視
     *
     * @param context 上下文環境
     */
    protected void initView(Context context) {
        View rootView = LayoutInflater.from(getContext()).inflate(R.layout.my_view1, this);

        mDesc = rootView.findViewById(R.id.feed_item_com_cont_desc);
        mTitle = rootView.findViewById(R.id.feed_item_com_cont_title);
    }
}

在佈局當中引用該控制元件 

<?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"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/text"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:clickable="true"
        android:enabled="false"
        android:focusable="true"
        android:text="trsfnjsfksjfnjsdfjksdhfjksdjkfhdsfsdddddddddddddddddddddddddd" />

    <com.example.myapplication.MyTextView
        android:id="@+id/myview"
        android:layout_width="100dp"
        android:layout_height="200dp"
        android:clickable="true"
        android:enabled="false"
        android:focusable="true"
        app:testAttr="520"
        app:text="helloWorld" />

    <com.example.myapplication.MyView1
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

</LinearLayout>

最終效果如下圖所示 :

 

繼承系統控制元件

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

業務需求:為文字設定背景,並在佈局中間新增一條橫線。

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

package com.example.myapplication;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Shader;
import android.text.TextPaint;
import android.util.AttributeSet;
import android.widget.TextView;


import static android.support.v4.content.ContextCompat.getColor;

/**
 * 包含分割線的textView
 * 文字左右兩邊有一條漸變的分割線
 * 樣式如下:
 * ———————— 文字 ————————
 */
public class DividingLineTextView extends TextView {
    /** 線性漸變 */
    private LinearGradient mLinearGradient;
    /** textPaint */
    private TextPaint mPaint;
    /** 文字 */
    private String mText = "";
    /** 螢幕寬度 */
    private int mScreenWidth;
    /** 開始顏色 */
    private int mStartColor;
    /** 結束顏色 */
    private int mEndColor;
    /** 字型大小 */
    private int mTextSize;


    /**
     * 建構函式
     */
    public DividingLineTextView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        mTextSize = getResources().getDimensionPixelSize(R.dimen.text_size);
        mScreenWidth = getCalculateWidth(getContext());
        mStartColor = getColor(getContext(), R.color.colorAccent);
        mEndColor = getColor(getContext(), R.color.colorPrimary);
        mLinearGradient = new LinearGradient(0, 0, mScreenWidth, 0,
                new int[]{mStartColor, mEndColor, mStartColor},
                new float[]{0, 0.5f, 1f},
                Shader.TileMode.CLAMP);
        mPaint = new TextPaint();
    }

    public DividingLineTextView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public DividingLineTextView(Context context) {
        this(context, null);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mPaint.setAntiAlias(true);
        mPaint.setTextSize(mTextSize);
        int len = getTextLength(mText, mPaint);
        // 文字繪製起始座標
        int sx = mScreenWidth / 2 - len / 2;
        // 文字繪製結束座標
        int ex = mScreenWidth / 2 + len / 2;
        int height = getMeasuredHeight();
        mPaint.setShader(mLinearGradient);
        // 繪製左邊分界線,從左邊開始:左邊距15dp, 右邊距距離文字15dp
        canvas.drawLine(mTextSize, height / 2, sx - mTextSize, height / 2, mPaint);
        mPaint.setShader(mLinearGradient);
        // 繪製右邊分界線,從文字右邊開始:左邊距距離文字15dp,右邊距15dp
        canvas.drawLine(ex + mTextSize, height / 2,
                mScreenWidth - mTextSize, height / 2, mPaint);
    }

    /**
     * 返回指定文字的寬度,單位px
     *
     * @param str   要測量的文字
     * @param paint 繪製此文字的畫筆
     * @return 返回文字的寬度,單位px
     */
    private int getTextLength(String str, TextPaint paint) {
        return (int) paint.measureText(str);
    }

    /**
     * 更新文字
     *
     * @param text 文字
     */
    public void update(String text) {
        mText = text;
        setText(mText);
        // 重新整理重繪
        requestLayout();
    }


    /**
     * 獲取需要計算的寬度,取螢幕高寬較小值,
     *
     * @param context context
     * @return 螢幕寬度值
     */
    public static int getCalculateWidth(Context context) {
        int height = context.getResources().getDisplayMetrics().heightPixels;
        // 動態螢幕寬度,在摺疊屏手機上寬度在分屏時會發生變化
        int Width = context.getResources().getDisplayMetrics().widthPixels;

        return Math.min(Width, height);
    }
}

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

看下佈局裡面的引用:

xml 佈局 

<?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"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">

   // ...... 跟前面一樣忽視
    <com.example.myapplication.DividingLineTextView
        android:id="@+id/divide"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center" />

</LinearLayout>

 

activty 裡面程式碼如下 :
  protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        DividingLineTextView te = findViewById(R.id.divide);
        te.update("DividingLineTextView");
  }

這裡通過 update() 對來重新繪製,確保邊線在文字的兩邊。視覺效果如下:

 

直接繼承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 的原始碼當中並沒有對 AT_MOST 和 EXACTLY 兩個模式做出區分,也就是說 View 在 wrap_content 和 match_parent 兩個模式下是完全相同的,都會是 match_parent,顯然這與我們平時用的 View 不同,所以我們要重寫 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);
        }
    }

 最終效果如圖所示:

可以發現,我們設定的是 wrap_content,但是最後還是有尺寸的。

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

  1. 在 onDraw 當中對 padding 屬性進行處理。

  2. 在 onMeasure 過程中對 wrap_content 屬性進行處理。

  3. 至少要有一個構造方法。

繼承ViewGroup

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

需求例項

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

佈局檔案:

<?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"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">
    <com.example.myapplication.MyHorizonView
        android:layout_width="wrap_content"
        android:background="@color/colorAccent"
        android:layout_height="400dp">

        <ListView
            android:id="@+id/list1"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="@color/colorAccent" />

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

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

    </com.example.myapplication.MyHorizonView>

    <TextView
        android:id="@+id/text"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:clickable="true"
        android:focusable="true"
        android:text="trsfnjsfksjfnjsdfjksdhfjksdjkfhdsfsdddddddddddddddddddddddddd" />

    <com.example.myapplication.MyTextView
        android:id="@+id/myview"
        android:layout_width="1dp"
        android:layout_height="2dp"
        android:clickable="true"
        android:enabled="false"
        android:focusable="true"
        app:testAttr="520"
        app:text="helloWorld" />

    <com.example.myapplication.RectView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <com.example.myapplication.MyView1
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <com.example.myapplication.DividingLineTextView
        android:id="@+id/divide"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center" />


</LinearLayout>

一個 ViewGroup 裡面放入 3 個 ListView,注意 ViewGroup 設定的寬是 wrap_conten,在測量的時候,會對 wrap_content 設定成與父 View 的大小一致,具體實現邏輯可看後面的程式碼。

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

public class MyHorizonView extends ViewGroup {

    private static final String TAG = "HorizontaiView";
    private List<View> mMatchedChildrenList = new ArrayList<>();


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

    public MyHorizonView(Context context, AttributeSet attributes) {
        super(context, attributes);
    }

    public MyHorizonView(Context context, AttributeSet attributes, int defStyleAttr) {
        super(context, attributes, defStyleAttr);
    }

    @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) {
                int childWidth = child.getMeasuredWidth();
                // 因為是水平滑動的,所以以寬度來適配
                child.layout(left, 0, left + childWidth, child.getMeasuredHeight());
                left += childWidth;
            }
        }
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        mMatchedChildrenList.clear();
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
        // 如果不是確定的的值,說明是 AT_MOST,與父 View 同寬高
        final boolean measureMatchParentChildren = heightSpecMode != MeasureSpec.EXACTLY ||
                widthSpecMode != MeasureSpec.EXACTLY;
        int childCount = getChildCount();
        View child;
        for (int i = 0; i < childCount; i++) {
            child = getChildAt(i);
            if (child.getVisibility() != View.GONE) {
                final LayoutParams layoutParams = child.getLayoutParams();
                measureChild(child, widthMeasureSpec, heightMeasureSpec);
                if (measureMatchParentChildren) {
                    // 需要先計算出父 View 的高度來再來測量子 view
                    if (layoutParams.width == LayoutParams.MATCH_PARENT
                            || layoutParams.height == LayoutParams.MATCH_PARENT) {
                        mMatchedChildrenList.add(child);
                    }
                }
            }
        }

        if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
            // 如果寬高都是AT_MOST的話,即都是wrap_content佈局模式,就用View自己想要的寬高值
            setMeasuredDimension(getMeasuredWidth(), getMeasuredHeight());
        } else if (widthSpecMode == MeasureSpec.AT_MOST) {
            // 如果只有寬度都是AT_MOST的話,即只有寬度是wrap_content佈局模式,寬度就用View自己想要的寬度值,高度就用父ViewGroup指定的高度值
            setMeasuredDimension(getMeasuredWidth(), heightSpecSize);
        } else if (heightSpecMode == MeasureSpec.AT_MOST) {
            // 如果只有高度都是AT_MOST的話,即只有高度是wrap_content佈局模式,高度就用View自己想要的寬度值,寬度就用父ViewGroup指定的高度值
            setMeasuredDimension(widthSpecSize, getMeasuredHeight());
        }

        for (int i = 0; i < mMatchedChildrenList.size(); i++) {
            View matchChild = getChildAt(i);
            if (matchChild.getVisibility() != View.GONE) {
                final LayoutParams layoutParams = matchChild.getLayoutParams();
                // 計運算元 View 寬的 MeasureSpec
                final int childWidthMeasureSpec;
                if (layoutParams.width == LayoutParams.MATCH_PARENT) {
                    childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.EXACTLY);
                } else {
                    childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec, 0, layoutParams.width);
                }
                // 計運算元 View 高的 MeasureSpec
                final int childHeightMeasureSpec;
                if (layoutParams.height == LayoutParams.MATCH_PARENT) {
                    childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(getMeasuredHeight(), MeasureSpec.EXACTLY);
                } else {
                    childHeightMeasureSpec = getChildMeasureSpec(widthMeasureSpec, 0, layoutParams.height);
                }
                // 根據 MeasureSpec 計算自己的寬高
                matchChild.measure(childWidthMeasureSpec, childHeightMeasureSpec);
            }
        }
    }
}

這裡我們只是重寫了兩個繪製過程中的重要的方法:onMeasure 和 onLayout 方法。

對於 onMeasure 方法具體邏輯如下:

  1. super.onMeasure 會先計算自定義 view 的大小;

  2. 呼叫 measureChild 對 子 View 進行測量;
  3. 自定義 view 設定的寬高參數不是 MeasureSpec.EXACTLY 的話,對於子 View 是 match_parent 需要額外處理,同時也需要對 MeasureSpec.AT_MOST 情況進行額外處理。

  4.  當自定義View 的大小確定後,在對子 View 是 match_parent 重新測量;

上述的測量過程的程式碼也是參考 FrameLayout 原始碼的,具體可以參看文章:

對於 onLayout 方法,因為是水平滑動的,所以要根據寬度來進行layout。

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

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

    /**
     * 因為我們定義的是ViewGroup,從onInterceptTouchEvent開始。
     * 重寫onInterceptTouchEvent,對橫向滑動事件進行攔截
     *
     * @param event
     * @return
     */
    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        boolean intercepted = false;
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                intercepted = false;//必須不能攔截,否則後續的ACTION_MOME和ACTION_UP事件都會攔截。
                break;
            case MotionEvent.ACTION_MOVE:
                intercepted = Math.abs(x - mLastX) > Math.abs(y - mLastY);
                break;
        }
        Log.d(TAG, "onInterceptTouchEvent: intercepted " + intercepted);
        mLastX = x;
        mLastY = y;
        return intercepted ? intercepted : super.onInterceptHoverEvent(event);
    }

    /**
     * 當ViewGroup攔截下使用者的橫向滑動事件以後,後續的Touch事件將交付給`onTouchEvent`進行處理。
     * 重寫onTouchEvent方法
     */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        mTracker.addMovement(event);
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - mLastX;
                Log.d(TAG, "onTouchEvent: deltaX " + deltaX);

                // scrollBy 方法將對我們當前 View 的位置進行偏移
                scrollBy(-deltaX, 0);
                break;
            case MotionEvent.ACTION_UP:
                Log.d(TAG, "onTouchEvent: " + getScrollX());
                // getScrollX()為在X軸方向發生的便宜,mChildWidth * currentIndex表示當前View在滑動開始之前的X座標
                // distance儲存的就是此次滑動的距離
                int distance = getScrollX() - mChildWidth * mCurrentIndex;
                //當本次滑動距離>View寬度的1/2時,切換View
                if (Math.abs(distance) > mChildWidth / 2) {
                    if (distance > 0) {
                        mCurrentIndex++;
                    } else {
                        mCurrentIndex--;
                    }
                } else {
                    //獲取X軸加速度,units為單位,預設為畫素,這裡為每秒1000個畫素點
                    mTracker.computeCurrentVelocity(1000);
                    float xV = mTracker.getXVelocity();
                    //當X軸加速度>50時,也就是產生了快速滑動,也會切換View
                    if (Math.abs(xV) > 50) {
                        if (xV < 0) {
                            mCurrentIndex++;
                        } else {
                            mCurrentIndex--;
                        }
                    }
                }

                //對currentIndex做出限制其範圍為【0,getChildCount() - 1】
                mCurrentIndex = mCurrentIndex < 0 ? 0 : mCurrentIndex > getChildCount() - 1 ? getChildCount() - 1 : mCurrentIndex;
                //滑動到下一個View
                smoothScrollTo(mCurrentIndex * mChildWidth, 0);
                mTracker.clear();

                break;
        }

        Log.d(TAG, "onTouchEvent: ");
        mLastX = x;
        mLastY = y;
        return super.onTouchEvent(event);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        return super.dispatchTouchEvent(ev);
    }

    private void smoothScrollTo(int destX, int destY) {
        // startScroll方法將產生一系列偏移量,從(getScrollX(), getScrollY()),destX - getScrollX()和destY - getScrollY()為移動的距離
        mScroller.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 (mScroller.computeScrollOffset()) {
            // 呼叫scrollTo方法進行滑動,滑動到scroller當中計算到的滑動位置
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            // 沒有滑動結束,繼續重新整理View
            postInvalidate();
        }
    }

具體效果如下圖所示:


對於 Scroller 的用法總結如下:

  1. 呼叫 Scroller 的 startScroll() 方法來進行一些滾動的初始化設定,然後迫使 View 進行繪製 (呼叫 View 的 invalidate() 或 postInvalidate() 就可以重新繪製 View);

  2. 繪製 View 的時候 drawchild 方法會呼叫 computeScroll() 方法,重寫 computeScroll(),通過 Scroller 的 computeScrollOffset() 方法來判斷滾動有沒有結束;

  3. scrollTo() 方法雖然會重新繪製 View,但還是要呼叫下 invalidate() 或者 postInvalidate() 來觸發介面重繪,重新繪製 View 又觸發 computeScroll();

  4. 如此往復進入一個迴圈階段,即可達到平滑滾動的效果;

也許有人會問,幹嘛還要呼叫來呼叫去最後在呼叫 scrollTo() 方法,還不如直接呼叫 scrollTo() 方法來實現滾動,其實直接呼叫是可以,只不過 scrollTo() 是瞬間滾動的,給人的使用者體驗不太好,所以 Android 提供了 Scroller 類實現平滑滾動的效果。

為了方面大家理解,我畫了一個簡單的呼叫示意圖:

 

 

到此,自定義 view 的方法就講完了。希望對大家有用。

參考文獻:

1、Android自定義View