Mobile Development 17 min read

Implementation of a Simple RedBook App Using Jetpack Compose with VersionCatalog, Navigation, Paging3, and Custom Layouts

The article walks through building a lightweight RedBook‑style Android app with Jetpack Compose, demonstrating modern techniques such as Gradle Version Catalog for dependency management, Navigation Compose routing, Paging3 data pagination, custom layouts and scroll handling, shared‑element transitions, edge‑to‑edge theming, and a bespoke circular progress indicator.

Sohu Tech Products
Sohu Tech Products
Sohu Tech Products
Implementation of a Simple RedBook App Using Jetpack Compose with VersionCatalog, Navigation, Paging3, and Custom Layouts

This article presents a lightweight recreation of the Xiaohongshu (RedBook) app built with Jetpack Compose. The implementation showcases a collection of modern Android development techniques, including VersionCatalog for dependency management, Navigation for routing, Paging3 for data pagination, custom Layouts, NestedScrollConnection, shared‑element transitions, Edge‑to‑Edge handling, and a custom theme.

Version Management

The project uses Gradle Version Catalog to centralize library versions, simplifying upgrades and ensuring consistency across modules.

Routing and Navigation

Navigation Compose is employed to define two primary destinations: main and home_detail . The main screen hosts a nested MainNavHost to keep the BottomBar visible while allowing the detail page to occupy the full screen.

class MainActivity {
  setContent {
    AppNavHost()
  }
}

fun AppNavHost() {
  composable("main") {
    Scaffold(bottomBar = { BottomBar() }) { paddingValues ->
      Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
        MainNavHost()
      }
    }
  }
  composable("home_detail")
}

fun MainNavHost() {
  composable("home")
  composable("shopping")
  composable("message")
  composable("me")
}

Home Page Structure

The home screen is built with a custom RedBookTabRow that mimics ScrollableTabRow but removes the default 90dp minimum width, allowing tabs to size themselves based on content.

HorizontalPager Priority

When both TabRow1 and TabRow2 are visible, the inner HorizontalPager2 consumes scroll events first. If TabRow2 is hidden, the outer HorizontalPager1 takes precedence. This is achieved by toggling userScrollEnabled .

// Simple handling
HorizontalPager(
    state = pagerState,
    modifier = Modifier.fillMaxSize(),
    userScrollEnabled = animateHeaderState.flag,
)

Pull‑to‑Refresh & Paging3

A custom composable CommonRefresh implements pull‑to‑refresh using a custom Layout combined with NestedScrollConnection . It works together with Paging3 to load data incrementally.

@Composable
fun CommonRefresh(
    modifier: Modifier = Modifier,
    state: CommonRefreshState,
    onRefresh: (suspend () -> Unit)? = null,
    headerIndicator: (@Composable () -> Unit)? = { CommonRefreshHeader(modifier = Modifier.padding(16.dp).fillMaxWidth().height(28.dp), state = state) },
    content: @Composable () -> Unit
) {
    // ... implementation omitted for brevity ...
}

The refresh logic updates isRefreshing , animates the offset, and handles fling cancellation to prevent the header from overscrolling.

Paging3 Usage Steps

Define a PagingSource that loads mock data.

Create a Pager with PagingConfig (pageSize = 20, prefetchDistance = 4).

Collect the PagingData flow in a ViewModel and cache it.

Display the data with LazyPagingItems , showing loading indicators for initial load, append, and empty states.

Trigger a refresh from CommonRefresh by calling lazyPagingItems.refresh() .

Custom CircleProgress

A lightweight circular progress indicator is drawn with Canvas , rendering two arcs (upper and lower) whose sweep angle is driven by a progress float.

@Composable
fun CircleProgress(
    modifier: Modifier = Modifier,
    color: Color = Color.LightGray,
    progress: Float = 1f
) {
    Canvas(modifier = modifier.drawWithCache {
        val ringStrokeWidth = 3.dp.toPx()
        val radius = min(size.width, size.height) / 2 - ringStrokeWidth
        val topLeft = Offset(size.width / 2 - radius, size.height / 2 - radius)
        onDrawWithContent {
            val sweepAngle = 140f * progress
            drawArc(color, startAngle = -160f, sweepAngle = sweepAngle, useCenter = false, topLeft = topLeft, size = Size(radius * 2, radius * 2), style = Stroke(width = ringStrokeWidth, cap = StrokeCap.Round))
            drawArc(color, startAngle = 20f, sweepAngle = sweepAngle, useCenter = false, topLeft = topLeft, size = Size(radius * 2, radius * 2), style = Stroke(width = ringStrokeWidth, cap = StrokeCap.Round))
        }
    }) {}
}

Coordinated Scrolling Analysis

The page combines three scrollable areas: the top bar, the tab/content region, and the non‑list area. By measuring the heights of TopBar , MeTabContent , and MeFunctionBar , a custom NestedScrollMeState records the overall offset and drives layout placement via SubcomposeLayout .

class NestedScrollMeState {
    var topBarHeight = 0
    var contentBarHeight = 0
    var functionBarHeight = 0
    private val _offset = Animatable(0f)
    val offset: Float get() = _offset.value
}

During layout, each composable is positioned with placeRelative using the current offset , enabling the top bar to fade in/out, the tab content to collapse/expand, and the background image to scale with pull‑to‑refresh.

Shared‑Element Transitions

Navigation shared‑element animations are achieved by wrapping the NavHost in SharedTransitionLayout and propagating SharedTransitionScope and AnimatedVisibilityScope via CompositionLocalProvider .

SharedTransitionLayout {
    CompositionLocalProvider(LocalNavHostSharedTransitionScope provides this) {
        AppNavHost()
    }
}

fun NavGraphBuilder.composableWithCompositionLocal(
    route: String,
    arguments: List
= emptyList(),
    content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit
) {
    composable(route = route, arguments = arguments) {
        CompositionLocalProvider(LocalAnimatedVisibilityScope provides this@composable) {
            content(it)
        }
    }
}

Theming & Edge‑to‑Edge Status Bar

A custom RedBookTheme supplies light and dark color palettes via CompositionLocalProvider . The status‑bar icon color is switched based on the current route and theme, using enableEdgeToEdge with SystemBarStyle.auto .

@Composable
fun RedBookTheme(themeType: AppThemeType = AppThemeType.Light, content: @Composable () -> Unit) {
    val colors = if (AppThemeType.isDark(themeType)) darkLorenColors else lightLorenColors
    CompositionLocalProvider(
        LocalCustomColors provides colors,
        LocalTextStyles provides RedBookTheme.textStyle
    ) {
        MaterialTheme(content = content)
    }
}

The full source code is available at https://github.com/Loren-Moon/RedBookComposeDemo .

AndroidJetpack ComposenavigationPull-to-RefreshCustom LayoutPaging3Shared Elementstheming
Sohu Tech Products
Written by

Sohu Tech Products

A knowledge-sharing platform for Sohu's technology products. As a leading Chinese internet brand with media, video, search, and gaming services and over 700 million users, Sohu continuously drives tech innovation and practice. We’ll share practical insights and tech news here.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.