9 min read

Jetpack Compose: Core Concepts Deep Dive (Part 2/2)

A detailed learning-in-public deep dive on Core Concepts in Jetpack Compose, covering concept batch 2/2.

#Learning-log#Compose#Android New Topic

Table of contents

  1. Context and Scope
  2. Conceptual Model & Analogy
  3. Deep Dive
  4. Implementation Patterns
  5. Baseline Example: A Simple Slotted Container
  6. Production-Grade Example: A CenteringTopBar Built with Subcomposition
  7. Common Pitfalls and Tradeoffs
  8. Migration Guidance
  9. Technical Note
  10. Sources & Further Reading
  11. Check Your Work
  12. Hands-on Exercise
  13. Brain Teaser

Context and Scope

This deep dive focuses on the Slot API pattern in Jetpack Compose: passing composable UI as parameters to create flexible, reusable components. You’ll learn how to design clean, testable Slot APIs, when to prefer named versus single “content” slots, how slots interact with insets and Scaffold, and how to push the pattern further with subcomposition for production-grade layouts. Material 3 and Compose 1.8+ guidance is used throughout, as of March 6, 2026. Material components and container composables (like Scaffold and TopAppBar) are prime examples of slot-based APIs. They accept composable lambdas for areas such as topBar, bottomBar, title, actions, etc., and pass PaddingValues to content for proper insetting. (developer.android.com)

Conceptual Model & Analogy

Think of a high-end camera bag with customizable compartments. The bag gives you sturdy walls and a few adjustable “slots,” but you decide whether a space holds a camera body, a lens, or a mic. A Compose Slot API is that bag: the container lays out structure and behavior; the slots are the adjustable compartments you fill with any composable content you need. This yields consistency without forcing one-size-fits-all child configurations. In Compose docs, this is framed as “slots leave an empty space in the UI for the developer to fill as they wish,” enabling components to accept child content instead of exposing every child parameter directly. (developer.android.com)

Deep Dive

  • What a slot is: a parameter typed as a composable lambda, commonly content: @Composable () -> Unit, sometimes with a receiver scope like RowScope.() -> Unit. Material components expose multiple slots for specific purposes (e.g., TopAppBar’s title, navigationIcon, actions). (developer.android.com)
  • Parameter design: Compose’s component API guidelines recommend the trailing content lambda pattern, and clear, discoverable named slots for common areas. This keeps call sites readable and encourages composition over configuration. (android.googlesource.com)
  • Insets and padding: Material 3 components often handle insets automatically. Scaffold passes inner PaddingValues to its content slot, and M2/M3 provide explicit insets parameters (windowInsets, contentWindowInsets) so you can fine-tune behavior. This changed across releases and can affect your slot design and usage. (developer.android.com)
  • Advanced layouts: SubcomposeLayout lets you compose different slot content during measure, enabling behaviors like measuring actions before laying out a title. You can also tune reuse with SubcomposeSlotReusePolicy to avoid churn when slot contents change. (developer.android.com)

Implementation Patterns

Baseline Example: A Simple Slotted Container

A compact container that lays out an optional header and actions row above arbitrary body content. It demonstrates:

  • Optional slots with sane defaults
  • A RowScope receiver for the actions slot
  • Proper consumption of PaddingValues for content
@Composable
fun Panel(
    modifier: Modifier = Modifier,
    header: (@Composable () -> Unit)? = null,
    actions: @Composable RowScope.() -> Unit = {},
    contentPadding: PaddingValues = PaddingValues(16.dp),
    content: @Composable () -> Unit
) {
    Surface(modifier = modifier) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(contentPadding),
            verticalArrangement = Arrangement.spacedBy(12.dp)
        ) {
            if (header != null || actions !== {}) {
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Box(Modifier.weight(1f)) {
                        header?.invoke()
                    }
                    Row(
                        horizontalArrangement = Arrangement.spacedBy(8.dp),
                        content = actions
                    )
                }
            }
            content()
        }
    }
}

