前言
我们所有的控件其实都是直接或间接继承自 View 的,所有的布局都是直接或间接继承自 ViewGroup 的。View 是安卓中最基本的 UI 组件,它可以在屏幕上绘制一块区域,并响应这块区域的各种事件,所以其实我们使用的各种控件就是在 View 的基础上添加了各自特有的功能。而 ViewGroup 是一种特殊的 View,它可以存放很多子 View 和子 ViewGroup,是一个用于放置控件和布局的容器。
那当系统内置的控件无法满足我们的需求时,我们就需要自定义控件。前置工作:先创建一个名为 UICustomViews 的 Empty Views Activity 项目,其他均采用默认值。
引入布局
很多应用的界面顶部都会有一个标题栏,标题栏上会有返回按钮,或是其他操作的按钮,我们来创建一个自定义的标题栏。
在 layout 目录下新建一个 title.xml 布局文件,代码如下:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#03A9F4"
android:orientation="horizontal">
<Button
android:id="@+id/titleBack"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="5dp"
android:backgroundTint="#29B6F6"
android:text="Back"
android:textColor="#fff" />
<TextView
android:id="@+id/titleText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_weight="1"
android:gravity="center"
android:text="Title Text"
android:textColor="#fff"
android:textSize="24sp" />
<Button
android:id="@+id/titleEdit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="5dp"
android:backgroundTint="#29B6F6"
android:text="Edit"
android:textColor="#fff" />
</LinearLayout>
可以看到我们的标题栏布局中有两个 Button 和一个 TextView。左边的 Button 用于返回,右边的 Button 用于启用编辑,中间的 TextView 用于显示标题文本。
其中有三个属性我们需要说一下。android:background 属性可为布局或控件指定全新的背景,你可以设置颜色值或是 Drawable 资源(Drawable 就是可被绘制出来的东西,如颜色、图片,形状,点击按钮时的涟漪效果)。android:backgroundTint 属性则是为控件已有的背景 Drawable进行着色,你可以设置颜色值。
这里我们给标题栏的 LinearLayout 根布局设置了 android:background 属性值为 "#03A9F4",给返回、编辑按钮的 android:backgroundTint 属性值设为 "#29B6F6"。
那它们之间有什么区别吗?
当你使用 android:background 属性为一个控件(如 Button)设置背景时,它会完全覆盖掉该控件提供的默认背景样式(包括圆角、内边距、阴影、涟漪效果、不同状态下的外观等),而 android:backgroundTint 会保留这些默认样式,仅仅改变其颜色色调。
另外,在两个 Button 中我们都使用了 android:layout_margin 这个属性,它可以指定控件的外间距(上下左右方向上),距离父布局的边界有多远。
效果图:
而一般我们的应用中,会有多个界面会用到这个标题栏,难道我们要在每个界面的布局中都重写一遍标题栏的布局吗?
当然不是,这样会导致代码的重复,这时我们可以考虑通过引用布局的方式来对标题栏布局进行复用。只需在 activity_main.xml 布局文件中通过 include 语句引入标题栏布局就可以了:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<include layout="@layout/title" />
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="This is a TextView"
android:textColor="#00BCD4"
android:textSize="18sp" />
</LinearLayout>
运行效果:
这样,以后有现有的布局需要复用,也可以使用 include 语句。
创建自定义控件
引入布局确实能够解决重复编写布局代码的问题。但控件往往需要能够响应事件,我们还是需要在 Activity 中为这些控件写事件注册的代码。
如果在每一个 Activity 中都需要重新注册事件,代码无疑又会重复,所以我们还可以通过使用自定义控件的方式来解决。
新建一个 TitleLayout 类,继承自 LinearLayout,我们让它成为我们自定义标题栏的控件。代码如下所示:
class TitleLayout(context: Context, attrs: AttributeSet?) : LinearLayout(context, attrs) {
init {
LayoutInflater.from(context).inflate(R.layout.title, this)
}
}
在布局中引入 TitleLayout 控件时,就会调用它的构造方法。然后我们还通过 LayoutInflater.inflate() 方法动态加载了标题栏的布局。
使用 LayoutInflater.from() 方法就能创建一个 LayoutInflater 对象,LayoutInflater.inflate() 方法接收两个参数,第一个参数是要加载的布局文件id,这里我们填的是之前的标题栏布局 R.layout.title,第二个参数是指定加载的布局的父布局,我们填当前布局 TitleLayout 就行了,传入 this,这样会将加载的布局作为 TitleLayout 的子视图添加进来。
自定义控件创建好后,在布局中使用就行了。来到 activity_main.xml 布局文件中,通过自定义控件的完整类名来引入:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.example.uicustomviews.TitleLayout
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="This is a TextView"
android:textColor="#00BCD4"
android:textSize="18sp" />
</LinearLayout>
重新运行程序,效果和引入布局是一模一样的。
只是我们还可以给标题栏控件添加逻辑,我们给标题栏中的两个按钮注册点击事件:
class TitleLayout(context: Context, attrs: AttributeSet?) : LinearLayout(context, attrs) {
// ViewBind 视图绑定
private var viewBinding: TitleBinding =
TitleBinding.inflate(LayoutInflater.from(context), this, true)
init {
// 注册点击监听器
viewBinding.titleBack.setOnClickListener {
(context as? Activity)?.finish()
}
viewBinding.titleEdit.setOnClickListener {
Toast.makeText(context, "You clicked Edit button", Toast.LENGTH_SHORT).show()
}
}
}
我们这里使用视图绑定来加载布局,并且通过 inflate 方法的第三个参数 attachToRoot 设为 true将加载的布局附加到了 TitleLayout,替代了之前手动调用的代码 LayoutInflater.from(context).inflate(R.layout.title, this)。
在点击返回按钮时,我们会尝试关闭当前界面。通常情况下,通过构造函数接收到的 context 参数会是一个 Activity 实例,我们可以直接使用 as 关键字进行强制类型转换,将 context 的类型从 Context 转为 Activity,再调用该 Activity 实例的 finish() 方法来关闭当前界面。
但如果 context 并非是 Activity 实例(如 TitleLayout 在非 Activity 的上下文中被使用了),这时使用 as 进行强制类型转换会导致程序会崩溃,抛出 ClassCastException 异常。 所以我们使用 as? 关键字来进行安全的类型转换,即使 context 不是 Activity 类型,as? 会返回 null 而不是抛出异常,并且再结合上 ?. 空安全操作符,转换失败时也不会调用 finish() 方法,有效避免程序崩溃的问题。
重新运行,你会发现自定义控件已经能够正常工作了。
这样,每当我们在布局中引入了 TitleLayout 自定义控件时,它会复用标题栏布局,并且注册好返回按钮、编辑按钮的点击事件。