Droid Manual // Android Knowledge Base

Droid Manual

Compose Custom Layouts and Modifiers

November 02, 2026 · 10 min read

Jetpack Compose shifts Android UI development away from declarative XML hierarchies toward a programmatic, composable approach. At the heart of that shift lie two concepts that every developer must master: Modifiers and custom layouts. Modifiers control how a composable is measured, laid out, drawn, and interacted with. Custom layouts give you full control over positioning when the built-in rows, columns, and boxes don't express the design you need. This tutorial walks through the Modifier chain, the custom Layout API, canvas-based drawing, and patterns for packaging everything into reusable components.

The Modifier Chain and Its Order

A Modifier is an immutable, ordered list of operations applied to a composable. The order matters because each step in the chain transforms the state passed to the next. Think of the chain as a pipeline: size, then border, then padding, then background, then click handling. Swap two steps and the visual result can change completely.

@Composable
fun OrderMattersCard() {
    Box(
        modifier = Modifier
            .size(200.dp)
            .border(2.dp, Color.Red)
            .padding(16.dp)
            .background(Color(0xFFBBDEFB), RoundedCornerShape(8.dp))
            .clickable { /* handle click */ }
            .shadow(4.dp, RoundedCornerShape(8.dp)),
        contentAlignment = Alignment.Center
    ) {
        Text("Order matters", color = Color.DarkGray)
    }
}

In the example above, size fixes the box at 200 × 200 dp before anything else runs. border draws a red outline inside that fixed size because border is applied after size. padding adds 16 dp of internal space on top of the already-sized box, shrinking the available area for content. background paints behind the content but inside the padding. clickable intercepts touch events at the outermost boundary. shadow renders the drop shadow around the final shape. If you moved padding before size, the box would expand beyond 200 dp instead of constraining the inner content. Moving shadow before border would cause the shadow to appear inside the red outline. The chain is evaluated top to bottom, and every transformation compounds on the result of the previous one.

Building a Custom Layout Composable

When the built-in layout composables cannot express your arrangement, you write a custom Layout composable. This composable measures its children yourself, decides where each one goes, and returns the parent's desired size. The key function is measure() from the Measurable interface, which returns a LayoutConstraint-driven Placeable for each child.

@Composable
fun MasonryGrid(
    items: List<String>,
    columns: Int = 2,
    itemHeight: Dp = 120.dp
) {
    Layout(
        content = {
            items.forEach { item ->
                Card(
                    modifier = Modifier
                        .padding(4.dp)
                        .height(itemHeight),
                    shape = RoundedCornerShape(8.dp)
                ) {
                    Box(
                        modifier = Modifier.fillMaxSize().padding(8.dp),
                        contentAlignment = Alignment.Center
                    ) {
                        Text(item, style = MaterialTheme.typography.bodyMedium)
                    }
                }
            }
        },
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(8.dp)
    ) { measurables, constraints ->
        val horizontalGap = 8.dp.roundToPx()
        val verticalGap = contentPadding.calculateTopPadding().roundToPx()
        val columnWidth = (constraints.maxWidth - (columns - 1) * horizontalGap) / columns
        val columnConstraints = Constraints.fixed(columnWidth)

        val columnHeights = IntArray(columns) { 0 }
        val placeables = measurables.mapIndexed { index, measurable ->
            val placeable = measurable.measure(columnConstraints)
            val column = index % columns
            columnHeights[column] += placeable.height + verticalGap
            placeable to column
        }

        val totalHeight = columnHeights.maxOrNull() ?: 0
        layout(constraints.maxWidth, totalHeight + contentPadding.calculateBottomPadding().roundToPx()) {
            placeables.forEach { (placeable, column) ->
                val x = column * (columnWidth + horizontalGap) + contentPadding.calculateLeftPadding(layoutDirection).roundToPx()
                val y = columnHeights[column] - placeable.height - verticalGap
                placeable.placeRelative(x, maxOf(0, y))
            }
        }
    }
}

This MasonryGrid composable divides the available width into equal columns, measures each card at the column width and a fixed item height, then tracks the current height of every column in columnHeights. Each new item is placed in the column whose index matches its position in the list (index % columns), so items fill columns in a round-robin fashion. The layout() call reports the widest constraint width and the height of the tallest column, which becomes the grid's measured size. Because the Layout composable uses placeRelative, RTL layouts are respected automatically—the leftmost column is always the start edge regardless of script direction.

The Low-Level Layout API

