界面最佳实践:写一个聊天界面

133 阅读7分钟

前言

现在我们来编写一个聊天界面。

准备工作: 创建名为 UIBestPractice 的 Empty Views Activity 项目。

制作消息气泡

聊天记录中的消息气泡是通过 9-Patch 图片来完成的。它是一种特殊的 png 图片,可以指定哪些区域可以被拉伸,哪些区域存放内容。我们先来看看如何制作 9-Patch 图片。

我们先添加 message_left.pngmessage_left.png 这两张气泡样式的图片到项目的 res/drawable-xxhdpi 目录中:

message_left_original.png

message_right_original.png

图片资源下载地址:传送门

然后在 Android Studio 中右键新导入 png 图片(所有为 png 类型的图片都可以制作成 9-Patch 图片),选择 Create 9-Patch file 以创建 9-Patch 图片,然后使用默认名称并保存到原地即可,它会创建以 9.png 为后缀的同名图片。

然后双击创建好的 9-Patch 图片,来到编辑界面:

image.png

我们可以在图片四周的透明边框(1像素)上绘制黑线,就是下图中除了粉色区域外的部分。在上边和左边绘制的部分表示图片拉伸时会拉伸的区域,在下边和右边绘制的部分则表示内容(如文本)允许被放置的区域。

image.png

使用鼠标拖动即可在边框上绘制黑线,要清除黑线的话,需要按住 Shift 键进行拖动。

完成后的效果如图所示:

image.png image.png

可以看到,制作起来还是挺简单的。你可以边调整边对照着预览图,并且勾选 Show content 选项后,可以看到内容被放置的区域。

制作完成后,你需要把原始的 png 图片删除,因为项目的同一个文件夹中不能出现同名文件,即使后缀名不同。或者可以保留,将原始的 png 图片重命名为 message_left_original.pngmessage_left_original.png

我们来测试一下效果,来到 activity_main.xml 布局文件中,将 message_left.9.png 图片作为 LinearLayout 根布局的背景图片:

<?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="50dp"
    android:layout_margin="10dp"
    android:background="@drawable/message_left">

</LinearLayout>

运行效果:

image.png

这样当图片需要拉伸时,就可以拉伸所指定的区域了。

编写聊天界面

我们需要使用 RecyclerView 列表控件来展示聊天消息,所以得先导入 RecyclerView 的依赖。并且所有视图实例的获取都是通过视图绑定来完成的,也需要先启用视图绑定。在 build.gradle.kts(:app) 配置文件中:

android {
    ...

    buildFeatures{
        viewBinding = true
    }

    ...
}

dependencies {
    implementation("androidx.recyclerview:recyclerview:1.4.0")
    ...
}

然后完成主界面的布局,在 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:background="#d8e0e8"
    android:orientation="vertical">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <EditText
            android:id="@+id/inputText"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:hint="Type something here"
            android:maxLines="5" />

        <Button
            android:id="@+id/send"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Send" />

    </LinearLayout>
</LinearLayout>

我们往布局中添加了一个 RecyclerView 列表控件用来显示消息记录,一个 EditText 用来输入内容,一个 Button 用来发送消息。

预览图:

image.png

再创建列表控件需要用到的数据类型,定义一个消息的实体类 Msg。代码如下:

class Msg(
    val content: String, // 消息的内容
    val type: Int,       // 消息的类型
) {
    companion object {
        const val TYPE_RECEIVED = 0 // 消息类型为收到的消息
        const val TYPE_SENT = 1     // 消息类型为发送的消息
    }
}

接下来自定义 RecyclerView 列表项的布局,先编写收到消息列表项的布局。新建 msg_left_item.xml 布局文件,代码如下:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="10dp">

    <TextView
        android:id="@+id/leftMsg"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="start"
        android:background="@drawable/message_left"
        android:gravity="start|center_vertical"
        android:paddingStart="15dp"
        android:paddingEnd="10dp"
        android:paddingTop="10dp"
        android:paddingBottom="10dp"
        android:textColor="@color/white" />

</FrameLayout>

我们让收到的消息气泡左对齐,消息气泡内的文本内容从左侧开始,并垂直居中对齐,使用 message_left.9.png 作为背景图片。

相应的,新建发送消息列表项的 msg_right_item.xml 布局文件,代码如下:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="10dp">

    <TextView
        android:id="@+id/rightMsg"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="end"
        android:background="@drawable/message_right"
        android:gravity="start|center_vertical"
        android:paddingStart="10dp"
        android:paddingEnd="15dp"
        android:paddingTop="10dp"
        android:paddingBottom="10dp"
        android:textColor="@color/black" />

</FrameLayout>

我们让发送的消息气泡右对齐,消息气泡内的文本内容也是从左侧开始,并垂直居中对齐,使用 message_right.9.png 作为背景图片。

然后创建 RecyclerView 的适配器类 MsgAdapter,代码如下:

class MsgAdapter(private val msgList: List<Msg>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    inner class LeftViewHolder(private val msgLeftItemBinding: MsgLeftItemBinding) :
        RecyclerView.ViewHolder(msgLeftItemBinding.root) {
        fun bind(msg: Msg) {
            msgLeftItemBinding.leftMsg.text = msg.content
        }
    }

    inner class RightViewHolder(private val msgRightItemBinding: MsgRightItemBinding) :
        RecyclerView.ViewHolder(msgRightItemBinding.root) {
        fun bind(msg: Msg) {
            msgRightItemBinding.rightMsg.text = msg.content
        }
    }

    override fun getItemViewType(position: Int): Int {
        val msg = msgList[position]
        return msg.type
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
        if (viewType == Msg.TYPE_RECEIVED) {
            val binding =
                MsgLeftItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
            LeftViewHolder(binding)
        } else {
            val binding =
                MsgRightItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
            RightViewHolder(binding)
        }


    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        val msg = msgList[position]

        when (holder) {
            is LeftViewHolder -> holder.bind(msg)
            is RightViewHolder -> holder.bind(msg)
            else -> throw IllegalArgumentException()
        }
    }

    override fun getItemCount() = msgList.size
}

