Android 自定义控件基本教程之自定义一个圆形ImageView可设置边框和阴影(demo带详细注释)

Android 自定义控件基本教程之自定义一个圆形ImageView可设置边框和阴影(demo带详细注释)

  • 前言
  • 目标
  • 效果图
  • 感谢
  • 流程
    • 第一步、自定义属性
    • 第二步、初始化控件
    • 第三步、测量控件宽高onMeasure
    • 第四步、绘制控件onDraw
    • 第五步、提供代码修改属性的方法
    • 第六步、使用
  • Github

前言

做Android开发,必定离不开的就是自定义控件。祖传的原生控件肯定不足以完成UI设计的全部要求。当然,很多情况下,百度一下(没有歧视Google的意思)你也能找到不错的轮子完成你的需求。但是,更多的时候你会发现费劲千辛万苦找到的几个类似的控件总有那么几个不完美的地方或者和你需求有出入的地方,这时候你就挠头了,这可怎么办啊!不过,也别挠了,没几根头发了。

话已至此,还是要学一下自定义控件的,理解流程也对我们修改现成的轮子有帮助。

目标

既然要学着做,那就先来个简单的、最常见的需求,圆形ImageView -----大部分的用户头像之类的都是这种。而这次我们要做的呢就是:

  • 圆形ImageView
  • 带边框宽度、颜色设置
  • 带阴影模糊半径、颜色设置

效果图

Android 自定义控件基本教程之自定义一个圆形ImageView可设置边框和阴影(demo带详细注释)_第1张图片

感谢

这文章参考了几篇文章,先表示一下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

你可能感兴趣的:(Android知识面,自定义控件)