Android自定義View簡介及入門

目錄

寫在前面

一、自定義View簡介

1.1、什麼是自定義View?

1.2、構造函數調用場景

1.3、onMeasure()方法

問題延伸(面試題):ScrollView嵌套ListView爲什麼會顯示不全(只顯示一條)?

1.4、onDraw()方法

1.5、onTouchEvent()方法

1.6、自定義屬性

二、自定義TextView

2.1、自定義屬性

2.2、實現TextView

2.3、在佈局中使用

2.4、運行效果


寫在前面

今天要說的內容可能對我來說是從做Android以來一直都是迷迷糊糊的一個東西——自定義View,這個標題很大哈,因爲它涉及到的東西確實很多,這對安卓的初中級工程師來說可能也確實是一個比較晦澀難懂的東西,比如你們公司的UI設計了一個比較複雜的效果讓你去做,可能你就要開始百度或者Google了,我之前也一直是這樣的一個狀態,因爲也是工作了幾年了,發現自己也是到了一個瓶頸期,所以這個時候確實需要靜下來仔細思考一下,究竟該如何提升自己了,當你靜下心來的時候,你會發現其實這一切都會很容易想的通,因爲是進階,所以首先你要確定好一個方向,從技術的角度來說就是先確定好一個細分領域,然後從基礎開始穩紮穩打,一步一步的順着一個技術棧由上到下做好每一個層級上的內容整合,慢慢的給自身形成一個技術體系,這樣一個系列一個系列的逐個攻破,說了這麼多的廢話只是自己的一個思考,想表達的思想很簡單就是學會思考學會總結提升自己。

一、自定義View簡介

OK,廢話不多說了,開始今天的內容。接下來我會分兩部分來說今天的內容,第一部分對自定義View做一個介紹,第二部分通過自定義一個系統的TextView作一個入門。

1.1、什麼是自定義View?

關於自定義View的概念,說實話很難用概念性的語言對它作一個定義,我在網上找了很久也沒看到能說的很明白的,所以這裏只能用大白話來介紹,自定義View可以分爲兩類,一類是繼承自系統的View,另一類是繼承自系統的ViewGroup,ViewGroup底層還是繼承自View,並且這兩類都是系統沒有提供給我們的效果(系統提供的比如TextView/ImageView/Button等等)。

1.2、構造函數調用場景

當我們開始自定義View的時候,一般都會重寫它的構造方法,那我們知道這些構造方法都是在什麼場景下被調用的嗎?

package com.jarchie.customview;

import android.content.Context;
import android.util.AttributeSet;
import android.view.View;

import androidx.annotation.Nullable;

/**
 * 作者: 喬布奇
 * 日期: 2020-04-19 16:11
 * 郵箱: [email protected]
 * 描述: 自定義TextView
 */
public class TextView extends View {
    public TextView(Context context) {
        super(context);
    }

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

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

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

第一個構造方法:它會在代碼裏面new的時候調用,比如你在代碼中這樣寫的時候:TextView tv = new TextView(this);

第二個構造方法:在佈局layout中使用的時候,比如我們在佈局中這樣寫:

<com.jarchie.customview.TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>

第三個構造方法:在佈局layout中使用但是會有style的時候調用,這個是什麼意思呢?比如我們佈局中某個控件有很多公共的屬性,一般情況下我們會將這些公共屬性提取出來放在style中,然後在佈局中引用style,比如:

<style name="CusTextView">
    <item name="android:layout_width">wrap_content</item>
    <item name="android:layout_height">wrap_content</item>
</style>
<!--在佈局中引用我們的style-->
<com.jarchie.customview.TextView
        style="@style/CusTextView"/>

第四個構造方法:關於這個構造方法,直接拿過來你會發現它會報錯,這裏先不說了,等後面用到的時候再說,防止把大家搞暈了

1.3、onMeasure()方法

說到自定義View首先要說的肯定是這個函數onMeasure(),而且絕大多數情況都是離不開它的,這個函數是用來測量佈局的寬高的,意思就是自定義View中佈局的寬高都是由這個方法去指定的,那麼它是如何去測量如何指定的呢?

我們首先來看一下上面這張圖,比如藍色部分是我們的ViewGroup,或者換句話說是父控件,內部垂直襬放兩個文本控件,每個文本控件的內容填充的不一樣,上面的文本寬度設置的是wrap_content,下面的文本設置的是100dp,思考一下系統是如何對各自指定對應的寬高的呢?這就要引出接下來要介紹的內容了:測量模式。

什麼是測量模式呢?如何獲取測量模式呢?來看下面的代碼:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    //獲取寬高的模式
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    //獲取寬高的值
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);
}

