前言
对于Android程序员来说,自定义View是绕不过的话题,作为Android终端,除了一些后台应用,大部分的应用最直接面对用户的还是我们的界面,界面的美观和流畅性某种程度上决定了用户的留存。
同时,自定义View也符合封装的思想,将通用的功能控件进行自定义弥补官方控件的使用不便,这将提升我们的开发效率。
除了便利性外,当然追求各类复杂View的自定义,也是我们作为Android程序员的综合素质的体现(才不是炫技呢)。
自定义View的分类
Android 官方将自定义View分为三类:
- 继承已有控件
- 组合控件
- 完全自定义控件
继承已有控件
如果需要实现的自定义View的功能与已有的控件功能类似,可以直接通过拓展已有控件的方式进行控件的自定义。
实现这类自定义View需要对需要继承的已有控件的相关属性和Api有所了解,当然,可以通过完全自定义View的方式来实现自定义,但是从那些已封装的控件开始会大大提高开发的效率。
组合控件
当有一些较复杂的自定义View需要实现的时候,组合控件是一个较好的选择,不会过于复杂又较为简单,一般是继承某一种布局来组合已有的控件。
这种实现自定义View的方式最为常用,在日常的开发过程中,经常会出现需要封装一些组件的需求,这时候组合控件就一个很好的选择。
完全自定义控件
当要进行一些不规则,极度复杂的控件封装时,就需要通过完全自定义的方式来实现View,通过实现提供的各种方法来实现。
一般的完全自定义控件会出现在现有控件无法实现的情况下,常见的是一些有着复杂形状的UI或是需要重新定义的布局模式,才会用到完全自定义控件。
自定义View的基本步骤(本文介绍前两个步骤)
不同方式实现的自定义View它们的基本实现套路大同小异的,一般会经过以下几个步骤:
- 继承已有的View或是ViewGroup,实现构造方法
- 自定义属性
- 测量
- 绘制
- 处理交互事件
- 其它一些功能上的逻辑处理
本文会优先介绍前两个步骤,它们可以被称为自定义View的创建
继承已有的View或是ViewGroup,实现构造方法
构造方法是每一个类的入口,View也不例外,为了更加直观地展现View的构造函数,这里给出继承自FrameLayout的构造方法(本文所用自定义View实例基于FrameLayout):
class CustomCreateView : FrameLayout {
// 第一个构造函数
constructor(context: Context) : super(context) {
}
// 第二个构造函数
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
}
// 第三个构造函数
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
) {
}
// 第四个构造函数
// 在 API 21 以上才能使用
constructor(
context: Context,
attrs: AttributeSet,
defStyleAttr: Int,
defStyleRes: Int
) : super(context, attrs, defStyleAttr, defStyleRes) {
}
}
一般地,通过代码实例化一个View对象会调用第一个构造函数,通过xml定义一个View会调用第二个构造函数,系统是不会直接调用第三和第四个构造函数的,那它们有什么用呢?
在展开构造函数之前,我们先要来了解一下自定义属性相关的概念,也就是第三和第四个构造函数所多出来的那几个参数内容有什么含义?
自定义属性
View的属性帮助我们可以快速实现我们所需要的功能,除了使用系统所提供给我们的那些View的属性,我们还可以自定义相关的属性,来实现原本所无法实现的功能。
实现步骤
定义自定义属性
在res/values创建attrs.xml文件,在<declare-styleable>标签下新建自定义属性,如下:
<resources>
<declare-styleable name="CustomCreateView">
<attr name="showText" format="boolean" />
<attr name="labelPosition" format="enum">
<enum name="left" value="0"/>
<enum name="right" value="1"/>
</attr>
</declare-styleable>
</resources>
在代码中,每一个 <attr> 属性都代表了一个自定义属性,可以看到每个 <attr> 中都包含了两个属性:
- name —— 在xml引用控件设置属性的名字
- format —— 属性值的类型
关于format的属性类型的设置
android在format中预设了很多属性的类型满足日常开发的属性类型的需求,下面来详细了解一下具体有哪些属性类型
属性类型 | 属性类型说明 |
---|---|
color | 颜色值,一般为颜色的16进制值,例如:#000000 |
dimension | 尺寸值,用于设置控件大小或是字体大小,例如:16dp、18sp |
integer | 整形数值 |
string | 字符串类型 |
boolean | 布尔值,当属性需要做true或false判断的时候使用 |
enum | 枚举类型,属性值只能选择一个值,子项的value要设置为整形数,具体设置方法见上面的代码 |
flags | 位或运算,属性值可以选择多个值,子项的value要设置为整形数,一般设置为2的倍数来进行区分(具体原因会在获取属性值的部分给出),具体设置方法同enum,将内层标签改为<flag> |
float | 浮点型数值 |
fraction | 百分数 |
reference | 资源ID |
在这些属性值中,enum和flags较为容易搞混,这里拿出来强调一下:
- enum —— 属性值只能选择一个值,例如LinearLayout的orientation属性
- flags —— 属性值可以选择多个值,例如gravity属性
另外需要注意的是format可以同时设置多个属性值,来指定多个属性类型,常见应用就是引用资源ID和其它的属性类型进行混合使用,多属性类型通过“|”连接。
在 XML 布局中指定属性值
当我们设置完自定义属性后,就可以在对应的xml文件下的对应控件下进行使用:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:custom="http://schemas.android.com/apk/res/com.example.customviews"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.redrain.viewdemo.custom_create.CustomCreateView
android:layout_width="match_parent"
android:layout_height="match_parent"
custom:showText="true"
custom:labelPosition="left"/>
</FrameLayout>
当然这样还不能实现属性的功能,因为我们还没有设置具体的属性作用逻辑,那么接下来就来讲讲如何接收设置好的自定义属性并进行功能设置。
接收自定义属性
还记得自定义View的构造函数吗?其中有一个参数AttributeSet就是表示所设定的属性,也就是通过它来获取到具体设置的值,但是直接从AttributeSet中获取参数会导致一些问题,所以需要通过obtainStyledAttributes()方法来获取属性值。
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
// 通过obtainStyledAttributes方法获取到TypedArray对象
val a = context.theme.obtainStyledAttributes(
attrs,
R.styleable.CustomCreateView,
0, 0
)
try {
// 获取相应的属性值
isShowText = a.getBoolean(R.styleable.CustomCreateView_showText, false)
textPos = a.getInteger(R.styleable.CustomCreateView_labelPosition, 0)
} finally {
// TypedArray是共享资源必须在使用后进行回收
a.recycle()
}
}
接受到属性值之后就可以进行根据需求来相关的逻辑操作了。
关于各类属性值如何获取
每一种属性类型都有着对应的获取属性值的方法:
// color的获取
// 参数说明:index表示对应的styleable ID;defValue表示默认的颜色
int getColor(int index, int defValue)
// dimension的获取
// 关于 dimension 的获取,获取到的值为px值,需要进行相应的转化
// 参数说明:index表示对应的styleable ID;defValue表示默认的大小
float getDimension(int index, float defValue) // 获取到像素值
int getDimensionPixelOffset(int index, int defValue) // 获取到像素值,并转化为整形数(取整)
int getDimensionPixelSize(int index, int defValue) // 获取到像素值,并转化为整形数(四舍五入的方式转化)
// integer的获取
// 参数说明:index表示对应的styleable ID;defValue表示默认值
// 注意:getInt方法在获取到属性值时,如果不是整形数那么会尝试强制转化为int类型,而getInteger则会抛出exception
int getInt(int index, int defValue)
int getInteger(int index, int defValue)
// string的获取
// 参数说明:index表示对应的styleable ID
String getString(int index)
// boolean的获取
// 参数说明:index表示对应的styleable ID;defValue表示默认值
boolean getBoolean(int index, boolean defValue)
// enum的获取
// 枚举类型的属性值获取,通过getInt或是getInteger来获取对应的属性值,获取到的相应的值来得到枚举的具体值
int getInt(int index, int defValue)
int getInteger(int index, int defValue)
// flags的获取
// 通过getInt或是getInteger来获取对应的属性值
// 注意:由于flags类型的属性可以设置多个属性值,获取到的值为所设置属性值对应的value值之和,来进一步判断选择了哪些属性值
int getInt(int index, int defValue)
int getInteger(int index, int defValue)
// float的获取
// 参数说明:index表示对应的styleable ID;defValue表示默认值
float getFloat(int index, float defValue)
// fraction的获取
// 参数说明:index表示对应的styleable ID;base表示属性值需要乘的值;pbase表示属性值需要除的值;defValue表示默认值
float getFraction(int index, int base, int pbase, float defValue)
// reference的获取
// 参数说明:index表示对应的styleable ID;defValue表示默认值
int getResourceId(int index, int defValue)
这里可能会有一个一个疑问了,我们知道有些属性可以通过多种方式来设置,比如text属性可以直接通过字符串或是通过字符串引用id来设置,这种情况只需要直接通过getString方法来设置即可,Android内部已经做了相应的逻辑处理,其它类型的属性值同理。
添加动态属性
上述介绍的都是关于控件的静态属性设置,有时候需要动态的对属性进行调整,所以对需要对每一个属性值提供一个setter&getter的方法来实现动态设置属性:
var isShowText: Boolean = false
set(value) {
field = value
tvText.visibility = if (value) VISIBLE else INVISIBLE
}
当然动态属性的设置方法不局限于上面这种,在定义动态属性的时候需要根据需求去灵活地调整实现方式。
构造函数参数的含义
了解自定义属性的相关概念,那么回到我们之前讲到的构造函数,不同的构造函数所包含的参数都分别代表着什么呢?
class CustomCreateView : View {
// 第一个构造函数
constructor(context: Context?) : super(context) {
}
// 第二个构造函数
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
}
// 第三个构造函数
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
) {
}
// 第四个构造函数
// 在 API 21 以上才能使用
constructor(
context: Context?,
attrs: AttributeSet?,
defStyleAttr: Int,
defStyleRes: Int
) : super(context, attrs, defStyleAttr, defStyleRes) {
}
}
- context 上下文就不展开了,都知道这是个什么东西
- attrs 表示属性值的集合,上文中已经讲解了
- defStyleAttr
- defStyleRes
重点来讲解一下后面两个参数,有做过代码实验的同学可定会发现获取属性的方法中包含有两个之前传0的参数:
val a = context.theme.obtainStyledAttributes(
attrs,
R.styleable.CustomCreateView,
0, 0
)
最后两个参数分别代表了defStyleAttr
和defStyleRes
。
defStyleAttr
相当于为View设置一个主题风格的属性配置,如果没有在xml中没有定义相关的属性,但又在主题中定义了相关属性,那么会从defStyleAttr
所指向的style中查找对应的属性。
可以这么理解它,它依赖于主题,不同主题中定义不同的defStyleAttr
,实现的效果也不同。
defStyleRes
指向Style的资源ID,但是仅在defStyleAttr
为0或者defStyleAttr
不为0但Theme中没有为defStyleAttr
属性赋值时起作用。
简单来说就是这种方式和主题无关,它就是兜底的默认属性风格。
具体传参使用方式
defStyleAttr
首先,要在values/attrs
中定义特定的属性名:
<attr name="CustomCreateViewDefStyleAttr" format="reference"/>
然后在values/styles
中定义所要设置的属性风格:
<style name="CustomCreateViewLeftStyleAttr">
<item name="showText">true</item>
<item name="labelPosition">left</item>
</style>
下一步,在所需要定义的style中定义对应的属性(本文这里就偷懒直接放在AppTheme上了):
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="CustomCreateViewDefStyleAttr">@style/CustomCreateViewLeftStyleAttr</item>
</style>
最后在构造函数中定义默认的属性风格:
constructor(context: Context, attrs: AttributeSet) : this(context, attrs, R.attr.CustomCreateViewDefStyleAttr) {
Log.d("customCreate", "第二个构造方法")
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
) {
Log.d("customCreate", "第三个构造方法")
val view = LayoutInflater.from(context).inflate(R.layout.view_custom_create, this)
tvText = view.findViewById(R.id.tv_text)
tvText.text = "CustomCreateView"
// 通过obtainStyledAttributes方法获取到TypedArray对象
val a = context.obtainStyledAttributes(
attrs,
R.styleable.CustomCreateView,
defStyleAttr, 0
)
try {
// 获取相应的属性值
isShowText = a.getBoolean(R.styleable.CustomCreateView_showText, false)
labelPosition = a.getInteger(R.styleable.CustomCreateView_labelPosition, 0)
} finally {
// TypedArray是共享资源必须在使用后进行回收
a.recycle()
}
tvText.visibility = if (isShowText) VISIBLE else INVISIBLE
tvText.gravity = if (labelPosition == 0) Gravity.LEFT else Gravity.RIGHT
}
注意这种方式的属性定义和一般的属性定义的区别在于:
- 通过二参构造函数实现三参构造函数,第三个参数定义为我们定义好的资源id
-
obtainStyledAttributes
方法中的第三个参数传入defStyleAttr
这样就实现了defStyleAttr
方式的属性定义,这种方式实际上会到theme中去寻找定义好的特定属性名,如果有,则会在优先级允许的情况下,使用特定属性名下的定义属性,如果没有则不会去获取属性。
defStyleRes
defStyleRes
实现只需要在defStyleAttr
实现上稍微做一点小改动。
去掉theme中的定义:
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
让三参构造函数实现四参构造函数:
constructor(context: Context, attrs: AttributeSet) : this(context, attrs, R.attr.CustomCreateViewDefStyleAttr) {
Log.d("customCreate", "第二个构造方法")
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : this(
context,
attrs,
defStyleAttr,
R.style.CustomCreateViewLeftStyleAttr
) {
Log.d("customCreate", "第三个构造方法")
}
constructor(
context: Context,
attrs: AttributeSet,
defStyleAttr: Int,
defStyleRes: Int
) : super(context, attrs, defStyleAttr, defStyleRes) {
Log.d("customCreate", "第四个构造方法")
val view = LayoutInflater.from(context).inflate(R.layout.view_custom_create, this)
tvText = view.findViewById(R.id.tv_text)
tvText.text = "CustomCreateView"
// 通过obtainStyledAttributes方法获取到TypedArray对象
val a = context.obtainStyledAttributes(
attrs,
R.styleable.CustomCreateView,
defStyleAttr, defStyleRes
)
try {
// 获取相应的属性值
isShowText = a.getBoolean(R.styleable.CustomCreateView_showText, false)
labelPosition = a.getInteger(R.styleable.CustomCreateView_labelPosition, 0)
} finally {
// TypedArray是共享资源必须在使用后进行回收
a.recycle()
}
tvText.visibility = if (isShowText) VISIBLE else INVISIBLE
tvText.gravity = if (labelPosition == 0) Gravity.LEFT else Gravity.RIGHT
}
注意这种方式的属性定义和一般的属性定义的区别在于:
- 通过三参构造函数实现四参构造函数,第四个参数定义为我们定义好的资源id
-
obtainStyledAttributes
方法中的第四个参数传入defStyleRes
由于在主题中,已经去掉了defStyleAttr
方式定义的属性资源,那么最终会调用defStyleRes
所定义的属性资源,也就是:
<style name="CustomCreateViewLeftStyleAttr">
<item name="showText">true</item>
<item name="labelPosition">right</item>
</style>
属性设置区别
上文中已经将各类的属性赋值一一讲解,那么它们区别以及应用场景是什么呢?
属性设置:
- 在布局xml中直接定义
- 在布局xml中通过style定义
- 自定义View所在的Activity的Theme中指定style引用
- 构造函数中defStyleRes指定的默认值
属性设置的方式有以上几种,优先级从高到低(如果高优先级的定义了,那么低优先级的就不会采用),下文中使用数字来表示:
- 很好理解,xml的属性定义是程序员的第一设置项,反映了程序员的期望。
- style定义可以理解为某一种设计的规范,增强了复用性,但其可以被1所定义的属性替换,在规范的同时,提升定制的可能。
- 更像一种主题性质的定义,统一的主题定义,方便了程序员来定制整体样式。
- 提供了最基础的默认值,保证了整体风格的统一。
Android将属性定义通过不同颗粒度的定义方式进行了区分,最主要的还是帮助开发者提高开发的效率。
总结
本文主要讲解自定义View的创建过程,从构造函数这个入口作为切入点,讲解自定义属性的定义过程和不同方式下对View的属性风格的把握,本文没有给出完整的源码,但是所使用的代码是连贯的,建议想尝试的同学可以自己写一写代码做一下尝试,尤其是defStyleAttr
和defStyleRes
的概念一定要好好地理解一下,实际开发中会极大地提高我们的开发效率。