如何使用Kotlin在Android中实现多个ViewHolders

261 阅读5分钟

使用Kotlin在Android中实现多个ViewHolders

在Android的命令式编程范式中,RecyclerView是一个用于显示可滚动项目的部件。通常情况下,开发者使用单一类型的项目来填充RecyclerView中的数据。

你有没有问过自己,如何在同一个RecyclerView中使用不同类型的数据项,同时保持无缝体验?

这就是多个ViewHolders的用处。它们允许我们在RecyclerView的回调中传递不同的数据对象。这样,我们就可以创建更多的互动和可扩展的应用程序。

前提条件

要跟上这个教程,你需要。

  • 安装[Android Studio IDE],最好是最新的版本。
  • 熟悉Android RecyclerView。
  • 对Kotlin和ViewBinding有基本了解。

目标

在本教程结束时,你将能够。

  • 理解为什么我们需要一个以上的ViewHolder类。
  • 在一个适配器中实现双类型的ViewHolders。
  • 管理RecyclerView的回调方法和它们的交互。

案例描述

为了演示我们如何使用多个ViewHolder类,我们将创建一个简单的应用程序,显示一个地标的列表。地标项目可以包含图片,也可以不包含。

开始吧

在Android Studio中创建一个空的项目,并给它取一个你喜欢的名字。

创建行项目

"行 "项是构成RecyclerView中的一个单元项的布局文件。通常情况下,我们会为每个RecyclerView使用一个行项。然而,我们可以为同一个RecyclerView使用一个以上的行项。这就是使用多个viewHolders的主要目的,每个viewHolders对应一个布局。

带图片的项目的布局

首先,创建一个名为landmark_with_image.xml的布局文件并粘贴以下代码。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="8dp">

    <androidx.cardview.widget.CardView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:cardCornerRadius="8dp">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <ImageView
                android:id="@+id/landmarkImage"
                android:layout_width="match_parent"
                android:layout_height="200dp"
                android:layout_marginBottom="10dp"
                android:scaleType="centerCrop"
                android:src="@drawable/ic_launcher_foreground"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />

            <TextView
                android:id="@+id/landmarkWithImageTitle"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="8dp"
                android:text="Test Title"
                android:textSize="20sp"
                android:textStyle="bold"
                app:layout_constraintEnd_toEndOf="@id/landmarkImage"
                app:layout_constraintHorizontal_bias="0.025"
                app:layout_constraintStart_toStartOf="@id/landmarkImage"
                app:layout_constraintTop_toBottomOf="@id/landmarkImage" />

            <TextView
                android:id="@+id/landmarkWithImageDesc"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_marginVertical="8dp"
                android:layout_marginEnd="8dp"
                android:maxLines="3"
                android:textSize="16sp"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="@id/landmarkImage"
                app:layout_constraintHorizontal_bias="0.0"
                app:layout_constraintStart_toStartOf="@id/landmarkWithImageTitle"
                app:layout_constraintTop_toBottomOf="@id/landmarkWithImageTitle"
                tools:text="@tools:sample/lorem/random" />
        </androidx.constraintlayout.widget.ConstraintLayout>
    </androidx.cardview.widget.CardView>
</androidx.constraintlayout.widget.ConstraintLayout>

上面的代码生成了一个带有图片的cardView和两个textView,一个用于显示标题,一个用于显示描述。

预览。

Item with image

没有图像的项目的布局

创建一个名为landmark_without_image.xml的布局文件并粘贴以下代码。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="8dp">

    <androidx.cardview.widget.CardView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:cardCornerRadius="8dp">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <TextView
                android:id="@+id/landmarkTitle"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Landmark Title"
                android:textSize="20sp"
                android:textStyle="bold"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintHorizontal_bias="0.025"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />

            <TextView
                android:id="@+id/landmarkDesc"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_marginVertical="8dp"
                android:layout_marginEnd="8dp"
                android:maxLines="3"
                android:textSize="16sp"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="@id/landmarkTitle"
                app:layout_constraintTop_toBottomOf="@id/landmarkTitle"
                tools:text="@tools:sample/lorem/random" />
        </androidx.constraintlayout.widget.ConstraintLayout>
    </androidx.cardview.widget.CardView>
</androidx.constraintlayout.widget.ConstraintLayout>

与之前的布局不同,上面的布局不包含图像。

预览。

Item without image

注意:你可以根据使用情况,创建任意多的布局。布局越多,需要的viewHolders就越多。

设置RecyclerView

activity_main.xml文件中,添加以下代码。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/landmarkRecyclerview"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        tools:listitem="@layout/landmark_without_image" />
</androidx.constraintlayout.widget.ConstraintLayout>

预览。

RecyclerView preview

对项目进行分类

如前所述,一个地标项目可以采取上述任何一种布局,取决于它是否有图像。

enum class HasImage {
    TRUE, FALSE
}

上面是一个枚举类,用于确定地标的类别。理想情况下,这将告诉适配器在给定位置的RecyclerView中要绑定什么样的项目。

地标数据模型

每个项目都应该有一个由数据类定义的通用结构/模型。