通過上面的代碼,我們可以清楚的看到寬高的測量模式是通過MeasureSpec.getMode()這個方法獲取的,這個沒啥難度,就是一個簡單的api調用,OK,現在關鍵的是這個MeasureSpec是個啥玩意啊?不懂啊翻譯一下,先從字面上看看啥意思:

谷歌給出的結果是測量規格,或者是測量說明書,不管你咋翻譯,這玩意看起來都好像或多或少的決定了View的測量過程。不明覺厲,來繼續往裏翻,點進去看源碼去,這裏我簡單抽取了一部分方便大家理解的:

public static class MeasureSpec {
        private static final int MODE_SHIFT = 30;
        private static final int MODE_MASK  = 0x3 << MODE_SHIFT;

        /**
         * Measure specification mode: The parent has not imposed any constraint
         * on the child. It can be whatever size it wants.
         */
        public static final int UNSPECIFIED = 0 << MODE_SHIFT;

        /**
         * Measure specification mode: The parent has determined an exact size
         * for the child. The child is going to be given those bounds regardless
         * of how big it wants to be.
         */
        public static final int EXACTLY     = 1 << MODE_SHIFT;

        /**
         * Measure specification mode: The child can be as large as it wants up
         * to the specified size.
         */
        public static final int AT_MOST     = 2 << MODE_SHIFT;

        /**
         * Creates a measure specification based on the supplied size and mode.
         * @param size the size of the measure specification
         * @param mode the mode of the measure specification
         * @return the measure specification based on size and mode
         */
        public static int makeMeasureSpec(@IntRange(from = 0, to = (1 << MeasureSpec.MODE_SHIFT) - 1) int size,
                                          @MeasureSpecMode int mode) {
            if (sUseBrokenMakeMeasureSpec) {
                return size + mode;
            } else {
                return (size & ~MODE_MASK) | (mode & MODE_MASK);
            }
        }

        /**
         * Extracts the mode from the supplied measure specification.
         *
         * @param measureSpec the measure specification to extract the mode from
         * @return {@link android.view.View.MeasureSpec#UNSPECIFIED},
         *         {@link android.view.View.MeasureSpec#AT_MOST} or
         *         {@link android.view.View.MeasureSpec#EXACTLY}
         */
        @MeasureSpecMode
        public static int getMode(int measureSpec) {
            //noinspection ResourceType
            return (measureSpec & MODE_MASK);
        }

        /**
         * Extracts the size from the supplied measure specification.
         *
         * @param measureSpec the measure specification to extract the size from
         * @return the size in pixels defined in the supplied measure specification
         */
        public static int getSize(int measureSpec) {
            return (measureSpec & ~MODE_MASK);
        }
}

從源碼上乍一看好像還挺複雜的,大體掃一遍有與或運算,還有移位運算,仔細一看,其實實現還是很簡單的,來詳細說說。

