Android 自定義 View 詳解


View 的繪制系列文章:

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

基礎知識

自定義 View 分類

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

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

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

構造函數

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

   
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); }
}

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

自定義屬性

寫過布局的同學都知道,系統控件的屬性在 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全解


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM