Creating a custom modifier attribute on Jetpack Compose

Background

In Jetpack Compose composables accept a Modifier that alters the appearance or behaviour of the composable they are applied to. Some of the modifiers, like width, height and padding are common to all composable functions while others can only be applied to specific composables, like weight for Row and Column, In this article we will see how to create a composable function with its own modifier attribute.

What we want to achieve is the following

We want to create a composable that takes a set of pairs of children, one being a label and the other being the description. We want the labels to adjust their width so that they are all of the same width, so that the descriptions start at the same horizontal offset. Furthermore, we want to be able to customize how the label and the description are aligned vertically within their rows, we want to allow for top, center and bottom alignment of the content.

Creating the composable

First we will create a composable without the alignment option, and later we will update it to add the option. As we want to ensure the labels are all of the same width, and we do not know how wide they may be before we display the content, we can’t use a Row, and instead we will use a Layout. Let’s see how we can render this content and explain how to achieve the correct alignment of the description so that they all start at the same horizontal offset.

Let’s analyze this composable:

  1. We use a layout for our root element, this is equivalent to creating a custom ViewGroup in the view system. For a layout composable we need to provide the content, an optional modifier and a lambda that will measure and place the content based on the incoming constraints; this lambda takes a list of measurables and the constraints our content must adhere to.
  2. For our specific case we require an even number of children (our children must come in pairs of label and description), so we ensure we are provided a valid content or throw.
  3. We make a copy of our incoming constraints with the min width and height set to 0 and we will use that to measure our children.
  4. We measure our children with the updated constraints. This gives us a Placeable for each Measurable that we will later place within the root layout.
  5. We split our content measurables by getting the odd entries in the list, which correspond to the labels.
  6. We do the same with the even entries, which correspond to our descriptions.
  7. We want to know how wide our labels need to be, so we walk our list of labels and get their widths, and then we get the max of those — that will be our label width.
  8. Next we want to know how tall our composable needs to be. Because we will stack our label + description combos vertically, we get the height of each combo (the max of the height of the label or the description) and add them all together.
  9. Now that we have all in the info we need to render our composable it’s time to call layout — this will place the composables within our root container. This function takes a width and a height; the width comes from our inherited constraints, and the height is the height we calculated in step 7, but constrained to the maximum height we received in our incoming constraints.
  10. We loop over all our composables — we use the labels indices as we know we have the same number of labels and descriptions.
  11. For the current row, its height will be the max of the height of the label and the description.
  12. The label is placed at the parent start (X coordinate equals to 0) and vertically (Y coordinate) offset by the accumulated height of all previous rows.
  13. The description is placed at the X offset corresponding to the widest label, and at the same vertical offset as the label.
  14. We increase our accumulated height by the height of the row we just laid out.

With this, we achieve the following layout

Creating the modifier attribute

Now that we have a basic layout to display the labels and descriptions let’s see how we can build on it to offer alignment options for the content. We could reuse the existing Alignment interface available in Jetpack Compose, but for this article we will create our own so we see how that is done. We will keep it simple and only offer 3 types of alignments on the vertical axis, top, center and bottom. We could further enhance this solution to offer additional alignment options on the horizontal axis, but the principle is the same so we will keep it simple and allow only vertical alignment here.

Defining our alignment options

Let’s first define our alignment options. As we have a discreet set with no additional functionality, we can use an enum for this, so let’s do so

This is pretty self explanatory so we won’t delve on it.

Defining the custom scope

Next we want to create the scope that will allow us to specify how to align the content within our composable. The convention for this in Jetpack Compose is to define an interface that provides extension functions on Modifier. By doing so we ensure that the extension is only accessible from classes inheriting from the interface.

We only need 1 attribute for our composable, the vertical alignment, so our interface will simply define a single extension method, that we will call align. Let’s see the code.

Let’s go over this in detail:

  1. We create our interface InfoLabelScope that will offer our alignment option during composition.
  2. This interface declares an extension function on Modifier that accepts our InfoAlingment defined earlier.
  3. The extension function creates an instance of a private class InfoLabelsData that we will use to hold the alignment information we receive as parameter. Note the use of then to chain our modifier attribute to previous modifiers.
  4. As our extension function is scoped to our interface, it is only accessible within classes inheriting from said interface. To be able to call the extension function we will need a concrete implementation of the interface, so we create an instance using the companion object of the interface. This creates an object (effectively a Singleton) that implements the interface; because we do not have any methods in our interface other than the extension method, there is nothing for us to implement in the object.
  5. We create the class to hold the alignment info. This class must inherit from ParentDataModifier as that will allow us to retrieve it during the layout of the composable.
  6. The only method ParentDataModifier interface defines is modifyParentData which must return the parent data from the modifier chain. Here we simply return the InfoLabelsData instance.
  7. As we defined our InfoLabelsScope as Stable we have to provide some guarantees which basically boil down to providing equals and hashCode — marking the interface as stable will allow the compiler to do some under the hood optimizations.
  8. Same as 7, we provide a hashCode to honor the Stable declaration.
  9. We also override toString to have a nicer print statement if we log our scope.

You may wonder why we are declaring a class an overriding equals and hashCode instead of creating a data class. The only reason is that data classes come with extra methods besides these 2 that we do not need; using a data class would create unnecessary method bloat. This is only relevant on libraries, if you are creating a custom scope to use only in your app you may prefer to use a data class in order to get these 2 methods for free.

Updating our composable

Now that we have our custom scope it’s time to update our InfoLabels composable to leverage it. Let’s see the code and we will walk over the changes in a moment:

Let’s describe the changes we have implemented to support our alignment options:

  1. We define a couple of helper extension functions on Measurable that will retrieve the InfoLabelsData and InfoAlignment from the measurables we want to lay out.
  2. In our composable we change the signature and make our content an extension function on InfoLabelsScope so that we have access to our alignment extension function on Modifier — this gives us access within our content to the align extension on Modifier.
  3. As we need a concrete instance of our interface to call the content on, we use the object from the interface and create a lambda wrapper to run the content on.
  4. Once we have our measurables we walk the list and obtain the alignment for each of them. This uses our helper extension functions defined in step 1. If no alignment was provided it will default to Top.
  5. We split our alignment options using the odd indices and get those corresponding to the labels.
  6. We do the same with the content, on even indices.
  7. We grab the pieces we need for the layout. What’s changed here is that we get the alignment for the label and description corresponding to the current row.
  8. We compute the vertical delta for the label and the description; this is how much taller the current row is relative to the child to lay out. We will use this later to position the children. Because the cell height is the max of the label and description heights, these deltas are always positive or zero.
  9. This is where the main change takes place. Instead of placing the child at the vertical offset corresponding to the accumulated height of all the previous rows, we check which type of alignment has been requested. If the alignment is Top we do as before, place the child at the accumulated vertical offset; if it is Center we add half the difference between the row height and the child height; finally, if it’s Bottom we add the full delta so that the bottom of the child is at the bottom of the row.
  10. We do the same for the description label.

With these updates we can now display our labels and specify the alignment we want. This is how we would use our custom layout:

and the result is shown below:

Conclusion

While this is a fairly simple example with a single attribute, the principles highlighted here will allow us to create custom modifier attributes that will greatly enhance the flexibility and usability of custom composables built using the layout composable.

The whole code with preview is available on this gist.

Senior Android Developer