MeasureSpec它是一個32位的int值,高2位代表SpecMode(即測量模式),低30位代表SpecSize(即某種測量模式下的規格大小),它通過將SpecMode和SpecSize打包成一個int值來避免過多的對象內存分配,爲了方便操作還提供了打包和解包的方法。SpecMode有三大類,每一類都有自己特殊的含義,如下所示:

  • AT_MOST:它是在佈局中指定了wrap_content,也叫最大值模式,View的大小不能大於這個值
  • EXACTLY:它是在佈局中指定了確切的值(100dp)或者match_parent、fill_parent,也叫精確值模式,View的最終大小就是SpecSize所指定的值
  • UNSPECIFIED:這種模式說實話用的比較少,一般是用於系統內部系統控件,它是父容器不對View有任何限制,儘可能的大,要多大給多大(ListView、ScrollView等在測量子佈局的時候會用UNSPECIFIED)

問題延伸(面試題):ScrollView嵌套ListView爲什麼會顯示不全(只顯示一條)?

爲什麼會出現這種情況呢,其實就是ScrollView在測量子佈局的時候會使用UNSPECIFIED這種測量模式,我們到源碼中看一下:

首先進入源碼中,查看onMeasure()方法,這裏只把需要的代碼展示出來:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    ...
    ...//此處省略一堆代碼
}

發現它調用了父類的onMeasure()方法,我們從super.onMeasure()點擊去,跟到這個方法裏面看看:

看到了這個measureChild...()方法,點進去這是ViewGroup中的方法:

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

然後回到子類ScrollView中搜索這個方法,發現該方法在子類中有自己的實現:

@Override
    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 usedTotal = mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin +
                heightUsed;
        final int childHeightMeasureSpec = MeasureSpec.makeSafeMeasureSpec(
                Math.max(0, MeasureSpec.getSize(parentHeightMeasureSpec) - usedTotal),
                MeasureSpec.UNSPECIFIED);

        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
    }

childHeightMeasureSpec這個值它的處理方法中傳入的是MeasureSpec.UNSPECIFIED。

然後繼續找到ListView中的onMeasure()方法:

可以發現它的高度的測量模式是通過ScrollView傳遞進來的heightMeasureSpec,而這個值正是MeasureSpec.UNSPECIFIED,所以它的高度值最終會走到這個if語句中去:

if (heightMode == MeasureSpec.UNSPECIFIED) {
    heightSize = mListPadding.top + mListPadding.bottom + childHeight +
        getVerticalFadingEdgeLength() * 2;
}

到這裏問題就找到了,這個高度值給的是什麼啊?list的top+bottom+一個child的高度,所以就造成了大家遇到的那個問題,滑動的時候界面上始終只展示了一個Item。

問題找到了該如何解決呢?還是看源碼,既然進到這個if語句裏面有問題,那麼我們不讓它進到這個if裏面不就OK了嗎,我們讓它進到下面的MeasureSpec.AT_MOST這個if裏面去,measureHeightOfChildren內部具體的測量方法這裏就不再繼續說了,它內部其實就是會不斷的測量每個Item並累加最終返回所有Item的高度了,大家有興趣的自己對着源碼看一下吧:

if (heightMode == MeasureSpec.AT_MOST) {
            // TODO: after first layout we should maybe start at the first visible position, not 0
            heightSize = measureHeightOfChildren(widthMeasureSpec, 0, NO_POSITION, heightSize, -1);
        }

其實網上關於這個問題的解決辦法還是很多的,大部分人採用的也都是上面這種解決辦法,代碼量比較少,很方便快捷,我們重寫一個MyListView繼承自ListView,重寫onMeasure()方法,將heightMeasureSpec強行指定爲AT_MOST模式:

package com.jk51.clouddoc.ui.widget;

import android.content.Context;
import android.util.AttributeSet;
import android.widget.ListView;

/**
 * 作者:created by Jarchie
 * 時間:2020/4/20 15:43:48
 * 郵箱:[email protected]
 * 說明:重寫ListView,解決嵌套顯示不全問題
 */
public class MyListView extends ListView {
    public MyListView(Context context) {
        super(context);
    }

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

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

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        //解決顯示不全的問題 heightMeasureSpec是一個32位的值,右移兩位
        heightMeasureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST);
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }
}

1.4、onDraw()方法

