天天看點

Android 自定義 View 基礎篇

簡介

View 和 ViewGroup

View:使用者界面元件的基本構成,一個 View 在螢幕上占據一個矩形,并負責繪制 UI 和處理事件,常用來建立 UI 元件(Button、TextView、ImageView 等),View 類是安卓所有控件的基類。

ViewGroup:View 類的子類,一種組合 View(RelativeLayout、LinearLayout 等),ViewGroup 可以有多個子 View。

自定義 View

當系統提供的元件 View 無法滿足界面要求的時候,需要自己實作 View 的各種效果。一般繼承 View、ViewGroup、其他原生控件實作自定義 View。

本文思維導圖

Android 自定義 View 基礎篇

正文

View 的加載流程圖

Android 自定義 View 基礎篇

View 的位置坐标系 (x,y)

螢幕左上角為 (0,0),向右 x 軸坐标增大,像下 y 軸坐标增大。

自定義 View 屬性

layout 屬性:即 XML 布局裡以 layout_ 開頭的屬性(layout_width、layout_height 等)。

其他系統屬性:textSize、hint、text、textColor 等。

自定義屬性:XML 布局檔案裡設定自定義 View 的屬性,自定義 View 在構造方法中通過

AttributeSet

擷取到自定義屬性,并運用到 View 上。

LayoutParams

LayoutParams:子 View 使用 LayoutParams 來告知其父 View 自己如何布局。ViewGroup.LayoutParams 包含了 View 的基礎 layout 屬性,即

layout_width

layout_height

。每個布局 View 都會有個 LayoutParams 對象,不同的 ViewGroup 有不同的 layout 屬性,也就是有不同的 LayoutParams。例如 LinearLayout 還有

layout_weight

layout_gravity

等屬性。

ViewGroup.LayoutParams 的構造方法:

public LayoutParams(Context c, AttributeSet attrs) {
        TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.ViewGroup_Layout);
        setBaseAttributes(a,
                R.styleable.ViewGroup_Layout_layout_width,
                R.styleable.ViewGroup_Layout_layout_height);
        a.recycle();
    }
           

ViewGroup.MarginLayoutParams:ViewGroup.LayoutParams 的子類,增加了 Margin 相關的 layout 屬性,

layout_marginLeft

layout_marginStart

等屬性。通常用來擷取 View 的 Margin。

LinearLayout.LayoutParams:ViewGroup.MarginLayoutParams 的子類,包含有 LinearLayout 的

layout_weight

layout_gravity

屬性。

RelativeLayout.LayoutParams:ViewGroup.MarginLayoutParams 的子類,包含有 RelativeLayout 的

layout_above

layout_alignLeft

layout_centerInParent

layout_toEndOf

等屬性。

其他 LayoutParams 子類略。

onMeasure

确定子 View 的測量值以及模式,以及設定自己的寬和高。我們可以通過

onMeasure()

方法提供的參數

widthMeasureSpec

heightMeasureSpec

來分别擷取控件寬度和高度的測量模式和測量值。

widthMeasureSpec 和 heightMeasureSpec 中封裝了測量值以及模式,通過

MeasureSpec.getMode(widthMeasureSpec)

MeasureSpec.getSize(widthMeasureSpec)

擷取控件的模式以及測量值。

MeasureSpec 的構成:MeasureSpec 由 size 和 mode 組成,其值由父容器的

MeasureSpec

和自身的

LayoutParams

來共同決定,是以不同的父容器和 View 本身不同的 LayoutParams 會使 View 可以有多種 MeasureSpec。mode 包括三種

:UNSPECIFIED

EXACTLY

AT_MOST

