Android -- 從源碼的角度一步步打造自己的TextView


1,自定義控件一直是我們的痛點,今天就和大家一點點去了解了解,首先一般的自定義控件都是繼承於View類,所以我們先來看看view的一些重要的方法,這是官方文檔,大家想了解更多也可以去看看,這里我展示對我們常用到的,如下所示:

  根據上面的方法,發現我們想繪制自己的view的話最簡單的就是重寫一下OnDraw()方法就行,今天和大家一起打造自己的Textview。

2,重寫OnDraw()方法

  創建一個MyTextView,繼承自View類,這里我們要重寫它的四個構造方法,一般重寫前三個構造方法就行,它們的參數不一樣分別對應不同的創建方式,比如只有一個Context參數的構造方法通常是通過代碼初始化控件時使用;而兩個參數的構造方法通常對應布局文件中控件被映射成對象時調用(需要解析屬性);通常我們讓這兩個構造方法最終調用三個參數的構造方法,然后在第三個構造方法中進行一些初始化操作。

package com.qianmo.activitydetail;

import android.content.Context;
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.Log;
import android.view.View;

/**
 * Created by wangjitao on 2017/3/20 0020.
 * E-Mail:543441727@qq.com
 */

public class MyTextView extends View {
    private final static String TAG = "MyTextView";

    //文字
    private String mText;

    //文字的顏色
    private int mTextColor;

    //文字的大小
    private int mTextSize;

    //繪制的范圍
    private Rect mBound;
    private Paint mPaint;

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

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

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

    /**
     * 初始化數據
     */
    private void init() {
        //初始化數據
        mText = " I Love You !";
        mTextColor = Color.RED;
        mTextSize = 30;

        //初始化Paint數據
        mPaint = new Paint();
        mPaint.setColor(mTextColor);
        mPaint.setTextSize(mTextSize);

        //獲取繪制的寬高
        mBound = new Rect();
        mPaint.getTextBounds(mText, 0, mText.length(), mBound);
        Log.i(TAG, "Left :" + mBound.left + ",Right:" + mBound.right + ",Top:" + mBound.top + ",Bottom:" + mBound.bottom);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        //繪制文字
        canvas.drawText(mText, getWidth() / 2 - mBound.width() / 2, getHeight() / 2 + mBound.height() / 2, mPaint);
        //注意一下我們這里的getWidth()和getHeight()是獲取的px
        Log.i(TAG, "getWidth():" + getWidth() + ",getHeight(): " + getHeight());
    }
}

  布局文件

<?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:myview="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    >

    <com.qianmo.activitydetail.MyTextView
        android:layout_width="200dp"
        android:layout_height="100dp"
        />


</LinearLayout>

  運行效果如下:

  注意一下,這里我們一直有一個疑問一下我們在onDraw()方法中得到的寬度和高度的單位是什么呢?是px還是sp還dp?讓我們看一看源碼子解釋的,這時候我們看一下源碼

   源碼高速我們返回的是px像素,那么我們在xml文件中設置的寬度是200dp怎么轉換成我們的px的呢?  

   舉例:HVGA屏320*480,一般是3.5寸,計算點密度為√ (320^2 + 480^2) / 3.5 = 164,約等於160,1pd=1px,WVGA屏480*800,按3.8寸屏算,點密度 √ (480^2 + 800^2) / 3.8 = 245,約等於240,1dp=1.5px。

PPI = √(長度像素數² + 寬度像素數²) / 屏幕對角線英寸數
dp:Density-independent pixels,以160PPI屏幕為標准,則1dp=1px,
dp和px的換算公式 :
dp*ppi/160 = px。比如1dp x 320ppi/160 = 2px。

  我們這個模擬器是1920*1080分辨率尺寸是5.2 按照上面的公式計算,我們機型的換算為大約得到 1dp = 2.75px 

  ok,題外話扯太多了

  上面的代碼我只是簡單的寫了下onDraw()方法,那我們現在有這一種需求,所寫入的文字、文字顏色、文字大小等屬性是可以有開發者動態控制的,那這時候我們該怎么辦呢?這時候就要使用到我們的自定義屬性了

3,自定義屬性

  在res/values文件下創建attrs文件,在文件下添加我們需要的自定義屬性,format是這個屬性的輸入格式 ,具體代碼如下:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="MyTextView2">
        <attr name="myText" format="string"/>
        <attr name="myTextColor" format="color"/>
        <attr name="myTextSize" format="dimension"/>
    </declare-styleable>
</resources>

  這時候我們在布局文件中想要使用我們的自定義屬性需要在根父容器加上自己命名空間 如:xmlns:myview="http://schemas.android.com/apk/res-auto" ,前面的myview名字可以自己改變,再看一下我們在布局文件中怎么動態的設置我們的屬性的

