В предыдущей статье я показал, как усовершенствовать приложение TrendNow путем добавления фильтрации трендовых новостей на основе выбранной пользователем темы и сохранения их локально в DataStore. Если вы пропустили эту статью, можете наверстать упущенное здесь:
Реализация тематических фильтров новостей в приложении TrendNow. Часть 3
В этой статье добавлю загрузку большего количества трендовых новостей, реализовав бесконечную прокрутку в LazyColumn.
Напоминаю, что исходный код в репозитории Github может отличаться от приведенных здесь фрагментов кода, поскольку я работал над ним до написания этой статьи.
Что я собираюсь делать?
- Добавить круговую загрузку, когда пользователь достигнет нижней части списка трендовых новостей, затем загрузить другие трендовые новости.
- Скрыть круговую загрузку, когда пользователь достигнет последней страницы.
Обновление новостного сервиса NewsService
Добавляю параметр запроса page в новостной сервис Retrofit.
interface NewsService {
@GET("trendings")
suspend fun fetchTrendingNews(
@Query("topic") topic: String,
@Query("language") language: String,
@Query("page") page: Int?, // этот параметр
): Response<PaginationResponse<List<News>>>
...
}
Чтобы увидеть поддерживаемые параметры запросов, ознакомиться с 1-й статьей этой серии: TrendNow: создание новостного Android-приложения с помощью Jetpack Compose.
Ознакомьтесь с исходным кодом на Github: NewsService.kt.
Обновление источника новостных данных и репозитория
Добавляю параметр page в источник данных getTrendingNews() и обновляю реализацию в NewsRemoteDataSource.
interface NewsDataSource {
suspend fun getTrendingNews(
topic: String,
language: String,
// установка значения по умолчанию в null, так как это опциональный параметр запроса
page: Int? = null
): ApiResult<List<News>>
}
@ActivityRetainedScoped
class NewsRemoteDataSource @Inject constructor(...) : NewsDataSource {
override suspend fun getTrendingNews(
...,
// обновление переопределенной функции
page: Int?
): ApiResult<List<News>> = try {
val response = retrofit.create(NewsService::class.java)
.fetchTrendingNews(
...,
// здесь - передача параметра page
page = page
)
...
} catch (e: Exception) {
...
}
}
Затем обновляю NewsRepository и также добавляю параметр page.
interface NewsRepository {
...
suspend fun fetchTrendingNews(
...,
page: Int? = null // this one
): ApiResult<List<News>>
}
@ActivityRetainedScoped
class NewsRepositoryImpl @Inject constructor(...) : NewsRepository {
override suspend fun fetchTrendingNews(
...,
// обновление переопределенной функции
page: Int?
): ApiResult<List<News>> = withContext(Dispatchers.IO) {
remoteDataSource.getTrendingNews(
...,
// здесь - передача параметра page
page = page
)
}
}
Ознакомьтесь с исходным кодом на Github: NewsDataSource.kt, NewsRemoteDataSource.kt и NewsRepository.kt.
Обновление NewsUiState и ViewModel
data class NewsUiState(
...,
// сюда добавить page и установить -1 как значение по умолчанию
page: Int = -1,
// также добавить showLoadMore
showLoadMore: Boolean = false,
)
page: используется для сохранения текущей страницы с трендовыми новостями.
showLoadMore: определяет, является ли текущая страница последней; если это последняя страница, то значениеshowLoadMoreдолжно быть равноfalse.
Теперь можно обновить реализацию в NewsViewModel. Сначала добавлю функцию для загрузки большего количества трендовых новостей.
@HiltViewModel
class NewsViewModel @Inject constructor(...) : ViewModel() {
...
// используйте отдельную переменную для loadingMore
// вместо добавления новой переменной в UI-состояние новостей
private var loadingMore: Boolean = false
...
// создайте отдельную функцию
fun loadMoreTrendingNews() {
if (loadingMore) {
// немедленный возврат, когда loadingMore еще работает,
// для предотвращения загрузки не той страницы при многократных вызовах
return
}
if (!_trendingNewsUiState.value.showLoadMore) {
// при достижении конца страницы нет необходимости в загрузке большего количества данных
return
}
loadingMore = true
fetchTrendingNews {
// установка loadingMore в false, когда функция выполнена
loadingMore = false
}
}
private fun fetchTrendingNews(
// добавить обратный вызов, когда получение трендовых новостей завершено
onFetchDone: () -> = {}
) = viewModelScope.launch {
...
//запуск обратного вызова onFetchDone
onFetchDone()
}
}
- Состояние
loadingMore: создаю отдельное состояние загрузки для loadingMore (загрузки большего количества новостей), чтобы избежать ненужной рекомпозиции при загрузке большего количества новостей.
- Отдельная функция
loadMoreTrendingNews(): поскольку для обработки последней страницы используется другая логика, предотвращается вызовfetchTrendingNews().
- Обратный вызов при выполнении
fetchTrendingNews(): используется для изменения значения состоянияloadingMore.
Затем обновляю реализацию fetchTrendingNews(), чтобы добавить параметр запроса page.
@HiltViewModel
class NewsViewModel @Inject constructor(...) : ViewModel() {
...
private fun fetchTrendingNews(
...
) = viewModelScope.launch {
val result = newsRepository.fetchTrendingNews(
...,
page = if (loadingMore) {
// установка значения page только при loadingMore
_trendingNewsUiState.value.page + 1
} else {
// в противном случае - установка в null
null
}
)
when (result) {
is ApiResult.Success -> {
val meta = result.meta // результирующие метаданные
_trendingNewsUiState.update {
_trendingNewsUiState.value.copy(
loading = false,
data = if (loadingMore) {
// добавление загруженных трендовых новостей в существующий список,
// если результат исходит от loadingMore
data.plus(result.data)
} else {
result.data
},
showLoadMore =
// определяет, нужно ли загружать больше новостей или нет,
// когда достигается нижняя часть списка трендовых новостей
meta.page > 0 && meta.page < meta.totalPages,
page = meta.page // сохранение текущего параметра page
)
}
}
is ApiResult.Error -> {
...
}
}
...
}
}
page: использование существующего значенияpageв_trendingNewsUiStateпри загрузке новых трендовых новостей.
_trendingNewsUiState.data: добавление данных из ответа на существующий список, чтобы отобразить только что загруженные трендовые новости вместо сброса всего списка.
showLoadMore: выполнение загрузки большего количества трендовых новостей только в том случае, если текущее значение page меньше, чемtotalPagesиз API-ответа.
Отображение круговой загрузки в конце списка на новостном экране LazyColumn
...
@Composable
fun NewsScreen(
...
) {
val trendingNewsUiState by newsViewModel.trendingNewsUiState.collectAsState()
Scaffold(
...
) { innerPadding ->
LazyColumn(
...
) {
if (trendingNewsUiState.loading) {
// показывать круговую загрузку при загрузке состояния экрана
item(key = "loading") {
Loading(modifier = Modifier.fillParentMaxSize())
}
} else {
// показывать список трендовых новостей
items(...) { news ->
NewsCard(...) {
...
}
}
// подгружать круговую загрузку в конце списка
if (trendingNewsUiState.showLoadMore) {
item(key = "loading-more") {
Loading(modifier = Modifier.fillParentMaxWidth())
}
}
}
}
}
}
// здесь - двигать круговую загрузку внутри trendingNewsUistate.loading,
// так как это может быть использовано состоянием loadingMore
@Composable
private fun Loading(modifier: Modifier = Modifier) {
Box(
modifier = modifier.padding(24.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
Вывод с NewsScreen будет выглядеть так, как показано на приведенных ниже изображениях.


Загрузка большего количества трендовых новостей
На следующем шаге добавлю прослушиватель, который будет определять, когда список трендовых новостей достигнет нижней части NewsScreen.
...
@Composable
fun NewsScreen(
...
) {
// создать состояние ленивого списка для LazyColumn
val newsListState = rememberLazyListState()
...
LaunchedEffect(Unit) {
snapshotFlow { newsListState.layoutInfo.visibleItemsInfo }
.map { visibleItems ->
// брать только lastVisibleItemIndex
// и общее количество элементов на коллекцию
val lastVisibleItemIndex = visibleItems.lastOrNull()?.index ?: -1
val totalItems = newsListState.layoutInfo.totalItemsCount
Pair(lastVisibleItemIndex, totalItems)
}
.collect { (lastVisibleItemIndex, totalItems) ->
if (trendingNewsUiState.data.isEmpty()) {
// игнорировать, когда данные по трендовым новостям пустуют
return@collect
}
if (lastVisibleItemIndex >= totalItems - 1) {
// загрузка большего количества трендовых новостей, когда пользователь достигает конца списка
newsViewModel.loadMoreTrendingNews()
}
}
}
Scaffold(
...
) { innerPadding ->
LazyColumn(
...,
// здесь - передача LazyListState новостей
state = newsListState
) {
if (trendingNewsUiState.loading) {
...
} else {
...
}
}
}
}
...
LaunchedEffect: гарантирует, что наблюдение начинается, когда composable входит в композицию.
snapshotFlow: отслеживает информацию о видимых элементах вLazyListState. Информация о видимых элементах изменяется, когда пользователь начинает прокручивать элементыLazyColumn.
Бесконечная прокрутка должна работать с приведенным выше кодом. Однако обратите внимание, что newsViewModel.loadMoreTrendingNews() будет вызываться много раз. Я добавил предохраняющее условие:
fun loadMoreTrendingNews() {
if (loadingMore) {
// немедленный возврат, когда loadingMore еще работает,
// для предотвращения загрузки не той страницы при многократных вызовах
return
}
...
}
Но нет ничего плохого в том, чтобы иметь еще одно предохраняющее условие.
@HiltViewModel
class NewsViewModel @Inject constructor(...) : ViewModel() {
// использование mutex
private val fetchTrendingNewsMutex = Mutex()
private var trendingNewsJob: Job? = null
...
fun loadMoreTrendingNews() {
...
loadingMore = true
fetchTrendingNews {
loadingMore = false
}
}
private fun fetchTrendingNews(
onFetchDone: () -> = {}
) {
// немедленный сброс работы trendingNewsJob
trendingNewsJob?.cancel()
viewModelScope.launch {
// использование mutex для запуска функции сдерживания получения трендовых новостей
fetchTrendingNewsMutex.withLock {
trendingNewsJob = launch {
try {
val result = newsRepository.fetchTrendingNews(
...
)
when (result) {
is ApiResult.Success -> {
...
}
is ApiResult.Error -> {
...
}
}
// убедитесь в перемещении сюда onFetchDone
onFetchDone()
finally {
// сброс работы по получению трендовых новостей
fetchTrendingNewsMutex.withLock { trendingNewsJob = null }
}
}
}
}
}
}
- Использование
Mutexпозволяет убедиться, что есть только одна корутина, которая может выполнять получение трендовых новостей, предотвращая возникновение проблем, вызываемых одновременным запуском нескольких корутин.
Наконец, реализация бесконечной прокрутки завершена, теперь NewsScreen будет выглядеть следующим образом:

Ознакомьтесь с полной реализацией на Github: NewsScreen.kt и NewsViewModel.kt.
Итоги реализации загрузки большего количества трендовых новостей

- После реализации в
LazyColumnJetpack Compose бесконечной прокрутки приложение Trend Now может без проблем загружать больше трендовых новостей по мере того, как пользователь прокручивает список.
- Хотя эта концепция похожа на
RecyclerView.OnScrollListener, ее реализация совершенно иная. В Jetpack Compose нет прослушивателя прокрутки. Необходимо использоватьsnapshotFlowдля просмотра видимых элементов вLazyListState.
В следующей статье расскажу о реализации механизма кэширования новостных тем на стороне клиента с использованием базы данных Room.
Ознакомьтесь с полным исходным кодом на Github.
Читайте также:
- Реализация подсказок с помощью Modifier в Jetpack Compose
- Создание анимированной кнопки-счетчика в Jetpack Compose
- Создание кастомизированного кругового загрузчика в Jetpack Compose: изучение Android Canvas и анимации
Читайте нас в Telegram, VK и Дзен
Перевод статьи Dani Mahardhika: Implement Infinite Scroll in LazyColumn Jetpack Compose





