Android 自定義控件基本教程之自定義一個圓形ImageView可設置邊框和陰影(demo帶詳細註釋)

前言

做Android開發,必定離不開的就是自定義控件。祖傳的原生控件肯定不足以完成UI設計的全部要求。當然,很多情況下,百度一下(沒有歧視Google的意思)你也能找到不錯的輪子完成你的需求。但是,更多的時候你會發現費勁千辛萬苦找到的幾個類似的控件總有那麼幾個不完美的地方或者和你需求有出入的地方,這時候你就撓頭了,這可怎麼辦啊!不過,也別撓了,沒幾根頭髮了。

話已至此,還是要學一下自定義控件的,理解流程也對我們修改現成的輪子有幫助。

目標

既然要學着做,那就先來個簡單的、最常見的需求,圓形ImageView -----大部分的用戶頭像之類的都是這種。而這次我們要做的呢就是:

  • 圓形ImageView
  • 帶邊框寬度、顏色設置
  • 帶陰影模糊半徑、顏色設置

效果圖

在這裏插入圖片描述

感謝

這文章參考了幾篇文章,先表示一下respect
Android自定義控件之基本原理 ---- 總李寫代碼
如何在圓形 imageView android 上添加一個陰影和邊界? ---- 尐新沒蠟筆
Bitmap截取中間正方形並取出圓形圖片 ---- soft_po

流程

敲代碼前先了解下流程:

  1. 自定義屬性
  2. 初始化控件
  3. 測量控件寬高onMeasure
  4. 佈局子控件onLayout (存在於繼承了ViewGroup類的組合型控件)
  5. 繪製控件onDraw
  6. 提供代碼修改屬性的方法
  7. 使用

第一步、自定義屬性

自定義屬性是什麼?爲什麼要自定義屬性?
自定義控件當然是有想要自定的邏輯在其中,如果想要在佈局的時候就能初始化一些控件的屬性,就需要自定義屬性。
好比如我們要做的圓形ImageView,我們多了邊框的功能,那麼我們就需要有邊框的寬度和顏色屬性給予開發者去配置,好讓開發者在預覽界面就能看到相應的效果。

那怎麼去自定義屬性呢?
我們需要明確我們的控件能夠給予開發者去配置的選項有哪些,然後列出來它們對應的參數單位;
然後在項目的 /res/values/ 文件夾下創建 attrs.xml 文件
打開它,並填入屬性

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="DLRoundImageView">
        <attr name="borderWidth" format="integer" /> <!--邊框寬度-->
        <attr name="borderColor" format="color" /> <!--邊框顏色-->
        <attr name="hasShadow" format="boolean" /> <!--是否有陰影-->
        <attr name="shadowColor" format="color" /> <!--陰影顏色-->
        <attr name="shadowRadius" format="float" /> <!--陰影模糊半徑-->
    </declare-styleable>
</resources>

對於屬性組名稱來說,一般填入自定義控件的名稱就好了

<declare-styleable name="DLRoundImageView">

而對於一組屬性來說,屬性名稱就需要按照駝峯式命名,而且最好要能見名知意

<attr name="borderWidth" format="integer" /> <!--邊框寬度-->

而後面的format指的是這個屬性的取值類型,類型有以下幾種:

  • reference:引用資源
  • string::字符串
  • Color:顏色
  • boolean:布爾值
  • dimension:尺寸值
  • float:浮點型
  • integer:整型
  • fraction:百分數
  • enum:枚舉類型
  • flag:位或運算

enum 和 flag 怎麼用呢?
下面舉例:enum就是單選;flag就是多選;

<declare-styleable name="viewTest">
        <attr name="flagTest">
            <flag name="flag0" value="0" />
            <flag name="flag1" value="1" />
        </attr>

        <attr name="enumTest">
            <enum name="enum0" value="0" />
            <enum name="enum1" value="1" />
        </attr>
 </declare-styleable>

第二步、初始化控件

建立好屬性表以後,我們就去新建一個Class,命名爲:DLRoundImageView 繼承 AppCompatImageView
初始化用到的變量