,size 就是配合 mode 給出的參考尺寸:

  • UNSPECIFIED:父控件對子 View 不加任何束縛,子 View 可以得到任意想要的大小,這種 MeasureSpec 一般是由父控件自身的特性決定的。比如 ScrollView,它的子 View 可以随意設定大小,無論多高,都能滾動顯示,這個時候 size 一般就沒什麼意義。
  • EXACTLY:父控件已經檢測出子 View 所需要的精确大小,這時的 MeasureSpec 一般是父控件根據自身的 MeasureSpec 跟子 View 的布局參數來确定的。一般對應 XML 布局參數采用 match_parent 或者指定大小 100dp 的時候。
  • AT_MOST:父控件為子 View 指定最大參考尺寸,希望子 View 的尺寸不要超過這個尺寸。這種模式也是父控件根據自身的 MeasureSpec 跟子 View 的布局參數來确定的,一般是子 View 的XML布局參數采用 wrap_content 的時候。

child.measure(childWidthMeasureSpec, childHeightMeasureSpec);

設定子 View 的的測量值大小以及模式。

measureChild

measureChildWithMargins

的源碼方法中,取出子 View 的 LayoutParams() 然後指派設定子 View 的 MeasureSpec,是以 MeasureSpec 的值由父容器的 MeasureSpec 和自身的 LayoutParams 來共同決定。其中 measureChildWithMargins 比 measureChild 方法多增加了一個 View 的 Margins 值。

protected void measureChild(View child, int parentWidthMeasureSpec,
        int parentHeightMeasureSpec) {
    final LayoutParams lp = child.getLayoutParams();

    final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
            mPaddingLeft + mPaddingRight, lp.width);
    final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
            mPaddingTop + mPaddingBottom, lp.height);

    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}

   protected void measureChildWithMargins(View child,
        int parentWidthMeasureSpec, int widthUsed,
        int parentHeightMeasureSpec, int heightUsed) {
    final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

    final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
            mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
                    + widthUsed, lp.width);
    final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
            mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin
                    + heightUsed, lp.height);

    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
} 
           

使用

setMeasuredDimension(width, height);

設定的自己最終的寬和高。

onLayout

确定 View 的矩形顯示區域位置。

再 onLayout 方法裡擷取子 View ,使用

child.layout(int l, int t, int r, int b)

方法設定子 View 的顯示區域位置。

l t:即 left、top 确定 View 的左上角位置。

r b:即 right、bottom 确定 View 的右下角位置。

onDraw

繪制自身和子 view 的内容。

繪制流程(一般情況下跳過第2、5步):

  1. 繪制背景:

    drawBackground(canvas);

  2. 如果有必要的話,儲存畫布圖層來準備漸變
  3. 繪制 View 的内容,該步驟方法 View 類裡是個空方法,ViewGroup 類裡會周遊所有子 View 并調用 child.draw():

    onDraw(canvas);

  4. 繪制子 View:

    dispatchDraw(canvas);

  5. 如果有必要的話,繪制漸變邊緣和還原圖層
  6. 繪制 View 的裝飾(滾動條):

    onDrawForeground(canvas);

  7. draw the default focus highlight(繪制預設焦點高亮):

    drawDefaultFocusHighlight(canvas);

官方原文:

/*
 * Draw traversal performs several drawing steps which must be executed
 * in the appropriate order:
 *
 *      1. Draw the background
 *      2. If necessary, save the canvas' layers to prepare for fading
 *      3. Draw view's content
 *      4. Draw children
 *      5. If necessary, draw the fading edges and restore layers
 *      6. Draw decorations (scrollbars for instance)
 */
           

自定義 View 觸摸事件

用來處理螢幕運動事件,一般主要處理螢幕手勢事件,增加使用者跟 View 的互動,比如實作 View 點選事件、手勢滑動、放大縮放等效果,以及處理滑動沖突事件。

ViewGroup 的 Touch 事件主要方法有三個:

onInterceptTouchEvent

dispatchTouchEvent

onTouchEvent

。View 的 Touch 事件主要方法隻有兩個:

dispatchTouchEvent

onTouchEvent

dispatchTouchEvent(事件分發):

  • return true:表示消費了整個事件,即不會再分發,也不會再處理。
  • return false:表示事件在本層不再繼續進行分發,并交由上層控件的 onTouchEvent 方法進行消費。
  • return super.dispatchTouchEvent(ev):預設事件将分發給本層的事件攔截 onInterceptTouchEvent 方法進行處理。

