软件也要拼脸蛋,UI 开发的点点滴滴
该如何编写程序界面
在过去,Android 应用程序的界面主要是通过编写 XML 的方式来实现的。写 XML 的好处是,我们不仅能够了解界面背后的实现原理,而且编写出来的界面还可以具备很好的屏幕适配性。等你完全掌握了使用 XML 来编写界面的方法之后,不管是进行高复杂度的界面实现,还是分析和修改当前现有的界面,对你来说都将是手到擒来。
不过最近几年,Google 又推出了一个全新的界面布局:ConstraintLayout。和以往传统的布局不同,ConstraintLayout 不是非常适合通过编写 XML 的方式来开发界面,而是更加适合在可视化编辑器中使用拖放控件的方式来进行操作,并且 Android Studio 中也提供了非常完备的可视化编辑器。
虽然现在 Google 官方更加推荐使用 ConstraintLayout 来开发程序界面,但由于 ConstraintLayout 的特殊性,很难展示如何通过可视化编辑器来对界面进行动态操作。因此本书中我们仍然采用编写 XML 的传统方式来开发程序界面,并且这也是我认为你必须掌握的基本技能。
讲了这么多理论的东西,也是时候学习一下到底如何编写程序界面了,我们就从 Android 中几种常见的控件开始吧。
常用控件的使用方法
Android 给我们提供了大量的 UI 控件,合理地使用这些控件就可以非常轻松地编写出相当不错的界面,下面我们就挑选几种常用的控件,详细介绍一下它们的使用方法。
首先新建一个 UIWidgetTest 项目。简单起见,我们还是允许 Android Studio 自动创建 Activity,Activity 名和布局名都使用默认值。
TextView
TextView 的基本用法很简单,这里就不讲解了。这里我们来讲解一个类 SpannableString, SpannableString 其实和 Sring 一样,Textview 可以直接通过设置 SpannableString 来显示文本,只是 SpannableString 可以显示更多的样式风格,我们来看这样一个效果,给一段文本设置下划线,只有下划线的地方可以点击,这样一个效果只用 TextView 是无法实现的,但是用 SpannableString 就很简单了。
我们来实现一下这个效果,布局代码很简单,如下所示:
MainActivity.kt 的代码如下所示:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setSpecialEffect()
}
private fun setSpecialEffect() {
tv_1.text = "已阅读并同意 "
val clickString = SpannableString("软件许可及服务协议")
clickString.setSpan(object : ClickableSpan() {
override fun onClick(widget: View) {
Toast.makeText(this@MainActivity, "点击了下划线部分内容", Toast.LENGTH_SHORT).show()
}
override fun updateDrawState(ds: TextPaint) {
super.updateDrawState(ds)
// 设置颜色
ds.color = resources.getColor(R.color.purple_200, null)
}
}, 0, clickString.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
tv_1.append(clickString)
// 设置点击效果
tv_1.highlightColor = Color.TRANSPARENT
// 开始响应点击事件
tv_1.movementMethod = LinkMovementMethod.getInstance()
}
}
我们来运行程序看一下效果:
当然 SpannableString 的可以设置的效果还有很多,有兴趣的可以去查看资料。
Button
同样,Button 的基本用法很简单,我们也不讲,我们来讲如何给 Button 设置一个点击效果,我们先来看看具体是怎么个效果,如下所示,点击按钮的时候,有一个反馈:
我们来实现一下这个效果,要想实现这个效果我们只需要在 drawable 下写一个背景选择器 selector 就可以了,具体代码如下:
-
-
然后在布局文件中设置 background 就可以实现点击效果了,具体代码如下:
我们来运行一下看一下效果:
EditText
EditText 是程序用于和用户进行交互的另一个重要控件,它允许用户在控件里输入和编辑内容,并可以在程序中对这些内容进行处理。EditText 的应用场景应该算是非常普遍了,发短信、发微博、聊 QQ 等等,在进行这些操作时,你不得不使用到 EditText。那我们来看一看如何在界面上加入 EditText 吧,如下所示:
看到这里,估计你已经总结出 Android 控件的使用规律了。用法都很相似,给控件定义一个 id,指定控件的宽度和高度,然后再适当加入些控件特有的属性就差不多了,所以使用 XML 来编写界面其实一点都不难。现在运行一下程序,EditText 就已经在界面上显示出来了,并且我们是可以在里面输入内容的。
ImageView
ImageView 是用于在界面上展示图片的一个控件,它可以让我们的程序界面变得更加丰富多彩。图片通常是放在以 drawable 开头的目录下的,并且要带上具体的分辨率。现在最主流的手机屏幕分辨率大多是 xxhdpi 的,所以我们在 res 目录下再新建一个 drawable-xxhdpi 目录,然后将事先准备好的图片复制到该目录当中就可以使用 ImageView 控件来将它们显示在界面上。
常用的控件肯定不止上面这几种,但是用起来都比较简单,所以这里就不讲了。
最常用和最难用的控件: ListView
ListView 在过去绝对可以称得上是 Android 中最常用的控件之一,几乎所有的应用程序都会用到它。由于手机屏幕空间比较有限,能够一次性在屏幕上显示的内容并不多,当我们的程序中有大量的数据需要展示的时候,就可以借助 ListView 来实现。ListView 允许用户通过手指上下滑动的方式将屏幕外的数据滚动到屏幕内,同时屏幕上原有的数据会滚动出屏幕。你其实每天都在使用这个控件,比如查看 QQ 聊天记录,翻阅微博最新消息,等等。不过比起前面介绍的几种控件,ListView 的用法相对复杂了很多,因此我们就单独来对 ListView 进行非常详细的讲解。
ListView 的简单用法
首先新建一个 ListViewTest 项目,并让 Android Studio 自动帮我们创建好 Activity。然后修改 activity_main.xml 中的代码,如下所示:
在布局中加入 ListView 控件还算非常简单,先为 ListView 指定一个 id,然后将宽度和高度都设置为 match_parent,这样 ListView 就占满了整个布局的空间。
接下来修改 MainActivity 中的代码,如下所示:
class MainActivity : AppCompatActivity() {
private val data = listOf("Apple", "Banana", "Orange", "Watermelon",
"Pear", "Grape", "Pineapple", "Strawberry", "Cherry", "Mango",
"Apple", "Banana", "Orange", "Watermelon", "Pear", "Grape",
"Pineapple", "Strawberry", "Cherry", "Mango")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, data)
listView.adapter = adapter
}
}
既然 ListView 是用于展示大量数据的,那我们就应该先将数据提供好。这些数据可以从网上下载,也可以从数据库中读取,应该视具体的应用程序场景而定。这里我们就简单使用一个 data 集合来进行测试,里面包含了很多水果的名称,初始化集合的方式使用的是 listOf() 函数。
不过,集合中的数据是无法直接传递给 ListView 的,我们还需要借助适配器来完成。Android 中提供了很多适配器的实现类,其中我认为最好用的就是 ArrayAdapter。它可以通过泛型来指定要适配的数据类型,然后在构造函数中把要适配的数据传入。ArrayAdapter 有多个构造函数的重载,你应该根据实际情况选择最合适的一种。由于我们这里提供的数据都是字符串,因此将 ArrayAdapter 的泛型指定为 String,然后在 ArrayAdapter 的构造函数中依次传入 Activity 的实例、ListView 子项布局的 id,以及数据源。注意,我们使用了 android.R.layout.simple_list_item_1 作为 ListView 子项布局的 id,这是一个 Android 内置的布局文件,里面只有一个 TextView,可用于简单地显示一段文本。这样适配器对象就构建好了。
最后,还需要调用 ListView 的 setAdapter() 方法,将构建好的适配器对象传递进去,这样 ListView 和数据之间的关联就建立完成了。
现在运行一下程序,效果如下图所示,可以通过滚动的方式查看屏幕外的数据。
定制 ListView 的界面
只能显示一段文本的 ListView 实在是太单调了,我们现在就来对 ListView 的界面进行定制,让它可以显示更加丰富的内容。
首先需要准备好一组图片资源,分别对应上面提供的每一种水果,待会我们要让这些水果名称的旁边都有一张相应的图片。
接着定义一个实体类,作为 ListView 适配器的适配类型。新建 Fruit 类,代码如下所示:
class Fruit(val name: String, val imageId: Int)
Fruit 类中只有两个字段:name 表示水果的名字,imageId 表示水果对应图片的资源 id。
然后需要为 ListView 的子项指定一个我们自定义的布局,在 layout 目录下新建 fruit_item.xml,代码如下所示:
在这个布局中,我们定义了一个 ImageView 用于显示水果的图片,又定义了一个 TextView 用于显示水果的名称,并让 ImageView 和 TextView 都在垂直方向上居中显示。
接下来需要创建一个自定义的适配器,这个适配器继承自 ArrayAdapter,并将泛型指定为 Fruit 类。新建类 FruitAdapter,代码如下所示:
class FruitAdapter(activity: Activity, private val resourceId: Int, data: List) :
ArrayAdapter(activity, resourceId, data) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = LayoutInflater.from(context).inflate(resourceId, parent, false)
val fruitImage: ImageView = view.findViewById(R.id.fruitImage)
val fruitName: TextView = view.findViewById(R.id.fruitName)
// 获取当前项的Fruit实例
val fruit = getItem(position)
if (fruit != null) {
fruitImage.setImageResource(fruit.imageId)
fruitName.text = fruit.name
}
return view
}
}
FruitAdapter 定义了一个主构造函数,用于将 Activity 的实例、ListView 子项布局的 id 和数据源传递进来。另外又重写了 getView() 方法,这个方法在每个子项被滚动到屏幕内的时候会被调用。
在 getView() 方法中,首先使用 LayoutInflater 来为这个子项加载我们传入的布局。LayoutInflater 的 inflate() 方法接收 3 个参数,前两个参数我们已经知道是什么意思了,第三个参数指定成 false,表示只让我们在父布局中声明的 layout 属性生效,但不会为这个 View 添加父布局。因为一旦 View 有了父布局之后,它就不能再添加到 ListView 中了。如果你现在还不能理解这段话的含义,也没关系,只需要知道这是 ListView 中的标准写法就可以了,当你以后对 View 理解得更加深刻的时候,再来读这段话就没有问题了。
我们继续往下看,接下来调用 View 的 findViewById() 方法分别获取到 ImageView 和 TextView 的实例,然后通过 getItem() 方法得到当前项的 Fruit 实例,并分别调用它们的 setImageResource() 和 setText() 方法设置显示的图片和文字,最后将布局返回,这样我们自定义的适配器就完成了。
需要注意的是,kotlin-android-extensions 插件在 ListView 的适配器中也能正常工作,将上述代码中的两处 findViewById() 方法分别替换成 view.fruitImage 和 view.fruitName,效果是一模一样的,你可以自己动手尝试一下。最后修改MainActivity中的代码,如下所示:
class MainActivity : AppCompatActivity() {
private val fruitList = ArrayList()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 初始化水果数据
initFruits()
val adapter = FruitAdapter(this, R.layout.fruit_item, fruitList)
listView.adapter = adapter
}
private fun initFruits() {
repeat(2) {
fruitList.add(Fruit("Apple", R.drawable.apple_pic))
fruitList.add(Fruit("Banana", R.drawable.banana_pic))
fruitList.add(Fruit("Orange", R.drawable.orange_pic))
fruitList.add(Fruit("Watermelon", R.drawable.watermelon_pic))
fruitList.add(Fruit("Pear", R.drawable.pear_pic))
fruitList.add(Fruit("Grape", R.drawable.grape_pic))
fruitList.add(Fruit("Pineapple", R.drawable.pineapple_pic))
fruitList.add(Fruit("Strawberry", R.drawable.strawberry_pic))
fruitList.add(Fruit("Cherry", R.drawable.cherry_pic))
fruitList.add(Fruit("Mango", R.drawable.mango_pic))
}
}
}
可以看到,这里添加了一个 initFruits() 方法,用于初始化所有的水果数据。在 Fruit 类的构造函数中将水果的名字和对应的图片 id 传入,然后把创建好的对象添加到水果列表中。另外,我们使用了一个 repeat 函数将所有的水果数据添加了两遍,这是因为如果只添加一遍的话,数据量还不足以充满整个屏幕。repeat 函数是 Kotlin 中另外一个非常常用的标准函数,它允许你传入一个数值n,然后会把 Lambda 表达式中的内容执行 n 遍。接着在 onCreate() 方法中创建了 FruitAdapter 对象,并将它作为适配器传递给 ListView,这样定制 ListView 界面的任务就完成了。
虽然目前我们定制的界面还很简单,但是相信你已经领悟到了诀窍,只要修改 fruit_item.xml 中的内容,就可以定制出各种复杂的界面了。
提升 ListView 的运行效率
之所以说 ListView 这个控件很难用,是因为它有很多细节可以优化,其中运行效率就是很重要的一点。目前我们 ListView 的运行效率是很低的,因为在 FruitAdapter 的 getView() 方法中,每次都将布局重新加载了一遍,当 ListView 快速滚动的时候,这就会成为性能的瓶颈。
仔细观察你会发现,getView() 方法中还有一个 convertView 参数,这个参数用于将之前加载好的布局进行缓存,以便之后进行重用,我们可以借助这个参数来进行性能优化。修改 FruitAdapter 中的代码,如下所示:
class FruitAdapter(activity: Activity, private val resourceId: Int, data: List) :
ArrayAdapter(activity, resourceId, data) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
// Kotlin 中 ?: 意思是如果 convertView 为空,View 就等于冒号后面的,否则View 就等于 convertView
val view: View =
convertView ?: LayoutInflater.from(context).inflate(resourceId, parent, false)
val fruitImage: ImageView = view.findViewById(R.id.fruitImage)
val fruitName: TextView = view.findViewById(R.id.fruitName)
// 获取当前项的 Fruit 实例
val fruit = getItem(position)
if (fruit != null) {
fruitImage.setImageResource(fruit.imageId)
fruitName.text = fruit.name
}
return view
}
}
可以看到,现在我们在 getView() 方法中进行了判断:如果 convertView 为 null,则使 LayoutInflater 去加载布局;如果不为 null,则直接对 convertView 进行重用。这样就大大提高了 ListView 的运行效率,在快速滚动的时候可以表现出更好的性能。
不过,目前我们的这份代码还是可以继续优化的,虽然现在已经不会再重复去加载布局,但是每次在 getView() 方法中仍然会调用 View 的 findViewById() 方法来获取一次控件的实例。我们可以借助一个 ViewHolder 来对这部分性能进行优化,修改 FruitAdapter 中的代码,如下所示:
class FruitAdapter(activity: Activity, private val resourceId: Int, data: List) :
ArrayAdapter(activity, resourceId, data) {
inner class ViewHolder(val fruitImage: ImageView, val fruitName: TextView)
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view: View
val viewHolder: ViewHolder
if (convertView == null) {
view = LayoutInflater.from(context).inflate(resourceId, parent, false)
val fruitImage: ImageView = view.findViewById(R.id.fruitImage)
val fruitName: TextView = view.findViewById(R.id.fruitName)
viewHolder = ViewHolder(fruitImage, fruitName)
view.tag = viewHolder
} else {
view = convertView
viewHolder = view.tag as ViewHolder
}
// 获取当前项的Fruit实例
val fruit = getItem(position)
if (fruit != null) {
viewHolder.fruitImage.setImageResource(fruit.imageId)
viewHolder.fruitName.text = fruit.name
}
return view
}
}
我们新增了一个内部类 ViewHolder,用于对 ImageView 和 TextView 的控件实例进行缓存,Kotlin 中使用 inner class 关键字来定义内部类。当 convertView 为 null 的时候,创建一个 ViewHolder 对象,并将控件的实例存放在 ViewHolder 里,然后调用 View 的 setTag() 方法,将 ViewHolder 对象存储在 View 中。当 convertView 不为 null 的时候,则调用 View 的 getTag() 方法,把 ViewHolder 重新取出。这样所有控件的实例都缓存在了 ViewHolder 里,就没有必要每次都通过 findViewById() 方法来获取控件实例了。
通过这两步优化之后,ListView 的运行效率就已经非常不错了。
ListView 的点击事件
话说回来,ListView 的滚动毕竟只是满足了我们视觉上的效果,可是如果 ListView 中的子项不能点击的话,这个控件就没有什么实际的用途了。因此,我们就来学习一下 ListView 如何才能响应用户的点击事件。
修改 MainActivity 中的代码,如下所示:
class MainActivity : AppCompatActivity() {
private val fruitList = ArrayList()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
initFruits() // 初始化水果数据
val adapter = FruitAdapter(this, R.layout.fruit_item, fruitList)
listView.adapter = adapter
listView.setOnItemClickListener { parent, view, position, id ->
val fruit = fruitList[position]
Toast.makeText(this, fruit.name, Toast.LENGTH_SHORT).show()
}
}
...
}
可以看到,我们使用 setOnItemClickListener() 方法为 ListView 注册了一个监听器,当用户点击了 ListView 中的任何一个子项时,就会回调到 Lambda 表达式中。这里我们可以通过 position 参数判断用户点击的是哪一个子项,然后获取到相应的水果,并通过 Toast 将水果的名字显示出来。
更强大的滚动控件:RecyclerView
ListView 由于强大的功能,在过去的 Android 开发当中可以说是贡献卓越,直到今天仍然还有不计其数的程序在使用 ListView。不过 ListView 并不是完美无缺的,比如如果不使用一些技巧来提升它的运行效率,那么 ListView 的性能就会非常差。还有,ListView 的扩展性也不够好,它只能实现数据纵向滚动的效果,如果我们想实现横向滚动的话,ListView 是做不到的。
为此,Android 提供了一个更强大的滚动控件——RecyclerView。它可以说是一个增强版的 ListView,不仅可以轻松实现和 ListView 同样的效果,还优化了 ListView 存在的各种不足之处。目前 Android 官方更加推荐使用 RecyclerView,未来也会有更多的程序逐渐从 ListView 转向 RecyclerView,那么我们就来详细讲解一下 RecyclerView 的用法。
首先新建一个 RecyclerViewTest 项目,并让 Android Studio 自动帮我们创建好 Activity。
RecyclerView 的基本用法
接下来修改 activity_main.xml 中的代码,如下所示:
在布局中加入 RecyclerView 控件也是非常简单的,先为 RecyclerView 指定一个 id,然后将宽度和高度都设置为 match_parent,这样 RecyclerView 就占满了整个布局的空间。需要注意的是,由于 RecyclerView 并不是内置在系统 SDK 当中的,所以需要把完整的包路径写出来。
这里我们想要使用 RecyclerView 来实现和 ListView 相同的效果,需要为 RecyclerView 准备一个适配器,新建 FruitAdapter 类,让这个适配器继承自 RecyclerView.Adapter,并将泛型指定为FruitAdapter.ViewHolder。其中,ViewHolder 是我们在 FruitAdapter 中定义的一个内部类,代码如下所示:
class FruitAdapter(private val fruitList: List) : RecyclerView.Adapter() {
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val fruitName: TextView = view.fruitName
val fruitImage: ImageView = view.fruitImage
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.fruit_item, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val fruit = fruitList[position]
holder.fruitName.text = fruit.name
holder.fruitImage.setImageResource(fruit.imageId)
}
override fun getItemCount() = fruitList.size
}
这是 RecyclerView 适配器标准的写法,虽然看上去好像多了好几个方法,但其实它比 ListView 的适配器要更容易理解。这里我们首先定义了一个内部类 ViewHolder,它要继承自 RecyclerView.ViewHolder。然后 ViewHolder 的主构造函数中要传入一个 View 参数,这个参数通常就是 RecyclerView 子项的最外层布局,那么我们就可以通过 findViewById() 方法来获取布局中 ImageView 和 TextView 的实例了。
FruitAdapter 中也有一个主构造函数,它用于把要展示的数据源传进来,我们后续的操作都将在这个数据源的基础上进行。
继续往下看,由于 FruitAdapter 是继承自 RecyclerView.Adapter 的,那么就必须重写 onCreateViewHolder()、onBindViewHolder() 和 getItemCount() 这 3 个方法。onCreateViewHolder() 方法是用于创建 ViewHolder 实例的,我们在这个方法中将 fruit_item 布局加载进来,然后创建一个ViewHolder 实例,并把加载出来的布局传入构造函数当中,最后将 ViewHolder 的实例返回。onBindViewHolder() 方法用于对 RecyclerView 子项的数据进行赋值,会在每个子项被滚动到屏幕内的时候执行,这里我们通过 position 参数得到当前项的 Fruit 实例,然后再将数据设置到 ViewHolder 的 ImageView 和 TextView 当中即可。getItemCount() 方法就非常简单了,它用于告诉 RecyclerView 一共有多少子项,直接返回数据源的长度就可以了。
适配器准备好了之后,我们就可以开始使用 RecyclerView 了,修改 MainActivity 中的代码,如下所示:
class MainActivity : AppCompatActivity() {
private val fruitList = ArrayList()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
initFruits() // 初始化水果数据
val layoutManager = LinearLayoutManager(this)
recyclerView.layoutManager = layoutManager
val adapter = FruitAdapter(fruitList)
recyclerView.adapter = adapter
}
private fun initFruits() {
repeat(2) {
fruitList.add(Fruit("Apple", R.drawable.apple_pic))
fruitList.add(Fruit("Banana", R.drawable.banana_pic))
fruitList.add(Fruit("Orange", R.drawable.orange_pic))
fruitList.add(Fruit("Watermelon", R.drawable.watermelon_pic))
fruitList.add(Fruit("Pear", R.drawable.pear_pic))
fruitList.add(Fruit("Grape", R.drawable.grape_pic))
fruitList.add(Fruit("Pineapple", R.drawable.pineapple_pic))
fruitList.add(Fruit("Strawberry", R.drawable.strawberry_pic))
fruitList.add(Fruit("Cherry", R.drawable.cherry_pic))
fruitList.add(Fruit("Mango", R.drawable.mango_pic))
}
}
}
可以看到,这里使用了一个同样的 initFruits() 方法,用于初始化所有的水果数据。接着在 onCreate() 方法中先创建了一个 LinearLayoutManager 对象,并将它设置到 RecyclerView 当中。LayoutManager 用于指定 RecyclerView 的布局方式,这里使用的 LinearLayoutManager 是线性布局的意思,可以实现和 ListView 类似的效果。接下来我们创建了 FruitAdapter 的实例,并将水果数据传入 FruitAdapter 的构造函数中,最后调用 RecyclerView 的 setAdapter() 方法来完成适配器设置,这样 RecyclerView 和数据之间的关联就建立完成了。
可以看到,我们使用 RecyclerView 实现了和 ListView 一模一样的效果,虽说在代码量方面并没有明显的减少,但是逻辑变得更加清晰了。当然这只是 RecyclerView 的基本用法而已,接下来我们就看一看RecyclerView 还能实现哪些 ListView 实现不了的效果。
实现横向滚动和瀑布流布局
我们已经知道,ListView 的扩展性并不好,它只能实现纵向滚动的效果,如果想进行横向滚动的话, ListView 就做不到了。那么 RecyclerView 就能做得到吗?当然可以,不仅能做得到,还非常简单。接下来我们就尝试实现一下横向滚动的效果。
首先要对 fruit_item 布局进行修改,因为目前这个布局里面的元素是水平排列的,适用于纵向滚动的场景,而如果我们要实现横向滚动的话,应该把 fruit_item 里的元素改成垂直排列才比较合理。修fruit_item.xml 中的代码,如下所示:
可以看到,我们将 LinearLayout 改成垂直方向排列,并把宽度设为 80dp。这里将宽度指定为固定值是因为每种水果的文字长度不一致,如果用 wrap_content 的话,RecyclerView 的子项就会有长有短,非常不美观,而如果用 match_parent 的话,就会导致宽度过长,一个子项占满整个屏幕。
然后我们将 ImageView 和 TextView 都设置成了在布局中水平居中,并且使用 layout_marginTop 属性让文字和图片之间保持一定距离。
接下来修改 MainActivity 中的代码,如下所示:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 初始化水果数据
initFruits()
val layoutManager = LinearLayoutManager(this)
// 只需要加这一行代码即可
layoutManager.orientation = LinearLayoutManager.HORIZONTAL
recyclerView.layoutManager = layoutManager
val adapter = FruitAdapter2(fruitList)
recyclerView.adapter = adapter
}
你可以用手指在水平方向上滑动来查看屏幕外的数据。
为什么 ListView 很难或者根本无法实现的效果在 RecyclerView 上这么轻松就实现了呢?这主要得益于 RecyclerView 出色的设计。ListView 的布局排列是由自身去管理的,而 RecyclerView 则将这个工作交给了 LayoutManager。LayoutManager 制定了一套可扩展的布局排列接口,子类只要按照接口的规范来实现,就能定制出各种不同排列方式的布局了。
除了 LinearLayoutManager 之外,RecyclerView 还给我们提供了 GridLayoutManager 和 StaggeredGridLayoutManager 这两种内置的布局排列方式。GridLayoutManager 可以用于实现网格
布局,StaggeredGridLayoutManager 可以用于实现瀑布流布局。这里我们来实现一下效果更加炫酷的瀑布流布局。
首先还是来修改一下 fruit_item.xml 中的代码,如下所示:
这里做了几处小的调整,首先将 LinearLayout 的宽度由 80dp 改成了 match_parent,因为瀑布流布局的宽度应该是根据布局的列数来自动适配的,而不是一个固定值。其次我们使用了 layout_margin 属性来让子项之间互留一点间距,这样就不至于所有子项都紧贴在一些。最后还将 TextView 的对齐属性改成了居左对齐,因为待会我们会将文字的长度变长,如果还是居中显示就会感觉怪怪的。
接着修改 MainActivity 中的代码,如下所示:
class MainActivity : AppCompatActivity() {
private val fruitList = ArrayList()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 初始化水果数据
initFruits()
val layoutManager = StaggeredGridLayoutManager(3, StaggeredGridLayoutManager.VERTICAL)
recyclerView.layoutManager = layoutManager
val adapter = FruitAdapter2(fruitList)
recyclerView.adapter = adapter
}
private fun initFruits() {
repeat(2) {
fruitList.add(Fruit(getRandomLengthString("Apple"),
R.drawable.apple_pic))
fruitList.add(Fruit(getRandomLengthString("Banana"),
R.drawable.banana_pic))
fruitList.add(Fruit(getRandomLengthString("Orange"),
R.drawable.orange_pic))
fruitList.add(Fruit(getRandomLengthString("Watermelon"),
R.drawable.watermelon_pic))
fruitList.add(Fruit(getRandomLengthString("Pear"),
R.drawable.pear_pic))
fruitList.add(Fruit(getRandomLengthString("Grape"),
R.drawable.grape_pic))
fruitList.add(Fruit(getRandomLengthString("Pineapple"),
R.drawable.pineapple_pic))
fruitList.add(Fruit(getRandomLengthString("Strawberry"),
R.drawable.strawberry_pic))
fruitList.add(Fruit(getRandomLengthString("Cherry"),
R.drawable.cherry_pic))
fruitList.add(Fruit(getRandomLengthString("Mango"),
R.drawable.mango_pic))
}
}
private fun getRandomLengthString(str: String): String {
val n = (1..20).random()
val builder = StringBuilder()
repeat(n) {
builder.append(str)
}
return builder.toString()
}
}
首先,在onCreate() 方法中,我们创建了一个 StaggeredGridLayoutManager 的实例。 StaggeredGridLayoutManager 的构造函数接收两个参数:第一个参数用于指定布局的列数,传入 3 表示会把布局分为 3 列;第二个参数用于指定布局的排列方向,传入 StaggeredGridLayoutManager.VERTICAL 表示会让布局纵向排列。最后把创建好的实例设置到 RecyclerView 当中就可以了,就是这么简单!
没错,仅仅修改了一行代码,我们就已经成功实现瀑布流布局的效果了。不过由于瀑布流布局需要各个子项的高度不一致才能看出明显的效果,为此我又使用了一个小技巧。这里我们把眼光聚焦到getRandomLengthString() 这个方法上,这个方法中调用了 Range 对象的 random() 函数来创造一个 1 到 20 之间的随机数,然后将参数中传入的字符串随机重复几遍。在 initFruits() 方法中,每个水果的名字都改成调用 getRandomLengthString() 这个方法来生成,这样就能保证各水果名字的长短差距比较大,子项的高度也就各不相同了。
现在重新运行一下程序,效果如下图所示:
RecyclerView 的点击事件
和 ListView 一样,RecyclerView 也必须能响应点击事件才可以,不然的话就没什么实际用途了。不过不同于 ListView 的是,RecyclerView 并没有提供类似于 setOnItemClickListener() 这样的注册监听器方法,而是需要我们自己给子项具体的 View 去注册点击事件。这相比于 ListView 来说,实现起来要复杂一些。
那么你可能就有疑问了,为什么 RecyclerView 在各方面的设计都要优于 ListView,偏偏在点击事件上却没有处理得非常好呢?其实不是这样的,ListView 在点击事件上的处理并不人性化, setOnItemClickListener() 方法注册的是子项的点击事件,但如果我想点击的是子项里具体的某一个按钮呢?虽然 ListView 也能做到,但是实现起来就相对比较麻烦了。为此,RecyclerView 干脆直接摒弃了子项点击事件的监听器,让所有的点击事件都由具体的 View 去注册,就再没有这个困扰了。
下面我们来具体学习一下如何在 RecyclerView 中注册点击事件,修改 FruitAdapter 中的代码,如下所示:
class FruitAdapter2(private val fruitList: List) : RecyclerView.Adapter() {
...
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.fruit_item, parent, false)
val viewHolder = ViewHolder(view)
viewHolder.itemView.setOnClickListener {
val position = viewHolder.adapterPosition
val fruit = fruitList[position]
Toast.makeText(parent.context, "you clicked view ${fruit.name}", Toast.LENGTH_SHORT).show()
}
viewHolder.fruitImage.setOnClickListener {
val position = viewHolder.adapterPosition
val fruit = fruitList[position]
Toast.makeText(parent.context, "you clicked image ${fruit.name}", Toast.LENGTH_SHORT).show()
}
return viewHolder
}
...
}
可以看到,这里我们是在 onCreateViewHolder() 方法中注册点击事件。上述代码分别为最外层布局和 ImageView 都注册了点击事件,itemView 表示的就是最外层布局。RecyclerView 的强大之处也在于此,它可以轻松实现子项中任意控件或布局的点击事件。我们在两个点击事件中先获取了用户点击的 position,然后通过 position 拿到相应的 Fruit 实例,再使用 Toast 分别弹出两种不同的内容以示区别。
编写界面的最佳实践
既然已经学习了那么多 UI 开发的知识,是时候实战一下了。这次我们要综合运用前面所学的大量内容来编写出一个较为复杂且相当美观的聊天界面,首先先创建一个 UIBestPractice 项目。
制作 9-Patch 图片
在实战正式开始之前,我们需要先学习一下如何制作 9-Patch 图片。你之前可能没有听说过这个名词,它是一种被特殊处理过的 png 图片,能够指定哪些区域可以被拉伸、哪些区域不可以。
那么 9-Patch 图片到底有什么实际作用呢?我们还是通过一个例子来看一下吧。首先在 UIBestPractice 项目中放置一张气泡样式的图片 message_left.png,如下图所示。
我们将这张图片设置为 LinearLayout 的背景图片,修改 activity_main.xml 中的代码,如下所示:
这里将 LinearLayout 的宽度指定为 match_parent,将它的背景图设置为 message_left。现在运行程序,效果如下图所示:
可以看到,由于 message_left 的宽度不足以填满整个屏幕的宽度,整张图片被均匀地拉伸了!这种效果非常差,用户肯定是不能容忍的,这时就可以使用 9-Patch 图片来进行改善。
制作 9-Patch 图片其实并不复杂,只要掌握好规则就行了,那么现在我们就来学习一下。
在 Android Studio 中,我们可以将任何 png 类型的图片制作成 9-Patch 图片。首先对着 message_left.png 图片右击 →Create 9-Patch file,会创建一张以 9.png 为后缀的同名图片,选中这张图片。这时 Android Studio 会显示如下图所示的编辑界面:
我们可以在图片的 4 个边框绘制一个个的小黑点,在上边框和左边框绘制的部分表示当图片需要拉伸时就拉伸黑点标记的区域,在下边框和右边框绘制的部分表示内容允许被放置的区域。使用鼠标在图片的边缘拖动就可以进行绘制了,按住 Shift 键拖动可以进行擦除。绘制完成后效果如下图所示:
最后记得要将原来的 message_left.png 图片删除,只保留制作好的 message_left.9.png 图片即可,因为Android 项目中不允许同一文件夹下有两张相同名称的图片(即使后缀名不同也不行)。重新运行程序,效果如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ssAYjiNU-1620372326265)(https://p1-juejin.byteimg.com...]
这样当图片需要拉伸的时候,就可以只拉伸指定的区域,程序在外观上也有了很大的改进。有
了这个知识储备之后,我们就可以进入实战环节了。
编写精美的聊天界面
既然是要编写一个聊天界面,那肯定要有收到的消息和发出的消息。上一小节中我们制作的message_left.9.png 可以作为收到消息的背景图,那么毫无疑问你还需要再制作一张 message_right.9.png 作为发出消息的背景图。制作过程是完全一样的,我就不再重复演示了。
图片都准备好了之后,就可以开始编码了,接下来开始编写主界面,修改 activity_main.xml 中的代码,如下所示:
我们在主界面中放置了一个 RecyclerView 用于显示聊天的消息内容,又放置了一个 EditText 用于输入消息,还放置了一个 Button 用于发送消息。
然后定义消息的实体类,新建 Msg,代码如下所示:
class Msg(val content: String, val type: Int) {
companion object {
const val TYPE_RECEIVED = 0
const val TYPE_SENT = 1
}
}
Msg 类中只有两个字段:content 表示消息的内容,type 表示消息的类型。其中消息类型有两个值可选: TYPE_RECEIVED 表示这是一条收到的消息,TYPE_SENT 表示这是一条发出的消息。这里我们将 TYPE_RECEIVED 和 TYPE_SENT 定义成了常量,定义常量的关键字是 const,注意只有在单例类、companion object 或顶层方法中才可以使用 const 关键字。
接下来开始编写 RecyclerView 的子项布局,新建 msg_left_item.xml,代码如下所示:
这是接收消息的子项布局。这里我们让收到的消息居左对齐,并使用 message_left.9.png 作为背景图。
类似地,我们还需要再编写一个发送消息的子项布局,新建 msg_right_item.xml,代码如下所示:
这里我们让发出的消息居右对齐,并使用 message_right.9.png 作为背景图,基本上和刚才的 msg_left_item.xml 是差不多的。
接下来需要创建 RecyclerView 的适配器类,新建类 MsgAdapter,代码如下所示:
class MsgAdapter(private val msgList: List) : RecyclerView.Adapter() {
inner class LeftViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val leftMsg: TextView = view.leftMsg
}
inner class RightViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val rightMsg: TextView = view.rightMsg
}
override fun getItemViewType(position: Int): Int {
val msg = msgList[position]
return msg.type
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
if (viewType == Msg.TYPE_RECEIVED) {
val view = LayoutInflater.from(parent.context).inflate(R.layout.msg_left_item, parent, false)
LeftViewHolder(view)
} else {
val view = LayoutInflater.from(parent.context).inflate(R.layout.msg_right_item, parent, false)
RightViewHolder(view)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val msg = msgList[position]
when (holder) {
is LeftViewHolder -> holder.leftMsg.text = msg.content
is RightViewHolder -> holder.rightMsg.text = msg.content
else -> throw IllegalArgumentException()
}
}
override fun getItemCount() = msgList.size
}
上述代码中用到了一个新的知识点:根据不同的 viewType 创建不同的界面。首先我们定义了 LeftViewHolder 和 RightViewHolder 这两个 ViewHolder,分别用于缓存 msg_left_item.xml 和 msg_right_item.xml 布局中的控件。然后要重写 getItemViewType() 方法,并在这个方法中返回当前 position 对应的消息类型。
接下来的代码你应该就比较熟悉了,和我们之前学习的R ecyclerView 用法是比较相似的,只是要在 onCreateViewHolder() 方法中根据不同的 viewType 来加载不同的布局并创建不同的 ViewHolder。然后在 onBindViewHolder() 方法中判断 ViewHolder 的类型:如果是 LeftViewHolder,就将内容显示到左边的消息布局;如果是 RightViewHolder,就将内容显示到右边的消息布局。
最后修改 MainActivity 中的代码,为 RecyclerView 初始化一些数据,并给发送按钮加入事件响应,代码如下所示:
class MainActivity : AppCompatActivity(), View.OnClickListener {
private val msgList = ArrayList()
private var mAdapter: MsgAdapter? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
initMsg()
val layoutManager = LinearLayoutManager(this)
recyclerView.layoutManager = layoutManager
mAdapter = MsgAdapter(msgList)
recyclerView.adapter = mAdapter
send.setOnClickListener(this)
}
private fun initMsg() {
val msg1 = Msg("Hello guy.", Msg.TYPE_RECEIVED)
msgList.add(msg1)
val msg2 = Msg("Hello. Who is that?", Msg.TYPE_SENT)
msgList.add(msg2)
val msg3 = Msg("This is Tom. Nice talking to you. ", Msg.TYPE_RECEIVED)
msgList.add(msg3)
}
override fun onClick(v: View?) {
when (v) {
send -> {
val content = inputText.text.toString()
if (content.isNotEmpty()) {
val msg = Msg(content, Msg.TYPE_SENT)
msgList.add(msg)
// 当有新消息时, 刷新 RecyclerView 中的显示
mAdapter?.notifyItemInserted(msgList.size - 1)
// 将 RecyclerView 定位到最后一行
recyclerView.scrollToPosition(msgList.size - 1)
// 清空输入框中的内容
inputText.setText("")
}
}
}
}
}
我们先在 initMsg() 方法中初始化了几条数据用于在 RecyclerView 中显示,接下来按照标准的方式构建 RecyclerView,给它指定一个 LayoutManager 和一个适配器。
然后在发送按钮的点击事件里获取了 EditText 中的内容,如果内容不为空字符串,则创建一个新的 Msg 对象并添加到 msgList 列表中去。之后又调用了适配器的 notifyItemInserted() 方法,用于通知列表有新的数据插入,这样新增的一条消息才能够在 RecyclerView 中显示出来。或者你也可以调用适配器的 notifyDataSetChanged() 方法,它会将 RecyclerView 中所有可见的元素全部刷新,这样不管是新增、删除、还是修改元素,界面上都会显示最新的数据,但缺点是效率会相对差一些。接着调用 RecyclerView 的 scrollToPosition() 方法将显示的数据定位到最后一行,以保证一定可以看得到最后发出的一条消息。最后调用 EditText 的 setText() 方法将输入的内容清空。
这样所有的工作都完成了,终于可以检验一下我们的成果了。运行程序之后,你将会看到非常美观的聊天界面,并且可以输入和发送消息,如下所示:
小结
本章从 Android 中的一些常见控件入手,依次介绍了基本布局的用法、ListView 的详细用法以及RecyclerView 的使用,基本已经将重要的 UI 知识点全部覆盖了。
虽然本章的内容很多,但我觉得学习起来应该还是挺愉快的吧。不同于上一章中我们来来回回使用那几个按钮,本章可以说是使用了各种各样的控件,制作出了丰富多彩的界面。