/** 邊框寬度 默認值 */
    private int mBorderWidth = 0;
    /** 邊框顏色 默認值 */
    private int mBorderColor = Color.WHITE;
    /** 是否有陰影 默認值 */
    private boolean mHasShadow = false;
    /** 陰影顏色 默認值 */
    private int mShadowColor = Color.BLACK;
    /** 陰影模糊半徑 默認值 */
    private float mShadowRadius = 4.0f;

    /** 圖片直徑 */
    private float mBitmapDiameter = 120f;
    /** 需要繪製的圖片 */
    private Bitmap mBitmap;
    /** 圖片的畫筆 */
    private Paint mBitmapPaint;
    /** 邊框的畫筆 */
    private Paint mBorderPaint;
    /** 圖片的渲染器 */
    private BitmapShader mBitmapShader;
    /** 控件寬度 */
    private float widthOrHeight;
    /** 控件初始設置寬度 */
    private float widthSpecSize;
    /** 控件初始設置寬度 */
    private float heightSpecSize;

初始化控件的基本參數

public DLRoundImageView(Context context) {
        super(context);
        initData(context, null);
    }

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

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

    /** * 帶參數初始化 * Initialization with parameters * @param context * @param attrs */
    private void initData(Context context, AttributeSet attrs) {
        if (null != attrs){
            // 如果用戶有設置參數
            // If the user has set parameters
            @SuppressLint("Recycle")
            TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.DLRoundImageView);
            if (null != typedArray){
                // 讀取邊框寬度
                // Read the border width
                mBorderWidth = typedArray.getInt(R.styleable.DLRoundImageView_borderWidth, mBorderWidth);
                // 讀取邊框顏色
                // Read the border color
                mBorderColor = typedArray.getColor(R.styleable.DLRoundImageView_borderColor, mBorderColor);
                // 讀取是否有陰影
                // Read has shadow
                mHasShadow = typedArray.getBoolean(R.styleable.DLRoundImageView_hasShadow, mHasShadow);
                // 讀取陰影顏色
                // Read the shadow color
                mShadowColor = typedArray.getColor(R.styleable.DLRoundImageView_shadowColor, mShadowColor);
                // 讀取陰影模糊半徑
                // Read the shadow radius
                mShadowRadius = typedArray.getFloat(R.styleable.DLRoundImageView_shadowRadius, mShadowRadius);
            }
        }
        // 實例化圖片的畫筆
        mBitmapPaint = new Paint();
        // 設置打開抗鋸齒功能
        // Turn on anti-aliasing
        mBitmapPaint.setAntiAlias(true);
        // 實例化邊框的畫筆
        mBorderPaint = new Paint();
        // 設置邊框顏色
        // Set the border color
        mBorderPaint.setColor(mBorderColor);
        // 設置打開抗鋸齒功能
        // Turn on anti-aliasing
        mBorderPaint.setAntiAlias(true);
        // 設置硬件加速
        // Set hardware acceleration
        this.setLayerType(LAYER_TYPE_SOFTWARE, mBorderPaint);
        // 設置陰影參數
        // Set shadow parameters
        if (mHasShadow){
            mBorderPaint.setShadowLayer(mShadowRadius, 0, 0, mShadowColor);
        }
    }

重點在於讀取開發者使用控件時在寫佈局的時候填入的參數值

if (null != attrs){
            // 如果用戶有設置參數
            // If the user has set parameters
            @SuppressLint("Recycle")
            TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.DLRoundImageView);
            if (null != typedArray){
                // 讀取邊框寬度
                // Read the border width
                mBorderWidth = typedArray.getInt(R.styleable.DLRoundImageView_borderWidth, mBorderWidth);
                // 讀取邊框顏色
                // Read the border color
                mBorderColor = typedArray.getColor(R.styleable.DLRoundImageView_borderColor, mBorderColor);
                // 讀取是否有陰影
                // Read has shadow
                mHasShadow = typedArray.getBoolean(R.styleable.DLRoundImageView_hasShadow, mHasShadow);
                // 讀取陰影顏色
                // Read the shadow color
                mShadowColor = typedArray.getColor(R.styleable.DLRoundImageView_shadowColor, mShadowColor);
                // 讀取陰影模糊半徑
                // Read the shadow radius
                mShadowRadius = typedArray.getFloat(R.styleable.DLRoundImageView_shadowRadius, mShadowRadius);
            }
        }

