How the Pull-to-Refresh Component Works with Jetpack Compose in GSYGitHubApp

The GSYPullRefresh composable in the GSYGitHubApp repository wraps Material 3's PullToRefreshBox with automatic load-more detection using derivedStateOf and LaunchedEffect to handle both refresh and pagination in a single reusable component.

The carguo/gsygithubappcompose repository demonstrates a production-ready implementation of pull-to-refresh functionality using Jetpack Compose. This article examines how the GSYPullRefresh component combines Material 3 APIs with custom scroll detection to create a seamless refresh and load-more experience.

Core Architecture of GSYPullRefresh

The component is implemented in core/ui/src/main/java/com/shuyu/gsygithubappcompose/core/ui/components/GSYPullRefresh.kt and follows a six-step architecture that separates UI state from data logic.

Wrapping with PullToRefreshBox

At the top level, the composable uses Material 3's PullToRefreshBox (lines 59-66). It accepts an isRefreshing boolean and an onRefresh lambda. The refresh gesture is only forwarded when !isLoadMore prevents simultaneous refresh and load-more operations, ensuring data consistency during paging operations.

Rendering the LazyColumn

Inside the box, a LazyColumn renders the actual list content (lines 68-76). The caller provides a customizable LazyListState and defines item rendering through the content lambda, ensuring full flexibility over list appearance while the component handles the container behavior.

After the list items, conditional logic (lines 77-124) renders a footer based on the paging state. Depending on isLoadMore, loadMoreError, hasMore, and itemCount, the component displays a loading spinner, an error retry button, a "loading more" text label, or a "no more data" indicator.

Detecting Scroll Position

The component uses derivedStateOf (lines 30-38) to create a shouldLoadMore state. This efficiently tracks when the last visible item index reaches totalItemsCount - 2, indicating the user has scrolled near the bottom without triggering recomposition on every scroll pixel.

Triggering Load More Operations

A LaunchedEffect (lines 50-54) observes shouldLoadMore. When true, and provided the list isn't empty, isn't already refreshing or loading, has more data available, and has no load-more errors, it invokes the onLoadMore callback to fetch the next page.

State Management Pattern

Each feature screen (e.g., TrendingScreen.kt lines 34-42) maintains a ViewModel that exposes isRefreshing, isLoadMore, hasMore, and other states. The screen simply passes these values to GSYPullRefresh, maintaining strict separation between UI presentation and business logic.

Implementation Examples

Basic Usage with ViewModel

The standard pattern used throughout the app (Trending, Search, Profile) involves collecting UI state from a ViewModel and passing it to the component:

@Composable
fun ExampleScreen(viewModel: ExampleViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsState()

    GSYPullRefresh(
        isRefreshing = uiState.isRefreshing,
        onRefresh = { viewModel.refresh() },
        isLoadMore = uiState.isLoadingMore,
        onLoadMore = { viewModel.loadMore() },
        hasMore = uiState.hasMore,
        itemCount = uiState.items.size,
        loadMoreError = uiState.loadMoreError,
        contentPadding = PaddingValues(8.dp),
        verticalArrangement = Arrangement.spacedBy(4.dp)
    ) {
        items(uiState.items) { item ->
            // Render each item
            Text(text = item.title)
        }
    }
}

Non-Paginated Lists

For screens like Trending where the API does not support pagination, set isLoadMore to false and provide no-op callbacks:

GSYPullRefresh(
    isRefreshing = uiState.isRefreshing,
    onRefresh = { viewModel.refresh() },
    isLoadMore = false,               // Trending API is not paginated
    onLoadMore = { /* no-op */ },
    hasMore = false,
    itemCount = uiState.repositories.size,
    loadMoreError = false,
    contentPadding = PaddingValues(5.dp),
    verticalArrangement = Arrangement.spacedBy(8.dp)
) {
    items(uiState.repositories) { repo ->
        RepositoryItem(repoItem = repo.toTrendingDisplayData())
    }
}

See the full screen implementation in feature/trending/src/main/java/com/shuyu/gsygithubappcompose/feature/trending/TrendingScreen.kt.

Internal Scroll Detection Logic

The core mechanism combining derivedStateOf for efficient reading and LaunchedEffect for side effects:

PullToRefreshBox(
    isRefreshing = isRefreshing,
    onRefresh = {
        if (!isLoadMore) onRefresh()
    }
) {
    LazyColumn(state = listState, /* … */) {
        content()
        // Footer UI based on paging state
    }

    // Detect bottom reach
    val shouldLoadMore by remember {
        derivedStateOf {
            val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()
            last?.index ?: -1 >= listState.layoutInfo.totalItemsCount - 2
        }
    }

    LaunchedEffect(shouldLoadMore) {
        if (itemCount > 0 && shouldLoadMore && !isLoadMore && !isRefreshing && hasMore && !loadMoreError) {
            onLoadMore()
        }
    }
}

Full source available at core/ui/src/main/java/com/shuyu/gsygithubappcompose/core/ui/components/GSYPullRefresh.kt.

Summary

  • Material 3 Integration: The component delegates pull-to-refresh gestures to PullToRefreshBox, inheriting platform-standard animations and accessibility handling.
  • Automatic Pagination: Uses derivedStateOf with LazyListState to detect when users reach the bottom two items, triggering onLoadMore via LaunchedEffect.
  • State Isolation: UI state (isRefreshing, isLoadMore, etc.) is supplied by the calling screen's ViewModel, keeping the component purely presentational.
  • Flexible Content: The content lambda accepts any LazyListScope items, allowing screens to customize list rendering while reusing the refresh and load-more logic.
  • Conflict Prevention: Refresh operations are blocked during load-more and vice versa, preventing race conditions in data fetching.

Frequently Asked Questions

How does GSYPullRefresh detect when to load more data?

The component creates a derivedStateOf that observes LazyListState.layoutInfo.visibleItemsInfo. When the last visible item's index is greater than or equal to totalItemsCount - 2, the boolean shouldLoadMore becomes true. A LaunchedEffect monitoring this state triggers onLoadMore() only when all safety conditions (not loading, not refreshing, has more data, no errors) are met.

Can I use GSYPullRefresh without pagination?

Yes. Set isLoadMore = false, hasMore = false, and provide an empty lambda for onLoadMore. The Trending screen in feature/trending/src/main/java/com/shuyu/gsygithubappcompose/feature/trending/TrendingScreen.kt demonstrates this pattern for APIs that return complete datasets without pagination.

Why use derivedStateOf instead of snapshotFlow for scroll detection?

derivedStateOf is optimized for reading state frequently without causing recomposition every time the underlying value changes. Since scroll position updates constantly during user interaction, derivedStateOf ensures the component only recomposes when the actual boolean condition (reaching near the bottom) changes, not on every pixel scrolled.

How does the component prevent simultaneous refresh and load operations?

The onRefresh callback inside PullToRefreshBox checks if (!isLoadMore) before invoking the user-provided onRefresh. Similarly, the LaunchedEffect for load-more checks !isRefreshing before triggering onLoadMore. These guards ensure that only one data operation runs at a time, preventing race conditions and inconsistent UI states.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →