Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -126,6 +127,66 @@ 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<Item>() }
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`. 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

Use the [JetLimeDefaults.columnStyle()](https://pushpalroy.github.io/JetLime/jetlime/com.pushpal.jetlime/-jet-lime-defaults/column-style.html)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
/*
* 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.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
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_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<String>().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<String>().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<Int, Boolean>()

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_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"))
val isNotEndByIndex = mutableMapOf<Int, Boolean>()

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()
}
}
24 changes: 24 additions & 0 deletions jetlime/src/commonMain/kotlin/com/pushpal/jetlime/EventPosition.kt
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,30 @@ 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 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.
* @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) {
if (index == 0) START else MIDDLE
} else {
eventPosition(index, listSize)
}

/**
* Internal function to determine the event position based on index and list size.
*/
Expand Down
Loading
Loading