Android 自定義 View 詳解

View 的繪製系列文章:

對於 Android 開發者來講,原生控件每每沒法知足要求,須要開發者自定義一些控件,所以,須要去了解自定義 view 的實現原理。這樣即便碰到須要自定義控件的時候,也能夠遊刃有餘。html

基礎知識

自定義 View 分類

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

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

從上到下愈來愈難,須要的瞭解的知識也是愈來愈多的。android

構造函數

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

   
public class MyTextView extends View {
  /** * 在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); }
}

對於每一種構造函數的做用,都已經再代碼裏面寫出來了。api

自定義屬性

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

Android 自定義屬性可分爲如下幾步:ide

  1. 自定義一個 View函數

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

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

  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 的方法就講完了。但願對你們有用。

參考文獻:

一、Android自定義View全解

相關文章
相關標籤/搜索