onDraw()方法在自定義View中就是用來繪製的,它可以繪製的東西有很多,文本、矩形、圓形、圓弧等等,這個方法裏面全都是一些api的調用,沒什麼太多需要介紹的,後面使用到的時候再具體介紹吧:

//繪製的方法
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawText(); //畫文本
        canvas.drawArc(); //畫弧
        canvas.drawCircle(); //畫圓
    }

可以先看一下我之前寫的一篇Android自定義繪製基礎:https://blog.csdn.net/JArchie520/article/details/78199580

1.5、onTouchEvent()方法

onTouchEvent()方法主要是處理跟用戶交互的相關操作,比如:手指觸摸按下、移動、擡起等等,這一塊涉及到的內容很多信息量很大,包括安卓面試中一個非常著名的問題:安卓的事件分發機制,今天先不分析這一塊的實現,後面的話會帶着源碼分析一下,源碼這東西是一看一臉懵逼,進去了就別想出來了,所以對於事件分發事件攔截到後面再看吧,得一步一步來,今天只瞭解一下最簡單的幾個api即可:

    /**
     * 處理跟用戶交互的,手指觸摸按下、移動、擡起等等
     *
     * @param event 事件分發事件攔截
     * @return
     */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                Log.e("TAG", "手指按下");
                break;
            case MotionEvent.ACTION_MOVE:
                Log.e("TAG", "手指移動");
                break;
            case MotionEvent.ACTION_UP:
                Log.e("TAG", "手指擡起");
                break;
        }
        return super.onTouchEvent(event);
    }

1.6、自定義屬性

自定義屬性就是用來配置的,比如:android:text="Jarchie"是系統的一個自定義屬性,當我們在自己寫自定義View的時候還這樣寫可以嗎?那當然不可以了,所以我們需要自己去定義你的View的相關屬性,那麼該如何做呢?

首先我們需要在res/values文件夾下新建一個屬性的配置文件,名字可以隨意起,但是爲了規範,一般命名爲attrs.xml,然後xml內部如何編寫呢?其實寫法都是按套路來就好了,具體的如何定義註釋我都放在代碼裏了,舉個栗子:

<resources>
    <!--name 自定義View的名字 TextView-->
    <declare-styleable name="TextView">
    <!-- name 屬性名稱
    format 格式: string 文字  color 顏色
                dimension 寬高 字體大小
                integer 數字 比如最大長度
                reference 資源(drawable)
    -->
        <attr name="text" format="string" />
        <attr name="textColor" format="color" />
        <attr name="textSize" format="dimension" />
        <attr name="maxLength" format="integer" />
        <attr name="background" format="reference|color" />

        <!-- 枚舉 -->
        <attr name="inputType">
            <enum name="number" value="1" />
            <enum name="text" value="2" />
            <enum name="password" value="3" />
        </attr>

    </declare-styleable>
</resources>

然後定義了相關屬性之後,如何在佈局中使用呢?

我們需要聲明命名空間:xmlns:jarchie="http://schemas.android.com/apk/res-auto",然後在自己的自定義View中使用,注意命名空間的名稱和下方引用的名稱需保持一致,名稱可以隨意起,但是一般都定義爲app,我這裏用自己的名字做個演示:

最後是如何在自定義View中獲取配置的屬性呢?其實也都是按套路來的代碼了,一起來看一下:

然後你一運行即將發現意外之喜,哎呦報錯啦,沒錯不能這樣寫哈,因爲上面我們在attrs.xml裏面定義的那些屬性,系統都是有的,也就是說系統不允許你將它已經有的屬性重新定義,所以給你一頓報錯,該如何解決呢?也很簡單啊,屬性名稱全部改掉,改成系統沒有的然後就OK了,比如上面的:<attr name="text" format="string" />這個name就不能這樣寫了,要改成系統沒有的<attr name="mytext" format="string" />,一定要記住哈!到這裏,自定義View的簡介就先說這麼多,第二部分會結合上面講的內容來完成自定義TextView的編寫。

二、自定義TextView

