Building your own custom layout with jetpack compose

Leveraging the power of jetpack compose

In jetpack compose there are different layouts such as Row, Column, and Box. When we need to align items in a horizontal fashion we use Row. For vertical alignment of items we use Columns and for stacking items one above the other we use the Box layout.

But, what if these layouts don't satisfy our needs. Let's take the example of a chat text message. Refer to the below image. As you can see we cannot achieve this layout using the Row layout. we will need our own custom implementation.

temp.jpg

As you can see from the image, the time view is adjusted as per the message. There are multiple possibilities of message and time placements over here.

  1. if the message line count is greater than 1 and the last line width plus time view width plus padding is greater than available max-width.

  2. if the message line count is greater than 1 and the last line width plus time view width plus padding is less than or equal to the available max-width.

  3. If a message line count is one and last line width plus time view width plus padding is greater than available max-width.

  4. Else, both message and time can be adjusted in available width.

Let's get back to the basics and understand the process of rendering composable to UI

Transforming State to UI

Let's understand how the views are drawn in jetpack compose. Jetpack compose transforms State into UI. There are 3 steps involved in rendering the view onto the screen using states.

State_to_UI.gif

1. Composition

Composition executes our composable functions, emits UI, and creates a structure of composable detonated by a tree. Please refer to the below image.

Composition.gif

2. Layout

There are 2 steps in the layout stage. 1st is Measure and 2nd is Place. These are equivalent to onMeasure() and onLayout() in the view world. But, in Jetpack Compose, these two phases are combined into a single phase.

The layout process is carried out as follows:

  1. Measure its children
  2. Decide its size
  3. Place its children

The UI tree is constructed after completing the above three steps in a single pass.

Lets us visualize how it is done with our example.

layout process.gif

Let us understand what is happening in the above diagram.

  • First, the root layout is asked for measurement since the root node is not a leaf node. It further asks its, children, to measure themselves. In our case, it's Row Composable.
  • Now, the Message Composable is asked to measure itself. Now, since the Message Composable is a leaf node, it measures itself reports its size, and also returns placement instructions about how to place any children. Also, Time Composable does the same as Message Composable.
  • Now, that the Row has measured its children, it can determine its own size and placement logic.
  • As all the composables have determined their size and placement logic, the tree is parsed again and all the placement instructions are executed.

The example mentioned above with TextMessage Composable will not solve our purpose, since we don't know where to place our message and time composable as it is decided dynamically based on text message.

Layout composable comes to the rescue

The basic building block of every composable is Layout composable

Let us look at the API definition of Layout composable.

@Composable 
inline fun Layout(
    content: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
)

let us go 1 by 1, what does each parameter do.

  1. content -> It consists of composable which need to be measured and placed as per the MeasurePolicy.

  2. modifier -> It is applied to the custom layout.

  3. measurePolicy -> It consist of measurement and placements instructions of the content layout.

By now you must be familiar with the layout phase. Let us start with building our own custom layout as per our needs.

Building Custom TextMessage Composable

Let's start by preparing the content composables. We need to have Message and Time composables.

@Composable
fun Message(
    modifier: Modifier = Modifier,
    message: String,
    onTextLayout: (TextLayoutResult) -> Unit = {}
) {
    Text(
        text = message,
        modifier = modifier,
        onTextLayout = onTextLayout
    )
}

Nothing fancy here, we have created a composable with text composable to show the message. We have passed onTextLayout lambda to determine the width, line count, and the last line width of the text message.

@Composable
fun Time(
    modifier: Modifier = Modifier,
    time: String
) {
    Text(
        modifier = modifier,
        text = time
    )
}

Same as the message composable, we have created another composable for time.

We will need to store the dimension data of parent layout as well as child layout, hence we will need to create a TextMessageDimens class to store these values.

class TextMessageDimens {
    var message: String = ""
        internal set
    var messageWidth: Int = 0
        internal set
    var lastLineWidth: Float = 0f
        internal set
    var lineCount: Int = 0
        internal set
    var rowWidth: Int = 0
        internal set
    var rowHeight: Int = 0
        internal set
    var parentWidth: Int = 0
        internal set
}

The next step is we need to create content for our Layout() composable as discussed above.

Let's start writing by creating a new composable TextMessage which will consist of content and layout.

