From d85216588e3c09f26496f9f3c4a19e86b0c18912 Mon Sep 17 00:00:00 2001 From: Kuzmin <«vkuzmin@icerockdev.com»> Date: Thu, 19 Feb 2026 20:31:21 +0300 Subject: [PATCH 1/3] MOKO-1415 added new pagination logic --- README.md | 80 ++- build.gradle.kts | 3 - gradle.properties | 9 +- gradle/libs.versions.toml | 48 +- gradle/wrapper/gradle-wrapper.properties | 3 +- paging/build.gradle.kts | 23 +- paging/src/androidMain/AndroidManifest.xml | 2 +- .../dev/icerock/moko/paging/BaseTestsClass.kt | 7 + .../moko/paging/LambdaPagedListDataSource.kt | 13 - .../dev/icerock/moko/paging/LiveDataExt.kt | 26 - .../dev/icerock/moko/paging/PageCount.kt | 20 + .../moko/paging/PagedListDataSource.kt | 9 - .../dev/icerock/moko/paging/Pagination.kt | 543 ++++++++++++------ .../icerock/moko/paging/PagingDataSource.kt | 47 ++ .../dev/icerock/moko/paging/PagingState.kt | 16 + .../moko/paging/ReachEndNotifierList.kt | 45 -- .../icerock/moko/paging/RefreshStrategy.kt | 23 + .../dev/icerock/moko/paging/RemoteStateExt.kt | 19 + .../icerock/moko/paging/IntegrationTests.kt | 98 ++-- .../dev/icerock/moko/paging/PaginationTest.kt | 71 ++- .../icerock/moko/paging/TestListDataSource.kt | 10 +- paging/src/iosMain/kotlin/Dummy.kt | 8 - remotestate/build.gradle.kts | 20 + .../icerock/moko/remotestate/RemoteState.kt | 60 ++ sample/android-app/build.gradle.kts | 26 +- .../android-app/src/main/AndroidManifest.xml | 7 +- .../java/com/icerockdev/LoadingUnitItem.kt | 29 - .../main/java/com/icerockdev/MainActivity.kt | 68 +-- .../main/java/com/icerockdev/PagingScreen.kt | 222 +++++++ .../src/main/res/layout/activity_main.xml | 19 - .../src/main/res/layout/unit_loading.xml | 11 - .../src/main/res/layout/unit_product.xml | 22 - sample/mpp-library/build.gradle.kts | 12 +- .../src/androidMain/AndroidManifest.xml | 2 +- .../com/icerockdev/library/ListViewModel.kt | 161 +++--- settings.gradle.kts | 13 +- 36 files changed, 1129 insertions(+), 666 deletions(-) create mode 100644 paging/src/androidUnitTest/kotlin/dev/icerock/moko/paging/BaseTestsClass.kt delete mode 100644 paging/src/commonMain/kotlin/dev/icerock/moko/paging/LambdaPagedListDataSource.kt delete mode 100644 paging/src/commonMain/kotlin/dev/icerock/moko/paging/LiveDataExt.kt create mode 100644 paging/src/commonMain/kotlin/dev/icerock/moko/paging/PageCount.kt delete mode 100644 paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagedListDataSource.kt create mode 100644 paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingDataSource.kt create mode 100644 paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingState.kt delete mode 100644 paging/src/commonMain/kotlin/dev/icerock/moko/paging/ReachEndNotifierList.kt create mode 100644 paging/src/commonMain/kotlin/dev/icerock/moko/paging/RefreshStrategy.kt create mode 100644 paging/src/commonMain/kotlin/dev/icerock/moko/paging/RemoteStateExt.kt delete mode 100644 paging/src/iosMain/kotlin/Dummy.kt create mode 100644 remotestate/build.gradle.kts create mode 100644 remotestate/src/commonMain/kotlin/dev/icerock/moko/remotestate/RemoteState.kt delete mode 100644 sample/android-app/src/main/java/com/icerockdev/LoadingUnitItem.kt mode change 100755 => 100644 sample/android-app/src/main/java/com/icerockdev/MainActivity.kt create mode 100644 sample/android-app/src/main/java/com/icerockdev/PagingScreen.kt delete mode 100755 sample/android-app/src/main/res/layout/activity_main.xml delete mode 100644 sample/android-app/src/main/res/layout/unit_loading.xml delete mode 100644 sample/android-app/src/main/res/layout/unit_product.xml diff --git a/README.md b/README.md index d13e8a3..1f4217e 100755 --- a/README.md +++ b/README.md @@ -15,15 +15,14 @@ This is a Kotlin MultiPlatform library that contains pagination logic for kotlin - [License](#license) ## Features -- **Pagination** implements pagination logic for the data from abstract `PagedListDataSource`. -- Managing a data loading process using **Pagination** asynchronous functions: `loadFirstPage`, `loadNextPage`, -`refresh` or their duplicates with `suspend` modifier. -- Observing states of **Pagination** using `LiveData` from **moko-mvvm**. +- **Pagination** implements pagination logic for the data from `PagingDataSource`. +- Managing data loading using `loadFirstPage`, `loadNextPage`, `refresh`. +- Observing states using `StateFlow` and `RemoteState`. ## Requirements -- Gradle version 6.8+ -- Android API 16+ -- iOS version 11.0+ +- Gradle 8.10+ +- Android API 21+ +- iOS 11.0+ ## Installation root build.gradle @@ -35,10 +34,11 @@ allprojects { } ``` -project build.gradle -```groovy +project build.gradle.kts +```kotlin dependencies { commonMainApi("dev.icerock.moko:paging:0.7.1") + commonMainApi("dev.icerock.moko:remotestate:0.1.0") } ``` @@ -49,30 +49,20 @@ You can use **Pagination** in `commonMain` sourceset. **Pagination** creation: ```kotlin -val pagination: Pagination = Pagination( - parentScope = coroutineScope, - dataSource = LambdaPagedListDataSource { currentList -> - extrenalRepository.loadPage(currentList) - }, - comparator = Comparator { a: Int, b: Int -> - a - b - }, - nextPageListener = { result: Result> -> - if (result.isSuccess) { - println("Next page successful loaded") - } else { - println("Next page loading failed") - } - }, - refreshListener = { result: Result> -> - if (result.isSuccess) { - println("Refresh successful") - } else { - println("Refresh failed") - } - }, - initValue = listOf(1, 2, 3) - ) +val pagination: Pagination = Pagination( + dataSource = PageSizePagingDataSource( + pageSize = 20, + loadPage = { page, pageSize -> repository.load(page = page, pageSize = pageSize) } + ), + itemKey = { item -> item.id }, + refreshStrategy = RefreshStrategy.ReplaceEverything, + nextPageListener = { result -> + result.onFailure { println("Next page loading failed: $it") } + }, + refreshListener = { result -> + result.onFailure { println("Refresh failed: $it") } + } +) ``` Managing data loading: @@ -94,20 +84,16 @@ pagination.setData(itemsList) Observing **Pagination** states: ```kotlin -// Observing the state of the pagination -pagination.state.addObserver { state: ResourceState, Throwable> -> - // ... -} - -// Observing the next page loading process -pagination.nextPageLoading.addObserver { isLoading: Boolean -> - // ... -} - -// Observing the refresh process -pagination.refreshLoading.addObserver { isRefreshing: Boolean -> - // ... -} +val state: StateFlow, Throwable>> = pagination.state + +state + .map { remoteState -> + when (remoteState) { + is RemoteState.Success -> remoteState.data.items + else -> emptyList() + } + } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) ``` ## Samples diff --git a/build.gradle.kts b/build.gradle.kts index 0969237..e26044e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,11 +13,8 @@ buildscript { dependencies { classpath(libs.kotlinGradlePlugin) classpath(libs.androidGradlePlugin) - classpath(libs.googleServicesGradlePlugin) classpath(libs.mokoGradlePlugin) classpath(libs.mobileMultiplatformGradlePlugin) - classpath(libs.kotlinSerializationGradlePlugin) - classpath(libs.mokoUnitsGeneratorGradlePlugin) } } diff --git a/gradle.properties b/gradle.properties index 9a54194..0203033 100755 --- a/gradle.properties +++ b/gradle.properties @@ -3,15 +3,12 @@ org.gradle.configureondemand=false org.gradle.parallel=true kotlin.code.style=official -kotlin.native.enableDependencyPropagation=false -kotlin.mpp.enableGranularSourceSetsMetadata=true -kotlin.mpp.enableCompatibilityMetadataVariant=true android.useAndroidX=true -moko.android.targetSdk=31 -moko.android.compileSdk=31 -moko.android.minSdk=16 +moko.android.targetSdk=35 +moko.android.compileSdk=35 +moko.android.minSdk=21 moko.publish.name=MOKO paging moko.publish.description=Pagination logic in common code for mobile (android & ios) Kotlin Multiplatform development diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9dd074a..234d8f3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,38 +1,38 @@ [versions] -kotlinVersion = "1.6.20" -androidAppCompatVersion = "1.2.0" -androidLifecycleVersion = "2.1.0" -androidCoreTestingVersion = "2.1.0" -recyclerViewVersion = "1.1.0" -swipeRefreshLayoutVersion = "1.1.0" -ktorClientVersion = "2.0.0" -coroutinesVersion = "1.6.0-native-mt" -mokoMvvmVersion = "0.12.0" -mokoResourcesVersion = "0.18.0" +kotlinVersion = "2.1.10" +lifecycleVersion = "2.8.7" +androidCoreTestingVersion = "2.2.0" +composeBomVersion = "2025.09.00" +activityComposeVersion = "1.10.1" +ktorClientVersion = "2.3.12" +coroutinesVersion = "1.10.2" +mokoMvvmVersion = "0.16.1" mokoUnitsVersion = "0.8.0" -mokoPagingVersion = "0.7.2" +mokoPagingVersion = "1.0.0" +napierVersion = "2.7.1" [libraries] -appCompat = { module = "androidx.appcompat:appcompat", version.ref = "androidAppCompatVersion" } -recyclerView = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerViewVersion" } -lifecycle = { module = "androidx.lifecycle:lifecycle-extensions", version.ref = "androidLifecycleVersion" } -swipeRefreshLayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swipeRefreshLayoutVersion" } +lifecycleViewModel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleVersion" } +lifecycleRuntimeCompose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleVersion" } +activityCompose = { module = "androidx.activity:activity-compose", version.ref = "activityComposeVersion" } +composeBom = { module = "androidx.compose:compose-bom", version.ref = "composeBomVersion" } +composeUi = { module = "androidx.compose.ui:ui" } +composeUiTooling = { module = "androidx.compose.ui:ui-tooling" } +composeUiToolingPreview = { module = "androidx.compose.ui:ui-tooling-preview" } +composeMaterial3 = { module = "androidx.compose.material3:material3" } coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutinesVersion" } +coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesVersion" } ktorClient = { module = "io.ktor:ktor-client-core", version.ref = "ktorClientVersion" } -mokoResources = { module = "dev.icerock.moko:resources", version.ref = "mokoResourcesVersion" } mokoUnits = { module = "dev.icerock.moko:units", version.ref = "mokoUnitsVersion" } -mokoUnitsDataBinding = { module = "dev.icerock.moko:units-databinding", version.ref = "mokoUnitsVersion" } mokoMvvmLiveData = { module = "dev.icerock.moko:mvvm-livedata", version.ref = "mokoMvvmVersion" } mokoMvvmState = { module = "dev.icerock.moko:mvvm-state", version.ref = "mokoMvvmVersion" } +mokoMvvmFlow = { module = "dev.icerock.moko:mvvm-flow", version.ref = "mokoMvvmVersion" } +napier = { module = "io.github.aakira:napier", version.ref = "napierVersion" } kotlinTestJUnit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlinVersion" } androidCoreTesting = { module = "androidx.arch.core:core-testing", version.ref = "androidCoreTestingVersion" } ktorClientMock = { module = "io.ktor:ktor-client-mock", version.ref = "ktorClientVersion" } kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlinVersion" } -androidGradlePlugin = { module = "com.android.tools.build:gradle", version = "7.0.4" } -googleServicesGradlePlugin = { module = "com.google.gms:google-services", version = "4.3.8" } -firebaseGradlePlugin = { module = "com.google.firebase:firebase-crashlytics-gradle", version = "2.2.0" } -mokoGradlePlugin = { module = "dev.icerock.moko:moko-gradle-plugin", version = "0.1.0" } -mobileMultiplatformGradlePlugin = { module = "dev.icerock:mobile-multiplatform", version = "0.14.1" } -kotlinSerializationGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlinVersion" } -mokoUnitsGeneratorGradlePlugin = { module = "dev.icerock.moko:units-generator", version.ref = "mokoUnitsVersion" } +androidGradlePlugin = { module = "com.android.tools.build:gradle", version = "8.6.1" } +mokoGradlePlugin = { module = "dev.icerock.moko:moko-gradle-plugin", version = "0.6.0" } +mobileMultiplatformGradlePlugin = { module = "dev.icerock:mobile-multiplatform", version = "0.14.4" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index aa991fc..5a0dad5 100755 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Thu Feb 19 11:14:28 MSK 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/paging/build.gradle.kts b/paging/build.gradle.kts index bb34262..11c0d39 100644 --- a/paging/build.gradle.kts +++ b/paging/build.gradle.kts @@ -10,15 +10,34 @@ plugins { } kotlin { - jvm() + jvm() + + sourceSets { + val androidUnitTest by getting { + dependencies { + implementation(libs.coroutinesTest) + } + } + val jvmTest by getting { + dependencies { + implementation(libs.coroutinesTest) + } + } + } +} + +android { + namespace = "dev.icerock.moko.paging" } dependencies { + commonMainApi(projects.remotestate) commonMainImplementation(libs.coroutines) + commonMainImplementation(libs.napier) commonMainApi(libs.mokoMvvmLiveData) commonMainApi(libs.mokoMvvmState) - commonTestImplementation(libs.kotlinTestJUnit) + commonTestImplementation(kotlin("test")) androidTestImplementation(libs.androidCoreTesting) commonTestImplementation(libs.ktorClient) commonTestImplementation(libs.ktorClientMock) diff --git a/paging/src/androidMain/AndroidManifest.xml b/paging/src/androidMain/AndroidManifest.xml index c441c39..8072ee0 100755 --- a/paging/src/androidMain/AndroidManifest.xml +++ b/paging/src/androidMain/AndroidManifest.xml @@ -1,2 +1,2 @@ - \ No newline at end of file + diff --git a/paging/src/androidUnitTest/kotlin/dev/icerock/moko/paging/BaseTestsClass.kt b/paging/src/androidUnitTest/kotlin/dev/icerock/moko/paging/BaseTestsClass.kt new file mode 100644 index 0000000..62ad33a --- /dev/null +++ b/paging/src/androidUnitTest/kotlin/dev/icerock/moko/paging/BaseTestsClass.kt @@ -0,0 +1,7 @@ +/* + * Copyright 2024 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.moko.paging + +actual open class BaseTestsClass diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/LambdaPagedListDataSource.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/LambdaPagedListDataSource.kt deleted file mode 100644 index 17d0de3..0000000 --- a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/LambdaPagedListDataSource.kt +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. - */ - -package dev.icerock.moko.paging - -class LambdaPagedListDataSource( - private val loadPageLambda: suspend (List?) -> List -) : PagedListDataSource { - override suspend fun loadPage(currentList: List?): List { - return loadPageLambda(currentList) - } -} diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/LiveDataExt.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/LiveDataExt.kt deleted file mode 100644 index 42e8664..0000000 --- a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/LiveDataExt.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. - */ - -package dev.icerock.moko.paging - -import dev.icerock.moko.mvvm.livedata.LiveData -import dev.icerock.moko.mvvm.livedata.map -import dev.icerock.moko.mvvm.livedata.mediatorOf - -fun LiveData>.withLoadingItem( - loading: LiveData, - itemFactory: () -> T -): LiveData> = mediatorOf(this, loading) { items, nextPageLoading -> - if (nextPageLoading) { - items + itemFactory() - } else { - items - } -} - -fun LiveData>.withReachEndNotifier( - action: (Int) -> Unit -): LiveData> = map { list -> - list.withReachEndNotifier(action) -} diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PageCount.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PageCount.kt new file mode 100644 index 0000000..f3c4e32 --- /dev/null +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PageCount.kt @@ -0,0 +1,20 @@ +package dev.icerock.moko.paging + +import kotlin.math.ceil + +/** + * Возвращает количество страниц, необходимых для вывода всех элементов. + * + * @param currentListSize Количество элементов (может быть null) + * @param pageSize Размер одной страницы (должен быть > 0) + * @return Количество страниц (целое неотрицательное число) + */ +fun calculateNextPage( + currentListSize: Int?, + pageSize: Int, +): Int { + // Если список пустой или размер неподходящий, сразу возвращаем 0 страниц + if (currentListSize == null || currentListSize == 0 || pageSize <= 0) return 0 + + return ceil(currentListSize.toDouble() / pageSize).toInt() +} diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagedListDataSource.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagedListDataSource.kt deleted file mode 100644 index bbff503..0000000 --- a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagedListDataSource.kt +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. - */ - -package dev.icerock.moko.paging - -interface PagedListDataSource { - suspend fun loadPage(currentList: List?): List -} diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/Pagination.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/Pagination.kt index b493d20..be74731 100644 --- a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/Pagination.kt +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/Pagination.kt @@ -1,200 +1,421 @@ -/* - * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. - */ - package dev.icerock.moko.paging -import dev.icerock.moko.mvvm.ResourceState -import dev.icerock.moko.mvvm.asState -import dev.icerock.moko.mvvm.livedata.MutableLiveData -import dev.icerock.moko.mvvm.livedata.readOnly -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.async +import dev.icerock.moko.remotestate.RemoteState +import io.github.aakira.napier.Napier +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlin.coroutines.CoroutineContext +/** + * Пагинированная загрузка списка + * + * обновленная версия moko-paging, перешли на StateFlow + * + * @param dataSource реализация интерфейса PagingDataSource с suspend методом загрузки элементов + * @param itemKey Лямбда для получения уникального ключа элемента `(Item) -> Any`. + * Используется для идентификации элементов (аналог equals/hashCode) и дедупликации + * при слиянии страниц (например, чтобы избежать дублей, если элемент сместился на другую страницу). + * @param refreshStrategy Стратегия поведения при обновлении (Pull-to-Refresh). + * Определяет, как поступать с уже загруженными данными при получении первой страницы: + * - [RefreshStrategy.MergeNewItems]: Пытается сохранить старые данные, добавляя новые в начало. + * Подходит для append-only списков (логи, чаты). Может приводить к рассинхрону при удалении + * элементов на бэкенде. + * - [RefreshStrategy.ReplaceEverything]: Полная замена. При успехе загрузки старый список + * полностью отбрасывается и заменяется новой первой страницей. Позволяет избежать "моргания" + * экрана (в отличие от reloadFirstPage), сохраняя старые данные видимыми до момента получения новых. + * @param nextPageListener обработчик завершения загрузки следующей страницы, + * вызывать для показа ошибки либо дополнительной обработки успеха + * @param refreshListener обработчик завершения загрузки обновления списка + * @param initValue начальное значение списка + * */ class Pagination( - parentScope: CoroutineScope, - private val dataSource: PagedListDataSource, - private val comparator: Comparator, - private val nextPageListener: (Result>) -> Unit, - private val refreshListener: (Result>) -> Unit, + private val dataSource: PagingDataSource, + private val itemKey: (Item) -> Any, + private val refreshStrategy: RefreshStrategy = RefreshStrategy.MergeNewItems, + private val nextPageListener: (Result>) -> Unit = {}, + private val refreshListener: (Result>) -> Unit = {}, initValue: List? = null -) : CoroutineScope { - - override val coroutineContext: CoroutineContext = parentScope.coroutineContext - - private val mStateStorage = - MutableLiveData, Throwable>>(initValue.asStateNullIsLoading()) - - val state = mStateStorage.readOnly() - - private val mNextPageLoading = MutableLiveData(false) - val nextPageLoading = mNextPageLoading.readOnly() - - private val mEndOfList = MutableLiveData(false) - - private val mRefreshLoading = MutableLiveData(false) - val refreshLoading = mRefreshLoading.readOnly() - - private val listMutex = Mutex() - - private var loadNextPageDeferred: Deferred>? = null - - fun loadFirstPage() { - launch { - loadFirstPageSuspend() +) { + private val _state = MutableStateFlow, Throwable>>( + initValue + ?.let { RemoteState.Success(PagingState(items = it)) } + ?: RemoteState.Loading + ) + + /** + * Стэйт пагинированного списка + * + * Пример использования: во ViewModel мапить стейт, + * преобразовывая Throwable в необходимый класс ошибки для вывода на ui + * pagination.state + * .map { state -> + * state.mapError { it.mapThrowable() } + * } + */ + val state: StateFlow, Throwable>> = _state.asStateFlow() + + private var loadFirstPageJob: Job? = null + private var refreshJob: Job? = null + private var loadNextPageJob: Job? = null + + /** + * Загрузка первой страницы данных. + * + * В случае загрузки первой страницы считаем что текущий стейт не нужен - сбрасываемся в полную + * загрузку. Далее в зависимости от успешности загрузки либо перейдем в успех, либо в ошибку. + * + * Если в момент вызова уже идет рефреш/загрузка другой страницы - вся эта активность отменяется, + * загрузка первой страницы имеет максимальный приоритет (пользователь хочет полностью данные с + * нуля загрузить). + * + * Если повторно вызываем когда уже запущено - ничего не делается (ждем предыдущий запущенный + * результат). + */ + suspend fun loadFirstPage() { + // если уже есть задача загрузки новой страницы - просто ждём её завершения. + // Корутину завершим только когда задача завершится - чтобы вызывающая сторона точно понимала + // что загрузка завершилась + loadFirstPageJob?.let { + it.join() + return } - } - - suspend fun loadFirstPageSuspend() { - loadNextPageDeferred?.cancel() - listMutex.lock() - - mEndOfList.value = false - mNextPageLoading.value = false - mStateStorage.value = ResourceState.Loading() - - @Suppress("TooGenericExceptionCaught") - try { - val items: List = dataSource.loadPage(null) - mStateStorage.value = items.asState() - } catch (error: Exception) { - mStateStorage.value = ResourceState.Failed(error) + // если есть рефреш/загрузка - отменяем + refreshJob?.let { + it.cancel() + refreshJob = null + } + loadNextPageJob?.let { + it.cancel() + loadNextPageJob = null } - listMutex.unlock() - } - fun loadNextPage() { - launch { - loadNextPageSuspend() + coroutineScope { + loadFirstPageJob = launch { + _state.value = RemoteState.Loading + + @Suppress("TooGenericExceptionCaught") + try { + val items: List = dataSource.loadPage(null) + _state.value = RemoteState.Success( + data = PagingState( + items = items, + isEndOfList = dataSource.isPageFull(items).not() + ) + ) + } catch (exc: CancellationException) { + throw exc + } catch (exc: Exception) { + Napier.e("can't load first page", exc) + _state.value = RemoteState.Error(exc) + } + }.apply { + // зануляем завершенную задачу + invokeOnCompletion { loadFirstPageJob = null } + } } } + /** + * Загрузка следующей страницы данных. + * + * Следующую страницу мы можем загружать только если находимся в состоянии успеха (то есть + * уже есть какие-то элементы в списке - первая или больше страниц). + * И если у нас в стейте отражено что список закончен - смысла пытаться подгружать еще элементы + * нету. + * + * Если в момент вызова еще уже загрузка первой страницы - мы ничего не делаем (выше описание). + * Если же идет загрузка refresh (обновление первой страницы, без полного сброса) - ждем пока + * оно завершится, чтобы список не деформировался. + * Если уже идет загрузка новой страницы - ничего не делаем (необходимая операция уже запущена). + */ @Suppress("ReturnCount") - suspend fun loadNextPageSuspend() { - if (mNextPageLoading.value) return - if (mRefreshLoading.value) return - if (mEndOfList.value) return - - listMutex.lock() - - mNextPageLoading.value = true - - @Suppress("TooGenericExceptionCaught") - try { - loadNextPageDeferred = coroutineScope { - async { - val currentList = mStateStorage.value.dataValue() - ?: throw IllegalStateException("Try to load next page when list is empty") - // load next page items - val items = dataSource.loadPage(currentList) - // remove already exist items - val newItems = items.filter { item -> - val existsItem = - currentList.firstOrNull { comparator.compare(item, it) == 0 } - existsItem == null - } - // append new items to current list - val newList = currentList.plus(newItems) - // mark end of list if no new items - if (newItems.isEmpty()) { - mEndOfList.value = true - } else { - // save - mStateStorage.value = newList.asState() + suspend fun loadNextPage() { + val currentState: RemoteState.Success> = + _state.value as? RemoteState.Success> ?: return + + // если уже всё выкачали - не надо нам ничего больше делать + if (currentState.data.isEndOfList) return + + // если уже грузим след страницу - просто ждем результат этой загрузки + loadNextPageJob?.let { + it.join() + return + } + // если идет рефреш - ждем пока закончится, только потом действуем сами + refreshJob?.join() + + coroutineScope { + loadNextPageJob = launch { + // Повторно проверяем стейт, так как с предыдущей проверки, другая корутина + // могла изменить его + val latest = + _state.value as? RemoteState.Success> ?: return@launch + if (latest.data.isEndOfList) return@launch + + _state.value = latest.withNextPageLoading(true) + + runCatching { + val currentList: List = latest.data.items + val nextPageItems: List = dataSource.loadPage(currentList = currentList) + val newState: PagingState = getNextPageState(currentList, nextPageItems) + + _state.value = RemoteState.Success(newState) + + // выдаем полученные значения новой страницы + nextPageItems + }.onFailure { exc -> + if (exc is CancellationException) throw exc + + Napier.e("can't load next page", exc) + // Проверяем что текущий стейт, Success, если другая корутина изменила его + // ничего не делаем + val successState = _state.value as? RemoteState.Success> + if (successState != null) { + _state.value = successState.withNextPageLoading(false) } - newList + }.let { result -> + nextPageListener(result) } + }.apply { + // зануляем завершенную задачу + invokeOnCompletion { loadNextPageJob = null } } - val newList = loadNextPageDeferred!!.await() - - // flag - mNextPageLoading.value = false - // notify - nextPageListener(Result.success(newList)) - } catch (error: Exception) { - // flag - mNextPageLoading.value = false - // notify - nextPageListener(Result.failure(error)) - } - listMutex.unlock() + } } - fun refresh() { - launch { - refreshSuspend() + suspend fun reloadFirstPage() { + // если уже есть задача загрузки первой страницы - отменяем её. + loadFirstPageJob?.let { + it.cancel() + loadFirstPageJob = null } - } - suspend fun refreshSuspend() { - loadNextPageDeferred?.cancel() - listMutex.lock() + // если есть рефреш/загрузка - отменяем + refreshJob?.let { + it.cancel() + refreshJob = null + } + loadNextPageJob?.let { + it.cancel() + loadNextPageJob = null + } - if (mRefreshLoading.value) { - listMutex.unlock() - return + coroutineScope { + loadFirstPageJob = launch { + _state.value = RemoteState.Loading + + @Suppress("TooGenericExceptionCaught") + try { + val items: List = dataSource.loadPage(null) + _state.value = RemoteState.Success( + data = PagingState( + items = items, + isEndOfList = dataSource.isPageFull(items).not() + ) + ) + } catch (exc: CancellationException) { + throw exc + } catch (exc: Exception) { + Napier.e("can't load first page", exc) + _state.value = RemoteState.Error(exc) + } + }.apply { + // зануляем завершенную задачу + invokeOnCompletion { loadFirstPageJob = null } + } } - if (mNextPageLoading.value) { - listMutex.unlock() + } + + /** + * Обновление содержимого списка без сброса в состояние Loading. + * Позволяет загрузить новые данные, сохраняя на экране текущие (Pull-to-Refresh). + * + * @param refreshStrategy Стратегия обновления для текущего вызова. + * По умолчанию используется стратегия, заданная в конструкторе ([this.refreshStrategy]). + * + * Варианты поведения: + * - [RefreshStrategy.MergeNewItems]: + * Если новые и старые данные пересекаются (есть одинаковые элементы) — старый список + * сохраняется, новые элементы добавляются в начало. + * Если пересечения нет — происходит полная замена списка. + * - [RefreshStrategy.ReplaceEverything]: + * Полная замена списка новыми данными. Старые данные остаются на экране до момента + * успешной загрузки новых, затем мгновенно заменяются. + * Используется, например, при изменении фильтров, когда объединение старых и новых + * данных некорректно. + * + * Условия запуска: + * - Выполняется только если данные уже загружены (состояние [RemoteState.Success]). + * - Если уже идет обновление ([refreshJob]) — ожидает его завершения. + * - Если идет загрузка следующей страницы ([loadNextPageJob]) — ожидает её завершения, + * чтобы избежать коллизий и деформации списка. + */ + suspend fun refresh(refreshStrategy: RefreshStrategy = this.refreshStrategy) { + if (_state.value !is RemoteState.Success<*>) return + + // идет обновление - ждем его результат + refreshJob?.let { + it.join() return } + // идет загрузка новой страницы - дожидаемся её и погнали + loadNextPageJob?.join() + + coroutineScope { + refreshJob = launch { + // Повторно проверяем стейт, так как с предыдущей проверки, другая корутина + // могла изменить его + val currentState = _state.value as? RemoteState.Success> + ?: return@launch + + _state.value = currentState.withRefreshing(true) + + runCatching { + val newItems: List = dataSource.loadPage(null) + + when (refreshStrategy) { + RefreshStrategy.ReplaceEverything -> { + // Просто берем новые данные. Старое удаляем. + val isEndOfList = !dataSource.isPageFull(newItems) + + _state.value = RemoteState.Success( + data = PagingState( + items = newItems, + isEndOfList = isEndOfList + ) + ) + } + + RefreshStrategy.MergeNewItems -> { + val newState: PagingState = mergeNewItemsState( + currentState = currentState, + newItems = newItems + ) + + _state.value = RemoteState.Success(newState) + } + } + + // передаем полученный список в результат + newItems + }.onFailure { exc -> + if (exc is CancellationException) throw exc - mRefreshLoading.value = true - - @Suppress("TooGenericExceptionCaught") - try { - // load first page items - val items = dataSource.loadPage(null) - // save - mStateStorage.value = items.asState() - // flag - mEndOfList.value = false - mRefreshLoading.value = false - // notify - refreshListener(Result.success(items)) - } catch (error: Exception) { - // flag - mRefreshLoading.value = false - // notify - refreshListener(Result.failure(error)) - } - listMutex.unlock() + Napier.e("can't refresh list of services", exc) + val latest = _state.value as? RemoteState.Success> + if (latest != null) { + _state.value = latest.withRefreshing(false) + } + }.let { result -> + refreshListener(result) + } + }.apply { + // зануляем завершенную задачу + invokeOnCompletion { refreshJob = null } + } + } } + /** + * Метод для ручного обновления списка снаружи + * + * Выполняет попытку атомарно изменить данные, + * Если стейт RemoteState.Success, обновит значение списка, не затрагивая другие данные + * Иначе присвоит значение RemoteState.Success c заданным значением списка элементов + * + * Так же остановятся все джобы по загрузке новых данных + */ fun setData(items: List?) { - launch { - setDataSuspend(items) + loadFirstPageJob?.cancel() + refreshJob?.cancel() + loadNextPageJob?.cancel() + + _state.update { currentState -> + when (currentState) { + is RemoteState.Success> -> { + val newPagingState = currentState.data.copy(items = items ?: emptyList()) + currentState.copy(data = newPagingState) + } + + else -> RemoteState.Success( + data = PagingState( + items ?: emptyList() + ) + ) + } } } - suspend fun setDataSuspend(items: List?) { - listMutex.lock() - mStateStorage.value = items.asStateNullIsEmpty() - mEndOfList.value = false - listMutex.unlock() + private fun getNextPageState( + currentList: List, + nextPageItems: List + ): PagingState { + // убираем элементы которые уже есть в оригинальном списке + // такая ситуация может происходить когда новые элементы появились в начале списка + // (на тех страницах что у нас уже загружены) + val currentKeys = currentList.map(itemKey).toHashSet() + val filteredItems = nextPageItems.filter { itemKey(it) !in currentKeys } + val newList: List = currentList + filteredItems + + return PagingState( + items = newList, + // если мы получили в ответ на страницу меньше элементов + // чем запрашивали - значит список кончился + isEndOfList = !dataSource.isPageFull(nextPageItems) + ) } -} - -fun List?.asStateNullIsEmpty() = asState { - ResourceState.Empty, E>() -} -fun List?.asStateNullIsLoading() = asState { - ResourceState.Loading, E>() -} + private fun mergeNewItemsState( + currentState: RemoteState.Success>, + newItems: List + ): PagingState { + val currentItems: List = currentState.data.items + + // Используем itemKey для быстрого поиска + val currentKeys = currentItems.map(itemKey).toHashSet() + + // Проверяем, есть ли пересечение (хотя бы один элемент из новых уже есть в старых) + val hasIntersection = newItems.any { itemKey(it) in currentKeys } + + // Если есть новые элементы, но нет пересечения со старыми и старые не пустые - + // считаем, что лента уехала полностью, делаем полную замену + if (!hasIntersection && newItems.isNotEmpty() && currentItems.isNotEmpty()) { + return PagingState( + items = newItems, + isEndOfList = !dataSource.isPageFull(newItems) + ) + } -interface IdEntity { - val id: Long -} + // Оставляем только те новые элементы, ключей которых нет в старом списке + val uniqueNewItems = newItems.filter { item -> + itemKey(item) !in currentKeys + } -class IdComparator : Comparator { - override fun compare(a: T, b: T): Int { - return if (a.id == b.id) 0 else 1 + val newState: PagingState = if (uniqueNewItems.isNotEmpty()) { + // Добавляем уникальные новые в начало + все старые + // isEndOfList не трогаем, так как старые элементы остались + PagingState( + items = uniqueNewItems + currentItems, + isEndOfList = currentState.data.isEndOfList + ) + } else { + // Если ничего нового нет - оставляем всё как было + // (или заменяем на newItems, если список был пуст) + if (currentItems.isEmpty()) { + PagingState( + items = newItems, + isEndOfList = !dataSource.isPageFull(newItems) + ) + } else { + currentState.data + } + } + return newState } } diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingDataSource.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingDataSource.kt new file mode 100644 index 0000000..971d247 --- /dev/null +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingDataSource.kt @@ -0,0 +1,47 @@ +package dev.icerock.moko.paging + +/** + * Интерфейс датасурса для Pagination + */ +interface PagingDataSource { + /** + * Метод проверки полная ли страница загружена (чтобы понять достигли ли мы конца списка) + */ + fun isPageFull(list: List): Boolean + + /** + * Метод загрузки страницы на основе текущих данных + * + * @param currentList загруженные элементы списка + * + * Возвращаемое значение - следующая страница + * */ + suspend fun loadPage(currentList: List?): List +} + +/** + * Имплементация интерфейса PagingDataSource для постраничной загрузки через page/pageSize + * + * @param pageSize размер страницы + * @param loadPage suspend метод для постраничной загрузки списка + * */ +@Suppress("FunctionName") +fun PageSizePagingDataSource( + pageSize: Int, + loadPage: suspend (page: Int, pageSize: Int) -> List +): PagingDataSource { + return object : PagingDataSource { + override fun isPageFull(list: List): Boolean { + return list.size == pageSize + } + + override suspend fun loadPage(currentList: List?): List { + val page: Int = calculateNextPage( + currentListSize = currentList?.size, + pageSize = pageSize + ) + + return loadPage(page, pageSize) + } + } +} diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingState.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingState.kt new file mode 100644 index 0000000..fe76360 --- /dev/null +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingState.kt @@ -0,0 +1,16 @@ +package dev.icerock.moko.paging + +/** + * Стейт списка для Pagination + * + * @param items загруженный список элементов + * @param isRefreshing состояние обновления страницы, использовать для показа pull-to-refresh + * @param isNextPageLoading состояние загрузки следущей страницы, использовать для показа лоадера в конце списка + * @param isEndOfList индикатор загрузки всего списка, в случае false не вызывать onLoadNextPage + * */ +data class PagingState( + val items: List, + val isRefreshing: Boolean = false, + val isNextPageLoading: Boolean = false, + val isEndOfList: Boolean = false +) diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/ReachEndNotifierList.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/ReachEndNotifierList.kt deleted file mode 100644 index 96e7ed4..0000000 --- a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/ReachEndNotifierList.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. - */ - -package dev.icerock.moko.paging - -@Suppress("TooManyFunctions") -class ReachEndNotifierList( - private val mWrappedList: List, - val onReachEnd: (Int) -> Unit -) : List { - - override val size: Int = mWrappedList.size - - override fun contains(element: T): Boolean = mWrappedList.contains(element) - - override fun containsAll(elements: Collection): Boolean = mWrappedList.containsAll(elements) - - override fun get(index: Int): T = mWrappedList[index] - - override fun indexOf(element: T): Int = mWrappedList.indexOf(element) - - override fun isEmpty(): Boolean = mWrappedList.isEmpty() - - override fun iterator(): Iterator = mWrappedList.iterator() - - override fun lastIndexOf(element: T): Int = mWrappedList.lastIndexOf(element) - - override fun listIterator(): ListIterator = mWrappedList.listIterator() - - override fun listIterator(index: Int): ListIterator = mWrappedList.listIterator(index) - - override fun subList(fromIndex: Int, toIndex: Int): List = - mWrappedList.subList(fromIndex, toIndex) - - fun notifyReachEnd() { - onReachEnd(mWrappedList.lastIndex) - } -} - -fun List.withReachEndNotifier(action: (Int) -> Unit): ReachEndNotifierList = - ReachEndNotifierList( - this, - action - ) diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RefreshStrategy.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RefreshStrategy.kt new file mode 100644 index 0000000..6618d52 --- /dev/null +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RefreshStrategy.kt @@ -0,0 +1,23 @@ +package dev.icerock.moko.paging + +enum class RefreshStrategy { + /** + * "Умное" добавление. + * Загружает первую страницу и пытается "приклеить" новые элементы к началу списка. + * Игнорирует элементы, которые уже есть в списке (даже если их данные изменились). + * Старые страницы (2, 3...) остаются в памяти. + * + * Подходит для: Ленты новостей, логи, бесконечные потоки. + */ + MergeNewItems, + + /** + * Полная перезагрузка. + * Загружает первую страницу и ПОЛНОСТЬЮ заменяет текущий список. + * Гарантирует актуальность данных. Сбрасывает пагинацию на начало. + * + * Подходит для: Каталогов товаров, списков заявок, банковских операций, + * любых списков, где данные элементов могут меняться. + */ + ReplaceEverything +} diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RemoteStateExt.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RemoteStateExt.kt new file mode 100644 index 0000000..f7c4150 --- /dev/null +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RemoteStateExt.kt @@ -0,0 +1,19 @@ +package dev.icerock.moko.paging + +import dev.icerock.moko.remotestate.RemoteState + +/** + * Используйте эту функцию для изменения состояния обновления (isRefreshing) в экземпляре PagingState, + * не затрагивая другие данные, находящиеся в объекте RemoteState.Success + */ +fun RemoteState.Success>.withRefreshing( + value: Boolean +): RemoteState.Success> = this.copy(data = this.data.copy(isRefreshing = value)) + +/** + * Используйте эту функцию для изменения состояния обновления (isNextPageLoading) в экземпляре PagingState, + * не затрагивая другие данные, находящиеся в объекте RemoteState.Success + */ +fun RemoteState.Success>.withNextPageLoading( + value: Boolean +): RemoteState.Success> = this.copy(data = this.data.copy(isNextPageLoading = value)) diff --git a/paging/src/commonTest/kotlin/dev/icerock/moko/paging/IntegrationTests.kt b/paging/src/commonTest/kotlin/dev/icerock/moko/paging/IntegrationTests.kt index 13e40f8..2b7ddc6 100644 --- a/paging/src/commonTest/kotlin/dev/icerock/moko/paging/IntegrationTests.kt +++ b/paging/src/commonTest/kotlin/dev/icerock/moko/paging/IntegrationTests.kt @@ -8,7 +8,8 @@ import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respondOk import io.ktor.client.request.get -import io.ktor.client.statement.* +import dev.icerock.moko.remotestate.data +import io.ktor.client.statement.bodyAsText import io.ktor.http.fullPath import kotlinx.coroutines.async import kotlinx.coroutines.cancel @@ -43,18 +44,21 @@ class IntegrationTests : BaseTestsClass() { @Test fun parallelRequests() = runTest { - val pagination = Pagination( - parentScope = this, - dataSource = LambdaPagedListDataSource { - println("start load new page with $it") - val randomJoke: String = httpClient - .get("http://api.icndb.com/jokes/random") - .bodyAsText() - - println("respond new item $randomJoke") - listOf(randomJoke) + val pagination = Pagination( + dataSource = object : PagingDataSource { + override fun isPageFull(list: List): Boolean = list.size == 1 + + override suspend fun loadPage(currentList: List?): List { + println("start load new page with $currentList") + val randomJoke: String = httpClient + .get("http://api.icndb.com/jokes/random") + .bodyAsText() + + println("respond new item $randomJoke") + return listOf(randomJoke) + } }, - comparator = Comparator { a, b -> a.compareTo(b) }, + itemKey = { it }, nextPageListener = { }, refreshListener = { } ) @@ -62,19 +66,19 @@ class IntegrationTests : BaseTestsClass() { for (i in 0..10) { println("--- ITERATION $i START ---") println("start load first page") - pagination.loadFirstPageSuspend() + pagination.loadFirstPage() println("end load first page") (0..3).flatMap { listOf( async { println("--> $it refresh start") - pagination.refreshSuspend() + pagination.refresh() println("<-- $it refresh end") }, async { println("--> $it load next page start") - pagination.loadNextPageSuspend() + pagination.loadNextPage() println("<-- $it load next page end") } ) @@ -84,18 +88,21 @@ class IntegrationTests : BaseTestsClass() { @Test fun parallelRequestsAndSetData() = runTest { - val pagination = Pagination( - parentScope = this, - dataSource = LambdaPagedListDataSource { - println("start load new page with $it") - val randomJoke: String = httpClient - .get("http://api.icndb.com/jokes/random") - .bodyAsText() - - println("respond new item $randomJoke") - listOf(randomJoke) + val pagination = Pagination( + dataSource = object : PagingDataSource { + override fun isPageFull(list: List): Boolean = list.size == 1 + + override suspend fun loadPage(currentList: List?): List { + println("start load new page with $currentList") + val randomJoke: String = httpClient + .get("http://api.icndb.com/jokes/random") + .bodyAsText() + + println("respond new item $randomJoke") + return listOf(randomJoke) + } }, - comparator = Comparator { a, b -> a.compareTo(b) }, + itemKey = { it }, nextPageListener = { }, refreshListener = { } ) @@ -103,26 +110,26 @@ class IntegrationTests : BaseTestsClass() { for (i in 0..10) { println("--- ITERATION $i START ---") println("start load first page") - pagination.loadFirstPageSuspend() + pagination.loadFirstPage() println("end load first page") (0..1).flatMap { listOf( async { println("--> $it refresh start") - pagination.refreshSuspend() + pagination.refresh() println("<-- $it refresh end") }, async { println("--> $it load next page start") - pagination.loadNextPageSuspend() + pagination.loadNextPage() println("<-- $it load next page end") }, async { println("--> $it set data start") - val data = pagination.state.value.dataValue().orEmpty() + val data = pagination.state.value.data?.items.orEmpty() val newData = data.plus("new item") - pagination.setDataSuspend(newData) + pagination.setData(newData) println("--> $it set data end") } ) @@ -134,25 +141,28 @@ class IntegrationTests : BaseTestsClass() { fun closingScope() = runTest { val exc = runCatching { coroutineScope { - val pagination = Pagination( - parentScope = this, - dataSource = LambdaPagedListDataSource { - println("start load new page with $it") - val randomJoke: String = httpClient - .get("http://api.icndb.com/jokes/random") - .bodyAsText() - - println("respond new item $randomJoke") - listOf(randomJoke) + val pagination = Pagination( + dataSource = object : PagingDataSource { + override fun isPageFull(list: List): Boolean = list.size == 1 + + override suspend fun loadPage(currentList: List?): List { + println("start load new page with $currentList") + val randomJoke: String = httpClient + .get("http://api.icndb.com/jokes/random") + .bodyAsText() + + println("respond new item $randomJoke") + return listOf(randomJoke) + } }, - comparator = Comparator { a, b -> a.compareTo(b) }, + itemKey = { it }, nextPageListener = { }, refreshListener = { } ) launch { println("start load") - pagination.loadFirstPageSuspend() + pagination.loadFirstPage() println("end load") } @@ -164,4 +174,4 @@ class IntegrationTests : BaseTestsClass() { println(exc) } -} \ No newline at end of file +} diff --git a/paging/src/commonTest/kotlin/dev/icerock/moko/paging/PaginationTest.kt b/paging/src/commonTest/kotlin/dev/icerock/moko/paging/PaginationTest.kt index bedb7cb..b5eba91 100644 --- a/paging/src/commonTest/kotlin/dev/icerock/moko/paging/PaginationTest.kt +++ b/paging/src/commonTest/kotlin/dev/icerock/moko/paging/PaginationTest.kt @@ -4,7 +4,8 @@ package dev.icerock.moko.paging -import kotlinx.coroutines.CoroutineScope +import dev.icerock.moko.remotestate.data +import dev.icerock.moko.remotestate.isSuccess import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlin.test.BeforeTest @@ -15,10 +16,6 @@ class PaginationTest : BaseTestsClass() { var paginationDataSource = TestListDataSource(3, 5) - val itemsComparator = Comparator { a: Int, b: Int -> - a - b - } - @BeforeTest fun setup() { paginationDataSource = TestListDataSource(3, 5) @@ -28,13 +25,13 @@ class PaginationTest : BaseTestsClass() { fun `load first page`() = runTest { val pagination = createPagination() - pagination.loadFirstPageSuspend() + pagination.loadFirstPage() assertTrue { pagination.state.value.isSuccess() } assertTrue { - pagination.state.value.dataValue()!!.compareWith(listOf(0, 1, 2)) + pagination.state.value.data?.items?.compareWith(listOf(0, 1, 2)) == true } } @@ -42,17 +39,17 @@ class PaginationTest : BaseTestsClass() { fun `load next page`() = runTest { val pagination = createPagination() - pagination.loadFirstPageSuspend() - pagination.loadNextPageSuspend() + pagination.loadFirstPage() + pagination.loadNextPage() assertTrue { - pagination.state.value.dataValue()!!.compareWith(listOf(0, 1, 2, 3, 4, 5)) + pagination.state.value.data?.items?.compareWith(listOf(0, 1, 2, 3, 4, 5)) == true } - pagination.loadNextPageSuspend() + pagination.loadNextPage() assertTrue { - pagination.state.value.dataValue()!!.compareWith(listOf(0, 1, 2, 3, 4, 5, 6, 7, 8)) + pagination.state.value.data?.items?.compareWith(listOf(0, 1, 2, 3, 4, 5, 6, 7, 8)) == true } } @@ -60,12 +57,12 @@ class PaginationTest : BaseTestsClass() { fun `refresh pagination`() = runTest { val pagination = createPagination() - pagination.loadFirstPageSuspend() - pagination.loadNextPageSuspend() - pagination.refreshSuspend() + pagination.loadFirstPage() + pagination.loadNextPage() + pagination.refresh(RefreshStrategy.ReplaceEverything) assertTrue { - pagination.state.value.dataValue()!!.compareWith(listOf(0, 1, 2)) + pagination.state.value.data?.items?.compareWith(listOf(0, 1, 2)) == true } } @@ -73,45 +70,48 @@ class PaginationTest : BaseTestsClass() { fun `set data`() = runTest { val pagination = createPagination() - pagination.loadFirstPageSuspend() - pagination.loadNextPageSuspend() + pagination.loadFirstPage() + pagination.loadNextPage() val setList = listOf(5, 2, 3, 1, 4) - pagination.setDataSuspend(setList) + pagination.setData(setList) assertTrue { - pagination.state.value.dataValue()!!.compareWith(setList) + pagination.state.value.data?.items?.compareWith(setList) == true } } @Test fun `double refresh`() = runTest { var counter = 0 - val pagination = Pagination( - parentScope = this, - dataSource = LambdaPagedListDataSource { - val load = counter++ - println("start load new page with $it") - delay(100) - println("respond new list $load") - listOf(1, 2, 3, 4) + val pagination = Pagination( + dataSource = object : PagingDataSource { + override fun isPageFull(list: List): Boolean = list.size == 4 + + override suspend fun loadPage(currentList: List?): List { + val load = counter++ + println("start load new page with $currentList") + delay(100) + println("respond new list $load") + return listOf(1, 2, 3, 4) + } }, - comparator = itemsComparator, + itemKey = { it }, nextPageListener = { }, refreshListener = { } ) println("start load first page") - pagination.loadFirstPageSuspend() + pagination.loadFirstPage() println("end load first page") println("start double refresh") val r1 = async { - pagination.refreshSuspend() + pagination.refresh() println("first refresh end") } val r2 = async { - pagination.refreshSuspend() + pagination.refresh() println("second refresh end") } @@ -119,13 +119,12 @@ class PaginationTest : BaseTestsClass() { r2.await() } - private fun CoroutineScope.createPagination( + private fun createPagination( nextPageListener: (Result>) -> Unit = {}, refreshListener: (Result>) -> Unit = {} - ) = Pagination( - parentScope = this, + ) = Pagination( dataSource = paginationDataSource, - comparator = itemsComparator, + itemKey = { it }, nextPageListener = nextPageListener, refreshListener = refreshListener ) diff --git a/paging/src/commonTest/kotlin/dev/icerock/moko/paging/TestListDataSource.kt b/paging/src/commonTest/kotlin/dev/icerock/moko/paging/TestListDataSource.kt index f672a23..8de9560 100644 --- a/paging/src/commonTest/kotlin/dev/icerock/moko/paging/TestListDataSource.kt +++ b/paging/src/commonTest/kotlin/dev/icerock/moko/paging/TestListDataSource.kt @@ -4,11 +4,15 @@ package dev.icerock.moko.paging -class TestListDataSource(val pageSize: Int, val totalPagesCount: Int) : PagedListDataSource { - val dataList = (0 .. pageSize * totalPagesCount).map { it } +class TestListDataSource(val pageSize: Int, val totalPagesCount: Int) : PagingDataSource { + private val dataList = (0 until pageSize * totalPagesCount).toList() + + override fun isPageFull(list: List): Boolean = list.size == pageSize override suspend fun loadPage(currentList: List?): List { val offset = currentList?.size ?: 0 - return dataList.subList(offset, offset + pageSize) + val endIndex = (offset + pageSize).coerceAtMost(dataList.size) + + return dataList.subList(offset, endIndex) } } diff --git a/paging/src/iosMain/kotlin/Dummy.kt b/paging/src/iosMain/kotlin/Dummy.kt deleted file mode 100644 index 97f62dc..0000000 --- a/paging/src/iosMain/kotlin/Dummy.kt +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. - */ - -package dev.icerock.moko.paging - -// required for produce `metadata/iosMain` -internal val sDummyVar: Int? = null diff --git a/remotestate/build.gradle.kts b/remotestate/build.gradle.kts new file mode 100644 index 0000000..266b6bc --- /dev/null +++ b/remotestate/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +plugins { + id("dev.icerock.moko.gradle.multiplatform.mobile") + id("dev.icerock.moko.gradle.detekt") +} + +kotlin { + jvm() +} + +android { + namespace = "dev.icerock.moko.remotestate" +} + +dependencies { + commonMainImplementation(libs.coroutines) +} diff --git a/remotestate/src/commonMain/kotlin/dev/icerock/moko/remotestate/RemoteState.kt b/remotestate/src/commonMain/kotlin/dev/icerock/moko/remotestate/RemoteState.kt new file mode 100644 index 0000000..381a409 --- /dev/null +++ b/remotestate/src/commonMain/kotlin/dev/icerock/moko/remotestate/RemoteState.kt @@ -0,0 +1,60 @@ +package dev.icerock.moko.remotestate + +import dev.icerock.moko.remotestate.RemoteState.Error +import dev.icerock.moko.remotestate.RemoteState.Loading +import dev.icerock.moko.remotestate.RemoteState.Success +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Сделано именно class, а не interface, чтобы на iOS стороне можно было hashable сделать реализацию. + * Также типы сделаны обязательно не нуллабельными тоже для iOS - чтобы нуллы не ожидал компилятор везде. + */ +sealed class RemoteState { + data object Loading : RemoteState() + data class Success(val data: T) : RemoteState() + data class Error( + val error: E, + ) : RemoteState() +} + +fun RemoteState.mapSuccess(map: (T) -> K): RemoteState { + return when (this) { + is Success -> Success(map(this.data)) + is Error -> this + Loading -> Loading + } +} + +fun RemoteState.mapError(map: (E) -> K): RemoteState { + return when (this) { + is Success -> this + is Error -> Error(map(this.error)) + Loading -> Loading + } +} + +fun RemoteState.isLoading(): Boolean = this is Loading + +fun RemoteState.isSuccess(): Boolean = this is Success + +val RemoteState.data: T? get() = (this as? Success)?.data + +/** + * Выполняет попытку атомарно изменить данные в состоянии RemoteState.Success + * + * @param function лямбда получения новых данных из текущих + * @return true если удалось обновить Success состояние. false - если состояние уже не Success. + */ +fun MutableStateFlow>.tryUpdateSuccess( + function: (T) -> T +): Boolean { + while (true) { + val currentState: Success = + this.value as? Success ?: return false + + val newState: Success = currentState.copy( + data = function(currentState.data) + ) + if (this.compareAndSet(expect = currentState, update = newState)) return true + } +} diff --git a/sample/android-app/build.gradle.kts b/sample/android-app/build.gradle.kts index c4371da..f64ff9e 100644 --- a/sample/android-app/build.gradle.kts +++ b/sample/android-app/build.gradle.kts @@ -4,12 +4,14 @@ plugins { id("dev.icerock.moko.gradle.android.application") - id("dev.icerock.mobile.multiplatform-units") - id("kotlin-kapt") + id("org.jetbrains.kotlin.plugin.compose") } android { - buildFeatures.dataBinding = true + namespace = "com.icerockdev" + buildFeatures { + compose = true + } defaultConfig { applicationId = "dev.icerock.moko.samples.paging" @@ -22,17 +24,13 @@ android { } dependencies { - implementation(libs.appCompat) - implementation(libs.recyclerView) - implementation(libs.lifecycle) - implementation(libs.swipeRefreshLayout) - implementation(libs.mokoUnitsDataBinding) - + implementation(libs.activityCompose) + implementation(platform(libs.composeBom)) + implementation(libs.composeMaterial3) + implementation(libs.composeUi) + implementation(libs.composeUiToolingPreview) + implementation(libs.lifecycleRuntimeCompose) implementation(projects.sample.mppLibrary) -} -multiplatformUnits { - classesPackage = "com.icerockdev" - dataBindingPackage = "com.icerockdev" - layoutsSourceSet = "main" + debugImplementation(libs.composeUiTooling) } diff --git a/sample/android-app/src/main/AndroidManifest.xml b/sample/android-app/src/main/AndroidManifest.xml index c426290..d8da4ef 100755 --- a/sample/android-app/src/main/AndroidManifest.xml +++ b/sample/android-app/src/main/AndroidManifest.xml @@ -1,16 +1,15 @@ + xmlns:tools="http://schemas.android.com/tools"> - diff --git a/sample/android-app/src/main/java/com/icerockdev/LoadingUnitItem.kt b/sample/android-app/src/main/java/com/icerockdev/LoadingUnitItem.kt deleted file mode 100644 index 4378faa..0000000 --- a/sample/android-app/src/main/java/com/icerockdev/LoadingUnitItem.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. - */ - -package com.icerockdev - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.lifecycle.LifecycleOwner -import androidx.recyclerview.widget.RecyclerView -import dev.icerock.moko.units.TableUnitItem - -class LoadingUnitItem : TableUnitItem { - override val itemId: Long = -2 // we have only one loading in list - override val viewType: Int = R.layout.unit_loading - - override fun createViewHolder( - parent: ViewGroup, - lifecycleOwner: LifecycleOwner - ): RecyclerView.ViewHolder { - val inflater = LayoutInflater.from(parent.context) - val loadingView = inflater.inflate(R.layout.unit_loading, parent, false) - return object : RecyclerView.ViewHolder(loadingView) {} - } - - override fun bindViewHolder(viewHolder: RecyclerView.ViewHolder) { - // do nothing - it's just loading item - } -} \ No newline at end of file diff --git a/sample/android-app/src/main/java/com/icerockdev/MainActivity.kt b/sample/android-app/src/main/java/com/icerockdev/MainActivity.kt old mode 100755 new mode 100644 index ee98393..e8303ba --- a/sample/android-app/src/main/java/com/icerockdev/MainActivity.kt +++ b/sample/android-app/src/main/java/com/icerockdev/MainActivity.kt @@ -5,65 +5,31 @@ package com.icerockdev import android.os.Bundle -import android.view.View -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.Observer -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier import com.icerockdev.library.ListViewModel import dev.icerock.moko.mvvm.getViewModel -import dev.icerock.moko.mvvm.livedata.data -import dev.icerock.moko.units.TableUnitItem -import dev.icerock.moko.units.adapter.UnitsRecyclerViewAdapter -class MainActivity : AppCompatActivity() { - - private val unitsFactory = object : ListViewModel.UnitsFactory { - override fun createProductUnit(id: Long, title: String): TableUnitItem { - // databinding generated unit - return UnitProduct().also { - it.itemId = id - it.title = title - } - } - - override fun createLoading(): TableUnitItem { - // manual created unit - return LoadingUnitItem() - } - } +class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - - val viewModel = getViewModel { ListViewModel(unitsFactory) } - val unitsAdapter = UnitsRecyclerViewAdapter(this) - val swipeRefreshLayout = findViewById(R.id.swipeRefresh) - val recyclerView = findViewById(R.id.recyclerView) + val viewModel = getViewModel { ListViewModel() } - with(recyclerView) { - layoutManager = LinearLayoutManager(this@MainActivity, RecyclerView.VERTICAL, false) - adapter = unitsAdapter - } - - viewModel.isRefreshing.ld().observe(this, Observer { swipeRefreshLayout.isRefreshing = it }) - viewModel.state.data().ld().observe(this, Observer { unitsAdapter.units = it.orEmpty() }) - - swipeRefreshLayout.setOnRefreshListener { viewModel.onRefresh() } - - recyclerView.addOnChildAttachStateChangeListener(object : RecyclerView.OnChildAttachStateChangeListener { - override fun onChildViewDetachedFromWindow(view: View) {} - - override fun onChildViewAttachedToWindow(view: View) { - val count = unitsAdapter.itemCount - val position = recyclerView.getChildAdapterPosition(view) - if (position != count - 1) return - - viewModel.onLoadNextPage() + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + PagingScreen(viewModel = viewModel) + } } - }) + } } } diff --git a/sample/android-app/src/main/java/com/icerockdev/PagingScreen.kt b/sample/android-app/src/main/java/com/icerockdev/PagingScreen.kt new file mode 100644 index 0000000..7cac0ee --- /dev/null +++ b/sample/android-app/src/main/java/com/icerockdev/PagingScreen.kt @@ -0,0 +1,222 @@ +package com.icerockdev + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.icerockdev.library.ListViewModel +import com.icerockdev.library.ListViewModel.ProductItem +import dev.icerock.moko.paging.PagingState +import dev.icerock.moko.remotestate.RemoteState + +@Composable +fun PagingScreen(viewModel: ListViewModel) { + val screenState by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.onStart() + } + + when (screenState) { + RemoteState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + is RemoteState.Error -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Error state text", + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(12.dp)) + TextButton(onClick = { viewModel.onRefresh() }) { + Text(text = "Retry") + } + } + } + } + + is RemoteState.Success> -> { + val pagingState: PagingState = + (screenState as RemoteState.Success>).data + + if (pagingState.items.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Empty state text", + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(12.dp)) + TextButton(onClick = { viewModel.onRefresh() }) { + Text(text = "Refresh") + } + } + } + } else { + PagingContent( + pagingState = pagingState, + onLoadNextRequested = { viewModel.onLoadNextPage() }, + onRefresh = { + viewModel.onRefresh() + } + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PagingContent( + pagingState: PagingState, + onLoadNextRequested: () -> Unit, + onRefresh: () -> Unit, +) { + val listState = rememberLazyListState() + val pullToRefreshState = rememberPullToRefreshState() + + val shouldLoadMore: Boolean by remember { + derivedStateOf { + val layoutInfo: LazyListLayoutInfo = listState.layoutInfo + val lastVisibleIndex: Int? = layoutInfo.visibleItemsInfo.lastOrNull()?.index + val totalCount: Int = layoutInfo.totalItemsCount + val threshold: Int = 3 + val endIndex = (totalCount - threshold).coerceAtLeast(0) + val isCloseToEnd: Boolean = lastVisibleIndex != null && + lastVisibleIndex >= endIndex + lastVisibleIndex != null && + !pagingState.isEndOfList && + !pagingState.isNextPageLoading && + !pagingState.isRefreshing && + isCloseToEnd + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) { + onLoadNextRequested() + } + } + + PullToRefreshBox( + isRefreshing = pagingState.isRefreshing, + onRefresh = onRefresh, + state = pullToRefreshState, + indicator = { + Indicator( + modifier = Modifier.align(Alignment.TopCenter), + isRefreshing = pagingState.isRefreshing, + containerColor = MaterialTheme.colorScheme.surface, + color = MaterialTheme.colorScheme.primary, + state = pullToRefreshState + ) + } + ) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize() + ) { + items( + items = pagingState.items, + key = { item -> item.id } + ) { item -> + ProductRow(item = item) + } + + if (pagingState.isNextPageLoading) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } + } + } +} + +@Composable +private fun ProductRow(item: ProductItem) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Text( + text = item.title, + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "#${item.id}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Preview +@Composable +private fun PagingContentPreview() { + PagingContent( + pagingState = PagingState( + items = listOf( + ProductItem(1, "PagingState 1"), + ProductItem(2, "PagingState 2"), + ProductItem(3, "PagingState 3"), + ProductItem(4, "PagingState 4"), + ProductItem(5, "PagingState 5"), + ProductItem(6, "PagingState 6"), + ProductItem(7, "PagingState 7"), + ) + ), + onLoadNextRequested = {}, + onRefresh = {} + ) +} diff --git a/sample/android-app/src/main/res/layout/activity_main.xml b/sample/android-app/src/main/res/layout/activity_main.xml deleted file mode 100755 index 2938da0..0000000 --- a/sample/android-app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - diff --git a/sample/android-app/src/main/res/layout/unit_loading.xml b/sample/android-app/src/main/res/layout/unit_loading.xml deleted file mode 100644 index f6031af..0000000 --- a/sample/android-app/src/main/res/layout/unit_loading.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/sample/android-app/src/main/res/layout/unit_product.xml b/sample/android-app/src/main/res/layout/unit_product.xml deleted file mode 100644 index cda9c4e..0000000 --- a/sample/android-app/src/main/res/layout/unit_product.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - diff --git a/sample/mpp-library/build.gradle.kts b/sample/mpp-library/build.gradle.kts index 511fa4e..6fcfd7b 100644 --- a/sample/mpp-library/build.gradle.kts +++ b/sample/mpp-library/build.gradle.kts @@ -8,20 +8,24 @@ plugins { id("dev.icerock.moko.gradle.detekt") } +android { + namespace = "com.icerockdev.library" +} + dependencies { commonMainImplementation(libs.coroutines) commonMainApi(projects.paging) commonMainApi(libs.mokoUnits) - commonMainApi(libs.mokoMvvmLiveData) + commonMainApi(libs.mokoMvvmFlow) commonMainApi(libs.mokoMvvmState) - commonMainApi(libs.mokoResources) - androidMainImplementation(libs.lifecycle) + commonMainImplementation(libs.napier) + androidMainImplementation(libs.lifecycleViewModel) } framework { export(libs.mokoUnits) - export(libs.mokoMvvmLiveData) + export(libs.mokoMvvmFlow) export(libs.mokoMvvmState) } diff --git a/sample/mpp-library/src/androidMain/AndroidManifest.xml b/sample/mpp-library/src/androidMain/AndroidManifest.xml index 8f69414..8072ee0 100755 --- a/sample/mpp-library/src/androidMain/AndroidManifest.xml +++ b/sample/mpp-library/src/androidMain/AndroidManifest.xml @@ -1,2 +1,2 @@ - + diff --git a/sample/mpp-library/src/commonMain/kotlin/com/icerockdev/library/ListViewModel.kt b/sample/mpp-library/src/commonMain/kotlin/com/icerockdev/library/ListViewModel.kt index 452bb9b..67991ae 100644 --- a/sample/mpp-library/src/commonMain/kotlin/com/icerockdev/library/ListViewModel.kt +++ b/sample/mpp-library/src/commonMain/kotlin/com/icerockdev/library/ListViewModel.kt @@ -4,109 +4,108 @@ package com.icerockdev.library -import dev.icerock.moko.mvvm.ResourceState -import dev.icerock.moko.mvvm.livedata.LiveData -import dev.icerock.moko.mvvm.livedata.dataTransform -import dev.icerock.moko.mvvm.livedata.errorTransform -import dev.icerock.moko.mvvm.livedata.map -import dev.icerock.moko.mvvm.livedata.mediatorOf +import dev.icerock.moko.mvvm.flow.CStateFlow +import dev.icerock.moko.mvvm.flow.cStateFlow import dev.icerock.moko.mvvm.viewmodel.ViewModel -import dev.icerock.moko.paging.IdComparator -import dev.icerock.moko.paging.IdEntity -import dev.icerock.moko.paging.LambdaPagedListDataSource +import dev.icerock.moko.paging.PageSizePagingDataSource import dev.icerock.moko.paging.Pagination -import dev.icerock.moko.units.TableUnitItem +import dev.icerock.moko.paging.PagingState +import dev.icerock.moko.paging.RefreshStrategy +import dev.icerock.moko.remotestate.RemoteState +import dev.icerock.moko.remotestate.mapError +import io.github.aakira.napier.Napier +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlin.math.min -private const val PAGE_LOAD_DURATION_MS: Long = 2000 - -class ListViewModel( - private val unitsFactory: UnitsFactory -) : ViewModel() { - private val pagination: Pagination = Pagination( - parentScope = viewModelScope, - dataSource = LambdaPagedListDataSource { - delay(PAGE_LOAD_DURATION_MS) - - it?.plus(generatePack(it.size.toLong())) ?: generatePack() +class ListViewModel : ViewModel() { + private val pagination: Pagination = Pagination( + dataSource = PageSizePagingDataSource( + pageSize = PAGE_SIZE, + loadPage = ::loadPage + ), + itemKey = { item -> item.id }, + refreshStrategy = RefreshStrategy.ReplaceEverything, + nextPageListener = { result -> + result.onFailure { + Napier.e("can't load next page", it) + } }, - comparator = IdComparator(), - nextPageListener = ::onNextPageResult, - refreshListener = ::onRefreshResult, - initValue = generatePack() - ) - - val isRefreshing: LiveData = pagination.refreshLoading - val state: LiveData, String>> = pagination.state - .dataTransform { - mediatorOf( - this.map { productList -> - productList.map { product -> - unitsFactory.createProductUnit( - id = product.id, - title = product.title - ) - } - }, - pagination.nextPageLoading - ) { items, nextPageLoading -> - if (nextPageLoading) { - items.plus(unitsFactory.createLoading()) - } else { - items - } + refreshListener = { result -> + result.onFailure { + Napier.e("can't load refresh", it) } } - .errorTransform { - map { it.toString() } - } + ) - fun onRetryPressed() { - pagination.loadFirstPage() - } + val state: CStateFlow, Throwable>> = + pagination.state.map { state -> + state.mapError { it } + }.cStateIn(viewModelScope, initValue = RemoteState.Loading) - fun onLoadNextPage() { - pagination.loadNextPage() + fun onStart() { + viewModelScope.launch { + pagination.loadFirstPage() + } } fun onRefresh() { - pagination.refresh() - } - - private fun onNextPageResult(result: Result>) { - if (result.isSuccess) { - println("next page successful loaded") - } else { - println("next page loading failed ${result.exceptionOrNull()}") + viewModelScope.launch { + if (pagination.state.value is RemoteState.Success<*>) { + pagination.refresh() + } else { + pagination.loadFirstPage() + } } } - private fun onRefreshResult(result: Result>) { - if (result.isSuccess) { - println("refresh successful") - } else { - println("refresh failed ${result.exceptionOrNull()}") + fun onLoadNextPage() { + viewModelScope.launch { + pagination.loadNextPage() } } - @Suppress("MagicNumber") - private fun generatePack(startId: Long = 0): List { - return List(20) { idx -> - val id = startId + idx - Product( - id = id, - title = "Product $id" - ) + private suspend fun loadPage(page: Int, pageSize: Int): List { + // delay simulated loading + delay(REFRESH_DELAY_MS) + val startIndex = page * pageSize + + if (startIndex >= TOTAL_ITEMS) return emptyList() + + val endIndex = min(startIndex + pageSize, TOTAL_ITEMS) + + return (startIndex until endIndex).map { index -> + val id = index + 1L + ProductItem(id = id, title = "Product #$id") } } - data class Product( - override val id: Long, + data class ProductItem( + val id: Long, val title: String - ) : IdEntity + ) - interface UnitsFactory { - fun createProductUnit(id: Long, title: String): TableUnitItem - fun createLoading(): TableUnitItem + private companion object { + const val PAGE_SIZE = 20 + const val TOTAL_ITEMS = 120 + const val REFRESH_DELAY_MS = 300L } } + +/** + * Сокращенный вариант создания CStateFlow из Flow + */ +fun Flow.cStateIn( + scope: CoroutineScope, + started: SharingStarted = SharingStarted.Eagerly, + initValue: T, +): CStateFlow = this.stateIn( + scope = scope, + started = started, + initialValue = initValue +).cStateFlow() diff --git a/settings.gradle.kts b/settings.gradle.kts index 65f6bfe..627d16b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,11 +2,21 @@ * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. */ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } + plugins { + id("org.jetbrains.kotlin.plugin.compose") version "2.1.10" + } +} + plugins { id("com.gradle.enterprise") version "3.10.1" } -enableFeaturePreview("VERSION_CATALOGS") enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") dependencyResolutionManagement { @@ -25,5 +35,6 @@ gradleEnterprise { } include(":paging") +include(":remotestate") include(":sample:android-app") include(":sample:mpp-library") From 8ee9547a7397b39cd9b38ed0129d8a9e77ecf7ff Mon Sep 17 00:00:00 2001 From: Kuzmin <«vkuzmin@icerockdev.com»> Date: Fri, 20 Feb 2026 10:23:13 +0300 Subject: [PATCH 2/3] MOKO-1415 fix Kdoc into English --- .../dev/icerock/moko/paging/PageCount.kt | 8 +- .../dev/icerock/moko/paging/Pagination.kt | 118 +++++++++--------- .../icerock/moko/paging/PagingDataSource.kt | 20 +-- .../dev/icerock/moko/paging/PagingState.kt | 12 +- .../icerock/moko/paging/RefreshStrategy.kt | 20 +-- .../dev/icerock/moko/paging/RemoteStateExt.kt | 8 +- .../icerock/moko/remotestate/RemoteState.kt | 10 +- .../com/icerockdev/library/ListViewModel.kt | 2 +- 8 files changed, 97 insertions(+), 101 deletions(-) diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PageCount.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PageCount.kt index f3c4e32..d81e3bc 100644 --- a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PageCount.kt +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PageCount.kt @@ -3,11 +3,11 @@ package dev.icerock.moko.paging import kotlin.math.ceil /** - * Возвращает количество страниц, необходимых для вывода всех элементов. + * Returns the number of pages required to display all items. * - * @param currentListSize Количество элементов (может быть null) - * @param pageSize Размер одной страницы (должен быть > 0) - * @return Количество страниц (целое неотрицательное число) + * @param currentListSize number of items (can be null) + * @param pageSize size of a single page (must be > 0) + * @return number of pages (a non-negative integer) */ fun calculateNextPage( currentListSize: Int?, diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/Pagination.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/Pagination.kt index be74731..bc7a2a1 100644 --- a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/Pagination.kt +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/Pagination.kt @@ -12,27 +12,27 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** - * Пагинированная загрузка списка + * Paginated list loader. * - * обновленная версия moko-paging, перешли на StateFlow + * Updated version of moko-paging, migrated to StateFlow. * - * @param dataSource реализация интерфейса PagingDataSource с suspend методом загрузки элементов - * @param itemKey Лямбда для получения уникального ключа элемента `(Item) -> Any`. - * Используется для идентификации элементов (аналог equals/hashCode) и дедупликации - * при слиянии страниц (например, чтобы избежать дублей, если элемент сместился на другую страницу). - * @param refreshStrategy Стратегия поведения при обновлении (Pull-to-Refresh). - * Определяет, как поступать с уже загруженными данными при получении первой страницы: - * - [RefreshStrategy.MergeNewItems]: Пытается сохранить старые данные, добавляя новые в начало. - * Подходит для append-only списков (логи, чаты). Может приводить к рассинхрону при удалении - * элементов на бэкенде. - * - [RefreshStrategy.ReplaceEverything]: Полная замена. При успехе загрузки старый список - * полностью отбрасывается и заменяется новой первой страницей. Позволяет избежать "моргания" - * экрана (в отличие от reloadFirstPage), сохраняя старые данные видимыми до момента получения новых. - * @param nextPageListener обработчик завершения загрузки следующей страницы, - * вызывать для показа ошибки либо дополнительной обработки успеха - * @param refreshListener обработчик завершения загрузки обновления списка - * @param initValue начальное значение списка - * */ + * @param dataSource implementation of PagingDataSource with a suspend load method + * @param itemKey lambda returning a unique item key `(Item) -> Any`. + * Used for item identity (equals/hashCode analogue) and deduplication + * when merging pages (for example, to avoid duplicates if an item moved to another page). + * @param refreshStrategy refresh behavior (Pull-to-Refresh). + * Defines how to handle already loaded data when the first page is fetched: + * - [RefreshStrategy.MergeNewItems]: Tries to keep old data by adding new items to the beginning. + * Suitable for append-only lists (logs, chats). Can lead to desync when items are deleted + * on the backend. + * - [RefreshStrategy.ReplaceEverything]: Full replacement. On successful load the old list + * is discarded and replaced by the new first page. Helps avoid UI "blink" + * (unlike reloadFirstPage), keeping old data visible until new data arrives. + * @param nextPageListener callback invoked when the next page load completes, + * use it to show errors or handle success + * @param refreshListener callback invoked when the refresh load completes + * @param initValue initial list value + */ class Pagination( private val dataSource: PagingDataSource, private val itemKey: (Item) -> Any, @@ -48,10 +48,10 @@ class Pagination( ) /** - * Стэйт пагинированного списка + * State of the paginated list. * - * Пример использования: во ViewModel мапить стейт, - * преобразовывая Throwable в необходимый класс ошибки для вывода на ui + * Usage example: map the state in a ViewModel, + * converting Throwable to the error class required for UI output * pagination.state * .map { state -> * state.mapError { it.mapThrowable() } @@ -64,17 +64,16 @@ class Pagination( private var loadNextPageJob: Job? = null /** - * Загрузка первой страницы данных. + * Loads the first page of data. * - * В случае загрузки первой страницы считаем что текущий стейт не нужен - сбрасываемся в полную - * загрузку. Далее в зависимости от успешности загрузки либо перейдем в успех, либо в ошибку. + * When loading the first page, the current state is discarded and we reset to full loading. + * Then, depending on the result, we move to success or error. * - * Если в момент вызова уже идет рефреш/загрузка другой страницы - вся эта активность отменяется, - * загрузка первой страницы имеет максимальный приоритет (пользователь хочет полностью данные с - * нуля загрузить). + * If a refresh or another page load is running at the moment of the call, all that activity + * is canceled. Loading the first page has the highest priority (the user wants a full reload + * from scratch). * - * Если повторно вызываем когда уже запущено - ничего не делается (ждем предыдущий запущенный - * результат). + * If called again while already running, nothing happens (we wait for the previous result). */ suspend fun loadFirstPage() { // если уже есть задача загрузки новой страницы - просто ждём её завершения. @@ -122,17 +121,16 @@ class Pagination( } /** - * Загрузка следующей страницы данных. + * Loads the next page of data. * - * Следующую страницу мы можем загружать только если находимся в состоянии успеха (то есть - * уже есть какие-то элементы в списке - первая или больше страниц). - * И если у нас в стейте отражено что список закончен - смысла пытаться подгружать еще элементы - * нету. + * We can load the next page only if we are in the success state (i.e., there are already + * items in the list - one or more pages). If the state indicates the list is finished, + * there is no point in loading more. * - * Если в момент вызова еще уже загрузка первой страницы - мы ничего не делаем (выше описание). - * Если же идет загрузка refresh (обновление первой страницы, без полного сброса) - ждем пока - * оно завершится, чтобы список не деформировался. - * Если уже идет загрузка новой страницы - ничего не делаем (необходимая операция уже запущена). + * If the first page is still loading when called, we do nothing (see above). + * If a refresh is running (updating the first page without a full reset), we wait for it + * to finish to avoid distorting the list. + * If a next page load is already running, we do nothing (the required operation is in flight). */ @Suppress("ReturnCount") suspend fun loadNextPage() { @@ -233,28 +231,26 @@ class Pagination( } /** - * Обновление содержимого списка без сброса в состояние Loading. - * Позволяет загрузить новые данные, сохраняя на экране текущие (Pull-to-Refresh). + * Refreshes the list contents without resetting to the Loading state. + * Loads new data while keeping the current items visible (Pull-to-Refresh). * - * @param refreshStrategy Стратегия обновления для текущего вызова. - * По умолчанию используется стратегия, заданная в конструкторе ([this.refreshStrategy]). + * @param refreshStrategy refresh strategy for this call. + * By default, uses the strategy configured in the constructor ([this.refreshStrategy]). * - * Варианты поведения: + * Behavior variants: * - [RefreshStrategy.MergeNewItems]: - * Если новые и старые данные пересекаются (есть одинаковые элементы) — старый список - * сохраняется, новые элементы добавляются в начало. - * Если пересечения нет — происходит полная замена списка. + * If new and old data overlap (there are identical items) the old list is preserved + * and new items are prepended. If there is no overlap, the list is fully replaced. * - [RefreshStrategy.ReplaceEverything]: - * Полная замена списка новыми данными. Старые данные остаются на экране до момента - * успешной загрузки новых, затем мгновенно заменяются. - * Используется, например, при изменении фильтров, когда объединение старых и новых - * данных некорректно. + * Fully replaces the list with new data. Old data stays on screen until the new + * data is successfully loaded, then it is replaced immediately. + * Used, for example, when filters change and merging old and new data is incorrect. * - * Условия запуска: - * - Выполняется только если данные уже загружены (состояние [RemoteState.Success]). - * - Если уже идет обновление ([refreshJob]) — ожидает его завершения. - * - Если идет загрузка следующей страницы ([loadNextPageJob]) — ожидает её завершения, - * чтобы избежать коллизий и деформации списка. + * Launch conditions: + * - Runs only if data is already loaded (state is [RemoteState.Success]). + * - If a refresh is already running ([refreshJob]), waits for it to complete. + * - If a next page load is running ([loadNextPageJob]), waits for it to complete + * to avoid collisions and list distortion. */ suspend fun refresh(refreshStrategy: RefreshStrategy = this.refreshStrategy) { if (_state.value !is RemoteState.Success<*>) return @@ -323,13 +319,13 @@ class Pagination( } /** - * Метод для ручного обновления списка снаружи + * Method for manually updating the list from the outside. * - * Выполняет попытку атомарно изменить данные, - * Если стейт RemoteState.Success, обновит значение списка, не затрагивая другие данные - * Иначе присвоит значение RemoteState.Success c заданным значением списка элементов + * Attempts to update the data atomically. + * If the state is RemoteState.Success, updates the list value without touching other data. + * Otherwise sets RemoteState.Success with the provided list value. * - * Так же остановятся все джобы по загрузке новых данных + * Also cancels all jobs that load new data. */ fun setData(items: List?) { loadFirstPageJob?.cancel() diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingDataSource.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingDataSource.kt index 971d247..bca243b 100644 --- a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingDataSource.kt +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingDataSource.kt @@ -1,30 +1,30 @@ package dev.icerock.moko.paging /** - * Интерфейс датасурса для Pagination + * Data source interface for Pagination. */ interface PagingDataSource { /** - * Метод проверки полная ли страница загружена (чтобы понять достигли ли мы конца списка) + * Checks whether a page is fully loaded (to determine if we've reached the end of the list). */ fun isPageFull(list: List): Boolean /** - * Метод загрузки страницы на основе текущих данных + * Loads a page based on the current data. * - * @param currentList загруженные элементы списка + * @param currentList already loaded list items * - * Возвращаемое значение - следующая страница - * */ + * @return the next page + */ suspend fun loadPage(currentList: List?): List } /** - * Имплементация интерфейса PagingDataSource для постраничной загрузки через page/pageSize + * PagingDataSource implementation for page/pageSize-based pagination. * - * @param pageSize размер страницы - * @param loadPage suspend метод для постраничной загрузки списка - * */ + * @param pageSize page size + * @param loadPage suspend method for page-by-page loading + */ @Suppress("FunctionName") fun PageSizePagingDataSource( pageSize: Int, diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingState.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingState.kt index fe76360..9b234d9 100644 --- a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingState.kt +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingState.kt @@ -1,13 +1,13 @@ package dev.icerock.moko.paging /** - * Стейт списка для Pagination + * List state used by Pagination. * - * @param items загруженный список элементов - * @param isRefreshing состояние обновления страницы, использовать для показа pull-to-refresh - * @param isNextPageLoading состояние загрузки следущей страницы, использовать для показа лоадера в конце списка - * @param isEndOfList индикатор загрузки всего списка, в случае false не вызывать onLoadNextPage - * */ + * @param items loaded list of items + * @param isRefreshing refresh state, use it to show the pull-to-refresh indicator + * @param isNextPageLoading next-page loading state, use it to show a loader at the end of the list + * @param isEndOfList indicator that the full list is loaded; when false, do not call onLoadNextPage + */ data class PagingState( val items: List, val isRefreshing: Boolean = false, diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RefreshStrategy.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RefreshStrategy.kt index 6618d52..1f306f7 100644 --- a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RefreshStrategy.kt +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RefreshStrategy.kt @@ -2,22 +2,22 @@ package dev.icerock.moko.paging enum class RefreshStrategy { /** - * "Умное" добавление. - * Загружает первую страницу и пытается "приклеить" новые элементы к началу списка. - * Игнорирует элементы, которые уже есть в списке (даже если их данные изменились). - * Старые страницы (2, 3...) остаются в памяти. + * "Smart" merge. + * Loads the first page and tries to prepend new items to the list. + * Ignores items that are already in the list (even if their data changed). + * Old pages (2, 3...) stay in memory. * - * Подходит для: Ленты новостей, логи, бесконечные потоки. + * Suitable for: news feeds, logs, infinite streams. */ MergeNewItems, /** - * Полная перезагрузка. - * Загружает первую страницу и ПОЛНОСТЬЮ заменяет текущий список. - * Гарантирует актуальность данных. Сбрасывает пагинацию на начало. + * Full reload. + * Loads the first page and COMPLETELY replaces the current list. + * Ensures data freshness. Resets pagination to the start. * - * Подходит для: Каталогов товаров, списков заявок, банковских операций, - * любых списков, где данные элементов могут меняться. + * Suitable for: product catalogs, request lists, bank transactions, + * any lists where item data can change. */ ReplaceEverything } diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RemoteStateExt.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RemoteStateExt.kt index f7c4150..f19405b 100644 --- a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RemoteStateExt.kt +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RemoteStateExt.kt @@ -3,16 +3,16 @@ package dev.icerock.moko.paging import dev.icerock.moko.remotestate.RemoteState /** - * Используйте эту функцию для изменения состояния обновления (isRefreshing) в экземпляре PagingState, - * не затрагивая другие данные, находящиеся в объекте RemoteState.Success + * Use this function to change the refresh state (isRefreshing) in a PagingState instance + * without touching other data stored in RemoteState.Success. */ fun RemoteState.Success>.withRefreshing( value: Boolean ): RemoteState.Success> = this.copy(data = this.data.copy(isRefreshing = value)) /** - * Используйте эту функцию для изменения состояния обновления (isNextPageLoading) в экземпляре PagingState, - * не затрагивая другие данные, находящиеся в объекте RemoteState.Success + * Use this function to change the next-page loading state (isNextPageLoading) in a PagingState instance + * without touching other data stored in RemoteState.Success. */ fun RemoteState.Success>.withNextPageLoading( value: Boolean diff --git a/remotestate/src/commonMain/kotlin/dev/icerock/moko/remotestate/RemoteState.kt b/remotestate/src/commonMain/kotlin/dev/icerock/moko/remotestate/RemoteState.kt index 381a409..093ab99 100644 --- a/remotestate/src/commonMain/kotlin/dev/icerock/moko/remotestate/RemoteState.kt +++ b/remotestate/src/commonMain/kotlin/dev/icerock/moko/remotestate/RemoteState.kt @@ -6,8 +6,8 @@ import dev.icerock.moko.remotestate.RemoteState.Success import kotlinx.coroutines.flow.MutableStateFlow /** - * Сделано именно class, а не interface, чтобы на iOS стороне можно было hashable сделать реализацию. - * Также типы сделаны обязательно не нуллабельными тоже для iOS - чтобы нуллы не ожидал компилятор везде. + * Implemented as a class, not an interface, so iOS can provide a hashable implementation. + * Types are intentionally non-nullable for iOS so the compiler does not expect nulls everywhere. */ sealed class RemoteState { data object Loading : RemoteState() @@ -40,10 +40,10 @@ fun RemoteState.isSuccess(): Boolean = this is Success val RemoteState.data: T? get() = (this as? Success)?.data /** - * Выполняет попытку атомарно изменить данные в состоянии RemoteState.Success + * Attempts to atomically update data in the RemoteState.Success state. * - * @param function лямбда получения новых данных из текущих - * @return true если удалось обновить Success состояние. false - если состояние уже не Success. + * @param function lambda that computes new data from the current value + * @return true if the Success state was updated, false if the state is no longer Success */ fun MutableStateFlow>.tryUpdateSuccess( function: (T) -> T diff --git a/sample/mpp-library/src/commonMain/kotlin/com/icerockdev/library/ListViewModel.kt b/sample/mpp-library/src/commonMain/kotlin/com/icerockdev/library/ListViewModel.kt index 67991ae..34e7a37 100644 --- a/sample/mpp-library/src/commonMain/kotlin/com/icerockdev/library/ListViewModel.kt +++ b/sample/mpp-library/src/commonMain/kotlin/com/icerockdev/library/ListViewModel.kt @@ -98,7 +98,7 @@ class ListViewModel : ViewModel() { } /** - * Сокращенный вариант создания CStateFlow из Flow + * A shortened way to create a CStateFlow from a Flow. */ fun Flow.cStateIn( scope: CoroutineScope, From d2f34e5a29d50e1415893bba4d74b1a9d3d4a331 Mon Sep 17 00:00:00 2001 From: Kuzmin <«vkuzmin@icerockdev.com»> Date: Mon, 23 Feb 2026 19:55:56 +0300 Subject: [PATCH 3/3] MOKO-1415 fix after review --- README.md | 32 +- gradle.properties | 2 +- gradle/libs.versions.toml | 9 +- paging-compose/build.gradle.kts | 31 ++ .../moko/paging/compose/PagingContent.kt | 75 +++++ .../moko/paging/compose/PagingStateContent.kt | 86 +++++ paging/build.gradle.kts | 15 +- paging/src/androidMain/AndroidManifest.xml | 2 - .../dev/icerock/moko/paging/BaseTestsClass.kt | 2 +- .../kotlin/dev/icerock/moko/paging/Utils.kt | 18 + .../dev/icerock/moko/paging/PageCount.kt | 20 -- .../dev/icerock/moko/paging/Pagination.kt | 86 ++--- .../icerock/moko/paging/PagingDataSource.kt | 15 +- .../dev/icerock/moko/paging/PagingState.kt | 4 + .../icerock/moko/paging/RefreshStrategy.kt | 4 + .../moko/paging/{ => utils}/RemoteStateExt.kt | 7 +- .../dev/icerock/moko/paging/PaginationTest.kt | 131 -------- .../icerock/moko/paging/PaginationTests.kt | 315 ++++++++++++++++++ .../icerock/moko/paging/TestListDataSource.kt | 18 - .../kotlin/dev/icerock/moko/paging/runTest.kt | 4 + .../icerock/moko/remotestate/RemoteState.kt | 4 + sample/android-app/build.gradle.kts | 5 + .../main/java/com/icerockdev/PagingScreen.kt | 258 +++++++------- sample/mpp-library/build.gradle.kts | 3 - .../com/icerockdev/library/ListViewModel.kt | 17 +- settings.gradle.kts | 1 + 26 files changed, 772 insertions(+), 392 deletions(-) create mode 100644 paging-compose/build.gradle.kts create mode 100644 paging-compose/src/androidMain/kotlin/dev/icerock/moko/paging/compose/PagingContent.kt create mode 100644 paging-compose/src/androidMain/kotlin/dev/icerock/moko/paging/compose/PagingStateContent.kt delete mode 100755 paging/src/androidMain/AndroidManifest.xml create mode 100644 paging/src/androidUnitTest/kotlin/dev/icerock/moko/paging/Utils.kt delete mode 100644 paging/src/commonMain/kotlin/dev/icerock/moko/paging/PageCount.kt rename paging/src/commonMain/kotlin/dev/icerock/moko/paging/{ => utils}/RemoteStateExt.kt (79%) delete mode 100644 paging/src/commonTest/kotlin/dev/icerock/moko/paging/PaginationTest.kt create mode 100644 paging/src/commonTest/kotlin/dev/icerock/moko/paging/PaginationTests.kt delete mode 100644 paging/src/commonTest/kotlin/dev/icerock/moko/paging/TestListDataSource.kt diff --git a/README.md b/README.md index 1f4217e..08aeeb0 100755 --- a/README.md +++ b/README.md @@ -16,12 +16,12 @@ This is a Kotlin MultiPlatform library that contains pagination logic for kotlin ## Features - **Pagination** implements pagination logic for the data from `PagingDataSource`. -- Managing data loading using `loadFirstPage`, `loadNextPage`, `refresh`. +- Managing data loading using `loadFirstPage`, `reloadFirstPage`, `loadNextPage`, `refresh`. - Observing states using `StateFlow` and `RemoteState`. ## Requirements - Gradle 8.10+ -- Android API 21+ +- Android API 16+ - iOS 11.0+ ## Installation @@ -37,7 +37,7 @@ allprojects { project build.gradle.kts ```kotlin dependencies { - commonMainApi("dev.icerock.moko:paging:0.7.1") + commonMainApi("dev.icerock.moko:paging:0.8.0") commonMainApi("dev.icerock.moko:remotestate:0.1.0") } ``` @@ -52,6 +52,9 @@ You can use **Pagination** in `commonMain` sourceset. val pagination: Pagination = Pagination( dataSource = PageSizePagingDataSource( pageSize = 20, + calculateNextPage = { currentList -> + // your logic for calculating the next page + }, loadPage = { page, pageSize -> repository.load(page = page, pageSize = pageSize) } ), itemKey = { item -> item.id }, @@ -84,16 +87,21 @@ pagination.setData(itemsList) Observing **Pagination** states: ```kotlin -val state: StateFlow, Throwable>> = pagination.state - -state - .map { remoteState -> - when (remoteState) { - is RemoteState.Success -> remoteState.data.items - else -> emptyList() - } +val state: RemoteState, Throwable> by viewModel.pagination.state.collectAsState() + +when (state) { + RemoteState.Loading -> { + // ... + } + + is RemoteState.Error -> { + // ... } - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + is RemoteState.Success> -> { + // ... + } +} ``` ## Samples diff --git a/gradle.properties b/gradle.properties index 0203033..16cf677 100755 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ android.useAndroidX=true moko.android.targetSdk=35 moko.android.compileSdk=35 -moko.android.minSdk=21 +moko.android.minSdk=16 moko.publish.name=MOKO paging moko.publish.description=Pagination logic in common code for mobile (android & ios) Kotlin Multiplatform development diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 234d8f3..4b5b6a2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,12 +4,10 @@ lifecycleVersion = "2.8.7" androidCoreTestingVersion = "2.2.0" composeBomVersion = "2025.09.00" activityComposeVersion = "1.10.1" -ktorClientVersion = "2.3.12" +ktorClientVersion = "3.4.0" coroutinesVersion = "1.10.2" mokoMvvmVersion = "0.16.1" -mokoUnitsVersion = "0.8.0" -mokoPagingVersion = "1.0.0" -napierVersion = "2.7.1" +mokoPagingVersion = "0.8.0" [libraries] lifecycleViewModel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleVersion" } @@ -23,12 +21,9 @@ composeMaterial3 = { module = "androidx.compose.material3:material3" } coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutinesVersion" } coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesVersion" } ktorClient = { module = "io.ktor:ktor-client-core", version.ref = "ktorClientVersion" } -mokoUnits = { module = "dev.icerock.moko:units", version.ref = "mokoUnitsVersion" } mokoMvvmLiveData = { module = "dev.icerock.moko:mvvm-livedata", version.ref = "mokoMvvmVersion" } mokoMvvmState = { module = "dev.icerock.moko:mvvm-state", version.ref = "mokoMvvmVersion" } mokoMvvmFlow = { module = "dev.icerock.moko:mvvm-flow", version.ref = "mokoMvvmVersion" } -napier = { module = "io.github.aakira:napier", version.ref = "napierVersion" } -kotlinTestJUnit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlinVersion" } androidCoreTesting = { module = "androidx.arch.core:core-testing", version.ref = "androidCoreTestingVersion" } ktorClientMock = { module = "io.ktor:ktor-client-mock", version.ref = "ktorClientVersion" } diff --git a/paging-compose/build.gradle.kts b/paging-compose/build.gradle.kts new file mode 100644 index 0000000..0ad1242 --- /dev/null +++ b/paging-compose/build.gradle.kts @@ -0,0 +1,31 @@ +/* + * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +plugins { + id("dev.icerock.moko.gradle.multiplatform.mobile") + id("dev.icerock.moko.gradle.publication") + id("dev.icerock.moko.gradle.detekt") + id("org.jetbrains.kotlin.plugin.compose") +} + +kotlin { + jvm() +} + +android { + namespace = "dev.icerock.moko.paging.compose" + + compileSdk = 35 + + defaultConfig { + minSdk = 21 + } +} + +dependencies { + commonMainApi(projects.paging) + implementation(libs.composeMaterial3) + androidMainImplementation(platform(libs.composeBom)) + androidMainImplementation(libs.composeUi) +} diff --git a/paging-compose/src/androidMain/kotlin/dev/icerock/moko/paging/compose/PagingContent.kt b/paging-compose/src/androidMain/kotlin/dev/icerock/moko/paging/compose/PagingContent.kt new file mode 100644 index 0000000..0f6537a --- /dev/null +++ b/paging-compose/src/androidMain/kotlin/dev/icerock/moko/paging/compose/PagingContent.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.moko.paging.compose + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import dev.icerock.moko.paging.PagingState + +/** + * Composable wrapper for paged content with pull-to-refresh and auto-load-next. + * + * @param state paging state with items and loading flags + * @param onLoadNextRequested callback for requesting the next page + * @param onRefresh callback for pull-to-refresh + * @param listState LazyListState used to detect proximity to the end + * @param pullToRefreshState PullToRefreshState for the indicator + * @param pullToRefreshIndicator composable for the pull-to-refresh indicator UI + * @param modifier modifier applied to the PullToRefreshBox + * @param content renders the list items + */ +@Suppress("LongMethod", "MagicNumber") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PagingContent( + state: PagingState, + onLoadNextRequested: () -> Unit, + onRefresh: () -> Unit, + listState: LazyListState, + pullToRefreshState: PullToRefreshState, + pullToRefreshIndicator: @Composable BoxScope.() -> Unit, + modifier: Modifier = Modifier, + content: @Composable (List) -> Unit, +) { + val shouldLoadMore: Boolean by remember { + derivedStateOf { + val layoutInfo: LazyListLayoutInfo = listState.layoutInfo + val lastVisibleIndex: Int? = layoutInfo.visibleItemsInfo.lastOrNull()?.index + val totalCount: Int = layoutInfo.totalItemsCount + val threshold: Int = PAGING_LOAD_THRESHOLD + val isCloseToEnd: Boolean = lastVisibleIndex != null && + lastVisibleIndex >= totalCount - threshold + lastVisibleIndex != 0 && !state.isEndOfList && isCloseToEnd + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore && !state.isNextPageLoading) { + onLoadNextRequested() + } + } + + PullToRefreshBox( + modifier = modifier, + isRefreshing = state.isRefreshing, + onRefresh = onRefresh, + state = pullToRefreshState, + indicator = pullToRefreshIndicator + ) { + content.invoke(state.items) + } +} + +private const val PAGING_LOAD_THRESHOLD: Int = 3 diff --git a/paging-compose/src/androidMain/kotlin/dev/icerock/moko/paging/compose/PagingStateContent.kt b/paging-compose/src/androidMain/kotlin/dev/icerock/moko/paging/compose/PagingStateContent.kt new file mode 100644 index 0000000..c5b5803 --- /dev/null +++ b/paging-compose/src/androidMain/kotlin/dev/icerock/moko/paging/compose/PagingStateContent.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.moko.paging.compose + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import dev.icerock.moko.paging.PagingState +import dev.icerock.moko.remotestate.RemoteState + +/** + * Composable that renders paging UI based on RemoteState. + * + * @param state RemoteState with PagingState data or error + * @param listState LazyListState used by PagingContent to detect end-of-list + * @param onRefresh callback for pull-to-refresh + * @param onLoadNextRequested callback for requesting the next page + * @param loadingContent UI shown for RemoteState.Loading + * @param errorContent UI shown for RemoteState.Error + * @param itemsContent UI shown for RemoteState.Success with non-empty data + * @param pullToRefreshModifier modifier applied to PullToRefreshBox + * @param pullToRefreshIndicatorColor indicator color + * @param pullToRefreshContainerColor indicator container color + * @param emptyContent optional UI shown when the list is empty + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PagingStateContent( + state: RemoteState, E>, + listState: LazyListState, + onRefresh: () -> Unit, + onLoadNextRequested: () -> Unit, + loadingContent: @Composable () -> Unit, + errorContent: @Composable (E) -> Unit, + itemsContent: @Composable (List) -> Unit, + pullToRefreshModifier: Modifier = Modifier, + pullToRefreshIndicatorColor: Color = PullToRefreshDefaults.indicatorColor, + pullToRefreshContainerColor: Color = PullToRefreshDefaults.containerColor, + emptyContent: (@Composable () -> Unit)? = null, +) { + when (state) { + RemoteState.Loading -> { + loadingContent.invoke() + } + + is RemoteState.Error -> { + errorContent.invoke(state.error) + } + + is RemoteState.Success> -> { + if (state.data.items.isEmpty() && emptyContent != null) { + emptyContent.invoke() + } else { + val pullToRefreshState = rememberPullToRefreshState() + + PagingContent( + modifier = pullToRefreshModifier, + state = state.data, + listState = listState, + pullToRefreshState = pullToRefreshState, + pullToRefreshIndicator = { + Indicator( + modifier = Modifier.align(Alignment.TopCenter), + isRefreshing = state.data.isRefreshing, + containerColor = pullToRefreshContainerColor, + color = pullToRefreshIndicatorColor, + state = pullToRefreshState + ) + }, + onLoadNextRequested = onLoadNextRequested, + onRefresh = onRefresh, + ) { items: List -> + itemsContent(items) + } + } + } + } +} diff --git a/paging/build.gradle.kts b/paging/build.gradle.kts index 11c0d39..597869c 100644 --- a/paging/build.gradle.kts +++ b/paging/build.gradle.kts @@ -11,19 +11,6 @@ plugins { kotlin { jvm() - - sourceSets { - val androidUnitTest by getting { - dependencies { - implementation(libs.coroutinesTest) - } - } - val jvmTest by getting { - dependencies { - implementation(libs.coroutinesTest) - } - } - } } android { @@ -33,12 +20,12 @@ android { dependencies { commonMainApi(projects.remotestate) commonMainImplementation(libs.coroutines) - commonMainImplementation(libs.napier) commonMainApi(libs.mokoMvvmLiveData) commonMainApi(libs.mokoMvvmState) commonTestImplementation(kotlin("test")) androidTestImplementation(libs.androidCoreTesting) + androidUnitTestImplementation(libs.coroutinesTest) commonTestImplementation(libs.ktorClient) commonTestImplementation(libs.ktorClientMock) iosX64TestImplementation(libs.coroutines) diff --git a/paging/src/androidMain/AndroidManifest.xml b/paging/src/androidMain/AndroidManifest.xml deleted file mode 100755 index 8072ee0..0000000 --- a/paging/src/androidMain/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/paging/src/androidUnitTest/kotlin/dev/icerock/moko/paging/BaseTestsClass.kt b/paging/src/androidUnitTest/kotlin/dev/icerock/moko/paging/BaseTestsClass.kt index 62ad33a..16393af 100644 --- a/paging/src/androidUnitTest/kotlin/dev/icerock/moko/paging/BaseTestsClass.kt +++ b/paging/src/androidUnitTest/kotlin/dev/icerock/moko/paging/BaseTestsClass.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. */ package dev.icerock.moko.paging diff --git a/paging/src/androidUnitTest/kotlin/dev/icerock/moko/paging/Utils.kt b/paging/src/androidUnitTest/kotlin/dev/icerock/moko/paging/Utils.kt new file mode 100644 index 0000000..edd91c1 --- /dev/null +++ b/paging/src/androidUnitTest/kotlin/dev/icerock/moko/paging/Utils.kt @@ -0,0 +1,18 @@ +@file:JvmName("UtilsAndroidUnitTestKt") + +/* + * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.moko.paging + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.runTest + +actual fun runTest(block: suspend CoroutineScope.() -> T): T { + var result: Result? = null + runTest { + result = runCatching { block() } + } + return result?.getOrThrow() ?: error("runTest returned null result") +} diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PageCount.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PageCount.kt deleted file mode 100644 index d81e3bc..0000000 --- a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PageCount.kt +++ /dev/null @@ -1,20 +0,0 @@ -package dev.icerock.moko.paging - -import kotlin.math.ceil - -/** - * Returns the number of pages required to display all items. - * - * @param currentListSize number of items (can be null) - * @param pageSize size of a single page (must be > 0) - * @return number of pages (a non-negative integer) - */ -fun calculateNextPage( - currentListSize: Int?, - pageSize: Int, -): Int { - // Если список пустой или размер неподходящий, сразу возвращаем 0 страниц - if (currentListSize == null || currentListSize == 0 || pageSize <= 0) return 0 - - return ceil(currentListSize.toDouble() / pageSize).toInt() -} diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/Pagination.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/Pagination.kt index bc7a2a1..356da0f 100644 --- a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/Pagination.kt +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/Pagination.kt @@ -1,7 +1,12 @@ +/* + * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + package dev.icerock.moko.paging +import dev.icerock.moko.paging.utils.withNextPageLoading +import dev.icerock.moko.paging.utils.withRefreshing import dev.icerock.moko.remotestate.RemoteState -import io.github.aakira.napier.Napier import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope @@ -76,15 +81,15 @@ class Pagination( * If called again while already running, nothing happens (we wait for the previous result). */ suspend fun loadFirstPage() { - // если уже есть задача загрузки новой страницы - просто ждём её завершения. - // Корутину завершим только когда задача завершится - чтобы вызывающая сторона точно понимала - // что загрузка завершилась + // if there is already a task to load a new page, we are just waiting for it to be completed. + // Coroutines we will complete it only when the task is completed - so that the caller understands exactly + // that the download has completed loadFirstPageJob?.let { it.join() return } - // если есть рефреш/загрузка - отменяем + // if there is a refresh/download, we cancel it. refreshJob?.let { it.cancel() refreshJob = null @@ -110,11 +115,10 @@ class Pagination( } catch (exc: CancellationException) { throw exc } catch (exc: Exception) { - Napier.e("can't load first page", exc) _state.value = RemoteState.Error(exc) } }.apply { - // зануляем завершенную задачу + // resetting a completed task invokeOnCompletion { loadFirstPageJob = null } } } @@ -137,21 +141,21 @@ class Pagination( val currentState: RemoteState.Success> = _state.value as? RemoteState.Success> ?: return - // если уже всё выкачали - не надо нам ничего больше делать + // If everything has already been uploaded, we don't need to do anything else if (currentState.data.isEndOfList) return - // если уже грузим след страницу - просто ждем результат этой загрузки + // if we are already uploading the next page, we are just waiting for the result of this download loadNextPageJob?.let { it.join() return } - // если идет рефреш - ждем пока закончится, только потом действуем сами + // if there is a refresh, we wait until it ends, only then we act on our own refreshJob?.join() coroutineScope { loadNextPageJob = launch { - // Повторно проверяем стейт, так как с предыдущей проверки, другая корутина - // могла изменить его + // We re-check the state, because from the previous check, another coroutine + // could have changed him val latest = _state.value as? RemoteState.Success> ?: return@launch if (latest.data.isEndOfList) return@launch @@ -165,15 +169,15 @@ class Pagination( _state.value = RemoteState.Success(newState) - // выдаем полученные значения новой страницы + // We give out the received values of the new page nextPageItems }.onFailure { exc -> if (exc is CancellationException) throw exc - Napier.e("can't load next page", exc) - // Проверяем что текущий стейт, Success, если другая корутина изменила его - // ничего не делаем + // We check that the current state is Success, if another coroutine has changed it + // we're not doing anything val successState = _state.value as? RemoteState.Success> + if (successState != null) { _state.value = successState.withNextPageLoading(false) } @@ -181,20 +185,20 @@ class Pagination( nextPageListener(result) } }.apply { - // зануляем завершенную задачу + // resetting a completed task invokeOnCompletion { loadNextPageJob = null } } } } suspend fun reloadFirstPage() { - // если уже есть задача загрузки первой страницы - отменяем её. + // if there is already a task to load the first page, cancel it. loadFirstPageJob?.let { it.cancel() loadFirstPageJob = null } - // если есть рефреш/загрузка - отменяем + // if there is a refresh/download, we cancel it. refreshJob?.let { it.cancel() refreshJob = null @@ -220,11 +224,10 @@ class Pagination( } catch (exc: CancellationException) { throw exc } catch (exc: Exception) { - Napier.e("can't load first page", exc) _state.value = RemoteState.Error(exc) } }.apply { - // зануляем завершенную задачу + // resetting a completed task invokeOnCompletion { loadFirstPageJob = null } } } @@ -255,18 +258,17 @@ class Pagination( suspend fun refresh(refreshStrategy: RefreshStrategy = this.refreshStrategy) { if (_state.value !is RemoteState.Success<*>) return - // идет обновление - ждем его результат + // An update is underway - we are waiting for its result. refreshJob?.let { it.join() return } - // идет загрузка новой страницы - дожидаемся её и погнали + // A new page is loading, so we wait for it and let's go. loadNextPageJob?.join() coroutineScope { refreshJob = launch { - // Повторно проверяем стейт, так как с предыдущей проверки, другая корутина - // могла изменить его + // We re-check the state, since from the previous check, another coroutine could have changed it val currentState = _state.value as? RemoteState.Success> ?: return@launch @@ -277,7 +279,7 @@ class Pagination( when (refreshStrategy) { RefreshStrategy.ReplaceEverything -> { - // Просто берем новые данные. Старое удаляем. + // We just take new data. We are deleting the old one. val isEndOfList = !dataSource.isPageFull(newItems) _state.value = RemoteState.Success( @@ -298,12 +300,11 @@ class Pagination( } } - // передаем полученный список в результат + // Passing the received list to the result. newItems }.onFailure { exc -> if (exc is CancellationException) throw exc - Napier.e("can't refresh list of services", exc) val latest = _state.value as? RemoteState.Success> if (latest != null) { _state.value = latest.withRefreshing(false) @@ -312,7 +313,7 @@ class Pagination( refreshListener(result) } }.apply { - // зануляем завершенную задачу + // resetting a completed task invokeOnCompletion { refreshJob = null } } } @@ -352,17 +353,16 @@ class Pagination( currentList: List, nextPageItems: List ): PagingState { - // убираем элементы которые уже есть в оригинальном списке - // такая ситуация может происходить когда новые элементы появились в начале списка - // (на тех страницах что у нас уже загружены) + // removing the items that are already in the original list + // This situation may occur when new items appear at the top of the list. + // (on the pages that we have already uploaded) val currentKeys = currentList.map(itemKey).toHashSet() val filteredItems = nextPageItems.filter { itemKey(it) !in currentKeys } val newList: List = currentList + filteredItems return PagingState( items = newList, - // если мы получили в ответ на страницу меньше элементов - // чем запрашивали - значит список кончился + // if we received fewer items in response to the page what was requested means that the list is over isEndOfList = !dataSource.isPageFull(nextPageItems) ) } @@ -373,14 +373,14 @@ class Pagination( ): PagingState { val currentItems: List = currentState.data.items - // Используем itemKey для быстрого поиска + // We use ItemKey for quick search val currentKeys = currentItems.map(itemKey).toHashSet() - // Проверяем, есть ли пересечение (хотя бы один элемент из новых уже есть в старых) + // Checking if there is an intersection (at least one of the new elements already exists in the old ones) val hasIntersection = newItems.any { itemKey(it) in currentKeys } - // Если есть новые элементы, но нет пересечения со старыми и старые не пустые - - // считаем, что лента уехала полностью, делаем полную замену + // If there are new elements, but there is no intersection with the old ones and the old ones + // are not empty, we assume that the tape is completely gone, we make a complete replacement. if (!hasIntersection && newItems.isNotEmpty() && currentItems.isNotEmpty()) { return PagingState( items = newItems, @@ -388,21 +388,21 @@ class Pagination( ) } - // Оставляем только те новые элементы, ключей которых нет в старом списке + // We leave only those new items whose keys are not in the old list. val uniqueNewItems = newItems.filter { item -> itemKey(item) !in currentKeys } val newState: PagingState = if (uniqueNewItems.isNotEmpty()) { - // Добавляем уникальные новые в начало + все старые - // isEndOfList не трогаем, так как старые элементы остались + // Adding unique new ones to the beginning + all the old ones + // We do not touch the isEndOfList, as the old elements remain. PagingState( items = uniqueNewItems + currentItems, isEndOfList = currentState.data.isEndOfList ) } else { - // Если ничего нового нет - оставляем всё как было - // (или заменяем на newItems, если список был пуст) + // If there is nothing new, we leave everything as it was. + // (or replace it with NewItems if the list was empty) if (currentItems.isEmpty()) { PagingState( items = newItems, diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingDataSource.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingDataSource.kt index bca243b..fc5ae59 100644 --- a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingDataSource.kt +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingDataSource.kt @@ -1,3 +1,7 @@ +/* + * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + package dev.icerock.moko.paging /** @@ -12,8 +16,6 @@ interface PagingDataSource { /** * Loads a page based on the current data. * - * @param currentList already loaded list items - * * @return the next page */ suspend fun loadPage(currentList: List?): List @@ -23,11 +25,13 @@ interface PagingDataSource { * PagingDataSource implementation for page/pageSize-based pagination. * * @param pageSize page size + * @param calculateNextPage returns the number of next page * @param loadPage suspend method for page-by-page loading */ @Suppress("FunctionName") fun PageSizePagingDataSource( pageSize: Int, + calculateNextPage: (List?) -> Int, loadPage: suspend (page: Int, pageSize: Int) -> List ): PagingDataSource { return object : PagingDataSource { @@ -36,12 +40,9 @@ fun PageSizePagingDataSource( } override suspend fun loadPage(currentList: List?): List { - val page: Int = calculateNextPage( - currentListSize = currentList?.size, - pageSize = pageSize - ) + val nextPage: Int = calculateNextPage(currentList) - return loadPage(page, pageSize) + return loadPage(nextPage, pageSize) } } } diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingState.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingState.kt index 9b234d9..00f4d2f 100644 --- a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingState.kt +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingState.kt @@ -1,3 +1,7 @@ +/* + * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + package dev.icerock.moko.paging /** diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RefreshStrategy.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RefreshStrategy.kt index 1f306f7..18eef83 100644 --- a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RefreshStrategy.kt +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RefreshStrategy.kt @@ -1,3 +1,7 @@ +/* + * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + package dev.icerock.moko.paging enum class RefreshStrategy { diff --git a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RemoteStateExt.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/utils/RemoteStateExt.kt similarity index 79% rename from paging/src/commonMain/kotlin/dev/icerock/moko/paging/RemoteStateExt.kt rename to paging/src/commonMain/kotlin/dev/icerock/moko/paging/utils/RemoteStateExt.kt index f19405b..57ee220 100644 --- a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RemoteStateExt.kt +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/utils/RemoteStateExt.kt @@ -1,5 +1,10 @@ -package dev.icerock.moko.paging +/* + * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.moko.paging.utils +import dev.icerock.moko.paging.PagingState import dev.icerock.moko.remotestate.RemoteState /** diff --git a/paging/src/commonTest/kotlin/dev/icerock/moko/paging/PaginationTest.kt b/paging/src/commonTest/kotlin/dev/icerock/moko/paging/PaginationTest.kt deleted file mode 100644 index b5eba91..0000000 --- a/paging/src/commonTest/kotlin/dev/icerock/moko/paging/PaginationTest.kt +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. - */ - -package dev.icerock.moko.paging - -import dev.icerock.moko.remotestate.data -import dev.icerock.moko.remotestate.isSuccess -import kotlinx.coroutines.async -import kotlinx.coroutines.delay -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertTrue - -class PaginationTest : BaseTestsClass() { - - var paginationDataSource = TestListDataSource(3, 5) - - @BeforeTest - fun setup() { - paginationDataSource = TestListDataSource(3, 5) - } - - @Test - fun `load first page`() = runTest { - val pagination = createPagination() - - pagination.loadFirstPage() - - assertTrue { - pagination.state.value.isSuccess() - } - assertTrue { - pagination.state.value.data?.items?.compareWith(listOf(0, 1, 2)) == true - } - } - - @Test - fun `load next page`() = runTest { - val pagination = createPagination() - - pagination.loadFirstPage() - pagination.loadNextPage() - - assertTrue { - pagination.state.value.data?.items?.compareWith(listOf(0, 1, 2, 3, 4, 5)) == true - } - - pagination.loadNextPage() - - assertTrue { - pagination.state.value.data?.items?.compareWith(listOf(0, 1, 2, 3, 4, 5, 6, 7, 8)) == true - } - } - - @Test - fun `refresh pagination`() = runTest { - val pagination = createPagination() - - pagination.loadFirstPage() - pagination.loadNextPage() - pagination.refresh(RefreshStrategy.ReplaceEverything) - - assertTrue { - pagination.state.value.data?.items?.compareWith(listOf(0, 1, 2)) == true - } - } - - @Test - fun `set data`() = runTest { - val pagination = createPagination() - - pagination.loadFirstPage() - pagination.loadNextPage() - - val setList = listOf(5, 2, 3, 1, 4) - pagination.setData(setList) - - assertTrue { - pagination.state.value.data?.items?.compareWith(setList) == true - } - } - - @Test - fun `double refresh`() = runTest { - var counter = 0 - val pagination = Pagination( - dataSource = object : PagingDataSource { - override fun isPageFull(list: List): Boolean = list.size == 4 - - override suspend fun loadPage(currentList: List?): List { - val load = counter++ - println("start load new page with $currentList") - delay(100) - println("respond new list $load") - return listOf(1, 2, 3, 4) - } - }, - itemKey = { it }, - nextPageListener = { }, - refreshListener = { } - ) - - println("start load first page") - pagination.loadFirstPage() - println("end load first page") - - println("start double refresh") - val r1 = async { - pagination.refresh() - println("first refresh end") - } - val r2 = async { - pagination.refresh() - println("second refresh end") - } - - r1.await() - r2.await() - } - - private fun createPagination( - nextPageListener: (Result>) -> Unit = {}, - refreshListener: (Result>) -> Unit = {} - ) = Pagination( - dataSource = paginationDataSource, - itemKey = { it }, - nextPageListener = nextPageListener, - refreshListener = refreshListener - ) -} diff --git a/paging/src/commonTest/kotlin/dev/icerock/moko/paging/PaginationTests.kt b/paging/src/commonTest/kotlin/dev/icerock/moko/paging/PaginationTests.kt new file mode 100644 index 0000000..afd0c6a --- /dev/null +++ b/paging/src/commonTest/kotlin/dev/icerock/moko/paging/PaginationTests.kt @@ -0,0 +1,315 @@ +/* + * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.moko.paging + +import dev.icerock.moko.remotestate.RemoteState +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class PaginationTests { + + @Test + fun `pagination flow test`() = runTest { + // channel to feed "server responses" during testing; null allows testing + // the waiting logic + val channel = Channel?>() + + val pagination: Pagination = Pagination( + dataSource = object : PagingDataSource { + override fun isPageFull(list: List): Boolean { + return list.isNotEmpty() + } + + override suspend fun loadPage(currentList: List?): List { + return channel + .receiveAsFlow() + .filterNotNull() + .first() + } + }, + itemKey = { it } + ) + + // initially loading state + assertIs(pagination.state.value) + + // then we start loading the first page; while loading, loading state should be + pagination.paginationAction( + action = { loadFirstPage() }, + channel = channel, + response = listOf(0, 1, 2), + onLoad = { assertIs(it) } + ) + + // once loaded, data should be present + pagination.state.value.let { state -> + assertIs>>(state) + assertEquals( + expected = PagingState( + items = listOf(0, 1, 2), + isRefreshing = false, + isNextPageLoading = false, + isEndOfList = false, + ), + actual = state.data + ) + } + + // then we load the next page; while loading, isNextPageLoading flag should be true + pagination.paginationAction( + action = { loadNextPage() }, + channel = channel, + response = listOf(3, 4, 5), + onLoad = { state -> + assertIs>>(state) + assertEquals( + expected = PagingState( + items = listOf(0, 1, 2), + isRefreshing = false, + isNextPageLoading = true, + isEndOfList = false, + ), + actual = state.data + ) + } + ) + + // after loading completes, list should be larger + pagination.state.value.let { state -> + assertIs>>(state) + assertEquals( + expected = PagingState( + items = listOf(0, 1, 2, 3, 4, 5), + isRefreshing = false, + isNextPageLoading = false, + isEndOfList = false, + ), + actual = state.data + ) + } + + // then we do pull-to-refresh; while loading, flag should be set + pagination.paginationAction( + action = { refresh() }, + channel = channel, + response = listOf(-1, 0, 1), + onLoad = { state -> + assertIs>>(state) + assertEquals( + expected = PagingState( + items = listOf(0, 1, 2, 3, 4, 5), + isRefreshing = true, + isNextPageLoading = false, + isEndOfList = false, + ), + actual = state.data + ) + } + ) + + // after loading, list slightly expands + pagination.state.value.let { state -> + assertIs>>(state) + assertEquals( + expected = PagingState( + items = listOf(-1, 0, 1, 2, 3, 4, 5), + isRefreshing = false, + isNextPageLoading = false, + isEndOfList = false, + ), + actual = state.data + ) + } + + // then we load further but no new pages exist; while loading, flag + // should light up again + pagination.paginationAction( + action = { loadNextPage() }, + channel = channel, + response = emptyList(), + onLoad = { state -> + assertIs>>(state) + assertEquals( + expected = PagingState( + items = listOf(-1, 0, 1, 2, 3, 4, 5), + isRefreshing = false, + isNextPageLoading = true, + isEndOfList = false, + ), + actual = state.data + ) + } + ) + + // loading complete - list fully loaded + pagination.state.value.let { state -> + assertIs>>(state) + assertEquals( + expected = PagingState( + items = listOf(-1, 0, 1, 2, 3, 4, 5), + isRefreshing = false, + isNextPageLoading = false, + isEndOfList = true, + ), + actual = state.data + ) + } + + // then we do another pull-to-refresh; while loading, flag should be + // set but refresh now yields completely new data + pagination.paginationAction( + action = { refresh() }, + channel = channel, + response = listOf(10, 11), + onLoad = { state -> + assertIs>>(state) + assertEquals( + expected = PagingState( + items = listOf(-1, 0, 1, 2, 3, 4, 5), + isRefreshing = true, + isNextPageLoading = false, + isEndOfList = true, + ), + actual = state.data + ) + } + ) + + // after loading, list is replaced and finished list state resets + pagination.state.value.let { state -> + assertIs>>(state) + assertEquals( + expected = PagingState( + items = listOf(10, 11), + isRefreshing = false, + isNextPageLoading = false, + isEndOfList = false, + ), + actual = state.data + ) + } + + // then we reload data from scratch + pagination.paginationAction( + action = { loadFirstPage() }, + channel = channel, + response = listOf(1, 3), + onLoad = { state -> + assertIs(state) + } + ) + + // after loading just a new list + pagination.state.value.let { state -> + assertIs>>(state) + assertEquals( + expected = PagingState( + items = listOf(1, 3), + isRefreshing = false, + isNextPageLoading = false, + isEndOfList = false, + ), + actual = state.data + ) + } + } + + @Test + fun `replaceEverything strategy test`() = runTest { + val channel = Channel?>() + + // initialize with the required strategy + val pagination: Pagination = Pagination( + dataSource = object : PagingDataSource { + override fun isPageFull(list: List): Boolean = list.isNotEmpty() + + override suspend fun loadPage(currentList: List?): List { + return channel.receiveAsFlow().filterNotNull().first() + } + }, + itemKey = { it }, + refreshStrategy = RefreshStrategy.ReplaceEverything + ) + + // 1. Load first page (initial data) + pagination.paginationAction( + action = { loadFirstPage() }, + channel = channel, + response = listOf(1, 2, 3), + onLoad = { assertIs(it) } + ) + + // verify initial data loaded + pagination.state.value.let { state -> + assertIs>>(state) + assertEquals(listOf(1, 2, 3), state.data.items) + } + + // 2. Perform refresh with new data + pagination.paginationAction( + action = { refresh() }, + channel = channel, + // return data that's completely different + // (or overlapping - for ReplaceEverything it doesn't matter) + response = listOf(4, 5), + onLoad = { state -> + assertIs>>(state) + // Important point: during loading (isRefreshing=true) + // old data is still displayed + assertEquals( + expected = PagingState( + items = listOf(1, 2, 3), + isRefreshing = true, + isNextPageLoading = false, + isEndOfList = false, + ), + actual = state.data + ) + } + ) + + // 3. Check final result: old data (1, 2, 3) should disappear, only (4, 5) remains + pagination.state.value.let { state -> + assertIs>>(state) + assertEquals( + expected = PagingState( + items = listOf(4, 5), // full replacement + isRefreshing = false, + isNextPageLoading = false, + isEndOfList = false, + ), + actual = state.data + ) + } + } + + private suspend fun Pagination.paginationAction( + action: suspend Pagination.() -> Unit, + channel: Channel?>, + response: List, + onLoad: (RemoteState, Throwable>) -> Unit + ) { + val pagination = this + coroutineScope { + val result = async { + pagination.action() + } + // wait until we're "waiting for server response" + channel.send(null) + // check what's happening at this moment + onLoad(pagination.state.value) + // respond from server + channel.send(response) + result.await() + } + } +} diff --git a/paging/src/commonTest/kotlin/dev/icerock/moko/paging/TestListDataSource.kt b/paging/src/commonTest/kotlin/dev/icerock/moko/paging/TestListDataSource.kt deleted file mode 100644 index 8de9560..0000000 --- a/paging/src/commonTest/kotlin/dev/icerock/moko/paging/TestListDataSource.kt +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. - */ - -package dev.icerock.moko.paging - -class TestListDataSource(val pageSize: Int, val totalPagesCount: Int) : PagingDataSource { - private val dataList = (0 until pageSize * totalPagesCount).toList() - - override fun isPageFull(list: List): Boolean = list.size == pageSize - - override suspend fun loadPage(currentList: List?): List { - val offset = currentList?.size ?: 0 - val endIndex = (offset + pageSize).coerceAtMost(dataList.size) - - return dataList.subList(offset, endIndex) - } -} diff --git a/paging/src/jvmTest/kotlin/dev/icerock/moko/paging/runTest.kt b/paging/src/jvmTest/kotlin/dev/icerock/moko/paging/runTest.kt index c6437bb..402ebf0 100644 --- a/paging/src/jvmTest/kotlin/dev/icerock/moko/paging/runTest.kt +++ b/paging/src/jvmTest/kotlin/dev/icerock/moko/paging/runTest.kt @@ -1,3 +1,7 @@ +/* + * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + package dev.icerock.moko.paging import kotlinx.coroutines.CoroutineScope diff --git a/remotestate/src/commonMain/kotlin/dev/icerock/moko/remotestate/RemoteState.kt b/remotestate/src/commonMain/kotlin/dev/icerock/moko/remotestate/RemoteState.kt index 093ab99..136c157 100644 --- a/remotestate/src/commonMain/kotlin/dev/icerock/moko/remotestate/RemoteState.kt +++ b/remotestate/src/commonMain/kotlin/dev/icerock/moko/remotestate/RemoteState.kt @@ -1,3 +1,7 @@ +/* + * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + package dev.icerock.moko.remotestate import dev.icerock.moko.remotestate.RemoteState.Error diff --git a/sample/android-app/build.gradle.kts b/sample/android-app/build.gradle.kts index f64ff9e..54182eb 100644 --- a/sample/android-app/build.gradle.kts +++ b/sample/android-app/build.gradle.kts @@ -13,12 +13,16 @@ android { compose = true } + compileSdk = 35 + defaultConfig { applicationId = "dev.icerock.moko.samples.paging" versionCode = 1 versionName = "0.1.0" + minSdk = 21 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } } @@ -31,6 +35,7 @@ dependencies { implementation(libs.composeUiToolingPreview) implementation(libs.lifecycleRuntimeCompose) implementation(projects.sample.mppLibrary) + implementation(projects.pagingCompose) debugImplementation(libs.composeUiTooling) } diff --git a/sample/android-app/src/main/java/com/icerockdev/PagingScreen.kt b/sample/android-app/src/main/java/com/icerockdev/PagingScreen.kt index 7cac0ee..21b2f36 100644 --- a/sample/android-app/src/main/java/com/icerockdev/PagingScreen.kt +++ b/sample/android-app/src/main/java/com/icerockdev/PagingScreen.kt @@ -1,3 +1,7 @@ +/* + * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + package com.icerockdev import androidx.compose.foundation.layout.Box @@ -8,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListLayoutInfo import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.CircularProgressIndicator @@ -16,14 +19,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator -import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -32,155 +30,124 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.icerockdev.library.ListViewModel import com.icerockdev.library.ListViewModel.ProductItem import dev.icerock.moko.paging.PagingState +import dev.icerock.moko.paging.compose.PagingStateContent import dev.icerock.moko.remotestate.RemoteState +import dev.icerock.moko.remotestate.data +@OptIn(ExperimentalMaterial3Api::class) @Composable fun PagingScreen(viewModel: ListViewModel) { val screenState by viewModel.state.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + LaunchedEffect(Unit) { viewModel.onStart() } - when (screenState) { - RemoteState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } - - is RemoteState.Error -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + PagingStateContent( + state = screenState, + listState = listState, + onRefresh = viewModel::onRefresh, + onLoadNextRequested = viewModel::onLoadNextPage, + loadingContent = { + LoadingContent() + }, + errorContent = { _ -> + ErrorContent( + onRefresh = viewModel::onRefresh + ) + }, + emptyContent = { + EmptyContent( + onRefresh = viewModel::onRefresh + ) + }, + itemsContent = { items -> + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize() ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Error state text", - style = MaterialTheme.typography.bodyLarge - ) - Spacer(modifier = Modifier.height(12.dp)) - TextButton(onClick = { viewModel.onRefresh() }) { - Text(text = "Retry") - } + items( + items = items, + key = { item -> item.id } + ) { item -> + ProductRow(item = item) } - } - } - - is RemoteState.Success> -> { - val pagingState: PagingState = - (screenState as RemoteState.Success>).data - if (pagingState.items.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Empty state text", - style = MaterialTheme.typography.bodyLarge - ) - Spacer(modifier = Modifier.height(12.dp)) - TextButton(onClick = { viewModel.onRefresh() }) { - Text(text = "Refresh") + if (screenState.data?.isNextPageLoading == true) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() } } } - } else { - PagingContent( - pagingState = pagingState, - onLoadNextRequested = { viewModel.onLoadNextPage() }, - onRefresh = { - viewModel.onRefresh() - } - ) } } - } + ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun PagingContent( - pagingState: PagingState, - onLoadNextRequested: () -> Unit, - onRefresh: () -> Unit, +private fun EmptyContent( + onRefresh: () -> Unit ) { - val listState = rememberLazyListState() - val pullToRefreshState = rememberPullToRefreshState() - - val shouldLoadMore: Boolean by remember { - derivedStateOf { - val layoutInfo: LazyListLayoutInfo = listState.layoutInfo - val lastVisibleIndex: Int? = layoutInfo.visibleItemsInfo.lastOrNull()?.index - val totalCount: Int = layoutInfo.totalItemsCount - val threshold: Int = 3 - val endIndex = (totalCount - threshold).coerceAtLeast(0) - val isCloseToEnd: Boolean = lastVisibleIndex != null && - lastVisibleIndex >= endIndex - lastVisibleIndex != null && - !pagingState.isEndOfList && - !pagingState.isNextPageLoading && - !pagingState.isRefreshing && - isCloseToEnd - } - } - - LaunchedEffect(shouldLoadMore) { - if (shouldLoadMore) { - onLoadNextRequested() + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Empty state text", + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(12.dp)) + TextButton(onClick = onRefresh) { + Text(text = "Refresh") + } } } +} - PullToRefreshBox( - isRefreshing = pagingState.isRefreshing, - onRefresh = onRefresh, - state = pullToRefreshState, - indicator = { - Indicator( - modifier = Modifier.align(Alignment.TopCenter), - isRefreshing = pagingState.isRefreshing, - containerColor = MaterialTheme.colorScheme.surface, - color = MaterialTheme.colorScheme.primary, - state = pullToRefreshState - ) - } +@Composable +private fun ErrorContent( + onRefresh: () -> Unit +) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize() + Column( + horizontalAlignment = Alignment.CenterHorizontally ) { - items( - items = pagingState.items, - key = { item -> item.id } - ) { item -> - ProductRow(item = item) - } - - if (pagingState.isNextPageLoading) { - item { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } + Text( + text = "Error state text", + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(12.dp)) + TextButton(onClick = onRefresh) { + Text(text = "Retry") } } } } +@Composable +private fun LoadingContent() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} + @Composable private fun ProductRow(item: ProductItem) { Column( @@ -201,11 +168,13 @@ private fun ProductRow(item: ProductItem) { } } +@OptIn(ExperimentalMaterial3Api::class) @Preview @Composable -private fun PagingContentPreview() { - PagingContent( - pagingState = PagingState( +private fun PagingStateContentPreview() { + val listState = rememberLazyListState() + val state = RemoteState.Success( + data = PagingState( items = listOf( ProductItem(1, "PagingState 1"), ProductItem(2, "PagingState 2"), @@ -215,8 +184,41 @@ private fun PagingContentPreview() { ProductItem(6, "PagingState 6"), ProductItem(7, "PagingState 7"), ) - ), + ) + ) + + PagingStateContent( + state = state, + listState = listState, onLoadNextRequested = {}, - onRefresh = {} + onRefresh = {}, + loadingContent = {}, + errorContent = { _ -> }, + itemsContent = { items -> + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize() + ) { + items( + items = items, + key = { item -> item.id } + ) { item -> + ProductRow(item = item) + } + + if (state.data.isNextPageLoading) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } + } + } ) } diff --git a/sample/mpp-library/build.gradle.kts b/sample/mpp-library/build.gradle.kts index 6fcfd7b..39111d6 100644 --- a/sample/mpp-library/build.gradle.kts +++ b/sample/mpp-library/build.gradle.kts @@ -16,16 +16,13 @@ dependencies { commonMainImplementation(libs.coroutines) commonMainApi(projects.paging) - commonMainApi(libs.mokoUnits) commonMainApi(libs.mokoMvvmFlow) commonMainApi(libs.mokoMvvmState) - commonMainImplementation(libs.napier) androidMainImplementation(libs.lifecycleViewModel) } framework { - export(libs.mokoUnits) export(libs.mokoMvvmFlow) export(libs.mokoMvvmState) } diff --git a/sample/mpp-library/src/commonMain/kotlin/com/icerockdev/library/ListViewModel.kt b/sample/mpp-library/src/commonMain/kotlin/com/icerockdev/library/ListViewModel.kt index 34e7a37..1aee981 100644 --- a/sample/mpp-library/src/commonMain/kotlin/com/icerockdev/library/ListViewModel.kt +++ b/sample/mpp-library/src/commonMain/kotlin/com/icerockdev/library/ListViewModel.kt @@ -13,7 +13,6 @@ import dev.icerock.moko.paging.PagingState import dev.icerock.moko.paging.RefreshStrategy import dev.icerock.moko.remotestate.RemoteState import dev.icerock.moko.remotestate.mapError -import io.github.aakira.napier.Napier import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -21,24 +20,34 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlin.math.ceil import kotlin.math.min class ListViewModel : ViewModel() { - private val pagination: Pagination = Pagination( + val pagination: Pagination = Pagination( dataSource = PageSizePagingDataSource( pageSize = PAGE_SIZE, + calculateNextPage = { currentList -> + val currentListSize = currentList?.size + + if (currentListSize == null || currentListSize == 0) { + 0 + } else { + ceil(currentListSize.toDouble() / PAGE_SIZE).toInt() + } + }, loadPage = ::loadPage ), itemKey = { item -> item.id }, refreshStrategy = RefreshStrategy.ReplaceEverything, nextPageListener = { result -> result.onFailure { - Napier.e("can't load next page", it) + println("Next page loading failed: $it") } }, refreshListener = { result -> result.onFailure { - Napier.e("can't load refresh", it) + println("Refresh failed: $it") } } ) diff --git a/settings.gradle.kts b/settings.gradle.kts index 627d16b..5e9ca1b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,6 +35,7 @@ gradleEnterprise { } include(":paging") +include(":paging-compose") include(":remotestate") include(":sample:android-app") include(":sample:mpp-library")