Это продолжение цикла статей про упрощение разработки адаптеров для RecyclerView.
В этой части рассмотрю следующие реализации потребностей отображения списков:
Загрузка изображения из сети, с использованием Glide
Пагинация (подгрузка списка)
Удаление элемента
Удаление и использование встроенного diffUtils
Одна из частых задач, для отображения элемента списка в android – это вывод изображения из сети, как пример это может быть аватарка пользователя, картинка товара и прочее.
Доработал проект, для демонстрации этой задачи. Создал новые activity, dataClass, layout и адаптер. Все по аналогии с прошлым примером, покажу изменения.
DataClass:
data class NotificationWithImageDTO(
val date: String,
val text: String,
var isRead: Boolean = false,
val imageUrl: String
)
Поле imageUrl хранит в себе ссылку на изображение.
Создал новый layout файл:
Добавил ImageView, для вывода изображения
Создал новый адаптер:
class NotificationWithImageAdapter(data: MutableList<NotificationWithImageDTO>) :
BaseQuickAdapter<NotificationWithImageDTO, BaseViewHolder>(R.layout.item_notification_with_image, data) {
init {
addChildClickViewIds(R.id.ivState)
}
override fun convert(holder: BaseViewHolder, item: NotificationWithImageDTO) {
holder.setGone(R.id.view, holder.layoutPosition == 0)
.setText(R.id.tvDateTime, item.date)
.setText(R.id.tvDsc, item.text)
.setImageResource(
R.id.ivState,
if (item.isRead) R.drawable.ic_delete
else R.drawable.ic_read
)
val imageView = holder.getView<ImageView>(R.id.imageView)
val context = holder.itemView.context
Glide.with(context)
.load(item.imageUrl)
.circleCrop()
.into(imageView)
}
}
К функционалу прошлого адаптера добавил получение конкретной view и context, для загрузи изображения через Glide. Для получения конкретной View использую метод холдера getView и типизирую его нужным мне типом. Для получения context, получаю ItemView, это из базовой реализации RecyclerView и из него получаю context. Работа Glide – полностью стоковая.
Результат:
Часто списки, возвращаемые бэком огромны, так что нельзя получить все одним запросом. Для этого используется подгрузка списка или другими словами – пагинация. В этой библиотеке – реализация максимально простая.
Реализовал интерфейс LoadMoreModule в адаптере, методов для переопределения нет.
class NotificationWithImageAdapter(data: MutableList<NotificationWithImageDTO>) :
BaseQuickAdapter<NotificationWithImageDTO, BaseViewHolder>(R.layout.item_notification_with_image, data),
LoadMoreModule
При подгрузке списка возможно несколько состояний:
Идет подгрузка
Подгружено успешно
Ошибка подгрузки
Подгружены все данные
Для отображения этих состояний в библиотеке предусмотрен абтрактный класс BaseLoadMoreView. Создал свой LoadMoreView являющийся его наследником:
class LoadMoreView : BaseLoadMoreView() {
override fun getRootView(parent: ViewGroup): View = LayoutInflater.from(parent.context)
.inflate(R.layout.view_load_more, parent, false)
override fun getLoadingView(holder: BaseViewHolder): View = holder.getView(R.id.load_more_loading_view)
override fun getLoadComplete(holder: BaseViewHolder): View = holder.getView(R.id.load_more_load_end_view)
override fun getLoadEndView(holder: BaseViewHolder): View = holder.getView(R.id.load_more_load_end_view)
override fun getLoadFailView(holder: BaseViewHolder): View = holder.getView(R.id.load_more_load_fail_view)
В нем описываются layout-файл и методы получения view для каждого состояния
Создал layout:
<?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="@dimen/dp_40"
xmlns:tools="http://schemas.android.com/tools">
<LinearLayout
android:id="@+id/load_more_loading_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="horizontal">
<ProgressBar
android:id="@+id/loading_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="?android:attr/progressBarStyleSmall"/>
</LinearLayout>
<FrameLayout
android:id="@+id/load_more_load_fail_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:visibility="visible"
android:visibility="gone">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:src="@drawable/ic_refresh"/>
</FrameLayout>
<FrameLayout
android:id="@+id/load_more_load_end_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="Load all data"
android:textColor="@android:color/darker_gray"/>
</FrameLayout>
</FrameLayout>
Доработал инициализацию адаптера для возможности подгрузки списка:
private val customLoadMoreView = LoadMoreView()
…
private fun initAdapter() {
rv.adapter = adapter
adapter.loadMoreModule.loadMoreView = customLoadMoreView
adapter.loadMoreModule.setOnLoadMoreListener { loadMore() }
adapter.loadMoreModule.isAutoLoadMore = true
adapter.setOnItemChildClickListener { _, view, position ->
if (view.id == R.id.ivState) {
val item = adapter.getItem(position)
if (!item.isRead) {
item.isRead = true
adapter.notifyItemChanged(position)
} else {
Toast.makeText(
this,
"Элемент будет удален, реализация в следующей
части",
Toast.LENGTH_SHORT
).show()
}
}
}
val data = repository.nextPage()
adapter.setNewInstance(data)
}
private fun loadMore() {
val data = repository.nextPage()
adapter.addData(data)
adapter.loadMoreModule.isEnableLoadMore = true
adapter.loadMoreModule.loadMoreComplete()
if (repository.isEnd()) {
adapter.loadMoreModule.loadMoreEnd()
}
}
При инициализации добавилась настройка loadMoreModule.
Метод loadMoreView устанавливает view описанную выше
isAutoLoadMore – определяет можно ли автоматически подгружать список, если нельзя – то подргузку необходимо будет запускать руками методом loadMoreToLoading()
setLoadMoreListener – устанавливает метод вызываемый на событие подгрузки, в моем случае это мой метод loadMore()
В методе loadMore() происходит запрос данных, для следующей страницы. После их получения, добавляю эти данные в адаптер методом addData. После добавления данных, разрешаю подгрузку данных снова, и выставляю статус loadMoreComplete. Этот статус скрывает view загрузки. Далее запрашиваю у репозитория была ли эта страница последней, если это была последняя – то выставляю статус loadMoreEnd, этот статус отображает view окончания загрузки.
Результат:
Подлагивания связаны с тем, что я ставил breakpoint и запускался под отладчиком для того, чтобы успеть показать view загрузки. В реальной жизни все работает идеально.
Иногда при подгрузке данных может возникнуть ошибка. Смоделирую ситуацию, чтоб метод репозитория случайным образом мог вкидывать exception. Получение данных оберну в блок try catch. В случае exception укажу, что произошла ошибка при подгрузке, методом adapter.loadMoreModule.loadMoreFail(). В этом случае отобразится errorView в нижней части RecyclerView. При клике на нее запустится метод подгрузки данных.
private fun loadMore() {
try {
val data = repository.nextPage()
adapter.addData(data)
adapter.loadMoreModule.isEnableLoadMore = true
adapter.loadMoreModule.loadMoreComplete()
if (repository.isEnd()) {
adapter.loadMoreModule.loadMoreEnd()
}
} catch (e: Exception) {
adapter.loadMoreModule.loadMoreFail()
}
}
Осталось рассмотреть удаление элементов. Удаление я разбил на две части. Первая – это удаление локальных данных, вторая – удаление на бэкенде, когда сообщаем бэку какой элемент удалить и в качестве ответа получаем новый список, без этого элемента.
Реализую локальное удаление, для этого доработаю инициализацию адаптера:
private fun initAdapter() {
rv.adapter = adapter
adapter.loadMoreModule.loadMoreView = customLoadMoreView
adapter.loadMoreModule.setOnLoadMoreListener { loadMore() }
adapter.loadMoreModule.isAutoLoadMore = true
adapter.setOnItemChildClickListener { _, view, position ->
if (view.id == R.id.ivState) {
val item = adapter.getItem(position)
if (!item.isRead) {
item.isRead = true
adapter.notifyItemChanged(position)
} else {
deleteLocalItem(position)
}
}
}
val data = repository.firstPage()
adapter.setNewInstance(data)
}
private fun deleteLocalItem(position: Int){
val item = adapter.getItem(position)
adapter.remove(item)
}
Написал метод для локального удаления элемента. Для этого получаю элемент по его позиции в адаптере, и вызываю метод remove(item) у адаптера.
В завершение рассмотрю вариант удаления через бэкенд, когда возвращается новый список. Какие подводные камни тут есть? Вижу, как минимум один, но прямо очень серьезный. Если пользователь проскроллил список вниз, удалил элемент, то при установке нового списка в RecyclerView позиция собьётся, а скролл перенесется в начало списка. Как один из вариантов решения этой проблемы – это использование DiffUtils. В BRVAH он уже интегрирован.
Описание diffCallback отношения к библиотеке не имеет, поэтому показывать его не буду. Дальше этот callback установлю для адаптера, с помощью метода adapter.setDiffCallback(NotificationDiffCallback()). Далее при установке данных в адаптер необходимо использовать метод adapter.setDiffNewData(data).
private fun initAdapter() {
rv.adapter = adapter
adapter.loadMoreModule.loadMoreView = customLoadMoreView
adapter.loadMoreModule.setOnLoadMoreListener { loadMore() }
adapter.loadMoreModule.isAutoLoadMore = true
adapter.setDiffCallback(NotificationDiffCallback())
adapter.setOnItemChildClickListener { _, view, position ->
if (view.id == R.id.ivState) {
val item = adapter.getItem(position)
if (!item.isRead) {
item.isRead = true
adapter.notifyItemChanged(position)
} else {
// deleteLocalItem(position)
deleteRemoteItem(position)
}
}
}
val data = repository.nextPage()
adapter.setNewInstance(data)
}
private fun deleteRemoteItem(position: Int) {
val data = repository.deleteImagedItem(position)
adapter.setDiffNewData(data)
}
Поведение UI не поменялось, результат выполнения показывать смысла нет.
В этой части рассмотрел достаточно частые требования к отображению списков, и способы легкой реализации их при помощи библиотеки. В следующих частях рассмотрю:
Анимацию появления элементов
Отображение загрузки списка и ошибки загрузки списка
Обработку «долгих» нажатий
Удаление элемента «свайпом»
Перемещение элементов
Использование нескольких layout в одном списке
Проект на Гите
elzahaggard13
Очень странная разметка. Зачем столько вложенностей? Особенно непонятно зачем нужен линеар
bsod_keks Автор
Это про LoadMoreView? Если про него, то написан он тыщу лет назад, и используется до сих пор. Linear был нужен в свое время чтоб рядом с progresBar поместить TextView c текстом "загрузка". В целом вся вложенность связана с тем что все эти вьюхи должны быть в одном файле(хотя на текущую версию библиотеки может и не актуально, раньше точно надо было). Цель просто показать возможности, а под себя уж каждый сам кастомизирует как надо)