diff --git a/README.md b/README.md index d13e8a3..08aeeb0 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`, `reloadFirstPage`, `loadNextPage`, `refresh`. +- Observing states using `StateFlow` and `RemoteState`. ## Requirements -- Gradle version 6.8+ +- Gradle 8.10+ - Android API 16+ -- iOS version 11.0+ +- 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:paging:0.8.0") + commonMainApi("dev.icerock.moko:remotestate:0.1.0") } ``` @@ -49,30 +49,23 @@ 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 +val pagination: Pagination = Pagination( + dataSource = PageSizePagingDataSource( + pageSize = 20, + calculateNextPage = { currentList -> + // your logic for calculating the next page }, - 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) - ) + 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,19 +87,20 @@ pagination.setData(itemsList) Observing **Pagination** states: ```kotlin -// Observing the state of the pagination -pagination.state.addObserver { state: ResourceState, Throwable> -> - // ... -} +val state: RemoteState, Throwable> by viewModel.pagination.state.collectAsState() -// Observing the next page loading process -pagination.nextPageLoading.addObserver { isLoading: Boolean -> - // ... -} +when (state) { + RemoteState.Loading -> { + // ... + } -// Observing the refresh process -pagination.refreshLoading.addObserver { isRefreshing: Boolean -> - // ... + is RemoteState.Error -> { + // ... + } + + is RemoteState.Success> -> { + // ... + } } ``` 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..16cf677 100755 --- a/gradle.properties +++ b/gradle.properties @@ -3,14 +3,11 @@ 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.targetSdk=35 +moko.android.compileSdk=35 moko.android.minSdk=16 moko.publish.name=MOKO paging diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9dd074a..4b5b6a2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,38 +1,33 @@ [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" -mokoUnitsVersion = "0.8.0" -mokoPagingVersion = "0.7.2" +kotlinVersion = "2.1.10" +lifecycleVersion = "2.8.7" +androidCoreTestingVersion = "2.2.0" +composeBomVersion = "2025.09.00" +activityComposeVersion = "1.10.1" +ktorClientVersion = "3.4.0" +coroutinesVersion = "1.10.2" +mokoMvvmVersion = "0.16.1" +mokoPagingVersion = "0.8.0" [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" } -kotlinTestJUnit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlinVersion" } +mokoMvvmFlow = { module = "dev.icerock.moko:mvvm-flow", version.ref = "mokoMvvmVersion" } 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-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 bb34262..597869c 100644 --- a/paging/build.gradle.kts +++ b/paging/build.gradle.kts @@ -10,16 +10,22 @@ plugins { } kotlin { - jvm() + jvm() +} + +android { + namespace = "dev.icerock.moko.paging" } dependencies { + commonMainApi(projects.remotestate) commonMainImplementation(libs.coroutines) commonMainApi(libs.mokoMvvmLiveData) commonMainApi(libs.mokoMvvmState) - commonTestImplementation(libs.kotlinTestJUnit) + 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 c441c39..0000000 --- a/paging/src/androidMain/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/paging/src/iosMain/kotlin/Dummy.kt b/paging/src/androidUnitTest/kotlin/dev/icerock/moko/paging/BaseTestsClass.kt similarity index 63% rename from paging/src/iosMain/kotlin/Dummy.kt rename to paging/src/androidUnitTest/kotlin/dev/icerock/moko/paging/BaseTestsClass.kt index 97f62dc..16393af 100644 --- a/paging/src/iosMain/kotlin/Dummy.kt +++ b/paging/src/androidUnitTest/kotlin/dev/icerock/moko/paging/BaseTestsClass.kt @@ -4,5 +4,4 @@ package dev.icerock.moko.paging -// required for produce `metadata/iosMain` -internal val sDummyVar: Int? = null +actual open class BaseTestsClass 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/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/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..356da0f 100644 --- a/paging/src/commonMain/kotlin/dev/icerock/moko/paging/Pagination.kt +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/Pagination.kt @@ -4,197 +4,414 @@ 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.paging.utils.withNextPageLoading +import dev.icerock.moko.paging.utils.withRefreshing +import dev.icerock.moko.remotestate.RemoteState +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 +/** + * Paginated list loader. + * + * Updated version of moko-paging, migrated to StateFlow. + * + * @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( - 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 _state = MutableStateFlow, Throwable>>( + initValue + ?.let { RemoteState.Success(PagingState(items = it)) } + ?: RemoteState.Loading + ) + + /** + * State of the paginated list. + * + * 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() } + * } + */ + val state: StateFlow, Throwable>> = _state.asStateFlow() + + private var loadFirstPageJob: Job? = null + private var refreshJob: Job? = null + 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() { + // 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 + } - private val mStateStorage = - MutableLiveData, Throwable>>(initValue.asStateNullIsLoading()) + // if there is a refresh/download, we cancel it. + refreshJob?.let { + it.cancel() + refreshJob = null + } + loadNextPageJob?.let { + it.cancel() + loadNextPageJob = null + } - val state = mStateStorage.readOnly() + 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) { + _state.value = RemoteState.Error(exc) + } + }.apply { + // resetting a completed task + invokeOnCompletion { loadFirstPageJob = null } + } + } + } - private val mNextPageLoading = MutableLiveData(false) - val nextPageLoading = mNextPageLoading.readOnly() + /** + * 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. + * + * 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() { + val currentState: RemoteState.Success> = + _state.value as? RemoteState.Success> ?: return - private val mEndOfList = MutableLiveData(false) + // If everything has already been uploaded, we don't need to do anything else + if (currentState.data.isEndOfList) return - private val mRefreshLoading = MutableLiveData(false) - val refreshLoading = mRefreshLoading.readOnly() + // 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() - private val listMutex = Mutex() + 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 - private var loadNextPageDeferred: Deferred>? = null + _state.value = latest.withNextPageLoading(true) - fun loadFirstPage() { - launch { - loadFirstPageSuspend() - } - } + runCatching { + val currentList: List = latest.data.items + val nextPageItems: List = dataSource.loadPage(currentList = currentList) + val newState: PagingState = getNextPageState(currentList, nextPageItems) - suspend fun loadFirstPageSuspend() { - loadNextPageDeferred?.cancel() + _state.value = RemoteState.Success(newState) - listMutex.lock() + // We give out the received values of the new page + nextPageItems + }.onFailure { exc -> + if (exc is CancellationException) throw exc - mEndOfList.value = false - mNextPageLoading.value = false - mStateStorage.value = ResourceState.Loading() + // 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> - @Suppress("TooGenericExceptionCaught") - try { - val items: List = dataSource.loadPage(null) - mStateStorage.value = items.asState() - } catch (error: Exception) { - mStateStorage.value = ResourceState.Failed(error) + if (successState != null) { + _state.value = successState.withNextPageLoading(false) + } + }.let { result -> + nextPageListener(result) + } + }.apply { + // resetting a completed task + invokeOnCompletion { loadNextPageJob = null } + } } - listMutex.unlock() } - fun loadNextPage() { - launch { - loadNextPageSuspend() + suspend fun reloadFirstPage() { + // if there is already a task to load the first page, cancel it. + loadFirstPageJob?.let { + it.cancel() + loadFirstPageJob = null } - } - @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() - } - newList + // if there is a refresh/download, we cancel it. + refreshJob?.let { + it.cancel() + refreshJob = null + } + loadNextPageJob?.let { + it.cancel() + loadNextPageJob = null + } + + 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) { + _state.value = RemoteState.Error(exc) } + }.apply { + // resetting a completed task + invokeOnCompletion { loadFirstPageJob = 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 refreshSuspend() { - loadNextPageDeferred?.cancel() - listMutex.lock() - - if (mRefreshLoading.value) { - listMutex.unlock() - return - } - if (mNextPageLoading.value) { - listMutex.unlock() + /** + * Refreshes the list contents without resetting to the Loading state. + * Loads new data while keeping the current items visible (Pull-to-Refresh). + * + * @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. + * + * 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 + + // 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 + + _state.value = currentState.withRefreshing(true) + + runCatching { + val newItems: List = dataSource.loadPage(null) + + when (refreshStrategy) { + RefreshStrategy.ReplaceEverything -> { + // We just take new data. We are deleting the old one. + 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) + } + } + + // Passing the received list to the result. + 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() + val latest = _state.value as? RemoteState.Success> + if (latest != null) { + _state.value = latest.withRefreshing(false) + } + }.let { result -> + refreshListener(result) + } + }.apply { + // resetting a completed task + invokeOnCompletion { refreshJob = null } + } + } } + /** + * Method for manually updating the list from the outside. + * + * 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?) { - 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 { + // 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) + ) } -} - -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 + + // 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, + isEndOfList = !dataSource.isPageFull(newItems) + ) + } -interface IdEntity { - val id: Long -} + // We leave only those new items whose keys are not in the old list. + 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()) { + // 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 { + // 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, + 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..fc5ae59 --- /dev/null +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingDataSource.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.moko.paging + +/** + * 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. + * + * @return the next page + */ + suspend fun loadPage(currentList: List?): List +} + +/** + * 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 { + override fun isPageFull(list: List): Boolean { + return list.size == pageSize + } + + override suspend fun loadPage(currentList: List?): List { + val nextPage: Int = calculateNextPage(currentList) + + 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 new file mode 100644 index 0000000..00f4d2f --- /dev/null +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/PagingState.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2020 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.moko.paging + +/** + * List state used by Pagination. + * + * @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, + 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..18eef83 --- /dev/null +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/RefreshStrategy.kt @@ -0,0 +1,27 @@ +/* + * 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 { + /** + * "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/utils/RemoteStateExt.kt b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/utils/RemoteStateExt.kt new file mode 100644 index 0000000..57ee220 --- /dev/null +++ b/paging/src/commonMain/kotlin/dev/icerock/moko/paging/utils/RemoteStateExt.kt @@ -0,0 +1,24 @@ +/* + * 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 + +/** + * 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)) + +/** + * 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 +): 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 deleted file mode 100644 index bedb7cb..0000000 --- a/paging/src/commonTest/kotlin/dev/icerock/moko/paging/PaginationTest.kt +++ /dev/null @@ -1,132 +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 kotlinx.coroutines.CoroutineScope -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) - - val itemsComparator = Comparator { a: Int, b: Int -> - a - b - } - - @BeforeTest - fun setup() { - paginationDataSource = TestListDataSource(3, 5) - } - - @Test - fun `load first page`() = runTest { - val pagination = createPagination() - - pagination.loadFirstPageSuspend() - - assertTrue { - pagination.state.value.isSuccess() - } - assertTrue { - pagination.state.value.dataValue()!!.compareWith(listOf(0, 1, 2)) - } - } - - @Test - fun `load next page`() = runTest { - val pagination = createPagination() - - pagination.loadFirstPageSuspend() - pagination.loadNextPageSuspend() - - assertTrue { - pagination.state.value.dataValue()!!.compareWith(listOf(0, 1, 2, 3, 4, 5)) - } - - pagination.loadNextPageSuspend() - - assertTrue { - pagination.state.value.dataValue()!!.compareWith(listOf(0, 1, 2, 3, 4, 5, 6, 7, 8)) - } - } - - @Test - fun `refresh pagination`() = runTest { - val pagination = createPagination() - - pagination.loadFirstPageSuspend() - pagination.loadNextPageSuspend() - pagination.refreshSuspend() - - assertTrue { - pagination.state.value.dataValue()!!.compareWith(listOf(0, 1, 2)) - } - } - - @Test - fun `set data`() = runTest { - val pagination = createPagination() - - pagination.loadFirstPageSuspend() - pagination.loadNextPageSuspend() - - val setList = listOf(5, 2, 3, 1, 4) - pagination.setDataSuspend(setList) - - assertTrue { - pagination.state.value.dataValue()!!.compareWith(setList) - } - } - - @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) - }, - comparator = itemsComparator, - nextPageListener = { }, - refreshListener = { } - ) - - println("start load first page") - pagination.loadFirstPageSuspend() - println("end load first page") - - println("start double refresh") - val r1 = async { - pagination.refreshSuspend() - println("first refresh end") - } - val r2 = async { - pagination.refreshSuspend() - println("second refresh end") - } - - r1.await() - r2.await() - } - - private fun CoroutineScope.createPagination( - nextPageListener: (Result>) -> Unit = {}, - refreshListener: (Result>) -> Unit = {} - ) = Pagination( - parentScope = this, - dataSource = paginationDataSource, - comparator = itemsComparator, - 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 f672a23..0000000 --- a/paging/src/commonTest/kotlin/dev/icerock/moko/paging/TestListDataSource.kt +++ /dev/null @@ -1,14 +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) : PagedListDataSource { - val dataList = (0 .. pageSize * totalPagesCount).map { it } - - override suspend fun loadPage(currentList: List?): List { - val offset = currentList?.size ?: 0 - return dataList.subList(offset, offset + pageSize) - } -} 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/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..136c157 --- /dev/null +++ b/remotestate/src/commonMain/kotlin/dev/icerock/moko/remotestate/RemoteState.kt @@ -0,0 +1,64 @@ +/* + * 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 +import dev.icerock.moko.remotestate.RemoteState.Loading +import dev.icerock.moko.remotestate.RemoteState.Success +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * 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() + 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 + +/** + * Attempts to atomically update data in the RemoteState.Success state. + * + * @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 +): 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..54182eb 100644 --- a/sample/android-app/build.gradle.kts +++ b/sample/android-app/build.gradle.kts @@ -4,12 +4,16 @@ 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 + } + + compileSdk = 35 defaultConfig { applicationId = "dev.icerock.moko.samples.paging" @@ -17,22 +21,21 @@ android { versionCode = 1 versionName = "0.1.0" + minSdk = 21 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } } 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) -} + implementation(projects.pagingCompose) -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..21b2f36 --- /dev/null +++ b/sample/android-app/src/main/java/com/icerockdev/PagingScreen.kt @@ -0,0 +1,224 @@ +/* + * 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 +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.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.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.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() + } + + 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() + ) { + items( + items = items, + key = { item -> item.id } + ) { item -> + ProductRow(item = item) + } + + if (screenState.data?.isNextPageLoading == true) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } + } + } + ) +} + +@Composable +private fun EmptyContent( + onRefresh: () -> Unit +) { + 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") + } + } + } +} + +@Composable +private fun ErrorContent( + onRefresh: () -> Unit +) { + 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 = onRefresh) { + Text(text = "Retry") + } + } + } +} + +@Composable +private fun LoadingContent() { + Box( + modifier = Modifier.fillMaxSize(), + 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 + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PagingStateContentPreview() { + val listState = rememberLazyListState() + val state = RemoteState.Success( + data = 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"), + ) + ) + ) + + PagingStateContent( + state = state, + listState = listState, + onLoadNextRequested = {}, + 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/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..39111d6 100644 --- a/sample/mpp-library/build.gradle.kts +++ b/sample/mpp-library/build.gradle.kts @@ -8,20 +8,21 @@ 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) + 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..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 @@ -4,109 +4,117 @@ 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 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.ceil +import kotlin.math.min -private const val PAGE_LOAD_DURATION_MS: Long = 2000 +class ListViewModel : ViewModel() { + val pagination: Pagination = Pagination( + dataSource = PageSizePagingDataSource( + pageSize = PAGE_SIZE, + calculateNextPage = { currentList -> + val currentListSize = currentList?.size -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() - }, - 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()) + if (currentListSize == null || currentListSize == 0) { + 0 } else { - items + ceil(currentListSize.toDouble() / PAGE_SIZE).toInt() } + }, + loadPage = ::loadPage + ), + 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") } } - .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 } } + +/** + * A shortened way to create a CStateFlow from a 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..5e9ca1b 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,7 @@ gradleEnterprise { } include(":paging") +include(":paging-compose") +include(":remotestate") include(":sample:android-app") include(":sample:mpp-library")