onInterceptTouchEvent(事件攔截):

  • return true:表示将事件進行攔截,并将攔截到的事件交由本層控件的 onTouchEvent 進行處理。
  • return false:表示不對事件進行攔截,并将事件傳遞給下一層 View 的 dispatchTouchEvent。
  • return super.onInterceptTouchEvent(ev):預設表示不攔截該事件,并将事件傳遞給下一層 View 的 dispatchTouchEvent。

onTouchEvent(事件處理):

  • return true:表示 onTouchEvent 處理完事件後消費此次事件,對該 View 設定點選事件 setOnClickListener() 将不會響應。
  • return fasle:表示不處理事件,那麼該事件将會不斷向上層 View 傳遞,直到某個 View 的 onTouchEvent 方法傳回 true 消費,如果都不處理則釋放此次事件。
  • return super.onTouchEvent(ev):表示處理事件。

MotionEvent:用于區分不同的運動(滑鼠, 觸摸筆, 手指, 跟蹤球)事件。MotionEvent 包含有關目前活動的所有指針的資訊,提供了許多查詢位置和其他屬性的方法,比如使用

event.getX()

event.getY()

擷取手勢觸摸螢幕位置。使用

event.getAction()

擷取正在執行的操作類型(ACTION_DOWN、ACTION_MOVE、ACTION_UP、多指觸摸相關事件等)。

GestureDetector:手勢事件監聽輔助類,有 onDown、onShowPress、onSingleTapUp、onLongPress、onFling 等事件方法監聽,就不需要自己判斷手勢事件類型,通常用在需要手勢處理的自定義 View 中。

自定義 View 動畫

在一定動畫事件内,通過不斷的重繪或者改變布局寬高大小、位置等達到動畫的視覺效果。自定義 View 動畫通常結合屬性動畫使用,在動畫更新監聽器

AnimatorUpdateListener.onAnimationUpdate()

裡不斷重繪達到動畫效果。

Matrix(矩陣)

Matrix 類包含一個 3x3 的矩陣,用于轉換坐标,在 Android 中主要作用是圖像變換和

Canvas

變換上,如平移、旋轉、縮放、錯切等。在自定義 View 中通常用來實作圖檔跟随手勢縮放控制、圖表庫的縮放平移、View 縮放平移等效果。屬于自定義 View 的進階用法。Matrix 介紹:連結。

ViewConfiguration

ViewConfiguration 是 view 包下的一個子類,這裡記錄了 view 的一些常量基礎配置。比如最大速率和最小速率、滑動距離、滾動距離、fling 距離等。

VelocityTracker

VelocityTracker 用來跟蹤觸摸事件的速度。可以實作 View 的慣性滑動效果,在清單控件中當手指滑動并離開螢幕後清單也會慣性滾動一段距離,而知道需要滾動多少距離,通常是需要根據手指離開螢幕時候的觸摸速度來決定後續慣性滾動的速度和距離。

示例

效果圖

Android 自定義 View 基礎篇

示例簡介

示例為一個流式清單布局,通過 onMeasure、onLayout 方法對子 View 進行布局展示,當子 View 寬度超過一行即換行,加入清單滾動功能。主要用來展示 onMeasure、onLayout 等一些方法的使用,是以有些細節不完善的地方。

添加子 View :