Understanding the three phases of measurement—measure(), layout(), and place()—separates developers who merely compose from those who build custom layouts. A Layout composable receives a Constraints object describing what the parent allows, and must return both a measured size and placement information for every child. The Placeable object produced by measure() carries width, height, and placement methods like placeRelative().

@Composable
fun StickyHeaderList(
    items: List<String>,
    headerItems: Set<String>
) {
    var scrollState = rememberScrollState()

    Column(modifier = Modifier.verticalScroll(scrollState)) {
        var lastHeaderY = 0

        items.chunked(3).forEachIndexed { groupIndex, group ->
            val headerText = "Group ${groupIndex + 1}"
            val pinned = headerItems.contains(headerText)

            if (pinned) {
                Box(
                    modifier = Modifier
                        .fillMaxWidth()
                        .background(Color(0xFF1976D2))
                        .zIndex(1f)
                        .height(48.dp)
                        .pointerInput(Unit) {},
                    contentAlignment = Alignment.CenterStart
                ) {
                    Text(
                        text = headerText,
                        color = Color.White,
                        style = MaterialTheme.typography.titleSmall,
                        modifier = Modifier.padding(start = 16.dp)
                    )
                }
            }

            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = 8.dp, vertical = 4.dp)
            ) {
                group.forEach { item ->
                    Box(
                        modifier = Modifier
                            .weight(1f)
                            .height(80.dp)
                            .background(Color(0xFFF5F5F5), RoundedCornerShape(4.dp))
                            .padding(8.dp),
                        contentAlignment = Alignment.Center
                    ) {
                        Text(item, style = MaterialTheme.typography.bodyMedium)
                    }
                }
            }
        }
    }
}

The StickyHeaderList composable demonstrates how to combine a vertical Column with a verticalScroll modifier and manual header pinning using zIndex. When a header is marked as sticky, it is rendered as a full-width Box with zIndex(1f), which places it above scrolling content in the Z-order. The content below scrolls freely underneath the pinned header because the header sits outside the scrollable region while the item rows sit inside it. This approach is simpler than a custom Layout but less flexible—for true stickiness during scroll you would use LazyColumn with item() and stickyHeader() entries, which handle recalculation and composable reuse automatically.

Drawing with drawBehind and drawWithContent

Modifiers that draw on the canvas let you add visual polish without building a separate composable. drawBehind lets you paint behind a composable's existing content, while drawWithContent gives you full control to paint behind, draw the composable's normal content, and then paint on top. Both operate within a Canvas scope that provides familiar drawing primitives.

@Composable
fun GradientBadge(text: String, maxDigits: Int = 3) {
    val digit = text.toIntOrNull() ?: 0
    val fraction = (digit % 10) / 10f

    Box(
        modifier = Modifier
            .requiredWidth(56.dp)
            .requiredHeight(28.dp)
            .clip(RoundedCornerShape(14.dp))
            .drawWithContent {
                val gradientColors = listOf(
                    Color(0xFFE53935).copy(alpha = 1f - fraction * 0.5f),
                    Color(0xFFFF7676).copy(alpha = 1f - fraction * 0.3f)
                )
                drawRect(
                    brush = Brush.horizontalGradient(
                        colors = gradientColors,
                        startX = 0f,
                        endX = size.width
                    ),
                    cornerRadius = CornerRadius(14.dp.toPx())
                )
                drawContent()
            },
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = "$digit",
            style = MaterialTheme.typography.labelLarge.copy(color = Color.White),
            modifier = Modifier.padding(4.dp)
        )
    }
}

This GradientBadge composable draws a rounded rectangle with a horizontal gradient whose intensity shifts based on the badge value. drawWithContent first paints the gradient Rect, then calls drawContent() to render the Text composable on top of it. The clip(RoundedCornerShape) modifier applied before drawWithContent ensures the gradient respects the rounded corners during drawing. Without the clip, the gradient would spill outside the rounded bounds and overlap neighboring composables. The fraction variable maps the last digit of the score to a value between 0 and 0.9, making the red tone lighter for higher scores—a common pattern in fitness and gaming badges.

@Composable
fun DashedSeparator() {
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(2.dp)
            .drawBehind {
                val dashWidth = 8f
                val gapWidth = 6f
                var x = 0f
                while (x < size.width) {
                    drawLine(
                        color = Color(0xFFBDBDBD),
                        start = PointF(x, 0f),
                        end = PointF(x + dashWidth, 0f),
                        strokeWidth = size.height
                    )
                    x += dashWidth + gapWidth
                }
            }
    )
}