這一部分通過上面介紹的基礎知識,來寫一個入門的自定義View的案例:自定義TextView。

2.1、自定義屬性

    <!--自定義TextView-->
    <declare-styleable name="TextView">
        <attr name="archieText" format="string"/>
        <attr name="archieTextColor" format="color"/>
        <attr name="archieTextSize" format="dimension"/>
        <attr name="archieMaxLength" format="integer"/>
        <!--background 自定義View都是繼承自View,背景都是由View管理的-->
        <!--<attr name="archieBackground" format="reference"/>-->

        <attr name="archieInputType">
            <enum name="number" value="1"/>
            <enum name="text" value="2"/>
            <enum name="password" value="3"/>
        </attr>
    </declare-styleable>

2.2、實現TextView

package com.healthrm.ningxia.ui.view;

import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.View;

import com.healthrm.ningxia.R;

/**
 * 作者:created by Jarchie
 * 時間:2020/4/21 09:15:29
 * 郵箱:[email protected]
 * 說明:安卓自定義TextView
 */
public class TextView extends View {
    private String mText;
    private int mTextSize = 15;
    private int mTextColor = Color.BLACK;
    private Paint mPaint;

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

    public TextView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public TextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        //獲取自定義屬性
        TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.TextView);
        mText = array.getString(R.styleable.TextView_archieText);
        mTextColor = array.getColor(R.styleable.TextView_archieTextColor, mTextColor);
        mTextSize = array.getDimensionPixelSize(R.styleable.TextView_archieTextSize, sp2px(mTextSize));
        array.recycle();
        mPaint = new Paint();
        mPaint.setAntiAlias(true); //抗鋸齒
        mPaint.setTextSize(mTextSize); //畫筆大小
        mPaint.setColor(mTextColor); //畫筆顏色
    }

    //sp轉px
    private int sp2px(int sp) {
        return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, getResources().getDisplayMetrics());
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //獲取寬高的測量模式
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        //1、確定的值,這種情況不需要計算,給的多少就是多少
        int width = MeasureSpec.getSize(widthMeasureSpec);
        int height = MeasureSpec.getSize(heightMeasureSpec);
        //2、wrap_content,需要計算
        if (widthMode == MeasureSpec.AT_MOST) {
            //計算的寬度與字體的長度、大小有關,用畫筆來
            Rect bounds = new Rect();
            //獲取文本的rect
            mPaint.getTextBounds(mText, 0, mText.length(), bounds);
            width = bounds.width() + getPaddingLeft() + getPaddingRight();
        }
        if (heightMode == MeasureSpec.AT_MOST) {
            Rect bounds = new Rect();
            mPaint.getTextBounds(mText, 0, mText.length(), bounds);
            height = bounds.height() + getPaddingTop() + getPaddingBottom();
        }
        //設置控件的寬高
        setMeasuredDimension(width, height);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //畫文字 text   x:開始位置   y:基線    paint
        //dy代表的是高度的一半到baseLine的距離
        Paint.FontMetricsInt fontMetricsInt = mPaint.getFontMetricsInt();
        //top是一個負值 bottom是一個正值 top、bottom的值代表的是baseLine到文字頂部和底部的距離
        int dy = (fontMetricsInt.bottom - fontMetricsInt.top) / 2 - fontMetricsInt.bottom;
        int baseLine = getHeight() / 2 + dy;
        int x = getPaddingLeft();
        canvas.drawText(mText, x, baseLine, mPaint);
    }
}

2.3、在佈局中使用

<?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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <com.healthrm.ningxia.ui.view.TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:archieText="Jarchie"
        android:padding="10dp"
        app:archieTextColor="@color/colorAccent"/>
    
    <com.healthrm.ningxia.ui.view.TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:archieText="Jarchie's Blog"
        android:padding="10dp"
        android:background="@color/colorAccent"
        app:archieTextColor="@color/red"/>
</LinearLayout>

2.4、運行效果

這就是我們的一個入門級的自定義View,今天的內容就這麼多了,下期再會!

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