Roman Kryvolapov Engineering Blog

Динамічна генерація UI в Android застосунку за допомогою RecyclerView та AdapterDelegates

Спробую розповісти про досвід використання динамічної генерації UI в Android застосунку за допомогою RecyclerView та бібліотеки

https://github.com/sockeqwe/AdapterDelegates

Є різні способи роботи з UI в Android застосунку — наприклад, ви можете зробити UI статичним, або згенерувати за допомогою Jetpack Compose, а можна обрати і певний проміжний варіант, де елементи будуть перевикористовуваними на рівні коду, а для їх відображення використовуватиметься RecyclerView. Спочатку додаємо бібліотеку за відповідною інструкцією з її сторінки.

Створення інтерфейсів та класів

У бібліотеці є клас AdapterDelegat, у якому є метод isForViewType, він повертає, чи належить елемент у списку до вказаного класу. Спочатку створюємо інтерфейс, який належатиме до певної сторінки застосунку та успадковуватиме клас DiffEquals

Якщо елемент необхідно перевикористати, створюємо super інтерфейс, який успадковуватиме всі інтерфейси сторінок, на яких має бути цей елемент, а в делегаті як дженерик використовуємо цей super інтерфейс, а не інтерфейс сторінки застосунку

Для кожного унікального елемента у списку створюємо клас, де зберігатимуться його дані, і буде певна логіка для порівняння, аналогічна DiffUtil.ItemCallback

interface DiffEquals {
fun isItemSame(other: Any?): Boolean
fun isContentSame(other: Any?): Boolean
}
fun interface DeepCopy<T> {
fun deepCopy(): T
}
@Suppress("UNCHECKED_CAST")
inline fun <reified T> T.equalTo(other: Any?, vararg properties: T.() -> Any?): Boolean {
if (other !is T) return false
properties.forEach {
val currentValue = it.invoke(this)
val otherValue = it.invoke(other)
if (currentValue is List<*>) {
if (otherValue !is List<*> || !currentValue.isListContentEqualsOrSame(otherValue)) {
return false
}
} else if (it.invoke(this) != it.invoke(other)) {
return false
}
}
return true
}
/**
* Compare two lists by their content.
* The entity of this list must have a DiffEquals implementation.
* @return true when same, false otherwise
*/
fun <T : DiffEquals> List<T>.isListContentSame(other: List<T>): Boolean {
if (size != other.size) return false
withIndex().forEach {
if (!it.value.isContentSame(other[it.index])) {
return false
}
}
return true
}
/**
* Compare two lists by their content.
* This method uses a default equal operation to compare list items
* @return true when same, false otherwise
*/
fun List<*>.isListContentEquals(other: List<*>): Boolean {
if (size != other.size) return false
withIndex().forEach {
if (it.value != other[it.index]) {
return false
}
}
return true
}
/**
* Compare two lists by their content.
* This method uses a default equal operation to compare list items
* or the diff equals mechanic if the list item is implements [DiffEquals]
* @return true when same, false otherwise
*/
fun List<*>.isListContentEqualsOrSame(other: List<*>): Boolean {
if (size != other.size) return false
withIndex().forEach {
if (it.value is DiffEquals && other[it.index] is DiffEquals) {
if (!(it.value as DiffEquals).isContentSame(other[it.index])) {
return false
}
} else if (it.value != other[it.index]) {
return false
}
}
return true
}
/**
* Makes a deep copy of the list. The items in the list should implement
* [DeepCopy] interface.
*/
fun <T : DeepCopy<T>> List<T>.deepCopy(): List<T> {
val oldList = this
return mutableListOf<T>().apply {
oldList.forEach {
add(it.deepCopy())
}
}
}
fun <T> List<T>.moveItemToFirstPosition(predicate: (T) -> Boolean): List<T> {
for (element in this.withIndex()) {
if (predicate(element.value)) {
return this.toMutableList().apply {
removeAt(element.index)
add(0, element.value)
}.toList()
}
}
return this
}
interface AccountAdapterMarker : DiffEquals
data class AccountName(
val accountName: String,
) : AccountAdapterMarker {
override fun isItemSame(other: Any?): Boolean {
return equalTo(
other,
{ accountName },
)
}
override fun isContentSame(other: Any?): Boolean {
return equalTo(
other,
{ accountName },
)
}
}

Створення делегата

далі створюємо делегат для цього класу, що успадковується від класу бібліотеки AdapterDelegate з дженериком інтерфейсу

class AccountNameDelegate() : AdapterDelegate<MutableList<AccountAdapterMarker>>() {
var accontNameClickListener: ((accountName: AccountName) -> Unit)? = null
override fun isForViewType(items: MutableList<CommonTitleAdapterMarker>, position: Int): Boolean {
return items[position] is AccountName
}
override fun onCreateViewHolder(parent: ViewGroup): RecyclerView.ViewHolder {
return ViewHolder(parent.inflateBinding(ListItemAccountNameBinding::inflate))
}
override fun onBindViewHolder(
items: MutableList<AccountAdapterMarker>,
position: Int,
holder: RecyclerView.ViewHolder,
payloads: MutableList<Any>
) {
(holder as ViewHolder).bind(items[position] as AccountName)
}
private inner class ViewHolder(
private val binding: ListItemAccountNameBinding,
) : RecyclerView.ViewHolder(binding.root) {
fun bind(accountName: AccountName) {
binding.tvAccountName.text = accountName.accountName
binding.tvAccountName.setOnClickListener{
accontNameClickListener?.invoke(accountName) }
}
}
}

Створення адаптера

відповідно, робимо такий делегат для кожного елемента, який має бути присутнім на сторінці. Далі робимо адаптер, до якого додаємо делегати цих елементів, також з дженериком інтерфейсу сторінки застосунку

class AccountAdapter (
private val accountNameDelegate: AccountNameDelegate,
) : AsyncListDifferDelegationAdapter<AccountAdapterMarker>(DefaultDiffUtilCallback()) {
var accountClickListener: AccountClickListener? = null
set(value) {
field = value
accountNameDelegate.accontNameClickListener = { field?.onAccountNameClicked(it) }
}
init {
items = mutableListOf()
@Suppress("UNCHECKED_CAST")
delegatesManager.apply {
addDelegate(
// каст потрібен, тільки якщо використовуємо super інтерфейс
// для перевикористання елементів, а в цьому прикладі
// він не обов'язковий
accountNameDelegate as AdapterDelegate<MutableList<AccountAdapterMarker>>
)
}
}
// успадковуємо цей інтерфейс у фрагменті,
// щоб обробляти натискання та інші дії
// і прописуємо у фрагменті
// accountAdapter.accountClickListener = this
interface AccountClickListener{
fun onAccountNameClicked(accountName: AccountName)
}
}

Використання

ну а далі потрібно у ViewModel або спеціальному мапері створити список з екземплярами класів, що успадковують AccountAdapterMarker, і викликати в адаптера метод setItems, а адаптер уже розбереться, інстанси якого класу в ньому знаходяться, і додасть ці елементи до RecyclerView.

Copyright: Roman Kryvolapov