@Composable
fun PanelSample() {
    Panel(
        header = { Text("Inbox", style = MaterialTheme.typography.titleLarge) },
        actions = {
            IconButton(onClick = {}) { Icon(Icons.Default.Search, null) }
            IconButton(onClick = {}) { Icon(Icons.Default.MoreVert, null) }
        }
    ) {
        Text("Slot-based containers let you decide what goes here.")
    }
}

Why this is idiomatic:

  • It uses named slots instead of forcing a rigid header model.
  • The actions slot is a RowScope receiver, so callers can use RowScope modifiers (e.g., weight) without exposing Row details.
  • The main content is a trailing lambda per Compose API guidelines. (android.googlesource.com)

Production-Grade Example: A CenteringTopBar Built with Subcomposition

This TopBar centers the title only if it won’t collide with navigation or trailing actions. Otherwise, it left-aligns after the nav icon. It demonstrates:

  • SubcomposeLayout for slot-aware measurement
  • Reuse-friendly slot keys
  • Receiver slots and defaults
private enum class BarSlot { Nav, Title, Actions }

@Composable
fun CenteringTopBar(
    modifier: Modifier = Modifier,
    height: Dp = 56.dp,
    navigationIcon: (@Composable () -> Unit)? = null,
    title: @Composable () -> Unit,
    actions: @Composable RowScope.() -> Unit = {},
    containerColor: Color = MaterialTheme.colorScheme.surface,
    contentColor: Color = contentColorFor(containerColor)
) {
    Surface(color = containerColor, contentColor = contentColor) {
        SubcomposeLayout(modifier) { constraints ->
            val density = this
            val barHeightPx = height.roundToPx()
            val barWidth = constraints.maxWidth
            val barConstraints = constraints.copy(minHeight = 0, maxHeight = barHeightPx)

            val navPlaceables = if (navigationIcon != null) {
                subcompose(BarSlot.Nav) { Box { navigationIcon() } }
                    .map { it.measure(barConstraints) }
            } else emptyList()

            val actionsPlaceables = subcompose(BarSlot.Actions) {
                Row(horizontalArrangement = Arrangement.spacedBy(8.dp), content = actions)
            }.map { it.measure(barConstraints) }

            val navWidth = navPlaceables.sumOf { it.width }
            val actionsWidth = actionsPlaceables.sumOf { it.width }

            val titlePlaceables = subcompose(BarSlot.Title) {
                Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) { title() }
            }.map { it.measure(barConstraints) }

            val titleWidth = titlePlaceables.maxOfOrNull { it.width } ?: 0

            // Compute ideal centered x, then clamp to avoid overlap.
            val centerX = barWidth / 2
            val idealTitleX = centerX - titleWidth / 2
            val minTitleStart = navWidth + with(density) { 16.dp.roundToPx() }
            val maxTitleEnd = barWidth - actionsWidth - with(density) { 16.dp.roundToPx() }
            val clampedTitleX = idealTitleX.coerceIn(minTitleStart, maxTitleEnd - titleWidth)

            layout(barWidth, barHeightPx) {
                var x = 0
                navPlaceables.forEach { it.placeRelative(x, (barHeightPx - it.height) / 2); x += it.width }
                actionsPlaceables.forEach { it.placeRelative(barWidth - it.width, (barHeightPx - it.height) / 2) }

                // If clamping forced us left, align start; otherwise we're centered.
                val actualTitleX = if (clampedTitleX == idealTitleX) clampedTitleX else minTitleStart
                titlePlaceables.forEach {
                    it.placeRelative(actualTitleX, (barHeightPx - it.height) / 2)
                }
            }
        }
    }
}

Why this is production-ready:

  • It uses SubcomposeLayout to measure actions and nav before placing the title, mirroring behavior in sophisticated app bars. Subcomposition is the Compose tool for composing slot content during measure when sizes influence composition. (developer.android.com)
  • The pattern scales: for dynamic menus, you can adopt SubcomposeSlotReusePolicy to retain previously composed slots for performance when content churns. (developer.android.com)