data class Landmark(
    val title: String,
    val desc: String,
    var resource: Int?,
    val hasImage: HasImage
)

设置RecyclerView适配器

一个适配器类负责用所提供的数据相应地填充RecyclerView。

class LandmarkAdapter(private var landmarks: ArrayList<Landmark>) :
    RecyclerView.Adapter<RecyclerView.ViewHolder>() {

}

注意:上面的viewHolder参数并不局限于一个自定义类型。相反,我们使用了内置的RecyclerView类中的viewHolder。这允许我们在我们的适配器中应用许多viewHolder。

override fun getItemCount(): Int = landmarks.size

这个方法通知适配器要生成多少个项目,通常是数据集合的大小。

接着,我们将为我们先前创建的两个布局文件创建两个viewHolder类。

带图片的地标的ViewHolder

inner class LandmarkWithImageViewHolder(private val landmarkWithImage: LandmarkWithImageBinding) : 
    RecyclerView.ViewHolder(landmarkWithImage.root) {
    fun bind(landmark: Landmark) {
        landmarkWithImage.landmarkImage.setImageResource(landmark.resource!!)
        landmarkWithImage.landmarkWithImageTitle.text = landmark.title
        landmarkWithImage.landmarkWithImageDesc.text = landmark.desc
    }
}

不含图片的地标的ViewHolder

inner class LandmarkWithoutImageViewHolder(private val landmarkWithoutImage: LandmarkWithoutImageBinding) :
    RecyclerView.ViewHolder(landmarkWithoutImage.root) {
    fun bind(landmark: Landmark) {
        landmarkWithoutImage.landmarkTitle.text = landmark.title
        landmarkWithoutImage.landmarkDesc.text = landmark.desc
    }
}

上述viewHolders持有各自类型的项目。它们在下面讨论的其他方法中被调用。

确定项目的类型

下面的方法用于确定特定位置上的项目类型。

override fun getItemViewType(position: Int): Int {
    return if (landmarks[position].hasImage == HasImage.TRUE) HASIMAGE else NOIMAGE
}

这些常量(返回值)被定义在一个对象中,如下所示。

private object Const{
    const val HASIMAGE = 0 // random unique value
    const val NOIMAGE = 1
}

onCreateViewHolder方法

这是我们根据getItemViewType 方法所提供的视图项的类型来返回一个viewHolder。

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
    return if (viewType == HASIMAGE) {
        val view =
            LandmarkWithImageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        LandmarkWithImageViewHolder(view)
    } else {
        val view =
            LandmarkWithoutImageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        LandmarkWithoutImageViewHolder(view)
    }
}

onBindViewHolder方法

当绑定数据时,我们需要首先检查一个项目相对于其位置的类型,然后将其传递给相应的持有人的bind 函数。持有人是根据viewType显式投递的。

override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
    if (getItemViewType(position) == HASIMAGE){
        (holder as LandmarkWithImageViewHolder).bind(landmarks[position])
    } else{
        (holder as LandmarkWithoutImageViewHolder).bind(landmarks[position])
    }
}

生成数据

以下是用于演示目的的假数据。理想情况下,你应该从一个真正的数据库中获取结构化的数据。

class LandmarkModel {
    companion object {
        fun getLandmarks(): ArrayList<Landmark> = arrayListOf(
            Landmark("Mt. Kenya", "This is a mountain in Kenya 1", null, HasImage.FALSE),
            Landmark("Mt. Kenya", "This is a mountain in Kenya 2", null, HasImage.FALSE),
            Landmark("Mt. Kenya", "This is a mountain in Kenya 3", null, HasImage.FALSE),
            Landmark(
                "Mt. Kenya", "This is a mountain in Kenya 4", R.drawable.ic_launcher_background,
                HasImage.TRUE
            ),
            Landmark("Mt. Kenya", "This is a mountain in Kenya 5", null, HasImage.FALSE),
            Landmark(
                "Mt. Kenya", "This is a mountain in Kenya 6", R.drawable.ic_launcher_foreground,
                HasImage.TRUE
            ),
            Landmark("Mt. Kenya", "This is a mountain in Kenya 7", null, HasImage.FALSE),
            Landmark("Mt. Kenya", "This is a mountain in Kenya 8", null, HasImage.FALSE)
        )
    }
}

上面提到的图片,是在启动项目时默认生成的。你也可以使用你选择的图片。

填充回收器视图

最后一步是将数据填充到RecyclerView中。

MainActivity.kt文件中,粘贴以下代码。

class MainActivity : AppCompatActivity() {
    private lateinit var mainBinding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // remember to set this value to null in the onDestroy method to avoid memory leaks.
        mainBinding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(mainBinding.root)
        
        val landmarks = LandmarkModel.getLandmarks()
        mainBinding.landmarkRecyclerview.apply {
            adapter = LandmarkAdapter(landmarks)
        }
    }
}

测试应用程序

在运行该应用程序时,你应该期望看到与此类似的东西。

Test App

总结

在本教程中,我们已经学习了在一个单一的RecyclerView中使用多个viewHolders来显示不同类型的项目的基本概念。在本教程中获得的知识可以应用于具有相同目标的其他更复杂的用例。