@Composable
fun TextMessage(
    modifier: Modifier = Modifier,
    message: String,
    time: String
) {
    val textMessageDimens = remember {
        TextMessageDimens()
    }
    //Content
    val content = @Composable {
        val onTextLayout: (TextLayoutResult) -> Unit = { textLayoutResult: TextLayoutResult ->
            textMessageDimens.apply {
                this.message = message
                this.messageWidth = textLayoutResult.size.width
                this.lineCount = textLayoutResult.lineCount
                this.lastLineWidth = textLayoutResult.getLineRight(textLayoutResult.lineCount - 1)
            }
        }
        //Message Composable
        Message(
            modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
            message = message,
            onTextLayout = onTextLayout
        )
        //Time Composable
        Time(modifier = Modifier.padding(start = 4.dp, end = 8.dp, bottom = 4.dp), time = time)
    }
    //Custom Layout [This does the magic]
    CustomTextMessageLayout(content = content, modifier = modifier, textMessageDimens = textMessageDimens)
}

In this composable we are setting message dimension data from text layout callback.

Let's look into custom layout composable now.

@Composable
internal fun CustomTextMessageLayout(
    content: @Composable () -> Unit,
    modifier: Modifier,
    textMessageDimens: TextMessageDimens
) {
    Layout(content = content, modifier = modifier) { measurables: List<Measurable>, constraints: Constraints ->
        //As discussed above, the measurables measures itself and calculates its size and placement instructions
        val placeables: List<Placeable> = measurables.map { measurable ->
            //Measuring max available width
            measurable.measure(Constraints(0, constraints.maxWidth))
        }
        //check if the message and time composable are measured, else throw IllegalArgumentException exception
        require(placeables.size == 2)

        //Placeables are the measured childrens which has its own size and placement instructions
        val message = placeables.first()
        val time = placeables.last()

        //Calculation how big the layout should be {Height and Width of Layout}
        textMessageDimens.parentWidth = constraints.maxWidth
        val padding = (message.measuredWidth - textMessageDimens.messageWidth) / 2

        //This seems to be self-explanatory
        if (textMessageDimens.lineCount > 1 && textMessageDimens.lastLineWidth + time.measuredWidth >= textMessageDimens.messageWidth + padding) {
            textMessageDimens.rowWidth = message.measuredWidth
            textMessageDimens.rowHeight = message.measuredHeight + time.measuredHeight
        } else if (textMessageDimens.lineCount > 1 && textMessageDimens.lastLineWidth + time.measuredWidth < textMessageDimens.messageWidth + padding) {
            textMessageDimens.rowWidth = message.measuredWidth
            textMessageDimens.rowHeight = message.measuredHeight
        } else if (textMessageDimens.lineCount == 1 && message.measuredWidth + time.measuredWidth >= textMessageDimens.parentWidth) {
            textMessageDimens.rowWidth = message.measuredWidth
            textMessageDimens.rowHeight = message.measuredHeight + time.measuredHeight
        } else {
            textMessageDimens.rowWidth = message.measuredWidth + time.measuredWidth
            textMessageDimens.rowHeight = message.measuredHeight
        }
        //Setting max width of the layout
        textMessageDimens.parentWidth = textMessageDimens.rowWidth.coerceAtLeast(minimumValue = constraints.minWidth)

        layout(width = textMessageDimens.parentWidth, height = textMessageDimens.rowHeight) {
            //Place message at (0,0) since we don't need to place it dynamically
            message.placeRelative(0, 0)
            //Place time using (maxAvailableWidth - timeWidth, messageHeight - timeHeight)
            time.placeRelative(
                textMessageDimens.parentWidth - time.width,
                textMessageDimens.rowHeight - time.height
            )
        }
    }
}

The above block of code does exactly what we have read in the earlier part of this blog. It first measures its children, calculates their size, and creates placement instructions.

Attaching the result of this for your reference. Find the below previews of TextMessage composable.

Screenshot 2022-06-25 at 5.59.40 PM.png

And the final step is drawing which is explained below.

Hope now the picture is clear and you can leverage the Layout composable in your projects.

3. Drawing

In the drawing stage, the UI tree is parsed again in the same fashion as above and all the elements are rendered onto the screen using x, y coordinates, width, and height.

Github link: TextMessageComposable

Thanks for reading! If you loved this article do share it with your friends. Enjoy!