要注意這麼一點,「R.styleable.DLRoundImageView_hasShadow」 的 DLRoundImageView_hasShadow 是由屬性組名稱加上屬性名稱組成,單獨填入屬性名稱是讀取不到的,在寫代碼時也會報錯說找不到這個屬性。

第三步、測量控件寬高onMeasure

複寫onMeasure函數,去設置控件寬度和高度,這是非常重要的一步,很多bug和需求都是這裏先改動。
specMode的值說明:

  • UNSPECIFIED:不對View大小做限制,如:ListView,ScrollView
  • EXACTLY:確切的大小,如:100dp或者march_parent
  • AT_MOST:大小不可超過某數值,如:wrap_content

因爲我們要做的是一個圓形圖片,所以我們要在這個測量方法中確定的是,用戶設置的寬高中,哪一個比較短,而更短的那個數值就是我們的圓形ImageView整個控件的直徑了,而其中的圖片顯示區域圓形的直徑則需要再減去兩邊的邊框寬度。

/** * 測量 * MeasureSpec值由specMode和specSize共同組成 * specMode的值有三個,MeasureSpec.EXACTLY、MeasureSpec.AT_MOST、MeasureSpec.UNSPECIFIED * MeasureSpec.EXACTLY:父視圖希望子視圖的大小應該是specSize中指定的。 * MeasureSpec.AT_MOST:子視圖的大小最多是specSize中指定的值,也就是說不建議子視圖的大小超過specSize中給定的值。 * MeasureSpec.UNSPECIFIED:我們可以隨意指定視圖的大小。 * @param widthMeasureSpec * @param heightMeasureSpec */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        // 得到寬度數據模式
        // Get width data mode
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        // 得到寬度數據
        // Get width data
        widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
        // 得到高度數據模式
        // Get height data mode
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        // 得到高度數據
        // Get height data
        heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
        // 初始化控件高度爲 圖片直徑 + 兩個邊框寬度(左右邊框)
        widthOrHeight = mBitmapDiameter + (mBorderWidth * 2);
        // 判斷寬高數據格式
        if (widthSpecMode == MeasureSpec.EXACTLY && heightSpecMode == MeasureSpec.EXACTLY){
            // 如果寬高都給定了數據
            if (widthSpecSize > heightSpecSize){
                // 如果高度小,圖片直徑等於高度減兩個邊框寬度
                mBitmapDiameter = heightSpecSize - (mBorderWidth * 2);
                // 控件寬度爲高度
                widthOrHeight = heightSpecSize;
            } else {
                // 如果寬度小,圖片直徑等於寬度減兩個邊框寬度
                mBitmapDiameter = widthSpecSize - (mBorderWidth * 2);
                // 控件寬度爲寬度
                widthOrHeight = widthSpecSize;
            }
        } else if (widthSpecMode == MeasureSpec.EXACTLY){
            // 如果只給了寬度,圖片直徑等於寬度減兩個邊框寬度
            mBitmapDiameter = widthSpecSize - (mBorderWidth * 2);
            // 控件寬度爲寬度
            widthOrHeight = widthSpecSize;
        } else if (heightSpecMode == MeasureSpec.EXACTLY){
            // 如果只給了高度,圖片直徑等於高度減兩個邊框寬度
            mBitmapDiameter = heightSpecSize - (mBorderWidth * 2);
            // 控件寬度爲高度
            widthOrHeight = heightSpecSize;
        }
        // 保存測量好的寬高,向上取整再加2爲了保證畫布足夠畫下邊框和陰影
        // Save the measured width and height
        setMeasuredDimension((int) Math.ceil((double) widthOrHeight + 2), (int) Math.ceil((double) widthOrHeight + 2));
    }

第四步、繪製控件onDraw

