Skip to content

Commit 21c281d

Browse files
authored
Merge pull request #68 from pushpalroy/proy/feat/pagination
feat: native pagination support (JetLimePaginatedColumn / JetLimePaginatedRow)
2 parents 0fcfa7f + fa8e9e2 commit 21c281d

7 files changed

Lines changed: 810 additions & 1 deletion

File tree

README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
- RTL layout support for JetLimeRow and JetLimeExtendedEvent (mirrors timelines and keeps content visible in right-to-left layouts)
3535
- Dashed/gradient/solid lines via Brush + PathEffect
3636
- Extended events with dual content slots (left/right), icons, and animations
37+
- Built-in pagination / infinite scroll (JetLimePaginatedColumn / JetLimePaginatedRow) with no extra dependency
3738
- Small, focused API with sensible defaults (JetLimeDefaults)
3839

3940
## 📦 Installation
@@ -126,6 +127,66 @@ JetLimeColumn(
126127
}
127128
```
128129

130+
### 🔄 Paginated Timeline (Infinite Scroll)
131+
132+
Use [JetLimePaginatedColumn](https://pushpalroy.github.io/JetLime/jetlime/com.pushpal.jetlime/-jet-lime-paginated-column.html)
133+
(or `JetLimePaginatedRow`) to load items page by page as the user scrolls. The component watches the
134+
scroll position and calls `onLoadMore` when the user gets within `loadMoreThreshold` items of the end,
135+
as long as a page is not already loading and more items remain. Pagination is implemented natively —
136+
no extra dependency is added.
137+
138+
You own the page state (`itemsList`, `isLoading`, `hasMoreItems`); append the next page inside
139+
`onLoadMore` and update the flags. While `hasMoreItems` is `true`, the timeline line stays continuous
140+
into the next page, and it terminates once you set `hasMoreItems = false`.
141+
142+
```kotlin
143+
val items = remember { mutableStateListOf<Item>() }
144+
var isLoading by remember { mutableStateOf(false) }
145+
var hasMore by remember { mutableStateOf(true) }
146+
val scope = rememberCoroutineScope()
147+
148+
// Trigger the first page; the scroll-based loader fires only once items exist.
149+
LaunchedEffect(Unit) { if (items.isEmpty()) loadNextPage() }
150+
151+
JetLimePaginatedColumn(
152+
itemsList = ItemsList(items),
153+
key = { _, item -> item.id },
154+
isLoading = isLoading,
155+
hasMoreItems = hasMore,
156+
onLoadMore = {
157+
scope.launch {
158+
isLoading = true
159+
val page = repository.loadNextPage() // your data source
160+
items.addAll(page.items)
161+
hasMore = !page.isLast
162+
isLoading = false
163+
}
164+
},
165+
) { index, item, position ->
166+
JetLimeEvent(
167+
style = JetLimeEventDefaults.eventStyle(position = position)
168+
) {
169+
// Content here
170+
}
171+
}
172+
```
173+
174+
A default centered progress indicator is shown while `isLoading` is `true`. Customize it by passing
175+
your own composable to `loadingContent`, or hide it entirely by passing `loadingContent = null` (no
176+
footer is added in that case):
177+
178+
```kotlin
179+
JetLimePaginatedColumn(
180+
itemsList = ItemsList(items),
181+
isLoading = isLoading,
182+
hasMoreItems = hasMore,
183+
onLoadMore = { /* ... */ },
184+
loadingContent = null, // or a custom composable, e.g. { MyLoader() }
185+
) { index, item, position ->
186+
// Content here
187+
}
188+
```
189+
129190
### 🎛️ Customize `JetLimeColumn` Style
130191

131192
Use the [JetLimeDefaults.columnStyle()](https://pushpalroy.github.io/JetLime/jetlime/com.pushpal.jetlime/-jet-lime-defaults/column-style.html)
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
/*
2+
* MIT License
3+
*
4+
* Copyright (c) 2024 Pushpal Roy
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*
24+
*/
25+
package com.pushpal.jetlime
26+
27+
import android.annotation.SuppressLint
28+
import androidx.compose.material3.Text
29+
import androidx.compose.runtime.mutableStateListOf
30+
import androidx.compose.ui.Modifier
31+
import androidx.compose.ui.platform.testTag
32+
import androidx.compose.ui.semantics.SemanticsProperties
33+
import androidx.compose.ui.test.SemanticsMatcher
34+
import androidx.compose.ui.test.assertIsDisplayed
35+
import androidx.compose.ui.test.hasText
36+
import androidx.compose.ui.test.junit4.createComposeRule
37+
import androidx.compose.ui.test.onNodeWithTag
38+
import androidx.compose.ui.test.onNodeWithText
39+
import androidx.compose.ui.test.performScrollToNode
40+
import androidx.test.ext.junit.runners.AndroidJUnit4
41+
import com.google.common.truth.Truth.assertThat
42+
import kotlinx.collections.immutable.persistentListOf
43+
import org.junit.Rule
44+
import org.junit.Test
45+
import org.junit.runner.RunWith
46+
47+
@SuppressLint("ComposableNaming")
48+
@RunWith(AndroidJUnit4::class)
49+
class JetLimePaginatedColumnTest {
50+
51+
@get:Rule
52+
val composeTestRule = createComposeRule()
53+
54+
@Test
55+
fun jetLimePaginatedColumn_displaysItems() {
56+
val itemsList = ItemsList(persistentListOf("Item 1", "Item 2"))
57+
58+
composeTestRule.setContent {
59+
JetLimePaginatedColumn(
60+
itemsList = itemsList,
61+
onLoadMore = {},
62+
itemContent = { _, item, _ ->
63+
JetLimeEvent {
64+
Text(text = item)
65+
}
66+
},
67+
)
68+
}
69+
70+
composeTestRule.onNodeWithText("Item 1").assertIsDisplayed()
71+
composeTestRule.onNodeWithText("Item 2").assertIsDisplayed()
72+
}
73+
74+
@Test
75+
fun jetLimePaginatedColumn_showsLoadingContentWhenLoading() {
76+
val itemsList = ItemsList(persistentListOf("Item 1", "Item 2"))
77+
78+
composeTestRule.setContent {
79+
JetLimePaginatedColumn(
80+
itemsList = itemsList,
81+
onLoadMore = {},
82+
isLoading = true,
83+
loadingContent = {
84+
Text(text = "Loading", modifier = Modifier.testTag("LoadingFooter"))
85+
},
86+
itemContent = { _, item, _ ->
87+
JetLimeEvent {
88+
Text(text = item)
89+
}
90+
},
91+
)
92+
}
93+
94+
composeTestRule.onNodeWithTag("LoadingFooter").assertIsDisplayed()
95+
}
96+
97+
@Test
98+
fun jetLimePaginatedColumn_showsDefaultLoaderWhenLoading() {
99+
val itemsList = ItemsList(persistentListOf("Item 1", "Item 2"))
100+
101+
composeTestRule.setContent {
102+
JetLimePaginatedColumn(
103+
itemsList = itemsList,
104+
onLoadMore = {},
105+
isLoading = true,
106+
itemContent = { _, item, _ ->
107+
JetLimeEvent {
108+
Text(text = item)
109+
}
110+
},
111+
)
112+
}
113+
114+
// The default footer is a circular progress indicator.
115+
composeTestRule.onNode(
116+
SemanticsMatcher.keyIsDefined(SemanticsProperties.ProgressBarRangeInfo),
117+
).assertIsDisplayed()
118+
}
119+
120+
@Test
121+
fun jetLimePaginatedColumn_hidesLoaderWhenLoadingContentIsNull() {
122+
val itemsList = ItemsList(persistentListOf("Item 1", "Item 2"))
123+
124+
composeTestRule.setContent {
125+
JetLimePaginatedColumn(
126+
itemsList = itemsList,
127+
onLoadMore = {},
128+
isLoading = true,
129+
loadingContent = null,
130+
itemContent = { _, item, _ ->
131+
JetLimeEvent {
132+
Text(text = item)
133+
}
134+
},
135+
)
136+
}
137+
138+
// Items still render, but no footer loader is added at all.
139+
composeTestRule.onNodeWithText("Item 1").assertIsDisplayed()
140+
composeTestRule.onNode(
141+
SemanticsMatcher.keyIsDefined(SemanticsProperties.ProgressBarRangeInfo),
142+
).assertDoesNotExist()
143+
}
144+
145+
@Test
146+
fun jetLimePaginatedColumn_triggersLoadMoreOnScroll() {
147+
val items = mutableStateListOf<String>().apply {
148+
addAll((1..30).map { "Item $it" })
149+
}
150+
var loadMoreCount = 0
151+
152+
composeTestRule.setContent {
153+
JetLimePaginatedColumn(
154+
modifier = Modifier.testTag("JetLimePaginatedColumn"),
155+
itemsList = ItemsList(items),
156+
key = { _, item -> item },
157+
onLoadMore = {
158+
loadMoreCount++
159+
val next = items.size
160+
items.addAll((next + 1..next + 10).map { "Item $it" })
161+
},
162+
itemContent = { _, item, _ ->
163+
JetLimeEvent {
164+
Text(text = item)
165+
}
166+
},
167+
)
168+
}
169+
170+
composeTestRule.onNodeWithTag("JetLimePaginatedColumn")
171+
.performScrollToNode(hasText("Item 29"))
172+
composeTestRule.waitForIdle()
173+
174+
assertThat(loadMoreCount).isAtLeast(1)
175+
// A subsequent page should have been appended and become reachable.
176+
composeTestRule.onNodeWithTag("JetLimePaginatedColumn")
177+
.performScrollToNode(hasText("Item 31"))
178+
composeTestRule.onNodeWithText("Item 31").assertIsDisplayed()
179+
}
180+
181+
@Test
182+
fun jetLimePaginatedColumn_doesNotLoadMoreWhenNoMoreItems() {
183+
val items = mutableStateListOf<String>().apply {
184+
addAll((1..30).map { "Item $it" })
185+
}
186+
var loadMoreCount = 0
187+
188+
composeTestRule.setContent {
189+
JetLimePaginatedColumn(
190+
modifier = Modifier.testTag("JetLimePaginatedColumn"),
191+
itemsList = ItemsList(items),
192+
key = { _, item -> item },
193+
hasMoreItems = false,
194+
onLoadMore = { loadMoreCount++ },
195+
itemContent = { _, item, _ ->
196+
JetLimeEvent {
197+
Text(text = item)
198+
}
199+
},
200+
)
201+
}
202+
203+
composeTestRule.onNodeWithTag("JetLimePaginatedColumn")
204+
.performScrollToNode(hasText("Item 30"))
205+
composeTestRule.waitForIdle()
206+
207+
assertThat(loadMoreCount).isEqualTo(0)
208+
}
209+
210+
@Test
211+
fun jetLimePaginatedColumn_lastItemConnectorContinuesWhileMoreItems() {
212+
val itemsList = ItemsList(persistentListOf("Item 1", "Item 2", "Item 3"))
213+
val isNotEndByIndex = mutableMapOf<Int, Boolean>()
214+
215+
composeTestRule.setContent {
216+
JetLimePaginatedColumn(
217+
itemsList = itemsList,
218+
onLoadMore = {},
219+
hasMoreItems = true,
220+
itemContent = { index, item, position ->
221+
isNotEndByIndex[index] = position.isNotEnd()
222+
JetLimeEvent {
223+
Text(text = item)
224+
}
225+
},
226+
)
227+
}
228+
229+
composeTestRule.waitForIdle()
230+
// While more items can load, the last loaded item must keep drawing its connector.
231+
assertThat(isNotEndByIndex[2]).isTrue()
232+
}
233+
234+
@Test
235+
fun jetLimePaginatedColumn_singleItemStaysStartWhileMoreItems() {
236+
val itemsList = ItemsList(persistentListOf("Item 1"))
237+
var isNotStart: Boolean? = null
238+
var isNotEnd: Boolean? = null
239+
240+
composeTestRule.setContent {
241+
JetLimePaginatedColumn(
242+
itemsList = itemsList,
243+
onLoadMore = {},
244+
hasMoreItems = true,
245+
itemContent = { _, item, position ->
246+
isNotStart = position.isNotStart()
247+
isNotEnd = position.isNotEnd()
248+
JetLimeEvent {
249+
Text(text = item)
250+
}
251+
},
252+
)
253+
}
254+
255+
composeTestRule.waitForIdle()
256+
// The single item is still the timeline start: only the outgoing connector is forced on,
257+
// so no incoming connector is drawn before the first event.
258+
assertThat(isNotStart).isFalse()
259+
assertThat(isNotEnd).isTrue()
260+
}
261+
262+
@Test
263+
fun jetLimePaginatedColumn_lastItemTerminatesOnLastPage() {
264+
val itemsList = ItemsList(persistentListOf("Item 1", "Item 2", "Item 3"))
265+
val isNotEndByIndex = mutableMapOf<Int, Boolean>()
266+
267+
composeTestRule.setContent {
268+
JetLimePaginatedColumn(
269+
itemsList = itemsList,
270+
onLoadMore = {},
271+
hasMoreItems = false,
272+
itemContent = { index, item, position ->
273+
isNotEndByIndex[index] = position.isNotEnd()
274+
JetLimeEvent {
275+
Text(text = item)
276+
}
277+
},
278+
)
279+
}
280+
281+
composeTestRule.waitForIdle()
282+
// On the last page, the last item is the timeline end and its connector terminates.
283+
assertThat(isNotEndByIndex[2]).isFalse()
284+
}
285+
}

jetlime/src/commonMain/kotlin/com/pushpal/jetlime/EventPosition.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,30 @@ class EventPosition internal constructor(val name: String) {
5656
@Stable
5757
fun dynamic(index: Int, listSize: Int) = eventPosition(index, listSize)
5858

59+
/**
60+
* Determines the event position for a paginated list, keeping the timeline line continuous
61+
* while more pages can still be loaded.
62+
*
63+
* When [isLastPage] is `false`, the last currently-loaded item only forces its outgoing
64+
* connector on so the line keeps extending toward the loading indicator / next page, avoiding a
65+
* flicker each time a new page is appended. For a trailing item that is not also the first item
66+
* this means [MIDDLE]; a single loaded item (index 0) stays [START] so no incoming connector is
67+
* drawn before the first event. Once [isLastPage] becomes `true`, the trailing item resolves to
68+
* [END] and the line terminates correctly.
69+
*
70+
* @param index The index of the item in the list.
71+
* @param listSize The total size of the currently-loaded list.
72+
* @param isLastPage Whether the last page has been reached (no more items to load).
73+
* @return [EventPosition] corresponding to the index, accounting for pending pages.
74+
*/
75+
@Stable
76+
internal fun dynamicPaginated(index: Int, listSize: Int, isLastPage: Boolean) =
77+
if (!isLastPage && index == listSize - 1) {
78+
if (index == 0) START else MIDDLE
79+
} else {
80+
eventPosition(index, listSize)
81+
}
82+
5983
/**
6084
* Internal function to determine the event position based on index and list size.
6185
*/

0 commit comments

Comments
 (0)