<com.qianmo.activitydetail.MyTextView2
        android:layout_width="200dp"
        android:layout_height="100dp"
        android:background="#00ff00"
        myview:myText=" I Love You ......"
        myview:myTextColor="#ff3399"
        myview:myTextSize="25sp"
        />

  這時候在構造函數中去獲取我們的自定義屬性

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

    /**
     * 初始化數據
     */
    private void init(Context context, AttributeSet attrs, int defStyleAttr) {
        //獲取自定義屬性的值
        TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.MyTextView2, defStyleAttr, 0);
        mText = a.getString(R.styleable.MyTextView2_myText);
        mTextColor = a.getColor(R.styleable.MyTextView2_myTextColor, Color.BLACK);
        mTextSize = a.getDimension(R.styleable.MyTextView2_myTextSize, 30f);
        a.recycle();

        //初始化Paint數據
        mPaint = new Paint();
        mPaint.setColor(mTextColor);
        mPaint.setTextSize(mTextSize);

        //獲取繪制的寬高
        mBound = new Rect();
        mPaint.getTextBounds(mText, 0, mText.length(), mBound);
        Log.i(TAG, "Left :" + mBound.left + ",Right:" + mBound.right + ",Top:" + mBound.top + ",Bottom:" + mBound.bottom);
    }

  看一下我們的運行效果

 

  可以看到我們的效果出來了,可以由開發者自定義,現在我們將myText屬性修改為“I Love You ...... I Love You ...... ”

   發現不文明現象,那我們試着按照我們的正常思維,將寬度和高度設置為wrap_content,看看效果

  感覺一臉的懵逼,我是寬度匹配內容物寬度,為什么會占用滿屏???既然出現了問題我們要找找錯誤的原因,我們看看上面展示的官方文檔,既然是大小出問題了,那就看看我們的onMeasure()源碼

4,onMeasure方法

  在學習onMeasure方法之前我們來了解MeasuerSpec這個類,點進入源碼,發現他是個尺寸和測量模式的集合,用來描述父控件對子控件的約束,接下來我們來看看部分源碼