The DashedSeparator uses drawBehind to paint a repeating dashed line across the full width of the box. It iterates with a while loop, advancing x by the sum of dash and gap widths each iteration, and calls drawLine for each segment. strokeWidth is set to size.height so the line fills the box vertically. This produces a cleaner dashed divider than manually placing a row of small Box composables because the drawing happens in a single canvas pass, avoiding unnecessary composable overhead and giving pixel-perfect alignment along the horizontal axis.

Creating Reusable UI Components

Reusable components combine layout, drawing, and state management into self-contained composables. A well-designed component exposes only the props it needs, defaults to sensible values, and respects modifier composition so callers can extend its appearance. The pattern is to build the component with a @Composable function that accepts parameters and applies modifiers transparently.

@Composable
fun SearchBar(
    query: String,
    onQueryChange: (String) -> Unit,
    onSearch: () -> Unit,
    modifier: Modifier = Modifier,
    placeholder: String = "Search...",
    clearOnSearch: Boolean = true
) {
    OutlinedTextField(
        value = query,
        onValueChange = onQueryChange,
        modifier = modifier
            .fillMaxWidth()
            .height(56.dp)
            .padding(horizontal = 8.dp),
        placeholder = {
            Text(
                text = placeholder,
                style = MaterialTheme.typography.bodyMedium.copy(color = Color(0xFF9E9E9E))
            )
        },
        leadingIcon = {
            Icon(
                painter = painterResource(id = android.R.drawable.ic_menu_search),
                contentDescription = "Search",
                tint = Color(0xFF757575)
            )
        },
        trailingIcon = {
            if (query.isNotEmpty()) {
                IconButton(onClick = { onQueryChange("") }) {
                    Icon(
                        painter = painterResource(id = android.R.drawable.ic_menu_close_clear_cancel),
                        contentDescription = "Clear",
                        tint = Color(0xFF757575)
                    )
                }
            }
        },
        singleLine = true,
        maxLines = 1,
        shape = RoundedCornerShape(28.dp),
        colors = OutlinedTextFieldDefaults.colors(
            focusedBorderColor = Color(0xFF1976D2),
            unfocusedBorderColor = Color(0xFFE0E0E0),
            cursorColor = Color(0xFF1976D2)
        ),
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
        keyboardActions = KeyboardActions(onSearch = { if (clearOnSearch) onQueryChange(""); onSearch() })
    )
}

The SearchBar composable wraps an OutlinedTextField with a consistent design system. It accepts modifier as the first styling parameter so callers can override width, height, or add gestures. The trailing clear icon appears only when query is non-empty, reducing visual noise on an empty field. The keyboardActions lambda triggers the search callback and optionally clears the input, configurable via clearOnSearch. Because the composable delegates all drawing and interaction to OutlinedTextField, it remains accessible and respects the current MaterialTheme. When used inside a Scaffold, it pairs naturally with a LazyColumn result list, and the modifier chain allows developers to stack additional decorations like shadow() or clickable() without breaking the internal logic.

Common Pitfalls

One of the most frequent mistakes is treating modifiers as order-independent. As demonstrated earlier, padding before size produces a different layout than size before padding. Always think of the Modifier chain as a pipeline where each step consumes the output of the previous step.

Another pitfall is placing expensive operations inside drawBehind or drawWithContent without memoization. Calculating gradient colors, loading resources, or computing paths inside a canvas lambda recomputes on every frame. Use remember to cache expensive values outside the draw lambda, or move computation into a LaunchedEffect that updates a remembered state variable.

Custom layouts that do not respect constraints.minWidth and constraints.minHeight can cause layout exceptions or incorrect measurements. Always clamp your measured width and height to the incoming constraints, and pass them through to the layout() call rather than using raw values from child measurements alone.

Finally, forgetting to pass the modifier parameter through to the underlying composable means your component cannot be styled externally. Every public composable should accept modifier: Modifier = Modifier and apply it to the root element inside the composition.

Conclusion

Mastering Compose custom layouts and modifiers comes down to understanding the Modifier pipeline, knowing when to reach for a custom Layout, and using canvas drawing sparingly and efficiently. Modifiers are composable, order-sensitive transformations that shape measurement, layout, drawing, and interaction in a single chain. Custom Layout composables give you direct access to the measurement and placement API for arrangements that built-in components cannot express. Canvas drawing with drawBehind and drawWithContent adds visual detail without introducing extra composables into the tree. Reusable components tie everything together by accepting modifiers, exposing clear parameters, and delegating to built-in primitives wherever possible. With these tools, you can build complex, efficient, and maintainable Android UIs entirely in code.