Six months have passed since I rolled from Pascalon kotlin and fell in love with android development, and now I already allow myself to publicly climb with my ideas into someone else's monastery. But there is a reason for that. After observing in profile chats what questions most often arise for android developers, and not only for beginners, I realized that in most cases, when a person encounters an error that he cannot understand, as he cannot understand the explanation of colleagues from the chat or their leading questions, the reason is the thoughtless use of ready-made pieces of code or libraries. However, relying on ready-made code examples that do not work for them (and in this area, the code written more than a year ago, by default, requires updating or generally reworking, and this applies to code with stack overflow, library guides, and even guides from Google itself) , they do not understand the reasons for the errors occurring or the different behavior,since rely on a library likeChinese room , without trying to understand its architecture and principles of work.
Since recycler view-related questions pop up very often, I would like to understand a little about how to make an extensible and clean code myself to display a multi-item list in an application.
Studying the architectural patterns of android development, I trained myself to first look for answers on the Google developer guides server . But sometimes there, especially in the training codelabs, there are examples of code that are more simplified than designed for versatility, purity and extensibility.
In this case, I had a need to use a fancy recycler view to display a list of items with different internal markup and logic. All modern applications are based on this idea - from instant messengers and social media feeds to banking applications. In addition, combining on the fly using a reactive approach of different visual elements of the recycler view list instead of manual layout markup is a bridge to the world of declarative-functional ui, which is offered to us in Jetpack Compose, and which sooner or later Google will gently offer to switch.
Codelab, recycler view , sealed . . , ,- , , . , /, ( , SOLID, ).
, Google id data- : id Long.MIN_VALUE, id data-. : data-, , . recycler view .
. adapter delegates, groupie epoxy. , . , , . , , , .
:
, , 10%, ;
: , - data- .
, , , , , , .
, , , , recycler view , . , .
, .
recycler view, ListAdapter, , :
getItemType - , ( , Google );
onCreateViewHolder - , ViewHolder , ( );
onBindViewHolder - , ( ) ViewHolder, .
recycler view , recycler view , , , , DiffUtil-.
DiffCallback,
class BaseDiffCallback : DiffUtil.ItemCallback<HasStringId>() {
override fun areItemsTheSame(oldItem: HasStringId, newItem: HasStringId): Boolean = oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: HasStringId, newItem: HasStringId): Boolean = oldItem == newItem
}
, areContentsTheSame , areItemsTheSame true. HasStringId, id String equals, data- , view. Data- id, DiffUtil , ui- id .
, , . , :
interface ViewHoldersManager {
fun registerViewHolder(itemType: Int, viewHolder: ViewHolderVisitor)
fun getItemType(item: Any): Int
fun getViewHolder(itemType: Int): ViewHolderVisitor
}
recycler view:
object ItemTypes {
const val UNKNOWN = -1
const val HEADER = 0
const val TWO_STRINGS = 1
const val ONE_LINE_STRINGS = 2
const val CARD = 3
}
"" adapter delegates, . .
hilt data binding, : ui. , , :
@Module
@InstallIn(FragmentComponent::class)
object DiModule {
@Provides
@FragmentScoped
fun provideAdaptersManager(): ViewHoldersManager = ViewHoldersManagerImpl().apply {
registerViewHolder(ItemTypes.HEADER, HeaderViewHolder())
registerViewHolder(ItemTypes.ONE_LINE_STRINGS, OneLine2ViewHolder())
registerViewHolder(ItemTypes.TWO_STRINGS, TwoStringsViewHolder())
registerViewHolder(ItemTypes.CARD, CardViewHolder())
}
}
:
ard item
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable name="card" type="ru.alexmaryin.recycleronvisitor.data.ui_models.CardItem" />
</data>
<androidx.cardview.widget.CardView xmlns:card_view="http://schemas.android.com/apk/res-auto"
android:id="@+id/card_view"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_margin="8dp"
card_view:cardBackgroundColor="@color/cardview_shadow_end_color"
card_view:cardCornerRadius="15dp">
<ImageView
android:id="@+id/card_background_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:scaleType="centerCrop"
tools:ignore="ContentDescription"
tools:src="@android:mipmap/sym_def_app_icon" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="@android:drawable/screen_background_dark_transparent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/card_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:textAllCaps="true"
android:textColor="#FFFFFF"
android:textStyle="bold"
tools:text="Cart title"
android:text="@{card.title}"/>
<TextView
android:id="@+id/txt_discription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textColor="#FFFFFF"
tools:text="this is a simple discription with losts of text lorem ipsum dolor sit amet,
consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
android:text="@{card.description}"/>
</LinearLayout>
</androidx.cardview.widget.CardView>
</layout>
One line item
<?xml version="1.0" encoding="utf-8"?>
<layout 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">
<data>
<variable name="model" type="ru.alexmaryin.recycleronvisitor.data.ui_models.OneLineItem2" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/text1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingStart="8dp"
android:text="@{model.left}"
android:textAlignment="textEnd"
android:textAppearance="?attr/textAppearanceListItem"
android:textColor="@color/cardview_dark_background"
app:layout_constraintEnd_toStartOf="@+id/divider"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="RtlSymmetry,TextContrastCheck"
tools:text="Left text" />
<ImageView
android:id="@+id/divider"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0.6"
android:padding="5dp"
android:scaleType="center"
android:scaleX="0.5"
android:scaleY="0.9"
android:src="@drawable/ic_outline_waves_24"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="@+id/text1"
app:layout_constraintEnd_toStartOf="@+id/text2"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/text1"
app:layout_constraintTop_toTopOf="@+id/text1"
app:srcCompat="@drawable/ic_outline_waves_24"
tools:ignore="ContentDescription"
tools:visibility="visible" />
<TextView
android:id="@id/text2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingEnd="8dp"
android:text="@{model.right}"
android:textAppearance="?attr/textAppearanceListItem"
app:layout_constraintBottom_toBottomOf="@+id/divider"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/divider"
app:layout_constraintTop_toTopOf="@+id/divider"
tools:ignore="RtlSymmetry"
tools:text="Right text" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
Two line item
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable name="model" type="ru.alexmaryin.recycleronvisitor.data.ui_models.TwoStringsItem" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?attr/listPreferredItemHeight"
android:mode="twoLine"
android:paddingStart="?attr/listPreferredItemPaddingStart"
android:paddingEnd="?attr/listPreferredItemPaddingEnd">
<TextView
android:id="@+id/text1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@{model.caption}"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:textAppearance="?attr/textAppearanceListItem" />
<TextView
android:id="@id/text2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@{model.details}"
app:layout_constraintTop_toBottomOf="@id/text1"
app:layout_constraintStart_toStartOf="parent"
android:textAppearance="?attr/textAppearanceListItemSecondary" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
Header item
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable
name="headerItem"
type="ru.alexmaryin.recycleronvisitor.data.ui_models.RecyclerHeader" />
</data>
<TextView
style="@style/regularText"
android:id="@+id/header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#591976D2"
android:textAlignment="center"
android:textStyle="italic"
android:text="@{headerItem.text}"/>
</layout>
, :
interface ViewHolderVisitor {
val layout: Int
fun acceptBinding(item: Any): Boolean
fun bind(binding: ViewDataBinding, item: Any, clickListener: AdapterClickListenerById)
}
( acceptVisitor execute, , ) - acceptBinding bind, layout, .
accept : ( ) , , , accept, , true. , , , . - (accept = true), - , .
, , . :
class ViewHoldersManagerImpl : ViewHoldersManager {
private val holdersMap = emptyMap<Int, ViewHolderVisitor>().toMutableMap()
override fun registerViewHolder(itemType: Int, viewHolder: ViewHolderVisitor) {
holdersMap += itemType to viewHolder
}
override fun getItemType(item: Any): Int {
holdersMap.forEach { (itemType, holder) ->
if(holder.acceptBinding(item)) return itemType
}
return ItemTypes.UNKNOWN
}
override fun getViewHolder(itemType: Int) = holdersMap[itemType] ?: throw TypeCastException("Unknown recycler item type!")
}
( ):
class CardViewHolder : ViewHolderVisitor {
override val layout: Int = R.layout.card_item
override fun acceptBinding(item: Any): Boolean = item is CardItem
override fun bind(binding: ViewDataBinding, item: Any, clickListener: AdapterClickListenerById) {
with(binding as CardItemBinding) {
card = item as CardItem
Picasso.get().load(item.image).into(cardBackgroundImage)
}
}
}
as . -, , : accept , CardItem, bind . : layout, binding data binding . -, , idea android studio ?
, recycler view,- , , , :
class BaseListAdapter(
private val clickListener: AdapterClickListenerById,
private val viewHoldersManager: ViewHoldersManager
) : ListAdapter<HasStringId, BaseListAdapter.DataViewHolder>(BaseDiffCallback()) {
inner class DataViewHolder(
private val binding: ViewDataBinding,
private val holder: ViewHolderVisitor
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: HasStringId, clickListener: AdapterClickListenerById) =
holder.bind(binding, item, clickListener)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DataViewHolder =
LayoutInflater.from(parent.context).run {
val holder = viewHoldersManager.getViewHolder(viewType)
DataViewHolder(DataBindingUtil.inflate(this, holder.layout, parent, false), holder)
}
override fun onBindViewHolder(holder: DataViewHolder, position: Int) = holder.bind(getItem(position), clickListener)
override fun getItemViewType(position: Int): Int = viewHoldersManager.getItemType(getItem(position))
}
view, :
// - :
// private val viewModel: MainViewModel by viewModels()
// private lateinit var recycler: RecyclerView
// @Inject lateinit var viewHoldersManager: ViewHoldersManager
// private val items = mutableListOf<HasStringId>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
recycler = requireActivity().findViewById(R.id.recycller)
val itemsAdapter = BaseListAdapter(AdapterClickListenerById {}, viewHoldersManager)
itemsAdapter.submitList(items)
recycler.apply {
layoutManager = LinearLayoutManager(requireContext())
addItemDecoration(DividerItemDecoration(requireContext(), (layoutManager as LinearLayoutManager).orientation))
adapter = itemsAdapter
}
populateRecycler()
}
private fun populateRecycler() {
lifecycleScope.launch {
viewModel.getItems().flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
.collect { items.add(it) }
}
}
"" , recycler view . :
-;
sealed ;
data- / , view data ;
- ;
, SOLID ;
, (YAGNI).
Of course, my implementation still has ways to improve and expand. You can, as in groupie, add grouping of elements and their visual collapse. You can abandon the data binding or supplement the adapter with options for a view binding or a regular markup inflate with all your favorite findViewById in view holders. And then the code will turn into the same library, of which there are already so many and so. For my specific purposes, at the moment when the need arose, the option with a simple Visitor is more than enough:
Please do not judge strictly, since this is my first birth in the android world. The complete example code from the text of the article will be available in the github repository .