Custom Compose Layouts

Photo by Susan Q Yin on Unsplash

Custom Compose Layouts

SubcomposeLayout

In compose, you can implement custom layouts using layout modified or Layout composable. To learn more about custom compose layouts check out the official documentation.

Compose has three main phases: composition, layout, and drawing phases.

  1. Composition phase: Compose runs composable functions and creates a UI tree. The UI tree is made of layout nodes that contain information that will be used in the next phases.

  2. Layout phase: Compose determines where to place the components in the UI. This happens in three steps. First, a parent layout node measures its children if any. It then uses the measurement of the children to determine its own size and lastly places its children in the 2D coordinates.

  3. Drawing phase: Each node in the UI tree is drawn on the device screen.

Majority of compose layouts follow this precise order of the phases, starting from composition, then the layout phase, and finally the drawing phase. However, there is one layout that breaks this rule. SubcomposeLayout and for good reasons.

SubcomposeLayout

Consider this UI design:

The second bar takes half the width of the first bar and it is centered with respect to the first item.

There is no way to know the width of the first item until the layout phase. Also, compose does not allow measuring multiple times. Measuring children twice will throw a runtime exception. SubcomposeLayout comes to the rescue.

SubcomposeLayout allows subcomposition of actual content during the measuring phase. This way it allows deferring composition and measuring of content until the constraints of the parent are known and some of the content is measured. It measures some child composables in the measurement phase, and then uses this information to compose some or all children.

Now, let us build the dynamic bars. :-)

@Composable
fun DynamicBarsLayout(
  modifier: Modifier = Modifier,
  mainContent: @Composable () -> Unit,
  deferredContent: @Composable () -> Unit
) {
  SubcomposeLayout(modifier = modifier) { constraints ->
    // 1. Measure width and height of the main composable
    val mainPlaceable = subcompose(SlotsEnum.MAIN, mainContent).map { measurable ->
      measurable.measure(constraints)
    }.first()

    // 2. Obtain size of the mainPlaceable
    val mainSize = IntSize(mainPlaceable.width, mainPlaceable.height)

    // 3. Rewrite the subtitle composable to have half the width of 
    // main composable
    val deferredPlaceable = subcompose(SlotsEnum.DEPENDANT, deferredContent).map {
      it.measure(constraints.copy(maxWidth = (mainSize.width / 2), minHeight = mainSize.height))
    }.first()

    layout(constraints.maxWidth, constraints.maxHeight) {
      // 4. Place children in parent layout
      mainPlaceable.place(0, 0)
      val childX = mainSize.width / 2 - deferredPlaceable.width / 2
      val childY = mainSize.height + 8
      deferredPlaceable.place(childX, childY)
    }
  }
}

enum class SlotsEnum() {
  MAIN,
  DEPENDANT
}
  1. Measure the main composable using the given constraints. measure() method returns a List of Placeable(s).

  2. Obtain the width and height of the mainPlaceable. This will be used in measuring deferred content.

  3. Rewrite deferred content giving it half width of the main composable.

  4. Place children in the parent layout.

subcompose() method performs subcomposition of the provided content. It also takes slotId as a parameter. slotId is a unique ID that represents the slot we are composing into. For a finite number of slots, you could use enums as slot ids. When working with lists, you could use item indices or provide unique keys for each item.

subcompose() function can be called several times in a layout block. slotId allow tracking and managing compositions created by subcompose() and determine the ones that should be used in recomposition.

If as slotId is no longer used, compose will dispose of the corresponding composition.

SubcomposeLayout changes the usual flow of compose layout. Instead of composing its children during the composition phase, it composes its children during the layout phase. This has a performance cost. Therefore, use SubcomposeLayout only when the composition of a child depends on the result of another child's measurement.

Use cases

BoxWithConstraints and Lazy components like LazyColumn and LazyRow use SubcomposeLayout under the hood. Consider a LazyColumn:

You'd like to show 100 items but cannot fit all of them on the screen at the same time. In this case, composing all children will be a waste of resources. Instead,

  1. Measure items to determine their size.

  2. Determine the number of items that can fit in the available viewport.

  3. Compose those items that fit the viewport. The remaining items will be composed when the user scrolls and they become visible.

SubcomposeLayout allows this deferring composition until the layout phase when parent constraints are known and the items measured. The result of the measure phase is then used to determine whether or not to compose some or all child composables.

Tips When Using Lazy Layouts

  1. Avoid using 0-pixel-sized items.

For instance, you are fetching data asynchronously from a remote API or database to populate the list at a later stage. This will cause a lazy layout to compose all items in the first measurement as the height of an item is 0px and it can fit all of them in the viewport.

@Composable
fun CharacterComposable(character: Character) {
  Row() {
    Image(
      painter = painterResource(id = character.imageId),
      contentDescription = ""
    )
    Text(text = character.name)
  }
}

@Composable
fun CharactersListComponent(characters: List<Character>){
    LazyColumn(){
        items(characters){ character ->
            CharacterComposable(character = character)
        }
    }
}

Once the data has been loaded and height expanded, the lazy layout will discard items that were unnecessarily composed in the first measurement as it cannot fit all of them in the viewport.

To avoid this, provide a default size for the items used in a lazy layout. This allows compose to correctly calculate the number of items it can fit in the viewport.

@Composable
fun CharacterComposable(character: Character) {
  Row(
    modifier = Modifier
      .fillMaxWidth()
      .height(350.dp)
  ) {
    Image(
      painter = painterResource(id = character.imageId),
      contentDescription = "",
    )
    Text(text = character.name)
  }
}
  1. Provide keys to the list of items.

Consider the following code:

// English, French, German, Harabic
LazyColumn() {
  items(languages) { language ->
    Text(text = "${language.name}")
  }
}

Assuming the languages are ordered alphabetically. Whoops! Harabic is misspelled. It should be Arabic. Correcting it brings it to the top of the list. Every other item has to move down by one position.

Changing the position of an item in a list makes the item lose its remembered state. This way compose will assume that the item was deleted and a new one created. As a result, Compose will recompose every item in the list where only one item changed.

To prevent unnecessarily recomposing all items in a list, provide keys for items in the list. Providing stable keys allows compose to know that the item now at position 2 is the same item that was in position 1. If there are no data changes, compose will not recompose the item.

// Arabic, English, French, German, 
LazyColumn() {
  items(languages, key = { language -> language.id }) { language ->
    Text(text = "${language.name}")
  }
}

Hooray! You've made it to the end🎉. You've learned about SubcomposeLayouts and performance tips when using lazy layouts.

Happy Composing!

Thanks to Gibson Ruitiari and Harun Wangereka for contributing and reviewing this article.

Resources