Common Pitfalls and Tradeoffs

  • Over-slotting your API: Too many narrowly-defined slots can bloat call sites and restrict composition. Prefer a few purposeful slots (e.g., header, actions, content). Compose’s API guidelines recommend using a trailing content lambda and named slots where they add clarity. (android.googlesource.com)
  • Forgetting to apply or consume insets: Scaffold passes inner PaddingValues to its content slot; you need to apply them or consumeWindowInsets to avoid visual glitches. Material 3 auto-handles many insets in its components; Material 2 requires you to wire windowInsets and contentWindowInsets yourself. This changed across versions and is a frequent source of confusion. (developer.android.com)
  • Capturing unstable objects in slot lambdas: Large captures can trigger more recompositions. Prefer passing immutable or stable state, or derive minimal values first (e.g., remember/derivedStateOf) and pass those into the slot content.
  • Doing side effects inside slot lambdas: Slots should be pure UI. Use effects (LaunchedEffect, SideEffect) at appropriate scopes instead of calling imperative code directly from slot content.
  • Accessibility gaps: If your container merges multiple slots into a single “thing,” verify semantics. Consider mergeDescendants, contentDescription, and test tags so screen readers and tests can locate slot content predictably.
  • Advanced layout cost: SubcomposeLayout is powerful but not free; compose-on-measure can add overhead. Introduce it only when measurement truly affects composition, and consider slot reuse policies for dynamic content. (developer.android.com)

Migration Guidance

  • From “props explosion” to slots: When a component exposes many child-related parameters (iconRes, iconTint, trailingButtonText…), replace them with one or more composable slots (leading, trailing, content). This simplifies API, increases flexibility, and reduces overloads.
  • Material 2 → Material 3 slot usage and insets:
    • Keep using Scaffold/TopAppBar slots, but note the insets model: Material 3 components tend to auto-handle insets; Material 2 requires explicit windowInsets on components and contentWindowInsets on Scaffold. Review older blog examples carefully and align with current docs. (developer.android.com)
    • Material 3 Scaffold exposes contentWindowInsets defaults via ScaffoldDefaults; prefer these when you need to override insets for the content slot. (developer.android.com)
  • Don’t confuse Slot API (UI pattern) with the runtime slot table (an internal data structure in Compose runtime). The former is your component API surface; the latter is a runtime implementation detail that evolves across releases. (developer.android.com)

Technical Note

Older tutorials often show manual status/navigation bar padding on Scaffold content or app bars. Current guidance clarifies:

  • Material 3 components often handle insets for you; you typically consume the PaddingValues that Scaffold passes to its content slot rather than adding ad-hoc padding.
  • Material 2 requires explicit windowInsets and contentWindowInsets parameters instead of manual padding hacks. If you follow pre-1.6 M2 guidance, migrate to these parameters for correctness with IME and system bars. (developer.android.com)

Sources & Further Reading

Check Your Work

Hands-on Exercise

  • Refactor a “settings row” that currently takes parameters like iconRes, titleText, subtitleText, showChevron, onClick into a slotted API:
    • leading: @Composable (() -> Unit)?
    • overline: @Composable (() -> Unit)?
    • trailing: @Composable RowScope.() -> Unit
    • content: @Composable () -> Unit
    • Implement it using a baseline like Panel above, then:
      • Add a parameter insets: PaddingValues and thread it through.
      • Write two call sites: one with a Switch in trailing, one with a Badge.
      • Add UI tests that locate leading and trailing slots via testTag and verify semantics.

Brain Teaser

  • Suppose you built a custom app bar with three slots: navigationIcon, title, actions. Using only SubcomposeLayout and no intrinsic measurements, how would you:
    1. Guarantee the title is centered when possible,
    2. Prevent overlap with dynamic action menus,
    3. Avoid recomposing the title when only actions change,
    4. Minimize measure passes as actions grow/shrink? Sketch your approach, including which slot(s) you’d subcompose first, what keys you’d use, when you’d apply a slot reuse policy, and how you’d clamp placement to stay collision-free.

References

Share

More to explore

Keep exploring

Previous

Flutter: Core Widget Concepts Deep Dive (Part 1/2)

Next

Kotlin Language: Variables & Types Refresher