这里,我们根据消息类型的不同,创建了不同的视图。首先我们定义了 LeftViewHolderRightViewHolder 这两个 ViewHolder,分别持有 MsgLeftItemBindingMsgRightItemBinding 的绑定对象。然后重写 getItemViewType() 方法,返回当前 position 对应的消息类型,以便在 onCreateViewHolder 方法中,能够根据 viewType 参数知道当前视图的类型。

onCreateViewHolder() 方法会根据消息类型的不同,来加载不同的布局并创建对应的视图绑定对象,然后创建对应的 ViewHolder 对象并返回。在 onBindViewHolder() 方法中将消息内容设置到消息布局对应的视图上。

最后在 MainActivity 中为 RecyclerView 列表添加一些数据,并且给按钮添加点击事件,以便点击时,能够发送新消息。代码如下:

class MainActivity : AppCompatActivity() {

    private val msgList = ArrayList<Msg>()
    private lateinit var adapter: MsgAdapter 
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        
        initMsg() // 初始化聊天信息

        // 设置列表的排列方式
        val layoutManager = LinearLayoutManager(this)
        binding.recyclerView.layoutManager = layoutManager

        // 设置适配器
        adapter = MsgAdapter(msgList)
        binding.recyclerView.adapter = adapter

        // 按钮添加点击事件
        binding.send.setOnClickListener {
            // 获取输入框输入的内容
            val content = binding.inputText.text.toString()

            if (content.isNotEmpty()) { // 内容不为空时
                val msg = Msg(content, Msg.TYPE_SENT)
                msgList.add(msg)
                adapter.notifyItemInserted(msgList.size - 1) // 刷新 RecyclerView 中的显示
                binding.recyclerView.scrollToPosition(msgList.size - 1) // 将 RecyclerView 定位到最后一行
                binding.inputText.setText("") // 清空输入框中的内容
            }
        }
    }

    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)
    }
}

我们在 initMsg 方法中初始化了几条数据用于显示,并且给 RecyclerView 列表控件指定了 LayoutManager 和适配器。

在发送按钮的点击事件中,我们会获取用户输入的内容。如果不为空,就添加一条消息数据到 msgList 列表中,并且为了让新增的消息能够在 RecyclerView 中显示出来。我们调用了适配器的 notifyItemInserted() 方法,用于通知列表有新的数据插入,并伴随着动画效果,或者你可以调用适配器的 notifyDataSetChanged() 方法,它会刷新 RecyclerView 中所有可见元素,你可以这样做,但效率不高且没有动画效果。然后我们调用了 RecyclerView 的 scrollToPosition() 方法,跳转到最新发送的消息处,最后将输入框的内容清空。

运行效果:

image.png image.png image.png

这样基本的聊天界面实战就完成了。

添加标题栏

另外,创建的项目默认是没有标题栏的,查看 res/values/themes.xml 文件。

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Base.Theme.UIBestPractice" parent="Theme.Material3.DayNight.NoActionBar">
        <!-- Customize your light theme here. -->
        <!-- <item name="colorPrimary">@color/my_light_primary</item> -->
    </style>

    <style name="Theme.UIBestPractice" parent="Base.Theme.UIBestPractice" />
</resources>

可以看到我们的主题是 Theme.Material3.DayNight.NoActionBar,确实是无标题样式的。但我们可以使用 Toolbar 控件来添加一个自定义的标题栏。

首先在 activity_main.xml 布局文件中添加 Toolbar 控件:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#d8e0e8"
    android:orientation="vertical">

    <androidx.appcompat.widget.Toolbar
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        android:background="#018577"
        app:titleTextColor="@color/white" />

    <com.google.android.material.divider.MaterialDivider
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:dividerColor="#d1d1d1"
        app:dividerThickness="1dp" />

    ...
    
</LinearLayout>

我们给标题栏的高度设置了标准标题栏的高度,宽度设为了 match_parent,并且添加在标题栏和聊天列表之间添加了分割线组件。

然后在 MainActivityonCreate 方法中,设置当前 Activity 的标题栏为布局中的 Toolbar:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    binding = ActivityMainBinding.inflate(layoutInflater)
    setContentView(binding.root)

    initMsg() // 初始化聊天信息

    // 设置当前界面的标题栏
    val toolbar: Toolbar = binding.toolbar
    setSupportActionBar(toolbar)

    // 设置标题内容
    supportActionBar?.title = "UIBestPractice"

    // ...
}

运行效果:

image.png

最后我们修改一下状态栏的颜色,在 res/values/themes.xml 主题文件中:

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Base.Theme.UIBestPractice" parent="Theme.Material3.DayNight.NoActionBar">
        <!--新增这两行-->
        <item name="android:statusBarColor" >#00584c</item>
        <item name="android:windowLightStatusBar" >false</item>
    </style>

    <style name="Theme.UIBestPractice" parent="Base.Theme.UIBestPractice" />
</resources>

其中我们通过 android:statusBarColor 设置了状态栏的颜色,将 android:windowLightStatusBar 属性设为 false,状态栏中的图标和文字颜色会是浅色,否则为深色。

最终运行效果:

image.png