for (int i = 0; i < 100; i++) {
        Button button = new Button(MainActivity.this);
        button.setMaxLines(1);
        button.setText(new Random().nextInt(2) == 0 ? "text" + i : "text " + i+" text");
        button.setLayoutParams(new ViewGroup.MarginLayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
        flowLayout.addView(button);
    }
           

示例代碼

public class FlowLayout extends ViewGroup {
    private int mHorizontalSpace = 0;//控件左右間距
    private int mVerticalSpace = 0;//清單垂直的間距
    //行集合,目前行子 View 個數
    private List<Integer> mChildCountInLines = new ArrayList<>();
    //行集合,目前行高度
    private List<Integer> mHeightInLines = new ArrayList<>();

    private Scroller mScroller;
    private VelocityTracker mVelocityTracker;
    private int mMinimumVelocity;
    private int mHeight;
    private float mLastMotionY;
    private Paint mPaint;

    public FlowLayout(Context context) {
        this(context, null);
        init();
    }

    public FlowLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        if (attrs != null) {
            //擷取自定義屬性
            TypedArray t = context.obtainStyledAttributes(attrs, R.styleable.FlowLayout);
            mHorizontalSpace = t.getDimensionPixelSize(R.styleable.FlowLayout_horizontalSpace, 0);
            mVerticalSpace = t.getDimensionPixelSize(R.styleable.FlowLayout_verticalSpace, 0);
            t.recycle();
        }
        init();
    }

    private void init() {
        mScroller = new Scroller(getContext());
        final ViewConfiguration configuration = ViewConfiguration.get(getContext());
        mMinimumVelocity = configuration.getScaledMinimumFlingVelocity();
		//初始化畫筆用來繪制分割線。
        mPaint = new Paint();
        mPaint.setColor(Color.BLACK);
        mPaint.setStrokeWidth(3);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        //擷取父 View 給的寬度
        int width = MeasureSpec.getSize(widthMeasureSpec);
        //View 在螢幕上能最大顯示的高度,用來判斷滾動範圍。
        mHeight = MeasureSpec.getSize(heightMeasureSpec);
        mChildCountInLines.clear();
        mHeightInLines.clear();
        //最終的高,初始值為 0,每多一行即增加一行的高度
        int measuredHeight = 0;
        //擷取最大能用的寬度
        int maxSizeOneLine = width - getPaddingLeft() - getPaddingRight();
        //目前行的寬度,用來判斷是否超過一行,如果超過一行即換行
        int lineHasUsedWidth = 0;
        //目前行子 view 最大高度
        int maxChildHeightOneLine = 0;
        //目前行子 view 個數
        int childCountOneLine = 0;
        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            if (child.getVisibility() == GONE) {
                continue;
            }
            measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);
            //轉 MarginLayoutParams 類型需要重寫 generateLayoutParams() 方法
            MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) child.getLayoutParams();
            //擷取子 view 的寬高 + 子 view 的 margin
            int childWidthWithMargin = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
            int childHeightWithMargin = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
            //寬度未超出一行
            if (lineHasUsedWidth + childWidthWithMargin < maxSizeOneLine) {
                lineHasUsedWidth += childWidthWithMargin + mHorizontalSpace;
                //目前行子 view 最大高度
                maxChildHeightOneLine = Math.max(childHeightWithMargin, maxChildHeightOneLine);
                //目前行子 view 個數 +1
                childCountOneLine += 1;
            } else {
                //寬度超過一行則換行,并把上一行的子 view 個數、高度添加到集合
                mChildCountInLines.add(childCountOneLine);
                mHeightInLines.add(maxChildHeightOneLine);
                measuredHeight += maxChildHeightOneLine;
                //新起一行,重置參數
                lineHasUsedWidth = childWidthWithMargin + mHorizontalSpace;
                childCountOneLine = 1;
                maxChildHeightOneLine = childHeightWithMargin;
            }
        }
        //添加最後一行的子 view 個數、行高度
        mChildCountInLines.add(childCountOneLine);
        mHeightInLines.add(maxChildHeightOneLine);
        measuredHeight += maxChildHeightOneLine;
        //最後為 measureHeight 加上 padding 和行間距
        measuredHeight += getPaddingTop() + getPaddingBottom();
        for (int k = 0; k < mChildCountInLines.size() - 1; k++) {
            measuredHeight += mVerticalSpace;
        }
        //設定自身最終的寬和高
        setMeasuredDimension(width, measuredHeight);
    }

    @Override
    public LayoutParams generateLayoutParams(AttributeSet attrs) {
        return new MarginLayoutParams(getContext(), attrs);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        //onLayout 設定子 view 的顯示位置
        //左上角坐标,第一個布局位置從 x,y 開始
        int x = getPaddingLeft();
        int y = getPaddingTop();
        int childIndex = 0;
        for (int j = 0; j < mChildCountInLines.size(); j++) {
            //取出目前行子 view 個數、高度
            int childCount = mChildCountInLines.get(j);
            int lineHeight = mHeightInLines.get(j);
            for (int h = 0; h < childCount; h++) {
                if (childIndex >= getChildCount()) {
                    break;
                }
                View child = getChildAt(childIndex);
                if (child.getVisibility() == GONE) {
                    continue;
                }
                int childWidth = child.getMeasuredWidth();
                int childHeight = child.getMeasuredHeight();
                int leftMargin = 0, rightMargin = 0, topMargin = 0, bottomMargin = 0;
                LayoutParams childlp = child.getLayoutParams();
                if (childlp instanceof MarginLayoutParams) {
                    leftMargin = ((MarginLayoutParams) childlp).leftMargin;
                    rightMargin = ((MarginLayoutParams) childlp).rightMargin;
                    topMargin = ((MarginLayoutParams) childlp).topMargin;
                    bottomMargin = ((MarginLayoutParams) childlp).bottomMargin;
                }
                child.layout(x + leftMargin, y + topMargin, x + leftMargin + childWidth, y + topMargin + childHeight);
                //移動橫坐标,重新确定基點 X
                x += leftMargin + childWidth + rightMargin + mHorizontalSpace;
                childIndex++;
            }
            //換行時重置 x,y 基點
            x = getPaddingLeft();
            //y 增加上一行的高度和垂直間距,進入下一行
            y += lineHeight + mVerticalSpace;
        }
    }

    @Override
    protected void dispatchDraw(Canvas canvas) {
        super.dispatchDraw(canvas);
        //由于繪制順序,需要在 super.dispatchDraw(canvas); 後畫分割線,不然會被子 View 遮蓋
        int y = getPaddingTop();
        for (int i = 0; i < mHeightInLines.size() - 1; i++) {
            y += mHeightInLines.get(i) + mVerticalSpace;
            //繪制分割線
            canvas.drawLine(0, y, getWidth(), y, mPaint);
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        obtainVelocityTracker(event);
        final int action = event.getAction();
        final float y = event.getY();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }
                mLastMotionY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                final int deltaY = (int) (mLastMotionY - y);
                mLastMotionY = y;
                //手指跟随螢幕滑動
                if (deltaY < 0) {
                    if (getScrollY() > 0) {
                        scrollBy(0, deltaY);
                    }
                } else if (deltaY > 0) {
                    //限制滑動的最大距離
                    if (getScrollY() < getMeasuredHeight() - Math.min(getHeight(), mHeight)) {
                        scrollBy(0, deltaY);
                    }
                }
                break;
            case MotionEvent.ACTION_UP:
                final VelocityTracker velocityTracker = mVelocityTracker;
                velocityTracker.computeCurrentVelocity(1000);
                //UP 後檢測垂直方向的速度
                int initialVelocity = (int) velocityTracker.getYVelocity();
                if ((Math.abs(initialVelocity) > mMinimumVelocity)
                        && getChildCount() > 0) {
                    fling(-initialVelocity);
                }
                releaseVelocityTracker();
                break;
            default:
                break;
        }
        return true;
    }

    /**
     * 初始化 VelocityTracker
     */
    private void obtainVelocityTracker(MotionEvent event) {
        if (null == mVelocityTracker) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        mVelocityTracker.addMovement(event);
    }

    /**
     * 釋放 VelocityTracker
     */
    private void releaseVelocityTracker() {
        if (null != mVelocityTracker) {
            mVelocityTracker.clear();
            mVelocityTracker.recycle();
            mVelocityTracker = null;
        }
    }

    public void fling(int velocityY) {
        //開始慣性滾動
        mScroller.fling(getScrollX(), getScrollY(), 0, velocityY, 0, 0, 0,
                getMeasuredHeight() - Math.min(getHeight(), mHeight));
        awakenScrollBars(mScroller.getDuration());
        invalidate();
    }

    @Override
    public void computeScroll() {
        //判斷是否滾動否完成
        if (mScroller.computeScrollOffset()) {
            int x = mScroller.getCurrX();
            int y = mScroller.getCurrY();
            scrollTo(x, y);
            postInvalidate();
        }
    }

}