/**MeasureSpec 封裝了父控件對其孩子的布局要求
有大小和模式兩種,而模式則有三種模式
public static class MeasureSpec {
        private static final int MODE_SHIFT = 30;
        private static final int MODE_MASK  = 0x3 << MODE_SHIFT;
        //父控件不強加任何約束給子控件,它可以為它逍遙的任何大小
        public static final int UNSPECIFIED = 0 << MODE_SHIFT; //0
        //父控件給子控件一個精確的值
        public static final int EXACTLY     = 1 << MODE_SHIFT; //1073741824
 	//父控件給子控件竟可能最大的值
        public static final int AT_MOST     = 2 << MODE_SHIFT; //-2147483648
        //設定尺寸和模式創建的統一約束規范
        public static int makeMeasureSpec(int size, int mode) {
            if (sUseBrokenMakeMeasureSpec) {
                return size + mode;
            } else {
                return (size & ~MODE_MASK) | (mode & MODE_MASK);
            }
        }
     // 從規范中獲取模式
        public static int getMode(int measureSpec) {
            return (measureSpec & MODE_MASK);
        }
     //從規范中獲取尺寸 
        public static int getSize(int measureSpec) {
            return (measureSpec & ~MODE_MASK);
        }
}

  這里我們重寫onMeasure方法,在方法里面打印一下我們的在xml文件中的布局對應的MeasuerSpec的模式,重寫的onMeasure()代碼如下

  @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); //獲取高的尺寸

        Log.i(TAG, "widthMode:" + widthMode);
        Log.i(TAG, "heightMode:" + heightMode);
        Log.i(TAG, "widthSize:" + widthSize);
        Log.i(TAG, "heightSize:" + heightSize);
}

  場景一:設置MyView的寬高為wrap_content

 <com.qianmo.activitydetail.MyTextView2
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="#00ff00"
        myview:myText=" I Love You ......I Love You ......"
        myview:myTextColor="#ff3399"
        myview:myTextSize="25sp"
        />

  看一下我們的輸出結果

03-20 05:09:20.020 15255-15255/? I/MyTextView: widthMode:-2147483648
03-20 05:09:20.020 15255-15255/? I/MyTextView: heightMode:-2147483648
03-20 05:09:20.020 15255-15255/? I/MyTextView: widthSize:1080
03-20 05:09:20.020 15255-15255/? I/MyTextView: heightSize:1731

  場景二:設置MyView的寬高為match_parent

<com.qianmo.activitydetail.MyTextView2
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#00ff00"
        myview:myText=" I Love You ......I Love You ......"
        myview:myTextColor="#ff3399"
        myview:myTextSize="25sp"
        />

  看一下輸出結果

03-20 05:08:17.041 14272-14272/? I/MyTextView: widthMode:1073741824
03-20 05:08:17.041 14272-14272/? I/MyTextView: heightMode:1073741824
03-20 05:08:17.041 14272-14272/? I/MyTextView: widthSize:1079
03-20 05:08:17.041 14272-14272/? I/MyTextView: heightSize:1541

  ok,這樣我們可以總結出了我們以前得到的一些結果,如下表所示

  那么我們現在有一個疑問了,為什么我們的wrap_content會占用的是整個屏幕的寬度和高度,這時候我們就要看一下我們OnMeasure()源碼中是怎么處理的

View的源碼
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
                getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
    }

public static int getDefaultSize(int size, int measureSpec) {
        int result = size;
        int specMode = MeasureSpec.getMode(measureSpec);
        int specSize = MeasureSpec.getSize(measureSpec);

        switch (specMode) {
        case MeasureSpec.UNSPECIFIED:
            result = size;
            break;
        case MeasureSpec.AT_MOST:
        case MeasureSpec.EXACTLY:
            result = specSize;
            break;
        }
        return result;
    }

  可以看到我們的源碼中調用是自身的getDefaultSize()方法,然后在MeasureSpec.AT_MOST和MeasureSpec.EXACTLY全部返回的是specSize,而specSize表示的是父控件剩余寬度,也就是我們看到的全屏。所以默認onMeasure方法中wrap_content 和match_parent 的效果是一樣的,都是填充剩余的空間。

4,重寫onMeasuer()方法

@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); //獲取高的尺寸

        Log.i(TAG, "widthMode:" + widthMode);
        Log.i(TAG, "heightMode:" + heightMode);
        Log.i(TAG, "widthSize:" + widthSize);
        Log.i(TAG, "heightSize:" + heightSize);

        //下面對wrap_content這種模式進行處理
        int width;
        int height;
        if (widthMode == MeasureSpec.EXACTLY) {
            width = widthSize;
        } else {
            //如果是wrap_content,我們要得到控件需要多大的尺寸
            //首先丈量文本的寬度
            float textWidth = mBound.width();
            //控件的寬度就是文本的寬度加上兩邊的內邊距。內邊距就是padding值,在構造方法執行完就被賦值
            width = (int) (getPaddingLeft() + textWidth + getPaddingRight());
        }
        if (heightMode == MeasureSpec.EXACTLY) {
            height = widthSize;
        } else {
            //如果是wrap_content,我們要得到控件需要多大的尺寸
            //首先丈量文本的寬度
            float textHeight = mBound.height();
            //控件的寬度就是文本的寬度加上兩邊的內邊距。內邊距就是padding值,在構造方法執行完就被賦值
            height = (int) (getPaddingTop() + textHeight + getPaddingBottom());
        }

        //保存丈量結果
        setMeasuredDimension(width, height);
    }

  看一下我們實現的效果,這樣我們的內容就全部展示出來了

   現在我們繼續該我們的需求,將myText屬性變成“I Love You ...... I Love You ...... I Love You ...... ”  看一下我們的效果,貌似沒把后面的內容展示完全,這時候我們想實現它自動換行

5,實現TextView的自動換行

  先說一下我們的思路,這里說一下我們打算先計算我們要展示的textview需要花費多少行展示,然后再將里面展示的內容切割,放到數組里面,在用過onDraw繪制文字,這種只是一種實現方式,而且感覺性能不太好,大家有時間的話可以去看一下TextView的源碼,看一些官方是怎么試下的,下面看一下我們切割text內容的代碼

package com.qianmo.activitydetail;

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.text.TextUtils;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.widget.TextView;

import java.util.ArrayList;

/**
 * Created by wangjitao on 2017/3/20 0020.
 * E-Mail:543441727@qq.com
 */

public class MyTextView4 extends View {
    private final static String TAG = "MyTextView";

    //文字
    private String mText;
    private ArrayList<String> mTextList;

    //文字的顏色
    private int mTextColor;

    //文字的大小
    private float mTextSize;

    //繪制的范圍
    private Rect mBound;
    private Paint mPaint;

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

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

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

    /**
     * 初始化數據
     */
    private void init(Context context, AttributeSet attrs, int defStyleAttr) {
        //獲取自定義屬性的值
        TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.MyTextView2, defStyleAttr, 0);
        mText = a.getString(R.styleable.MyTextView2_myText);
        mTextColor = a.getColor(R.styleable.MyTextView2_myTextColor, Color.BLACK);
        mTextSize = a.getDimension(R.styleable.MyTextView2_myTextSize, 30f);
        a.recycle();

