diff --git a/CHANGELOG.md b/CHANGELOG.md index 730a1a35fec..a12e65174f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.25.1 + +* `Coroutines`: + * Add `SortedMapLikeBinaryTreeNode` +* `Pagination`: + * `Compose`: + * One more rework of `InfinityPagedComponent` and `PagedComponent` + ## 0.25.0 * `Repos`: diff --git a/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/collections/SortedBinaryTreeNode.kt b/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/collections/SortedBinaryTreeNode.kt index fade45c3e61..96a40bdcb85 100644 --- a/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/collections/SortedBinaryTreeNode.kt +++ b/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/collections/SortedBinaryTreeNode.kt @@ -1,7 +1,6 @@ package dev.inmo.micro_utils.coroutines.collections import dev.inmo.micro_utils.coroutines.SmartRWLocker -import dev.inmo.micro_utils.coroutines.waitReadRelease import dev.inmo.micro_utils.coroutines.withReadAcquire import dev.inmo.micro_utils.coroutines.withWriteLock import kotlinx.coroutines.job @@ -93,7 +92,7 @@ class SortedBinaryTreeNode( * This process will continue until function will not find place to put [SortedBinaryTreeNode] with data or * [SortedBinaryTreeNode] with [SortedBinaryTreeNode.data] same as [newData] will be found */ -private suspend fun SortedBinaryTreeNode.addSubNode( +private suspend fun SortedBinaryTreeNode.upsertSubNode( subNode: SortedBinaryTreeNode, skipLockers: Set = emptySet() ): SortedBinaryTreeNode { @@ -149,7 +148,7 @@ private suspend fun SortedBinaryTreeNode.addSubNode( * [SortedBinaryTreeNode] with [SortedBinaryTreeNode.data] same as [newData] will be found */ suspend fun SortedBinaryTreeNode.addSubNode(newData: T): SortedBinaryTreeNode { - return addSubNode( + return upsertSubNode( SortedBinaryTreeNode(newData, comparator) ) } @@ -198,8 +197,8 @@ suspend fun SortedBinaryTreeNode.findParentNode(data: T): SortedBinaryTre */ suspend fun SortedBinaryTreeNode.removeSubNode(data: T): Pair, SortedBinaryTreeNode>? { val onFoundToRemoveCallback: suspend SortedBinaryTreeNode.(left: SortedBinaryTreeNode?, right: SortedBinaryTreeNode?) -> Unit = { left, right -> - left ?.also { leftNode -> addSubNode(leftNode, setOf(locker)) } - right ?.also { rightNode -> addSubNode(rightNode, setOf(locker)) } + left ?.also { leftNode -> upsertSubNode(leftNode, setOf(locker)) } + right ?.also { rightNode -> upsertSubNode(rightNode, setOf(locker)) } } while (coroutineContext.job.isActive) { val foundParentNode = findParentNode(data) ?: return null diff --git a/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/collections/SortedMapLikeBinaryTreeNode.kt b/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/collections/SortedMapLikeBinaryTreeNode.kt new file mode 100644 index 00000000000..c5d5c3d4be7 --- /dev/null +++ b/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/collections/SortedMapLikeBinaryTreeNode.kt @@ -0,0 +1,401 @@ +package dev.inmo.micro_utils.coroutines.collections + +import dev.inmo.micro_utils.coroutines.SmartRWLocker +import dev.inmo.micro_utils.coroutines.withReadAcquire +import dev.inmo.micro_utils.coroutines.withWriteLock +import kotlinx.coroutines.job +import kotlinx.serialization.Serializable +import kotlin.coroutines.coroutineContext + +/** + * Creates simple [Comparator] which will use [compareTo] of [T] for both objects + */ +private fun , C : T> T.createComparator() = Comparator { o1, o2 -> o1.compareTo(o2) } + +@Serializable +class SortedMapLikeBinaryTreeNode( + val key: K, + val value: V, + internal val comparator: Comparator, +) : Iterable> { + internal var leftNode: SortedMapLikeBinaryTreeNode? = null + internal var rightNode: SortedMapLikeBinaryTreeNode? = null + internal val locker: SmartRWLocker by lazy { + SmartRWLocker() + } + + suspend fun getLeftNode() = locker.withReadAcquire { + leftNode + } + + suspend fun getRightNode() = locker.withReadAcquire { + rightNode + } + + suspend fun getLeftKey() = getLeftNode() ?.key + suspend fun getLeftValue() = getLeftNode() ?.value + + suspend fun getRightKey() = getRightNode() ?.value + suspend fun getRightValue() = getRightNode() ?.value + + override fun equals(other: Any?): Boolean { + return other === this || (other is SortedMapLikeBinaryTreeNode<*, *> && other.key == key && other.rightNode == rightNode && other.leftNode == leftNode) + } + + override fun hashCode(): Int { + return key.hashCode() * 31 + rightNode.hashCode() + leftNode.hashCode() + } + + suspend fun size(): Int { + return locker.withReadAcquire { + 1 + (leftNode ?.size() ?: 0) + (rightNode ?.size() ?: 0) + } + } + + /** + * This [Iterator] will run from less to greater values of nodes starting the + * [dev.inmo.micro_utils.coroutines.collections.SortedMapLikeBinaryTreeNode]-receiver. Due to non-suspending + * nature of [iterator] builder, this [Iterator] **DO NOT** guarantee consistent content due to iterations. It + * means, that tree can be changed during to iteration process + */ + override fun iterator(): Iterator> = iterator { + leftNode ?.let { + it.iterator().forEach { yield(it) } + } + yield(this@SortedMapLikeBinaryTreeNode) + rightNode ?.let { + it.iterator().forEach { yield(it) } + } + } + + override fun toString(): String { + return "$key($leftNode;$rightNode)" + } + + companion object { + operator fun , V> invoke( + key: K, + value: V + ) = SortedMapLikeBinaryTreeNode( + key, + value, + key.createComparator() + ) + } +} + +/** + * Will add subnode in tree if there are no any node with [newData] + * + * * If [newData] is greater than [SortedMapLikeBinaryTreeNode.key] of currently checking node, + * will be used [SortedMapLikeBinaryTreeNode.rightNode] + * * If [newData] is equal to [SortedMapLikeBinaryTreeNode.key] of currently + * checking node - will be returned currently checking node + * * If [newData] is less than [SortedMapLikeBinaryTreeNode.key] of currently + * checking node - will be used [SortedMapLikeBinaryTreeNode.leftNode] + * + * This process will continue until function will not find place to put [SortedMapLikeBinaryTreeNode] with data or + * [SortedMapLikeBinaryTreeNode] with [SortedMapLikeBinaryTreeNode.key] same as [newData] will be found + * + * @param replaceMode Will replace only value if node already exists + */ +private suspend fun SortedMapLikeBinaryTreeNode.upsertSubNode( + subNode: SortedMapLikeBinaryTreeNode, + skipLockers: Set = emptySet(), + replaceMode: Boolean +): SortedMapLikeBinaryTreeNode { + var currentlyChecking = this + var latestParent: SortedMapLikeBinaryTreeNode? = null + val lockedLockers = mutableSetOf() + try { + while (coroutineContext.job.isActive) { + if (currentlyChecking.locker !in lockedLockers && currentlyChecking.locker !in skipLockers) { + currentlyChecking.locker.lockWrite() + lockedLockers.add(currentlyChecking.locker) + } + val left = currentlyChecking.leftNode + val right = currentlyChecking.rightNode + val comparingResult = currentlyChecking.comparator.compare(subNode.key, currentlyChecking.key) + val isGreater = comparingResult > 0 + when { + comparingResult == 0 -> { + val resultNode = if (replaceMode) { + subNode + } else { + val newNode = SortedMapLikeBinaryTreeNode( + subNode.key, + subNode.value, + currentlyChecking.comparator, + ) + newNode.leftNode = currentlyChecking.leftNode + newNode.rightNode = currentlyChecking.rightNode + newNode + } + + latestParent ?.let { + when { + it.leftNode === currentlyChecking -> it.leftNode = resultNode + it.rightNode === currentlyChecking -> it.rightNode = resultNode + } + } + + return resultNode + } + isGreater && right == null -> { + currentlyChecking.rightNode = subNode + return subNode + } + isGreater && right != null -> { + latestParent = currentlyChecking + currentlyChecking = right + } + left == null -> { + currentlyChecking.leftNode = subNode + return subNode + } + else -> { + latestParent = currentlyChecking + currentlyChecking = left + } + } + } + } finally { + lockedLockers.forEach { + runCatching { it.unlockWrite() } + } + } + error("Unable to add node") +} + +/** + * Will add subnode in tree if there are no any node with [key] + * + * * If [key] is greater than [SortedMapLikeBinaryTreeNode.key] of currently checking node, + * will be used [SortedMapLikeBinaryTreeNode.rightNode] + * * If [key] is equal to [SortedMapLikeBinaryTreeNode.key] of currently + * checking node - will be returned currently checking node + * * If [key] is less than [SortedMapLikeBinaryTreeNode.key] of currently + * checking node - will be used [SortedMapLikeBinaryTreeNode.leftNode] + * + * This process will continue until function will not find place to put [SortedMapLikeBinaryTreeNode] with data or + * [SortedMapLikeBinaryTreeNode] with [SortedMapLikeBinaryTreeNode.key] same as [key] will be found + */ +suspend fun SortedMapLikeBinaryTreeNode.upsertSubNode( + key: K, + value: V +): SortedMapLikeBinaryTreeNode { + return upsertSubNode( + SortedMapLikeBinaryTreeNode(key, value, comparator), + replaceMode = false + ) +} + +suspend fun SortedMapLikeBinaryTreeNode.findParentNode(data: K): SortedMapLikeBinaryTreeNode? { + var currentParent: SortedMapLikeBinaryTreeNode? = null + var currentlyChecking: SortedMapLikeBinaryTreeNode? = this + val lockedLockers = mutableSetOf() + try { + while (coroutineContext.job.isActive) { + if (currentlyChecking == null) { + return null + } + if (currentlyChecking.locker !in lockedLockers) { + currentlyChecking.locker.acquireRead() + lockedLockers.add(currentlyChecking.locker) + } + val comparingResult = currentlyChecking.comparator.compare(data, currentlyChecking.key) + when { + comparingResult > 0 -> { + currentParent = currentlyChecking + currentlyChecking = currentlyChecking.rightNode + continue + } + comparingResult < 0 -> { + currentParent = currentlyChecking + currentlyChecking = currentlyChecking.leftNode + continue + } + else -> return currentParent + } + } + } finally { + lockedLockers.forEach { + runCatching { it.releaseRead() } + } + } + error("Unable to find node") +} + +/** + * Will remove (detach) node from tree starting with [this] [SortedMapLikeBinaryTreeNode] + * + * @return If data were found, [Pair] where [Pair.first] is the parent node where from [Pair.second] has been detached; + * null otherwise + */ +suspend fun SortedMapLikeBinaryTreeNode.removeSubNode(data: K): Pair, SortedMapLikeBinaryTreeNode>? { + val onFoundToRemoveCallback: suspend SortedMapLikeBinaryTreeNode.(left: SortedMapLikeBinaryTreeNode?, right: SortedMapLikeBinaryTreeNode?) -> Unit = { left, right -> + left ?.also { leftNode -> upsertSubNode(leftNode, setOf(locker), replaceMode = true) } + right ?.also { rightNode -> upsertSubNode(rightNode, setOf(locker), replaceMode = true) } + } + while (coroutineContext.job.isActive) { + val foundParentNode = findParentNode(data) ?: return null + foundParentNode.locker.withWriteLock { + val left = foundParentNode.leftNode + val right = foundParentNode.rightNode + when { + left != null && left.comparator.compare(data, left.key) == 0 -> { + foundParentNode.leftNode = null + foundParentNode.onFoundToRemoveCallback(left.leftNode, left.rightNode) + return foundParentNode to left + } + right != null && right.comparator.compare(data, right.key) == 0 -> { + foundParentNode.rightNode = null + foundParentNode.onFoundToRemoveCallback(right.leftNode, right.rightNode) + return foundParentNode to right + } + else -> { + return@withWriteLock // data has been changed, new search required + } + } + } + } + error("Unable to remove node") +} +suspend fun SortedMapLikeBinaryTreeNode.findNode(key: K): SortedMapLikeBinaryTreeNode? { + var currentlyChecking: SortedMapLikeBinaryTreeNode? = this + val lockedLockers = mutableSetOf() + try { + while (coroutineContext.job.isActive) { + if (currentlyChecking == null) { + return null + } + if (currentlyChecking.locker !in lockedLockers) { + currentlyChecking.locker.acquireRead() + lockedLockers.add(currentlyChecking.locker) + } + val comparingResult = currentlyChecking.comparator.compare(key, currentlyChecking.key) + when { + comparingResult > 0 -> { + currentlyChecking = currentlyChecking.rightNode + continue + } + comparingResult < 0 -> { + currentlyChecking = currentlyChecking.leftNode + continue + } + else -> return currentlyChecking + } + } + } finally { + lockedLockers.forEach { + runCatching { it.releaseRead() } + } + } + error("Unable to find node") +} +suspend fun SortedMapLikeBinaryTreeNode.contains(data: K): Boolean = findNode(data) != null + +suspend fun SortedMapLikeBinaryTreeNode.findNodesInRange(from: K, to: K, fromInclusiveMode: Boolean, toInclusiveMode: Boolean): Set> { + val results = mutableSetOf>() + val leftToCheck = mutableSetOf(this) + val lockedLockers = mutableSetOf() + val fromComparingFun: (SortedMapLikeBinaryTreeNode) -> Boolean = if (fromInclusiveMode) { + { it.comparator.compare(from, it.key) <= 0 } + } else { + { it.comparator.compare(from, it.key) < 0 } + } + val toComparingFun: (SortedMapLikeBinaryTreeNode) -> Boolean = if (toInclusiveMode) { + { it.comparator.compare(to, it.key) >= 0 } + } else { + { it.comparator.compare(to, it.key) > 0 } + } + try { + while (coroutineContext.job.isActive && leftToCheck.isNotEmpty()) { + val currentlyChecking = leftToCheck.first() + leftToCheck.remove(currentlyChecking) + if (currentlyChecking in results) { + continue + } + currentlyChecking.locker.acquireRead() + lockedLockers.add(currentlyChecking.locker) + if (fromComparingFun(currentlyChecking) && toComparingFun(currentlyChecking)) { + results.add(currentlyChecking) + currentlyChecking.leftNode ?.let { leftToCheck.add(it) } + currentlyChecking.rightNode ?.let { leftToCheck.add(it) } + continue + } + when { + currentlyChecking.comparator.compare(to, currentlyChecking.key) < 0 -> currentlyChecking.leftNode ?.let { leftToCheck.add(it) } + currentlyChecking.comparator.compare(from, currentlyChecking.key) > 0 -> currentlyChecking.rightNode ?.let { leftToCheck.add(it) } + } + } + return results.toSet() + } finally { + lockedLockers.forEach { + runCatching { it.releaseRead() } + } + } + error("Unable to find nodes range") +} +suspend fun SortedMapLikeBinaryTreeNode.deepEquals(other: SortedMapLikeBinaryTreeNode): Boolean { + val leftToCheck = mutableSetOf(this) + val othersToCheck = mutableSetOf(other) + val lockedLockers = mutableSetOf() + try { + while (leftToCheck.isNotEmpty() && othersToCheck.isNotEmpty()) { + val thisToCheck = leftToCheck.first() + leftToCheck.remove(thisToCheck) + + val otherToCheck = othersToCheck.first() + othersToCheck.remove(otherToCheck) + + if (thisToCheck.locker !in lockedLockers) { + thisToCheck.locker.acquireRead() + lockedLockers.add(thisToCheck.locker) + } + if (otherToCheck.locker !in lockedLockers) { + otherToCheck.locker.acquireRead() + lockedLockers.add(otherToCheck.locker) + } + + if (thisToCheck.key != otherToCheck.key || thisToCheck.value != otherToCheck.value) { + return false + } + + if ((thisToCheck.leftNode == null).xor(otherToCheck.leftNode == null)) { + return false + } + if ((thisToCheck.rightNode == null).xor(otherToCheck.rightNode == null)) { + return false + } + + thisToCheck.leftNode?.let { leftToCheck.add(it) } + thisToCheck.rightNode?.let { leftToCheck.add(it) } + + otherToCheck.leftNode?.let { othersToCheck.add(it) } + otherToCheck.rightNode?.let { othersToCheck.add(it) } + } + } finally { + lockedLockers.forEach { + runCatching { it.releaseRead() } + } + } + + return leftToCheck.isEmpty() && othersToCheck.isEmpty() +} +suspend fun SortedMapLikeBinaryTreeNode.findNodesInRange(from: K, to: K): Set> = findNodesInRange( + from = from, + to = to, + fromInclusiveMode = true, + toInclusiveMode = true +) +suspend fun SortedMapLikeBinaryTreeNode.findNodesInRangeExcluding(from: K, to: K): Set> = findNodesInRange( + from = from, + to = to, + fromInclusiveMode = false, + toInclusiveMode = false +) +suspend fun , V> SortedMapLikeBinaryTreeNode.findNodesInRange(range: ClosedRange): Set> = findNodesInRange( + from = range.start, + to = range.endInclusive, +) \ No newline at end of file diff --git a/coroutines/src/jvmTest/kotlin/dev/inmo/micro_utils/coroutines/SortedMapLikeBinaryTreeNodeTests.kt b/coroutines/src/jvmTest/kotlin/dev/inmo/micro_utils/coroutines/SortedMapLikeBinaryTreeNodeTests.kt new file mode 100644 index 00000000000..221f46b1634 --- /dev/null +++ b/coroutines/src/jvmTest/kotlin/dev/inmo/micro_utils/coroutines/SortedMapLikeBinaryTreeNodeTests.kt @@ -0,0 +1,118 @@ +package dev.inmo.micro_utils.coroutines + +import dev.inmo.micro_utils.coroutines.collections.* +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class SortedMapLikeBinaryTreeNodeTests { + @Test + fun insertOnZeroLevelWorks() = runTest { + val zeroNode = SortedMapLikeBinaryTreeNode(0, 0) + zeroNode.upsertSubNode(1, 1) + zeroNode.upsertSubNode(-1, -1) + + assertEquals(0, zeroNode.key) + assertEquals(1, zeroNode.getRightNode() ?.key) + assertEquals(-1, zeroNode.getLeftNode() ?.key) + + assertEquals(0, zeroNode.findNode(0) ?.value) + assertEquals(1, zeroNode.findNode(1) ?.value) + assertEquals(-1, zeroNode.findNode(-1) ?.value) + } + @Test + fun searchOnZeroLevelWorks() = runTest { + val zeroNode = SortedMapLikeBinaryTreeNode(0, 0) + val oneNode = zeroNode.upsertSubNode(1, 1) + val minusOneNode = zeroNode.upsertSubNode(-1, -1) + + val assertingNodesToSearchQuery = mapOf( + setOf(oneNode) to (1 .. 1), + setOf(zeroNode, oneNode) to (0 .. 1), + setOf(minusOneNode, zeroNode, oneNode) to (-1 .. 1), + setOf(minusOneNode, zeroNode) to (-1 .. 0), + setOf(minusOneNode) to (-1 .. -1), + setOf(zeroNode) to (0 .. 0), + ) + + assertingNodesToSearchQuery.forEach { + val foundData = zeroNode.findNodesInRange(it.value) + assertTrue(foundData.containsAll(it.key)) + assertTrue(it.key.containsAll(foundData)) + } + } + @Test + fun deepReInsertOnWorks() = runTest(timeout = 300.seconds) { + var zeroNode = SortedMapLikeBinaryTreeNode(0, 0) + val rangeRadius = 500 + val nodes = mutableMapOf>() + for (i in -rangeRadius .. rangeRadius) { + nodes[i] = zeroNode.upsertSubNode(i, i) + if (i == zeroNode.key) { + zeroNode = nodes.getValue(i) + } + } + + for (i in -rangeRadius .. rangeRadius) { + val expectedNode = nodes.getValue(i) + val foundNode = zeroNode.findNode(i) + + assertEquals(expectedNode, foundNode) + + if (expectedNode === zeroNode) continue + + val parentNode = zeroNode.findParentNode(i) + assertTrue( + parentNode ?.getLeftNode() === expectedNode || parentNode ?.getRightNode() === expectedNode, + "It is expected, that parent node with data ${parentNode ?.key} will be parent of ${expectedNode.key}, but its left subnode is ${parentNode ?.getLeftNode() ?.key} and right one is ${parentNode ?.getRightNode() ?.key}" + ) + assertTrue( + foundNode != null && expectedNode.deepEquals(foundNode) + ) + + zeroNode.upsertSubNode(i, -i) + val foundModifiedNode = zeroNode.findNode(i) + assertEquals(foundNode ?.value, foundModifiedNode ?.value ?.times(-1)) + } + } + @Test + fun deepInsertOnWorks() = runTest(timeout = 240.seconds) { + val zeroNode = SortedMapLikeBinaryTreeNode(0, 0) + val rangeRadius = 500 + val nodes = mutableMapOf>() + for (i in -rangeRadius .. rangeRadius) { + if (zeroNode.key != i) { + nodes[i] = zeroNode.upsertSubNode(i, i) + } + } + nodes[zeroNode.key] = zeroNode + + for (i in -rangeRadius .. rangeRadius) { + val expectedNode = nodes.getValue(i) + val foundNode = zeroNode.findNode(i) + + assertTrue(expectedNode === foundNode) + + if (expectedNode === zeroNode) continue + + val parentNode = zeroNode.findParentNode(i) + assertTrue( + parentNode ?.getLeftNode() === expectedNode || parentNode ?.getRightNode() === expectedNode, + "It is expected, that parent node with data ${parentNode ?.key} will be parent of ${expectedNode.key}, but its left subnode is ${parentNode ?.getLeftNode() ?.key} and right one is ${parentNode ?.getRightNode() ?.key}" + ) + } + + val sourceTreeSize = zeroNode.size() + + var previousData = -rangeRadius - 1 + for (node in zeroNode) { + assertTrue(nodes[node.key] === node) + assertTrue(previousData == node.key - 1) + previousData = node.key + } + + assertTrue(sourceTreeSize == zeroNode.size()) + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 9431853c085..0d5ea9a07d5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,5 +15,5 @@ crypto_js_version=4.1.1 # Project data group=dev.inmo -version=0.25.0 -android_code_version=290 +version=0.25.1 +android_code_version=291 diff --git a/pagination/compose/src/commonMain/kotlin/InfinityPagedComponent.kt b/pagination/compose/src/commonMain/kotlin/InfinityPagedComponent.kt index 377fe15a6c2..c52766f2e28 100644 --- a/pagination/compose/src/commonMain/kotlin/InfinityPagedComponent.kt +++ b/pagination/compose/src/commonMain/kotlin/InfinityPagedComponent.kt @@ -2,7 +2,13 @@ package dev.inmo.micro_utils.pagination.compose import androidx.compose.runtime.* import dev.inmo.micro_utils.coroutines.SpecialMutableStateFlow +import dev.inmo.micro_utils.coroutines.launchLoggingDropExceptions +import dev.inmo.micro_utils.coroutines.runCatchingLogging import dev.inmo.micro_utils.pagination.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** * Context for managing infinite pagination in a Compose UI. @@ -16,30 +22,46 @@ import dev.inmo.micro_utils.pagination.* */ class InfinityPagedComponentContext internal constructor( page: Int, - size: Int + size: Int, + private val scope: CoroutineScope, + private val loader: suspend InfinityPagedComponentContext.(Pagination) -> PaginationResult ) { internal val startPage = SimplePagination(page, size) - internal val currentlyLoadingPage = SpecialMutableStateFlow(startPage) internal val latestLoadedPage = SpecialMutableStateFlow?>(null) internal val dataState = SpecialMutableStateFlow?>(null) + internal var loadingJob: Job? = null + internal val loadingMutex = Mutex() /** * Loads the next page of data. If the current page is the last one, the function returns early. */ - fun loadNext() { - if (latestLoadedPage.value ?.isLastPage == true) return - if (currentlyLoadingPage.value != null) return // Data loading has been inited but not loaded yet - - currentlyLoadingPage.value = latestLoadedPage.value ?.nextPage() ?: startPage + fun loadNext(): Job { + return scope.launchLoggingDropExceptions { + loadingMutex.withLock { + if (latestLoadedPage.value ?.isLastPage == true) return@launchLoggingDropExceptions + loadingJob = loadingJob ?: scope.launchLoggingDropExceptions { + runCatching { + loader(latestLoadedPage.value ?.nextPage() ?: startPage) + }.onSuccess { + latestLoadedPage.value = it + dataState.value = (dataState.value ?: emptyList()) + it.results + } + loadingMutex.withLock { + loadingJob = null + } + } + loadingJob + } ?.join() + } } /** * Reloads the pagination from the first page, clearing previously loaded data. */ - fun reload() { + fun reload(): Job { latestLoadedPage.value = null - currentlyLoadingPage.value = null - loadNext() + dataState.value = null + return loadNext() } } @@ -58,17 +80,13 @@ internal fun InfinityPagedComponent( page: Int, size: Int, loader: suspend InfinityPagedComponentContext.(Pagination) -> PaginationResult, + predefinedScope: CoroutineScope? = null, block: @Composable InfinityPagedComponentContext.(List?) -> Unit ) { - val context = remember { InfinityPagedComponentContext(page, size) } - - val currentlyLoadingState = context.currentlyLoadingPage.collectAsState() - LaunchedEffect(currentlyLoadingState.value) { - val paginationResult = loader(context, currentlyLoadingState.value ?: return@LaunchedEffect) - context.latestLoadedPage.value = paginationResult - context.currentlyLoadingPage.value = null - - context.dataState.value = (context.dataState.value ?: emptyList()) + paginationResult.results + val scope = predefinedScope ?: rememberCoroutineScope() + val context = remember { InfinityPagedComponentContext(page, size, scope, loader) } + remember { + context.reload() } val dataState = context.dataState.collectAsState() @@ -88,12 +106,14 @@ internal fun InfinityPagedComponent( fun InfinityPagedComponent( pageInfo: Pagination, loader: suspend InfinityPagedComponentContext.(Pagination) -> PaginationResult, + predefinedScope: CoroutineScope? = null, block: @Composable InfinityPagedComponentContext.(List?) -> Unit ) { InfinityPagedComponent( pageInfo.page, pageInfo.size, loader, + predefinedScope, block ) } @@ -111,7 +131,8 @@ fun InfinityPagedComponent( fun InfinityPagedComponent( size: Int, loader: suspend InfinityPagedComponentContext.(Pagination) -> PaginationResult, + predefinedScope: CoroutineScope? = null, block: @Composable InfinityPagedComponentContext.(List?) -> Unit ) { - InfinityPagedComponent(0, size, loader, block) + InfinityPagedComponent(0, size, loader, predefinedScope, block) } diff --git a/pagination/compose/src/commonMain/kotlin/PagedComponent.kt b/pagination/compose/src/commonMain/kotlin/PagedComponent.kt index 9f13388d06c..90d93f7906b 100644 --- a/pagination/compose/src/commonMain/kotlin/PagedComponent.kt +++ b/pagination/compose/src/commonMain/kotlin/PagedComponent.kt @@ -2,7 +2,12 @@ package dev.inmo.micro_utils.pagination.compose import androidx.compose.runtime.* import dev.inmo.micro_utils.coroutines.SpecialMutableStateFlow +import dev.inmo.micro_utils.coroutines.launchLoggingDropExceptions import dev.inmo.micro_utils.pagination.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** * Context for managing paginated data in a Compose UI. @@ -18,39 +23,73 @@ import dev.inmo.micro_utils.pagination.* */ class PagedComponentContext internal constructor( initialPage: Int, - size: Int + size: Int, + private val scope: CoroutineScope, + private val loader: suspend PagedComponentContext.(Pagination) -> PaginationResult ) { internal val startPage = SimplePagination(initialPage, size) - internal val currentlyLoadingPageState = SpecialMutableStateFlow(startPage) internal val latestLoadedPage = SpecialMutableStateFlow?>(null) + internal val dataState = SpecialMutableStateFlow?>(null) + internal var loadingJob: Job? = null + internal val loadingMutex = Mutex() + + private fun initLoadingJob( + skipCheckerInLock: () -> Boolean, + pageGetter: () -> Pagination + ): Job { + return scope.launchLoggingDropExceptions { + loadingMutex.withLock { + if (skipCheckerInLock()) return@launchLoggingDropExceptions + loadingJob = loadingJob ?: scope.launchLoggingDropExceptions { + runCatching { + loader(pageGetter()) + }.onSuccess { + latestLoadedPage.value = it + dataState.value = it + } + loadingMutex.withLock { + loadingJob = null + } + } + loadingJob + } ?.join() + } + } /** * Loads the next page of data. If the last page is reached, this function returns early. */ - fun loadNext() { - when { - currentlyLoadingPageState.value != null -> return - latestLoadedPage.value ?.isLastPage == true -> return - else -> currentlyLoadingPageState.value = (latestLoadedPage.value ?.nextPage()) ?: startPage + fun loadNext(): Job { + return initLoadingJob( + { latestLoadedPage.value ?.isLastPage == true } + ) { + latestLoadedPage.value ?.nextPage() ?: startPage } } /** * Loads the previous page of data if available. */ - fun loadPrevious() { - when { - currentlyLoadingPageState.value != null -> return - latestLoadedPage.value ?.isFirstPage == true -> return - else -> currentlyLoadingPageState.value = (latestLoadedPage.value ?.previousPage()) ?: startPage + fun loadPrevious(): Job { + return initLoadingJob( + { latestLoadedPage.value ?.isFirstPage == true } + ) { + latestLoadedPage.value ?.previousPage() ?: startPage } } /** * Reloads the current page, refreshing the data. */ - fun reload() { - currentlyLoadingPageState.value = latestLoadedPage.value + fun reload(): Job { + return initLoadingJob( + { + latestLoadedPage.value = null + true + } + ) { + startPage + } } } @@ -69,18 +108,16 @@ internal fun PagedComponent( initialPage: Int, size: Int, loader: suspend PagedComponentContext.(Pagination) -> PaginationResult, + predefinedScope: CoroutineScope? = null, block: @Composable PagedComponentContext.(PaginationResult) -> Unit ) { - val context = remember { PagedComponentContext(initialPage, size) } - - val currentlyLoadingState = context.currentlyLoadingPageState.collectAsState() - LaunchedEffect(currentlyLoadingState.value) { - val paginationResult = loader(context, currentlyLoadingState.value ?: return@LaunchedEffect) - context.latestLoadedPage.value = paginationResult - context.currentlyLoadingPageState.value = null + val scope = predefinedScope ?: rememberCoroutineScope() + val context = remember { PagedComponentContext(initialPage, size, scope, loader) } + remember { + context.reload() } - val pageState = context.latestLoadedPage.collectAsState() + val pageState = context.dataState.collectAsState() pageState.value ?.let { context.block(it) } @@ -98,12 +135,14 @@ internal fun PagedComponent( fun PagedComponent( pageInfo: Pagination, loader: suspend PagedComponentContext.(Pagination) -> PaginationResult, + predefinedScope: CoroutineScope? = null, block: @Composable PagedComponentContext.(PaginationResult) -> Unit ) { PagedComponent( pageInfo.page, pageInfo.size, loader, + predefinedScope, block ) } @@ -120,7 +159,8 @@ fun PagedComponent( fun PagedComponent( size: Int, loader: suspend PagedComponentContext.(Pagination) -> PaginationResult, + predefinedScope: CoroutineScope? = null, block: @Composable PagedComponentContext.(PaginationResult) -> Unit ) { - PagedComponent(0, size, loader, block) + PagedComponent(0, size, loader, predefinedScope, block) } diff --git a/pagination/compose/src/jvmTest/kotlin/InfinityPagedComponentTests.kt b/pagination/compose/src/jvmTest/kotlin/InfinityPagedComponentTests.kt index daabfeca6ac..fe1f84f349c 100644 --- a/pagination/compose/src/jvmTest/kotlin/InfinityPagedComponentTests.kt +++ b/pagination/compose/src/jvmTest/kotlin/InfinityPagedComponentTests.kt @@ -30,13 +30,13 @@ class InfinityPagedComponentTests { } ) { if (it == null) { - assertEquals(0, this.currentlyLoadingPage.value ?.page) + assertEquals(null, it) } else { assertEquals(expectedList, it) } LaunchedEffect(it ?.size) { - loadNext() + loadNext().join() } } }