Always provide a Modifier parameter

Always provide a Modifier parameter

This is hopefully going to be a short post, but one which I think is timely as more people start using Jetpack Compose.

Over the past year or so, I've seen lots of composables which look great but they have one fatal flaw: they don't expose a modifier: Modifier parameter in their signature.

If you don't want to read the whole post, the TL;DR of this blog post is:

Any composable you write which emits layout (even a simple Box), should have a modifier: Modifier parameter, which is then used in the layout.

EDIT: I lied, this blog post turned out much longer than I anticipated. 😬

Modifiers

Modifiers are probably my favourite thing in Compose. They truly encapsulate the idea of composition over inheritance, by providing a number of interfaces and implementations to attach logic and behavior to layouts.

In fact, huge amounts of Compose is provided and implemented through modifiers. Some examples:

  • LazyColumn, LazyRow, etc all use the scrollable() modifier to power their scrolling behavior.
  • Card and Surface draws elevated shadows through shadow modifier.
  • There's a huge list of modifiers available here.

You've probably know all this, so why am I telling you again? Well, modifiers are universally important and useful for all layout composables.

It's too easy to skip the modifier parameters in your own code-base and do something like the following:

@Composable
private fun HeaderText(text: String) {
    Text(
        text = text,
        color = ...,
        style = ...,
        maxLines = ...,
        overflow = ...,
        modifier = Modifier.fillMaxWidth(),
    )
}

This code looks fine overall, but has a number of issues:

#1: Customizability and Re-Use

By not providing a modifier parameter on the function, callers can not influence how the subtitle text should be laid out. Maybe the caller wants it to be aligned to the top-end? Maybe it wants it to be clickable? Maybe I want to use this composable from multiple composables, but with different sizes?

That's not possible right now as the calling function has no way to provide a modifier.

#2: Implicit layout behavior

The other problem here is that the code above has baked in some implicit layout behavior.

Implicit behavior isn't necessarily a bad thing, after all it is how much of the composables in Compose are written. If you look at how Surface is implemented, it adds a number of implicit modifiers to implement the desired behavior for a surface:

@Composable
fun Surface(
    // other parameters
    modifier: Modifier,
) {
    Box(
        modifier
            .shadow(...)
            .background(...)
            .clip(...),
    ) {
        content()
    }
}
Simplified version of Surface()

The key distinctions here are that:

  1. It takes a modifier parameter, and then appends its modifiers to that: modifier.shadow(...). This allows the caller to provide customized behavior, but also for Surface to provide its behavior.
  2. It doesn't apply any layout modifiers, specifically any size modifiers (fillMaxSize(), size(), etc).

Which brings us back to the example above. That code implicitly uses modifier = Modifier.fillMaxWidth(), with the common intention that it should always 'fill' its parent. The issue here is that fillMax*() is quite a large hammer to use in layouts.

Without going into too much details here (it's not the point of the post), the layout measurement system works in Compose is based on Constraints. Constraints provide layouts with the minimum and maximum length they can use for their width and height. By using fillMaxWidth(), you're telling the parent layout that you want to use the maximum available space for the width. This then means that the parent needs to use its maximum width, and its parent need to use its maximum width, and so on we go up the tree.

For some layouts this won't matter, as they are set to constrain themselves to some width, but for layouts which are left to wrap their content, adding this single HeaderText() anywhere in the subtree will mean that the entire layout will fill the maximum width.

#3: Breaking the parent/child layout relationship

The final point is related to what we just spoke about. If you think back to the Android View system, the way you influenced the layout system was through LayoutParams. Each ViewGroup you used (FrameLayout, etc) provided their own LayoutParams implementations which contained enough information specific to that view group. For example:

This is just to illustrate that every layout is different, supporting different values and behavior. This is one of the reasons why you should never set attr:layout_* in view styles. The style has no idea what parent the view is going to be used in, that can only be set in the layout itself.

In summary, the parent should always be telling the child how to measure and be laid out, not the other way around. The child should only think about it's own content. This is the parent/child layout relationship.

Compose

The same rule and ideas apply to Compose, although the term 'parent' gets a little fuzzy, as it has broken apart the emitted layout (what is actually laid out), from the declaration of layout (your composable functions). 'Parent' here refers to the calling composable function, rather than the parent layout.

Similarly to the different ViewGroup LayoutParams implementations we spoke about earlier, Compose provides layout-specific modifiers through their content scopes. For example Box provides the matchParentSize() modifier to it's content, but you can only use it from the scope:

Box(modifier) {
    // We're now in the BoxScope
    
    Image(
        // matchParentSize is only available from BoxScope
        modifier = Modifier.matchParentSize(),
    )
}
Example of scope provided Modifiers

This means that the only way* to use that modifier on descendant layouts is to pass it through using a modifier: Modifier parameter!

Box isn't the only layout which provides its own modifiers, LazyRow/Column 's item scope provides fillParentMaxSize(), ConstraintLayout provides constrainAs(), and so on.

* Technically, you could define a @Composable fun BoxScope.MyComposable() , but this then means that you can only use that composable in a Box 🤮.

The Fix

If we copy back the example from earlier, you can see more clearly how the child (HeaderText) is telling the parent (HeaderGroup) how it wants to be laid out:

@Composable
fun HeaderGroup(
    headerText: String,
    modifier: Modifier = Modifier,
) {
   Box(modifier) {
       // How can I use Modifier.align()?
       HeaderText(headerText)
   }
}

@Composable
private fun HeaderText(text: String) {
    Text(
        modifier = Modifier.fillMaxWidth(),
        // ...
    )
}

So lets fix it, through the simple addition of a modifier parameter:

@Composable
fun HeaderGroup(
    headerText: String,
    modifier: Modifier = Modifier,
) {
   Box(modifier) {
       HeaderText(
           text = headerText,
           // We can add any behavior we wish here!
           modifier = Modifier
               .align(Alignment.TopEnd)
               .width(128.dp),
       )
   }
}

/**
 * This now has a modifier parameter, allowing callers
 * to customize the layout, behavior and more!
 */
@Composable
private fun HeaderText(
    text: String,
    modifier: Modifier = Modifier,
) {
    Text(
        modifier = modifier,
        // ...
    )
}
Our example with a modifier parameter

And voilà, we've fixed all of the issues above!

  • The parent (caller) is now responsible for telling the child how to be measured.
  • We can now use the composable in different places, with different behavior.
  • We can now easily use layout specific modifiers.

Everyone is a librarian now

I hope this post has helped explain one of my favourite parts of Compose, and why we should continue to use the paradigms which Compose has provided, in our own composables going forward. Everyone is a librarian (library developer) now!