        //初始化Paint數據
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setColor(mTextColor);
        mPaint.setTextSize(mTextSize);

        //獲取繪制的寬高
        mBound = new Rect();
        mTextList = new ArrayList<>();
        mPaint.getTextBounds(mText, 0, mText.length(), mBound);

    }

    @Override
    protected void onDraw(Canvas canvas) {
        //繪制文字
        for (int i = 0; i < mTextList.size(); i++) {
            mPaint.getTextBounds(mTextList.get(i), 0, mTextList.get(i).length(), mBound);
            Log.v(TAG, "在X:" + (getWidth() / 2 - mBound.width() / 2) + "  Y:" + (getPaddingTop() + (mBound.height() * i)) + "  繪制:" + mTextList.get(i));
            canvas.drawText(mTextList.get(i), (getWidth() / 2 - mBound.width() / 2), (getPaddingTop() + (mBound.height() * (i + 1))), mPaint);
            Log.i(TAG, "getWidth() :" + getWidth() + ", mBound.width():" + mBound.width() + ",getHeight:" + getHeight() + ",mBound.height() *i:" + mBound.height() * i);
        }
    }

    boolean isOneLine = true;
    float lineNum;
    float splineNum;

    @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); //獲取高的尺寸

        Log.i(TAG, "widthMode:" + widthMode);
        Log.i(TAG, "heightMode:" + heightMode);
        Log.i(TAG, "widthSize:" + widthSize);
        Log.i(TAG, "heightSize:" + heightSize);

        float textWidth = mBound.width();   //文本的寬度
        if (mTextList.size() == 0) {
            //將文本分段
            int padding = getPaddingLeft() + getPaddingRight();
            int specWidth = widthSize - padding; //剩余能夠顯示文本的最寬度
            if (textWidth <= specWidth) {
                //可以顯示一行
                lineNum = 1;
                mTextList.add(mText);
            } else {
                //超過一行
                isOneLine = false;
                splineNum = textWidth / specWidth;
                //如果有小數的話則進1
                if ((splineNum + "").contains(".")) {
                    lineNum = (int) (splineNum + 0.5);
//                    lineNum = Integer.parseInt((splineNum + "").substring(0, (splineNum + "").indexOf("."))) + 1;
                } else {
                    lineNum = splineNum;
                }
                int lineLength = (int) (mText.length() / splineNum);
                for (int i = 0; i < lineNum; i++) {
                    String lineStr;
                    //判斷是否可以一行展示
                    if (mText.length() < lineLength) {
                        lineStr = mText.substring(0, mText.length());
                    } else {
                        lineStr = mText.substring(0, lineLength);
                    }
                    mTextList.add(lineStr);
                    if (!TextUtils.isEmpty(mText)) {
                        if (mText.length() < lineLength) {
                            mText = mText.substring(0, mText.length());
                        } else {
                            mText = mText.substring(lineLength, mText.length());
                        }
                    } else {
                        break;
                    }
                }

            }
        }

        //下面對wrap_content這種模式進行處理
        int width;
        int height;
        if (widthMode == MeasureSpec.EXACTLY) {
            width = widthSize;
        } else {
            //如果是wrap_content,我們要得到控件需要多大的尺寸
            if (isOneLine) {
                //控件的寬度就是文本的寬度加上兩邊的內邊距。內邊距就是padding值,在構造方法執行完就被賦值
                width = (int) (getPaddingLeft() + textWidth + getPaddingRight());
            } else {
                //如果是多行,說明控件寬度應該填充父窗體
                width = widthSize;
            }

        }
        if (heightMode == MeasureSpec.EXACTLY) {
            height = heightSize;
        } else {
            //如果是wrap_content,我們要得到控件需要多大的尺寸
            //首先丈量文本的寬度
            float textHeight = mBound.height();
            if (isOneLine) {
                //控件的寬度就是文本的寬度加上兩邊的內邊距。內邊距就是padding值,在構造方法執行完就被賦值
                height = (int) (getPaddingTop() + textHeight + getPaddingBottom());
            } else {
                //如果是多行
                height = (int) (getPaddingTop() + textHeight * lineNum + getPaddingBottom());
            }
        }

        //保存丈量結果
        setMeasuredDimension(width, height);
    }
}

  效果如下:

  ok,自動換行實現了,下面的是Github源碼地址,有需要的同學可以下下來看看。See You Next Time ····

 


免責聲明!

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



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