diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/LoadMoreEffect.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/LoadMoreEffect.kt deleted file mode 100644 index e040722..0000000 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/LoadMoreEffect.kt +++ /dev/null @@ -1,35 +0,0 @@ -package soil.playground.query.compose - -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.snapshotFlow -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.filter -import kotlin.time.Duration.Companion.milliseconds - -@OptIn(FlowPreview::class) -@Composable -inline fun LoadMoreEffect( - state: LazyListState, - noinline loadMore: suspend (T) -> Unit, - loadMoreParam: T?, - crossinline predicate: (totalCount: Int, lastIndex: Int) -> Boolean = { totalCount, lastIndex -> - totalCount > 0 && lastIndex > totalCount - 5 - } -) { - LaunchedEffect(state, loadMore, loadMoreParam) { - if (loadMoreParam == null) return@LaunchedEffect - snapshotFlow { - val totalCount = state.layoutInfo.totalItemsCount - val lastIndex = state.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 - predicate(totalCount, lastIndex) - } - .debounce(250.milliseconds) - .filter { it } - .collect { - loadMore(loadMoreParam) - } - } -} diff --git a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt index ff9c556..35be77e 100644 --- a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt @@ -17,7 +17,6 @@ import io.ktor.client.plugins.ResponseException import soil.playground.Alert import soil.playground.query.compose.ContentLoading import soil.playground.query.compose.ContentUnavailable -import soil.playground.query.compose.LoadMoreEffect import soil.playground.query.compose.PostListItem import soil.playground.query.compose.rememberGetPostsQuery import soil.playground.query.data.PageParam @@ -28,6 +27,7 @@ import soil.query.compose.rememberQueriesErrorReset import soil.query.compose.runtime.Await import soil.query.compose.runtime.Catch import soil.query.compose.runtime.ErrorBoundary +import soil.query.compose.runtime.LazyLoadEffect import soil.query.compose.runtime.Suspense @Composable @@ -84,7 +84,7 @@ private fun HelloQueryContent( } val pageParam = state.loadMoreParam if (state.posts.isNotEmpty() && pageParam != null) { - item(pageParam, contentType = "loading") { + item(true, contentType = "loading") { ContentLoading( modifier = Modifier.fillMaxWidth(), size = 20.dp @@ -92,10 +92,11 @@ private fun HelloQueryContent( } } } - LoadMoreEffect( + LazyLoadEffect( state = lazyListState, loadMore = state.loadMore, - loadMoreParam = state.loadMoreParam + loadMoreParam = state.loadMoreParam, + totalItemsCount = state.posts.size ) } } diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/LazyLoadEffect.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/LazyLoadEffect.kt new file mode 100644 index 0000000..d85a929 --- /dev/null +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/LazyLoadEffect.kt @@ -0,0 +1,161 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +@file:Suppress("unused") + +package soil.query.compose.runtime + +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.take +import kotlin.jvm.JvmInline + +/** + * Provides a [LaunchedEffect] to perform additional loading for [soil.query.compose.InfiniteQueryObject]. + * + * The percentage is calculated from the values of [computeVisibleItemIndex] and [totalItemsCount]. + * It only triggers when the [threshold] is reached. + * + * @param state The scroll state of a [androidx.compose.foundation.lazy.LazyColumn] or [androidx.compose.foundation.lazy.LazyRow]. + * @param loadMore Specifies the function to call for loading more items, typically [soil.query.compose.InfiniteQueryObject.loadMore]. + * @param loadMoreParam The parameter passed to [soil.query.compose.InfiniteQueryObject.loadMore]. If `null`, the effect is not triggered. + * @param totalItemsCount The total number of items related to [state]. + * @param threshold The threshold scroll position at which to trigger additional loading. + * @param direction The direction for loading more items. + * @param computeVisibleItemIndex A function that calculates the index of the visible item used as the reference for additional loading. + */ +@Composable +inline fun LazyLoadEffect( + state: LazyListState, + noinline loadMore: suspend (T) -> Unit, + loadMoreParam: T?, + totalItemsCount: Int, + threshold: LazyLoadThreshold = LazyLoadThreshold.Lazily, + direction: LazyLoadDirection = LazyLoadDirection.Forward, + crossinline computeVisibleItemIndex: (state: LazyListState) -> Int = { + it.firstVisibleItemIndex + it.layoutInfo.visibleItemsInfo.size / 2 + } +) { + LazyLoadCustomEffect( + state = state, + loadMore = loadMore, + loadMoreParam = loadMoreParam, + totalItemsCount = totalItemsCount, + threshold = threshold, + direction = direction, + computeVisibleItemIndex = computeVisibleItemIndex + ) +} + +/** + * Provides a [LaunchedEffect] to perform additional loading for [soil.query.compose.InfiniteQueryObject]. + * + * The percentage is calculated from the values of [computeVisibleItemIndex] and [totalItemsCount]. + * It only triggers when the [threshold] is reached. + * + * @param state The scroll state of a [androidx.compose.foundation.lazy.grid.LazyGrid]. + * @param loadMore Specifies the function to call for loading more items, typically [soil.query.compose.InfiniteQueryObject.loadMore]. + * @param loadMoreParam The parameter passed to [soil.query.compose.InfiniteQueryObject.loadMore]. If `null`, the effect is not triggered. + * @param totalItemsCount The total number of items related to [state]. + * @param threshold The threshold scroll position at which to trigger additional loading. + * @param direction The direction for loading more items. + * @param computeVisibleItemIndex A function that calculates the index of the visible item used as the reference for additional loading. + */ +@Composable +inline fun LazyLoadEffect( + state: LazyGridState, + noinline loadMore: suspend (T) -> Unit, + loadMoreParam: T?, + totalItemsCount: Int, + threshold: LazyLoadThreshold = LazyLoadThreshold.Lazily, + direction: LazyLoadDirection = LazyLoadDirection.Forward, + crossinline computeVisibleItemIndex: (state: LazyGridState) -> Int = { + it.firstVisibleItemIndex + it.layoutInfo.visibleItemsInfo.size / 2 + } +) { + LazyLoadCustomEffect( + state = state, + loadMore = loadMore, + loadMoreParam = loadMoreParam, + totalItemsCount = totalItemsCount, + threshold = threshold, + direction = direction, + computeVisibleItemIndex = computeVisibleItemIndex + ) +} + +/** + * Provides a [LaunchedEffect] to perform additional loading for [soil.query.compose.InfiniteQueryObject]. + * + * The percentage is calculated from the values of [computeVisibleItemIndex] and [totalItemsCount]. + * It only triggers when the [threshold] is reached. + * + * @param state The scroll state inherited from [ScrollableState]. + * @param loadMore Specifies the function to call for loading more items, typically [soil.query.compose.InfiniteQueryObject.loadMore]. + * @param loadMoreParam The parameter passed to [soil.query.compose.InfiniteQueryObject.loadMore]. If `null`, the effect is not triggered. + * @param totalItemsCount The total number of items related to [state]. + * @param threshold The threshold scroll position at which to trigger additional loading. + * @param direction The direction for loading more items. + * @param computeVisibleItemIndex A function that calculates the index of the visible item used as the reference for additional loading. + */ +@Composable +inline fun LazyLoadCustomEffect( + state: S, + noinline loadMore: suspend (T) -> Unit, + loadMoreParam: T?, + totalItemsCount: Int, + threshold: LazyLoadThreshold = LazyLoadThreshold.Lazily, + direction: LazyLoadDirection = LazyLoadDirection.Forward, + crossinline computeVisibleItemIndex: (state: S) -> Int +) { + LaunchedEffect(state, totalItemsCount, loadMore, loadMoreParam) { + if (totalItemsCount == 0 || loadMoreParam == null) return@LaunchedEffect + snapshotFlow { + val itemIndex = computeVisibleItemIndex(state).coerceIn(0, totalItemsCount - 1) + itemIndex to direction.canScrollMore(state) + } + .filter { (itemIndex, canScrollMore) -> + !canScrollMore || direction.getScrollPositionRatio(itemIndex, totalItemsCount) >= threshold.value + } + .take(1) + .collect { + loadMore(loadMoreParam) + } + } +} + +@JvmInline +value class LazyLoadThreshold(val value: Float) { + init { + require(value in 0.0f..1.0f) { "Threshold value must be in the range [0.0, 1.0]" } + } + + companion object { + val Eagerly = LazyLoadThreshold(0.5f) + val Lazily = LazyLoadThreshold(0.75f) + } +} + +sealed interface LazyLoadDirection { + fun canScrollMore(state: ScrollableState): Boolean + fun getScrollPositionRatio(itemIndex: Int, totalItemsCount: Int): Float + + data object Backward : LazyLoadDirection { + override fun canScrollMore(state: ScrollableState): Boolean = state.canScrollBackward + override fun getScrollPositionRatio(itemIndex: Int, totalItemsCount: Int): Float { + return 1f - (1f * itemIndex / totalItemsCount) + } + } + + data object Forward : LazyLoadDirection { + override fun canScrollMore(state: ScrollableState): Boolean = state.canScrollForward + override fun getScrollPositionRatio(itemIndex: Int, totalItemsCount: Int): Float { + return 1f * (itemIndex + 1) / totalItemsCount + } + } +}