測量完就會進入繪製階段,繪製階段就是業務實現的階段了,所有的邏輯必須要清晰,每個參數的變動是做什麼用的都要明確,不然隨意修改的後果就是再也回不去了。。。

而我們的需求對應的邏輯就是:

  • 拿到圖片
  • 剪裁出一個圖片中間的正方形
  • 然後去畫邊框,其實也不是邊框就是畫個圓
  • 再畫一個圓形圖片蓋住上面的「邊框」圓
  • 就出現邊框了
  • 而陰影是畫邊框的時候帶的
/** * 繪製 * @param canvas */
    @SuppressLint({"DrawAllocation", "CanvasSize"})
    @Override
    protected void onDraw(Canvas canvas) {
        // 加載圖片
        // load the bitmap
        loadBitmap();
        // 剪裁圖片獲得中間正方形
        // Crop the picture to get the middle square
        mBitmap = centerSquareScaleBitmap(mBitmap, (int) Math.ceil((double) mBitmapDiameter + 1));
        // 確保拿到圖片
        if (mBitmap != null) {
            // 初始化渲染器
            // init shader
            mBitmapShader = new BitmapShader(mBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
            // 配置渲染器
            // Configuring the renderer
            mBitmapPaint.setShader(mBitmapShader);
            // 判斷是否有陰影
            // Determine if there is a shadow
            if (mHasShadow){
                // 配置陰影
                mBorderPaint.setShadowLayer(mShadowRadius, 0, 0, mShadowColor);
                // 畫邊框
                canvas.drawCircle(widthOrHeight / 2, widthOrHeight / 2,
                        mBitmapDiameter / 2 + mBorderWidth - mShadowRadius, mBorderPaint);
                // 畫圖片
                canvas.drawCircle(widthOrHeight / 2, widthOrHeight / 2,
                        mBitmapDiameter / 2 - mShadowRadius, mBitmapPaint);
            } else {
                // 配置陰影
                mBorderPaint.setShadowLayer(0, 0, 0, mShadowColor);
                // 畫邊框
                canvas.drawCircle(widthOrHeight / 2, widthOrHeight / 2,
                        mBitmapDiameter / 2 + mBorderWidth, mBorderPaint);
                // 畫圖片
                canvas.drawCircle(widthOrHeight / 2, widthOrHeight / 2,
                        mBitmapDiameter / 2, mBitmapPaint);
            }
        }
    }

    /** * 加載圖片 * load the bitmap */
    private void loadBitmap() {
        BitmapDrawable bitmapDrawable = (BitmapDrawable) this.getDrawable();
        if (bitmapDrawable != null)
            mBitmap = bitmapDrawable.getBitmap();
    }

    /** * 裁切圖片 * Crop picture * 得到圖片中間正方形的圖片 * Get a picture of the middle square of the picture * @param bitmap 原始圖片 * @param edgeLength 要裁切的正方形邊長 * @return Bitmap 圖片中間正方形的圖片 */
    private Bitmap centerSquareScaleBitmap(Bitmap bitmap, int edgeLength){
        if (null == bitmap || edgeLength <= 0) {
            // 避免參數錯誤
            // Avoid parameter errors
            return bitmap;
        }
        // 初始化結果
        // Initialization result
        Bitmap result = bitmap;
        // 拿到圖片原始寬度
        // Get the original width of the image
        int widthOrg = bitmap.getWidth();
        // 拿到圖片原始高度
        // Get the original height of the image
        int heightOrg = bitmap.getHeight();
        // 要保證圖片寬高要大於要裁切的正方形邊長
        // Make sure that the width of the image is greater than the length of the square to be cropped.
        if (widthOrg >= edgeLength && heightOrg >= edgeLength){
            // 得到對應寬高比例的另一個更長的邊的長度
            // Get the length of the other longer side corresponding to the aspect ratio
            int longerEdge = (int) (edgeLength * Math.max(widthOrg, heightOrg) / Math.min(widthOrg, heightOrg));
            // 分配寬度
            // Distribution width
            int scaledWidth = widthOrg > heightOrg ? longerEdge : edgeLength;
            // 分配高度
            // Distribution height
            int scaledHeight = widthOrg > heightOrg ? edgeLength : longerEdge;
            // 定義一個新壓縮的圖片位圖
            // Define a new compressed picture bitmap
            Bitmap scaledBitmap;
            try {
                // 壓縮圖片,以一個新的尺寸創建新的位圖
                // Compress the image to create a new bitmap in a new size
                scaledBitmap = Bitmap.createScaledBitmap(bitmap, scaledWidth, scaledHeight, true);
            }catch (Exception e) {
                return bitmap;
            }
            // 得到裁切中間位置圖形的X軸偏移量
            // Get the X-axis offset of the cut intermediate position graphic
            int xTopLeft = (scaledWidth - edgeLength) / 2;
            // 得到裁切中間位置圖形的Y軸偏移量
            // Get the Y-axis offset of the cut intermediate position graphic
            int yTopLeft = (scaledHeight - edgeLength) / 2;
            try {
                // 在指定偏移位置裁切出新的正方形位圖
                // Crop a new square bitmap at the specified offset position
                result = Bitmap.createBitmap(scaledBitmap, xTopLeft, yTopLeft, edgeLength, edgeLength);
                // 釋放內存,回收資源
                // Free up memory, recycle resources
                scaledBitmap.recycle();
            }catch (Exception e) {
                return bitmap;
            }
        }
        return result;
    }

第五步、提供代碼修改屬性的方法

繪製完成後,還要提供一些方法給開發者去動態的修改屬性,使得控件更加實用

/** * 設置邊框寬度 * Set the border width * @param borderWidth */
    public void setBorderWidth(int borderWidth) {
        this.mBorderWidth = borderWidth;
        // 重新繪製
        // repaint
        this.invalidate();
    }

    /** * 設置邊框顏色 * Set the border color * Exposure method * @param borderColor */
    public void setBorderColor(int borderColor) {
        if (mBorderPaint != null)
            mBorderPaint.setColor(borderColor);
        // 重新繪製
        // repaint
        this.invalidate();
    }

    /** * 設置是否有陰影 * Set whether there is a shadow * @param hasShadow */
    public void setHasShadow(boolean hasShadow) {
        this.mHasShadow = hasShadow;
        // 重新繪製
        // repaint
        this.invalidate();
    }

    /** * 設置陰影顏色 * Set the shadow color * @param shadowColor */
    public void setShadowColor(int shadowColor) {
        this.mShadowColor = shadowColor;
        // 重新繪製
        // repaint
        this.invalidate();
    }

    /** * 設置陰影模糊半徑 * Set the shadow blur radius * @param shadowRadius */
    public void setShadowRadius(float shadowRadius) {
        this.mShadowRadius = shadowRadius;
        // 重新繪製
        // repaint
        this.invalidate();
    }

第六步、使用

在頁面佈局裏添加

<com.dlong.rep.dlroundimageview.DLRoundImageView
        android:id="@+id/img"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="center"
        android:src="@mipmap/dlong" />

帶屬性的話

<com.dlong.rep.dlroundimageview.DLRoundImageView
        android:id="@+id/img"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="center"
        android:src="@mipmap/dlong"
        app:borderColor="@android:color/white"
        app:borderWidth="20"
        app:hasShadow="true"
        app:shadowColor="@color/colorAccent"
        app:shadowRadius="30" />

代碼動態修改屬性

public class MainActivity extends AppCompatActivity {
    private DLRoundImageView img;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        img = (DLRoundImageView) findViewById(R.id.img);
        img.setBorderWidth(20);
        img.setBorderColor(Color.WHITE);
        img.setHasShadow(true);
        img.setShadowColor(Color.GRAY);
        img.setShadowRadius(30f);
    }
}

Github

添加依賴:

Add it in your root build.gradle at the end of repositories:

allprojects {
    	repositories {
    		...
    		maven { url 'https://jitpack.io' }
    	}
    }

Step 2. Add the dependency

dependencies {
	        implementation 'com.github.D10NGYANG:DL10RoundImageView:1.0.0'
	}

github: D10NGYANG/DL10RoundImageView