From de596bf318c8eb9832d145d71e4c7312915ec6e1 Mon Sep 17 00:00:00 2001 From: Pushpal Roy Date: Sun, 14 Jun 2026 23:03:34 +0530 Subject: [PATCH 1/3] feat: add native pagination support (JetLimePaginatedColumn / JetLimePaginatedRow) Adds optional, dependency-free pagination (infinite scroll) for timelines, resolving discussion #64. Two new composables fire an `onLoadMore` callback when the user scrolls within `loadMoreThreshold` items of the end (guarded by `isLoading`/`hasMoreItems`) and render an optional loading footer. The existing JetLimeColumn/JetLimeRow are untouched (fully backward compatible). - JetLimePaginatedList.kt: JetLimePaginatedColumn / JetLimePaginatedRow built on derivedStateOf over LazyListState + a rememberUpdatedState-guarded LaunchedEffect. - EventPosition.dynamicPaginated(): keeps the last loaded item as MIDDLE while more pages can load so the connecting line stays continuous, terminating only on the last page. - JetLimeDefaults.LoadMoreThreshold: default trigger distance. - Sample: new "Paginated" tab + PaginatedVerticalTimeLine screen. - README: Pagination section + highlight. - Instrumented tests for display, loading footer, scroll trigger, guards, and the connector-continuity logic. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 48 +++ .../jetlime/JetLimePaginatedColumnTest.kt | 207 ++++++++++++ .../com/pushpal/jetlime/EventPosition.kt | 18 ++ .../com/pushpal/jetlime/JetLimeDefaults.kt | 6 + .../pushpal/jetlime/JetLimePaginatedList.kt | 296 ++++++++++++++++++ .../composeApp/src/commonMain/kotlin/Home.kt | 7 +- .../timelines/PaginatedVerticalTimeLine.kt | 130 ++++++++ 7 files changed, 711 insertions(+), 1 deletion(-) create mode 100644 jetlime/src/androidTest/java/com/pushpal/jetlime/JetLimePaginatedColumnTest.kt create mode 100644 jetlime/src/commonMain/kotlin/com/pushpal/jetlime/JetLimePaginatedList.kt create mode 100644 sample/composeApp/src/commonMain/kotlin/timelines/PaginatedVerticalTimeLine.kt diff --git a/README.md b/README.md index 71bfa89..89f07a6 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ - RTL layout support for JetLimeRow and JetLimeExtendedEvent (mirrors timelines and keeps content visible in right-to-left layouts) - Dashed/gradient/solid lines via Brush + PathEffect - Extended events with dual content slots (left/right), icons, and animations +- Built-in pagination / infinite scroll (JetLimePaginatedColumn / JetLimePaginatedRow) with no extra dependency - Small, focused API with sensible defaults (JetLimeDefaults) ## 📦 Installation @@ -126,6 +127,53 @@ JetLimeColumn( } ``` +### 🔄 Paginated Timeline (Infinite Scroll) + +Use [JetLimePaginatedColumn](https://pushpalroy.github.io/JetLime/jetlime/com.pushpal.jetlime/-jet-lime-paginated-column.html) +(or `JetLimePaginatedRow`) to load items page by page as the user scrolls. The component watches the +scroll position and calls `onLoadMore` when the user gets within `loadMoreThreshold` items of the end, +as long as a page is not already loading and more items remain. Pagination is implemented natively — +no extra dependency is added. + +You own the page state (`itemsList`, `isLoading`, `hasMoreItems`); append the next page inside +`onLoadMore` and update the flags. While `hasMoreItems` is `true`, the timeline line stays continuous +into the next page, and it terminates once you set `hasMoreItems = false`. + +```kotlin +val items = remember { mutableStateListOf() } +var isLoading by remember { mutableStateOf(false) } +var hasMore by remember { mutableStateOf(true) } +val scope = rememberCoroutineScope() + +// Trigger the first page; the scroll-based loader fires only once items exist. +LaunchedEffect(Unit) { if (items.isEmpty()) loadNextPage() } + +JetLimePaginatedColumn( + itemsList = ItemsList(items), + key = { _, item -> item.id }, + isLoading = isLoading, + hasMoreItems = hasMore, + onLoadMore = { + scope.launch { + isLoading = true + val page = repository.loadNextPage() // your data source + items.addAll(page.items) + hasMore = !page.isLast + isLoading = false + } + }, +) { index, item, position -> + JetLimeEvent( + style = JetLimeEventDefaults.eventStyle(position = position) + ) { + // Content here + } +} +``` + +A default centered progress indicator is shown while `isLoading` is `true`; override it with the +`loadingContent` parameter. + ### 🎛️ Customize `JetLimeColumn` Style Use the [JetLimeDefaults.columnStyle()](https://pushpalroy.github.io/JetLime/jetlime/com.pushpal.jetlime/-jet-lime-defaults/column-style.html) diff --git a/jetlime/src/androidTest/java/com/pushpal/jetlime/JetLimePaginatedColumnTest.kt b/jetlime/src/androidTest/java/com/pushpal/jetlime/JetLimePaginatedColumnTest.kt new file mode 100644 index 0000000..424fd41 --- /dev/null +++ b/jetlime/src/androidTest/java/com/pushpal/jetlime/JetLimePaginatedColumnTest.kt @@ -0,0 +1,207 @@ +/* +* MIT License +* +* Copyright (c) 2024 Pushpal Roy +* +* Permission is hereby granted, free of charge, to any person obtaining a copy +* of this software and associated documentation files (the "Software"), to deal +* in the Software without restriction, including without limitation the rights +* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +* copies of the Software, and to permit persons to whom the Software is +* furnished to do so, subject to the following conditions: +* +* The above copyright notice and this permission notice shall be included in all +* copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +* SOFTWARE. +* +*/ +package com.pushpal.jetlime + +import android.annotation.SuppressLint +import androidx.compose.material3.Text +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollToNode +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import kotlinx.collections.immutable.persistentListOf +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@SuppressLint("ComposableNaming") +@RunWith(AndroidJUnit4::class) +class JetLimePaginatedColumnTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun jetLimePaginatedColumn_displaysItems() { + val itemsList = ItemsList(persistentListOf("Item 1", "Item 2")) + + composeTestRule.setContent { + JetLimePaginatedColumn( + itemsList = itemsList, + onLoadMore = {}, + itemContent = { _, item, _ -> + JetLimeEvent { + Text(text = item) + } + }, + ) + } + + composeTestRule.onNodeWithText("Item 1").assertIsDisplayed() + composeTestRule.onNodeWithText("Item 2").assertIsDisplayed() + } + + @Test + fun jetLimePaginatedColumn_showsLoadingContentWhenLoading() { + val itemsList = ItemsList(persistentListOf("Item 1", "Item 2")) + + composeTestRule.setContent { + JetLimePaginatedColumn( + itemsList = itemsList, + onLoadMore = {}, + isLoading = true, + loadingContent = { + Text(text = "Loading", modifier = Modifier.testTag("LoadingFooter")) + }, + itemContent = { _, item, _ -> + JetLimeEvent { + Text(text = item) + } + }, + ) + } + + composeTestRule.onNodeWithTag("LoadingFooter").assertIsDisplayed() + } + + @Test + fun jetLimePaginatedColumn_triggersLoadMoreOnScroll() { + val items = mutableStateListOf().apply { + addAll((1..30).map { "Item $it" }) + } + var loadMoreCount = 0 + + composeTestRule.setContent { + JetLimePaginatedColumn( + modifier = Modifier.testTag("JetLimePaginatedColumn"), + itemsList = ItemsList(items), + key = { _, item -> item }, + onLoadMore = { + loadMoreCount++ + val next = items.size + items.addAll((next + 1..next + 10).map { "Item $it" }) + }, + itemContent = { _, item, _ -> + JetLimeEvent { + Text(text = item) + } + }, + ) + } + + composeTestRule.onNodeWithTag("JetLimePaginatedColumn") + .performScrollToNode(hasText("Item 29")) + composeTestRule.waitForIdle() + + assertThat(loadMoreCount).isAtLeast(1) + // A subsequent page should have been appended and become reachable. + composeTestRule.onNodeWithTag("JetLimePaginatedColumn") + .performScrollToNode(hasText("Item 31")) + composeTestRule.onNodeWithText("Item 31").assertIsDisplayed() + } + + @Test + fun jetLimePaginatedColumn_doesNotLoadMoreWhenNoMoreItems() { + val items = mutableStateListOf().apply { + addAll((1..30).map { "Item $it" }) + } + var loadMoreCount = 0 + + composeTestRule.setContent { + JetLimePaginatedColumn( + modifier = Modifier.testTag("JetLimePaginatedColumn"), + itemsList = ItemsList(items), + key = { _, item -> item }, + hasMoreItems = false, + onLoadMore = { loadMoreCount++ }, + itemContent = { _, item, _ -> + JetLimeEvent { + Text(text = item) + } + }, + ) + } + + composeTestRule.onNodeWithTag("JetLimePaginatedColumn") + .performScrollToNode(hasText("Item 30")) + composeTestRule.waitForIdle() + + assertThat(loadMoreCount).isEqualTo(0) + } + + @Test + fun jetLimePaginatedColumn_lastItemConnectorContinuesWhileMoreItems() { + val itemsList = ItemsList(persistentListOf("Item 1", "Item 2", "Item 3")) + val isNotEndByIndex = mutableMapOf() + + composeTestRule.setContent { + JetLimePaginatedColumn( + itemsList = itemsList, + onLoadMore = {}, + hasMoreItems = true, + itemContent = { index, item, position -> + isNotEndByIndex[index] = position.isNotEnd() + JetLimeEvent { + Text(text = item) + } + }, + ) + } + + composeTestRule.waitForIdle() + // While more items can load, the last loaded item must keep drawing its connector. + assertThat(isNotEndByIndex[2]).isTrue() + } + + @Test + fun jetLimePaginatedColumn_lastItemTerminatesOnLastPage() { + val itemsList = ItemsList(persistentListOf("Item 1", "Item 2", "Item 3")) + val isNotEndByIndex = mutableMapOf() + + composeTestRule.setContent { + JetLimePaginatedColumn( + itemsList = itemsList, + onLoadMore = {}, + hasMoreItems = false, + itemContent = { index, item, position -> + isNotEndByIndex[index] = position.isNotEnd() + JetLimeEvent { + Text(text = item) + } + }, + ) + } + + composeTestRule.waitForIdle() + // On the last page, the last item is the timeline end and its connector terminates. + assertThat(isNotEndByIndex[2]).isFalse() + } +} diff --git a/jetlime/src/commonMain/kotlin/com/pushpal/jetlime/EventPosition.kt b/jetlime/src/commonMain/kotlin/com/pushpal/jetlime/EventPosition.kt index d50dbcf..e4d09b3 100644 --- a/jetlime/src/commonMain/kotlin/com/pushpal/jetlime/EventPosition.kt +++ b/jetlime/src/commonMain/kotlin/com/pushpal/jetlime/EventPosition.kt @@ -56,6 +56,24 @@ class EventPosition internal constructor(val name: String) { @Stable fun dynamic(index: Int, listSize: Int) = eventPosition(index, listSize) + /** + * Determines the event position for a paginated list, keeping the timeline line continuous + * while more pages can still be loaded. + * + * When [isLastPage] is `false`, the last currently-loaded item is treated as [MIDDLE] instead + * of [END] so its connector line keeps extending toward the loading indicator / next page, + * avoiding a flicker each time a new page is appended. Once [isLastPage] becomes `true`, the + * trailing item resolves to [END] and the line terminates correctly. + * + * @param index The index of the item in the list. + * @param listSize The total size of the currently-loaded list. + * @param isLastPage Whether the last page has been reached (no more items to load). + * @return [EventPosition] corresponding to the index, accounting for pending pages. + */ + @Stable + internal fun dynamicPaginated(index: Int, listSize: Int, isLastPage: Boolean) = + if (!isLastPage && index == listSize - 1) MIDDLE else eventPosition(index, listSize) + /** * Internal function to determine the event position based on index and list size. */ diff --git a/jetlime/src/commonMain/kotlin/com/pushpal/jetlime/JetLimeDefaults.kt b/jetlime/src/commonMain/kotlin/com/pushpal/jetlime/JetLimeDefaults.kt index 6680c35..4b6cf1d 100644 --- a/jetlime/src/commonMain/kotlin/com/pushpal/jetlime/JetLimeDefaults.kt +++ b/jetlime/src/commonMain/kotlin/com/pushpal/jetlime/JetLimeDefaults.kt @@ -52,6 +52,12 @@ object JetLimeDefaults { private val ContentDistance: Dp = 16.dp private val ItemSpacing: Dp = 8.dp + /** + * The default number of items from the end of the list at which [JetLimePaginatedColumn] or + * [JetLimePaginatedRow] triggers the `onLoadMore` callback to fetch the next page. + */ + val LoadMoreThreshold: Int = 3 + /** * Creates a linear gradient brush for lines in [JetLimeColumn] or [JetLimeRow] components. * diff --git a/jetlime/src/commonMain/kotlin/com/pushpal/jetlime/JetLimePaginatedList.kt b/jetlime/src/commonMain/kotlin/com/pushpal/jetlime/JetLimePaginatedList.kt new file mode 100644 index 0000000..fd58f83 --- /dev/null +++ b/jetlime/src/commonMain/kotlin/com/pushpal/jetlime/JetLimePaginatedList.kt @@ -0,0 +1,296 @@ +/* +* MIT License +* +* Copyright (c) 2024 Pushpal Roy +* +* Permission is hereby granted, free of charge, to any person obtaining a copy +* of this software and associated documentation files (the "Software"), to deal +* in the Software without restriction, including without limitation the rights +* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +* copies of the Software, and to permit persons to whom the Software is +* furnished to do so, subject to the following conditions: +* +* The above copyright notice and this permission notice shall be included in all +* copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +* SOFTWARE. +* +*/ +package com.pushpal.jetlime + +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.pushpal.jetlime.Arrangement.HORIZONTAL +import com.pushpal.jetlime.Arrangement.VERTICAL + +/** + * A composable function that creates a vertical timeline with built-in pagination (infinite scroll). + * + * It behaves exactly like [JetLimeColumn] but additionally invokes [onLoadMore] when the user scrolls + * within [loadMoreThreshold] items of the end, as long as [isLoading] is `false` and [hasMoreItems] + * is `true`. While a page is being fetched ([isLoading] is `true`), [loadingContent] is rendered as a + * footer. The page state ([itemsList], [isLoading], [hasMoreItems]) is owned by the caller — the + * caller appends the next page inside [onLoadMore] and flips the flags accordingly. + * + * The timeline's connecting line stays continuous across page boundaries: while [hasMoreItems] is + * `true` the last loaded item keeps drawing its connector toward the next page instead of terminating. + * + * Example usage: + * + * ``` + * val items = remember { mutableStateListOf() } + * var isLoading by remember { mutableStateOf(false) } + * var hasMore by remember { mutableStateOf(true) } + * val scope = rememberCoroutineScope() + * + * JetLimePaginatedColumn( + * itemsList = ItemsList(items), + * isLoading = isLoading, + * hasMoreItems = hasMore, + * key = { _, item -> item.id }, + * onLoadMore = { + * scope.launch { + * isLoading = true + * val page = repository.loadNextPage() + * items.addAll(page.items) + * hasMore = !page.isLast + * isLoading = false + * } + * }, + * ) { _, item, position -> + * JetLimeEvent(style = JetLimeEventDefaults.eventStyle(position = position)) { + * ComposableContent(item = item) + * } + * } + * ``` + * + * @param T The type of items in the items list. + * @param itemsList A list of items currently loaded in the timeline. + * @param onLoadMore Callback invoked when the next page should be loaded. + * @param modifier A modifier to be applied to the LazyColumn. + * @param style The JetLime style configuration. Defaults to a predefined column style. + * @param listState The state object to be used for the LazyColumn. + * @param contentPadding The padding to apply to the content inside the LazyColumn. + * @param key A factory of stable and unique keys representing the item. If null is passed the position in the list will represent the key. + * @param isLoading Whether a page fetch is currently in flight. Suppresses further [onLoadMore] calls and shows [loadingContent]. + * @param hasMoreItems Whether more pages can be loaded. When `false`, [onLoadMore] is not called and the timeline line terminates at the last item. + * @param loadMoreThreshold The number of items from the end at which [onLoadMore] is triggered. + * @param loadingContent The footer composable shown while [isLoading] is `true`. + * @param itemContent A composable lambda that takes an index, an item of type [T], and an [EventPosition] to build each item's content. + */ +@Composable +fun JetLimePaginatedColumn( + itemsList: ItemsList, + onLoadMore: () -> Unit, + modifier: Modifier = Modifier, + style: JetLimeStyle = JetLimeDefaults.columnStyle(), + listState: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + key: ((index: Int, item: T) -> Any)? = null, + isLoading: Boolean = false, + hasMoreItems: Boolean = true, + loadMoreThreshold: Int = JetLimeDefaults.LoadMoreThreshold, + loadingContent: @Composable () -> Unit = { DefaultColumnLoadingContent() }, + itemContent: @Composable (index: Int, T, EventPosition) -> Unit, +) { + TriggerLoadMore( + listState = listState, + loadMoreThreshold = loadMoreThreshold, + isLoading = isLoading, + hasMoreItems = hasMoreItems, + onLoadMore = onLoadMore, + ) + + val providedStyle = remember(style) { style.alignment(VERTICAL) } + CompositionLocalProvider(LocalJetLimeStyle provides providedStyle) { + LazyColumn( + modifier = modifier, + state = listState, + reverseLayout = false, + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.Start, + flingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled = true, + contentPadding = contentPadding, + ) { + itemsIndexed( + items = itemsList.items, + key = key, + ) { index, item -> + val eventPosition = EventPosition.dynamicPaginated( + index = index, + listSize = itemsList.items.size, + isLastPage = !hasMoreItems, + ) + itemContent(index, item, eventPosition) + } + if (isLoading) { + item(key = LoadMoreItemKey) { loadingContent() } + } + } + } +} + +/** + * A composable function that creates a horizontal timeline with built-in pagination (infinite scroll). + * + * It behaves exactly like [JetLimeRow] but additionally invokes [onLoadMore] when the user scrolls + * within [loadMoreThreshold] items of the end, as long as [isLoading] is `false` and [hasMoreItems] + * is `true`. While a page is being fetched ([isLoading] is `true`), [loadingContent] is rendered as a + * trailing item. The page state ([itemsList], [isLoading], [hasMoreItems]) is owned by the caller. + * + * The timeline's connecting line stays continuous across page boundaries: while [hasMoreItems] is + * `true` the last loaded item keeps drawing its connector toward the next page instead of terminating. + * + * @param T The type of items in the items list. + * @param itemsList A list of items currently loaded in the timeline. + * @param onLoadMore Callback invoked when the next page should be loaded. + * @param modifier A modifier to be applied to the LazyRow. + * @param style The JetLime style configuration. Defaults to a predefined row style. + * @param listState The state object to be used for the LazyRow. + * @param contentPadding The padding to apply to the content inside the LazyRow. + * @param key A factory of stable and unique keys representing the item. If null is passed the position in the list will represent the key. + * @param isLoading Whether a page fetch is currently in flight. Suppresses further [onLoadMore] calls and shows [loadingContent]. + * @param hasMoreItems Whether more pages can be loaded. When `false`, [onLoadMore] is not called and the timeline line terminates at the last item. + * @param loadMoreThreshold The number of items from the end at which [onLoadMore] is triggered. + * @param loadingContent The trailing composable shown while [isLoading] is `true`. + * @param itemContent A composable lambda that takes an index, an item of type [T], and an [EventPosition] to build each item's content. + */ +@Composable +fun JetLimePaginatedRow( + itemsList: ItemsList, + onLoadMore: () -> Unit, + modifier: Modifier = Modifier, + style: JetLimeStyle = JetLimeDefaults.rowStyle(), + listState: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + key: ((index: Int, item: T) -> Any)? = null, + isLoading: Boolean = false, + hasMoreItems: Boolean = true, + loadMoreThreshold: Int = JetLimeDefaults.LoadMoreThreshold, + loadingContent: @Composable () -> Unit = { DefaultRowLoadingContent() }, + itemContent: @Composable (index: Int, T, EventPosition) -> Unit, +) { + TriggerLoadMore( + listState = listState, + loadMoreThreshold = loadMoreThreshold, + isLoading = isLoading, + hasMoreItems = hasMoreItems, + onLoadMore = onLoadMore, + ) + + val providedStyle = remember(style) { style.alignment(HORIZONTAL) } + CompositionLocalProvider(LocalJetLimeStyle provides providedStyle) { + LazyRow( + modifier = modifier, + state = listState, + reverseLayout = false, + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.Top, + flingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled = true, + contentPadding = contentPadding, + ) { + itemsIndexed( + items = itemsList.items, + key = key, + ) { index, item -> + val eventPosition = EventPosition.dynamicPaginated( + index = index, + listSize = itemsList.items.size, + isLastPage = !hasMoreItems, + ) + itemContent(index, item, eventPosition) + } + if (isLoading) { + item(key = LoadMoreItemKey) { loadingContent() } + } + } + } +} + +/** Stable key for the load-more footer/trailing item. */ +private val LoadMoreItemKey = "jetlime-load-more" + +/** + * Observes [listState] and invokes [onLoadMore] once the user scrolls within [loadMoreThreshold] + * items of the end, guarded by [isLoading] and [hasMoreItems]. + */ +@Composable +private fun TriggerLoadMore( + listState: LazyListState, + loadMoreThreshold: Int, + isLoading: Boolean, + hasMoreItems: Boolean, + onLoadMore: () -> Unit, +) { + val currentOnLoadMore by rememberUpdatedState(onLoadMore) + val shouldLoadMore by remember(listState, loadMoreThreshold) { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val totalItems = layoutInfo.totalItemsCount + val lastVisibleIndex = + layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf false + totalItems > 0 && lastVisibleIndex >= totalItems - 1 - loadMoreThreshold + } + } + + LaunchedEffect(shouldLoadMore, isLoading, hasMoreItems) { + if (shouldLoadMore && !isLoading && hasMoreItems) { + currentOnLoadMore() + } + } +} + +/** Default centered progress indicator used as the footer of [JetLimePaginatedColumn]. */ +@Composable +private fun DefaultColumnLoadingContent() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +/** Default centered progress indicator used as the trailing item of [JetLimePaginatedRow]. */ +@Composable +private fun DefaultRowLoadingContent() { + Box( + modifier = Modifier + .fillMaxHeight() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} diff --git a/sample/composeApp/src/commonMain/kotlin/Home.kt b/sample/composeApp/src/commonMain/kotlin/Home.kt index 1e64ebc..674fbcd 100644 --- a/sample/composeApp/src/commonMain/kotlin/Home.kt +++ b/sample/composeApp/src/commonMain/kotlin/Home.kt @@ -54,6 +54,7 @@ import timelines.BasicVerticalTimeLine import timelines.CustomizedHorizontalTimeLine import timelines.CustomizedVerticalTimeLine import timelines.ExtendedVerticalTimeLine +import timelines.PaginatedVerticalTimeLine import timelines.VerticalDynamicTimeLine @Composable @@ -82,7 +83,7 @@ fun HomeScreen( @OptIn(ExperimentalAnimationApi::class) @Composable fun HomeContent(modifier: Modifier = Modifier) { - val tabs = remember { listOf("Basic", "Dashed", "Dynamic", "Custom", "Extended") } + val tabs = remember { listOf("Basic", "Dashed", "Dynamic", "Custom", "Extended", "Paginated") } var selectedIndex by remember { mutableIntStateOf(0) } val snackBarState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() @@ -132,6 +133,10 @@ fun HomeContent(modifier: Modifier = Modifier) { } 4 -> ExtendedVerticalTimeLine { coroutineScope.launch { snackBarState.showSnackbar(it) } } + + 5 -> PaginatedVerticalTimeLine { + coroutineScope.launch { snackBarState.showSnackbar(it) } + } } } } diff --git a/sample/composeApp/src/commonMain/kotlin/timelines/PaginatedVerticalTimeLine.kt b/sample/composeApp/src/commonMain/kotlin/timelines/PaginatedVerticalTimeLine.kt new file mode 100644 index 0000000..da69661 --- /dev/null +++ b/sample/composeApp/src/commonMain/kotlin/timelines/PaginatedVerticalTimeLine.kt @@ -0,0 +1,130 @@ +/* +* MIT License +* +* Copyright (c) 2024 Pushpal Roy +* +* Permission is hereby granted, free of charge, to any person obtaining a copy +* of this software and associated documentation files (the "Software"), to deal +* in the Software without restriction, including without limitation the rights +* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +* copies of the Software, and to permit persons to whom the Software is +* furnished to do so, subject to the following conditions: +* +* The above copyright notice and this permission notice shall be included in all +* copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +* SOFTWARE. +* +*/ +package timelines + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.pushpal.jetlime.ItemsList +import com.pushpal.jetlime.JetLimeDefaults +import com.pushpal.jetlime.JetLimeEvent +import com.pushpal.jetlime.JetLimeEventDefaults +import com.pushpal.jetlime.JetLimePaginatedColumn +import data.Item +import data.getCharacters +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.compose.ui.tooling.preview.Preview +import timelines.event.VerticalEventContent + +private const val PAGE_SIZE = 6 + +@Composable +fun PaginatedVerticalTimeLine( + modifier: Modifier = Modifier, + showSnackbar: (message: String) -> Unit, +) { + val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + val allCharacters = remember { getCharacters().distinct() } + + val items = remember { mutableStateListOf() } + var page by remember { mutableIntStateOf(0) } + var isLoading by remember { mutableStateOf(false) } + var hasMoreItems by remember { mutableStateOf(true) } + + val loadPage: () -> Unit = { + coroutineScope.launch { + isLoading = true + // Simulate a network/database fetch. + delay(800) + val start = page * PAGE_SIZE + val nextChunk = allCharacters.drop(start).take(PAGE_SIZE) + items.addAll(nextChunk) + page += 1 + hasMoreItems = items.size < allCharacters.size + isLoading = false + } + } + + // The first page must be triggered explicitly; the scroll-based loader only fires once items exist. + LaunchedEffect(Unit) { + if (items.isEmpty()) loadPage() + } + + Scaffold( + modifier = modifier, + contentWindowInsets = WindowInsets(0.dp), + ) { paddingValues -> + Surface( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize(), + ) { + JetLimePaginatedColumn( + modifier = Modifier.padding(32.dp), + itemsList = ItemsList(items), + listState = listState, + style = JetLimeDefaults.columnStyle( + lineBrush = JetLimeDefaults.lineGradientBrush(), + ), + key = { _, item -> item.id }, + isLoading = isLoading, + hasMoreItems = hasMoreItems, + onLoadMore = loadPage, + ) { index, item, position -> + JetLimeEvent( + modifier = Modifier.clickable { + showSnackbar("Clicked on item: $index") + }, + style = JetLimeEventDefaults.eventStyle(position = position), + ) { + VerticalEventContent(item = item) + } + } + } + } +} + +@Preview +@Composable +private fun PreviewPaginatedVerticalTimeLine() { + PaginatedVerticalTimeLine {} +} From 1d16c893c0c1f60366db442bffa4807ceb6de814 Mon Sep 17 00:00:00 2001 From: Pushpal Roy Date: Sun, 14 Jun 2026 23:11:49 +0530 Subject: [PATCH 2/3] feat: make pagination loader customizable and hideable Make `loadingContent` nullable on JetLimePaginatedColumn / JetLimePaginatedRow. It keeps the default centered progress indicator and remains fully customizable via a slot, and passing `null` now cleanly omits the footer entirely (no empty item is added to the lazy list). Documented the behavior and added tests for the default loader, a custom loader, and the hidden (null) case. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 17 ++++++- .../jetlime/JetLimePaginatedColumnTest.kt | 50 +++++++++++++++++++ .../pushpal/jetlime/JetLimePaginatedList.kt | 18 ++++--- 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 89f07a6..b0e1892 100644 --- a/README.md +++ b/README.md @@ -171,8 +171,21 @@ JetLimePaginatedColumn( } ``` -A default centered progress indicator is shown while `isLoading` is `true`; override it with the -`loadingContent` parameter. +A default centered progress indicator is shown while `isLoading` is `true`. Customize it by passing +your own composable to `loadingContent`, or hide it entirely by passing `loadingContent = null` (no +footer is added in that case): + +```kotlin +JetLimePaginatedColumn( + itemsList = ItemsList(items), + isLoading = isLoading, + hasMoreItems = hasMore, + onLoadMore = { /* ... */ }, + loadingContent = null, // or a custom composable, e.g. { MyLoader() } +) { index, item, position -> + // Content here +} +``` ### 🎛️ Customize `JetLimeColumn` Style diff --git a/jetlime/src/androidTest/java/com/pushpal/jetlime/JetLimePaginatedColumnTest.kt b/jetlime/src/androidTest/java/com/pushpal/jetlime/JetLimePaginatedColumnTest.kt index 424fd41..baae5ba 100644 --- a/jetlime/src/androidTest/java/com/pushpal/jetlime/JetLimePaginatedColumnTest.kt +++ b/jetlime/src/androidTest/java/com/pushpal/jetlime/JetLimePaginatedColumnTest.kt @@ -29,6 +29,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.mutableStateListOf import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule @@ -92,6 +94,54 @@ class JetLimePaginatedColumnTest { composeTestRule.onNodeWithTag("LoadingFooter").assertIsDisplayed() } + @Test + fun jetLimePaginatedColumn_showsDefaultLoaderWhenLoading() { + val itemsList = ItemsList(persistentListOf("Item 1", "Item 2")) + + composeTestRule.setContent { + JetLimePaginatedColumn( + itemsList = itemsList, + onLoadMore = {}, + isLoading = true, + itemContent = { _, item, _ -> + JetLimeEvent { + Text(text = item) + } + }, + ) + } + + // The default footer is a circular progress indicator. + composeTestRule.onNode( + SemanticsMatcher.keyIsDefined(SemanticsProperties.ProgressBarRangeInfo), + ).assertIsDisplayed() + } + + @Test + fun jetLimePaginatedColumn_hidesLoaderWhenLoadingContentIsNull() { + val itemsList = ItemsList(persistentListOf("Item 1", "Item 2")) + + composeTestRule.setContent { + JetLimePaginatedColumn( + itemsList = itemsList, + onLoadMore = {}, + isLoading = true, + loadingContent = null, + itemContent = { _, item, _ -> + JetLimeEvent { + Text(text = item) + } + }, + ) + } + + // Items still render, but no footer loader is added at all. + composeTestRule.onNodeWithText("Item 1").assertIsDisplayed() + composeTestRule.onNode( + SemanticsMatcher.keyIsDefined(SemanticsProperties.ProgressBarRangeInfo), + ).assertDoesNotExist() + } + @Test fun jetLimePaginatedColumn_triggersLoadMoreOnScroll() { val items = mutableStateListOf().apply { diff --git a/jetlime/src/commonMain/kotlin/com/pushpal/jetlime/JetLimePaginatedList.kt b/jetlime/src/commonMain/kotlin/com/pushpal/jetlime/JetLimePaginatedList.kt index fd58f83..561e9c7 100644 --- a/jetlime/src/commonMain/kotlin/com/pushpal/jetlime/JetLimePaginatedList.kt +++ b/jetlime/src/commonMain/kotlin/com/pushpal/jetlime/JetLimePaginatedList.kt @@ -102,7 +102,7 @@ import com.pushpal.jetlime.Arrangement.VERTICAL * @param isLoading Whether a page fetch is currently in flight. Suppresses further [onLoadMore] calls and shows [loadingContent]. * @param hasMoreItems Whether more pages can be loaded. When `false`, [onLoadMore] is not called and the timeline line terminates at the last item. * @param loadMoreThreshold The number of items from the end at which [onLoadMore] is triggered. - * @param loadingContent The footer composable shown while [isLoading] is `true`. + * @param loadingContent The footer composable shown while [isLoading] is `true`. Defaults to a centered circular progress indicator; pass `null` to show no footer at all. * @param itemContent A composable lambda that takes an index, an item of type [T], and an [EventPosition] to build each item's content. */ @Composable @@ -117,7 +117,7 @@ fun JetLimePaginatedColumn( isLoading: Boolean = false, hasMoreItems: Boolean = true, loadMoreThreshold: Int = JetLimeDefaults.LoadMoreThreshold, - loadingContent: @Composable () -> Unit = { DefaultColumnLoadingContent() }, + loadingContent: (@Composable () -> Unit)? = { DefaultColumnLoadingContent() }, itemContent: @Composable (index: Int, T, EventPosition) -> Unit, ) { TriggerLoadMore( @@ -151,8 +151,9 @@ fun JetLimePaginatedColumn( ) itemContent(index, item, eventPosition) } - if (isLoading) { - item(key = LoadMoreItemKey) { loadingContent() } + val footer = loadingContent + if (isLoading && footer != null) { + item(key = LoadMoreItemKey) { footer() } } } } @@ -180,7 +181,7 @@ fun JetLimePaginatedColumn( * @param isLoading Whether a page fetch is currently in flight. Suppresses further [onLoadMore] calls and shows [loadingContent]. * @param hasMoreItems Whether more pages can be loaded. When `false`, [onLoadMore] is not called and the timeline line terminates at the last item. * @param loadMoreThreshold The number of items from the end at which [onLoadMore] is triggered. - * @param loadingContent The trailing composable shown while [isLoading] is `true`. + * @param loadingContent The trailing composable shown while [isLoading] is `true`. Defaults to a centered circular progress indicator; pass `null` to show no trailing loader at all. * @param itemContent A composable lambda that takes an index, an item of type [T], and an [EventPosition] to build each item's content. */ @Composable @@ -195,7 +196,7 @@ fun JetLimePaginatedRow( isLoading: Boolean = false, hasMoreItems: Boolean = true, loadMoreThreshold: Int = JetLimeDefaults.LoadMoreThreshold, - loadingContent: @Composable () -> Unit = { DefaultRowLoadingContent() }, + loadingContent: (@Composable () -> Unit)? = { DefaultRowLoadingContent() }, itemContent: @Composable (index: Int, T, EventPosition) -> Unit, ) { TriggerLoadMore( @@ -229,8 +230,9 @@ fun JetLimePaginatedRow( ) itemContent(index, item, eventPosition) } - if (isLoading) { - item(key = LoadMoreItemKey) { loadingContent() } + val footer = loadingContent + if (isLoading && footer != null) { + item(key = LoadMoreItemKey) { footer() } } } } From fa8e9e2b0073b8b26a7e4e961ceb78965ed62c2d Mon Sep 17 00:00:00 2001 From: Pushpal Roy Date: Sun, 14 Jun 2026 23:14:19 +0530 Subject: [PATCH 3/3] fix: preserve START for single-item pending pages Addresses Codex review on EventPosition.dynamicPaginated: when a paginated list has exactly one loaded item and more pages remain, index 0 was returned as MIDDLE, making isNotStart() true and drawing a spurious incoming connector before the first event. Only the outgoing connector should be forced on while pages remain, so a single-item pending list now stays START (isNotEnd() true, isNotStart() false). Added a test for the single-item pending case. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jetlime/JetLimePaginatedColumnTest.kt | 28 +++++++++++++++++++ .../com/pushpal/jetlime/EventPosition.kt | 16 +++++++---- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/jetlime/src/androidTest/java/com/pushpal/jetlime/JetLimePaginatedColumnTest.kt b/jetlime/src/androidTest/java/com/pushpal/jetlime/JetLimePaginatedColumnTest.kt index baae5ba..757602e 100644 --- a/jetlime/src/androidTest/java/com/pushpal/jetlime/JetLimePaginatedColumnTest.kt +++ b/jetlime/src/androidTest/java/com/pushpal/jetlime/JetLimePaginatedColumnTest.kt @@ -231,6 +231,34 @@ class JetLimePaginatedColumnTest { assertThat(isNotEndByIndex[2]).isTrue() } + @Test + fun jetLimePaginatedColumn_singleItemStaysStartWhileMoreItems() { + val itemsList = ItemsList(persistentListOf("Item 1")) + var isNotStart: Boolean? = null + var isNotEnd: Boolean? = null + + composeTestRule.setContent { + JetLimePaginatedColumn( + itemsList = itemsList, + onLoadMore = {}, + hasMoreItems = true, + itemContent = { _, item, position -> + isNotStart = position.isNotStart() + isNotEnd = position.isNotEnd() + JetLimeEvent { + Text(text = item) + } + }, + ) + } + + composeTestRule.waitForIdle() + // The single item is still the timeline start: only the outgoing connector is forced on, + // so no incoming connector is drawn before the first event. + assertThat(isNotStart).isFalse() + assertThat(isNotEnd).isTrue() + } + @Test fun jetLimePaginatedColumn_lastItemTerminatesOnLastPage() { val itemsList = ItemsList(persistentListOf("Item 1", "Item 2", "Item 3")) diff --git a/jetlime/src/commonMain/kotlin/com/pushpal/jetlime/EventPosition.kt b/jetlime/src/commonMain/kotlin/com/pushpal/jetlime/EventPosition.kt index e4d09b3..9d1de86 100644 --- a/jetlime/src/commonMain/kotlin/com/pushpal/jetlime/EventPosition.kt +++ b/jetlime/src/commonMain/kotlin/com/pushpal/jetlime/EventPosition.kt @@ -60,10 +60,12 @@ class EventPosition internal constructor(val name: String) { * Determines the event position for a paginated list, keeping the timeline line continuous * while more pages can still be loaded. * - * When [isLastPage] is `false`, the last currently-loaded item is treated as [MIDDLE] instead - * of [END] so its connector line keeps extending toward the loading indicator / next page, - * avoiding a flicker each time a new page is appended. Once [isLastPage] becomes `true`, the - * trailing item resolves to [END] and the line terminates correctly. + * When [isLastPage] is `false`, the last currently-loaded item only forces its outgoing + * connector on so the line keeps extending toward the loading indicator / next page, avoiding a + * flicker each time a new page is appended. For a trailing item that is not also the first item + * this means [MIDDLE]; a single loaded item (index 0) stays [START] so no incoming connector is + * drawn before the first event. Once [isLastPage] becomes `true`, the trailing item resolves to + * [END] and the line terminates correctly. * * @param index The index of the item in the list. * @param listSize The total size of the currently-loaded list. @@ -72,7 +74,11 @@ class EventPosition internal constructor(val name: String) { */ @Stable internal fun dynamicPaginated(index: Int, listSize: Int, isLastPage: Boolean) = - if (!isLastPage && index == listSize - 1) MIDDLE else eventPosition(index, listSize) + if (!isLastPage && index == listSize - 1) { + if (index == 0) START else MIDDLE + } else { + eventPosition(index, listSize) + } /** * Internal function to determine the event position based on index and list size.