[ad_1]
CASE STUDIES
Vertically center text in a two-line height container
Given a requirement, sometimes multiple alternatives may be available to implement it. In this blog we look at a practical use case for positioning text in Compose. We’ll analyze multiple alternatives to solve it, using different Compose text APIs. We’ll review pros and cons and, as a bonus, the performance of each solution.
Caveat: We don’t expect you to undertake such an analysis for every piece of code you write, however we considered it interesting to share our approach to discover what options you have to solve a concrete scenario and how we went about choosing among them.
Consider the following use case:
- Vertically center a
Text
composable in a container. - Text has a maximum of 2 lines.
- The container’s height is always the height of 2 lines of text, no matter the actual number of lines the text would occupy. This is the requirement which made the implementation non trivial.
By default the text height changes based on the number of lines, and wrap around them. This is the “default behavior” case.
Compose 1.4 introduced a minLines
parameter in Text
and TextField
.
Setting both minLines
and maxLines
to 2 may solve our issue. But does it? 🤔Let’s see:
Text
minlinesThe text is not vertically aligned in the measure region. There is a feature request to provide additional vertical alignment configuration.
In general, a simple heuristic we want could be:
At this point you might think why don’t we just set the container height to a fixed value? While this works in principle, the solution is not exact and does not guarantee 2 lines are always visible, for example when the user changes font size for better accessibility.
For simplicity, we assume that our text is not multi-styled so calculating two lines with any set of characters would give us the same height in all cases.
Which text could we measure that is always 2 lines in height? This question has an easy answer: a string containing newline char, or “\n”, represents 2 empty lines.
Finding the best solution often involves striking a balance between different factors, such as the following:
- Readability: how easily the code can be read, understood and maintained by other developers.
- Compose-idiomacy: how well the code follows the conventions and patterns of the programming language or framework being used, Compose in our case.
- Performance: how efficiently the code performs in terms of frame rendering speed and memory usage.
While optimising for one factor may improve the overall quality of the code, it can come at the expense of other aspects. At the end we pick the one that achieves the optimal balance between these, resulting in code that is easy to understand, efficient, and follows best practices.
Now take a few moments before we start and think: how would you have solved this problem?
Done? Are you ready? Let’s go! 🚀
What follows is an exploration of some of the alternatives we came up:
- ✅#1: Two
Text
composables. Adding a constant 2 linesText
composable as spacer. - ✅#2:
TextMeasurer
. Measuring ”\n” in composition, and then using this measurement to setText
height. - ✅#3:
TextLayoutResult
andonTextLayout
lambda. Using a layout callback to recompose with the correctText
height. - ✅#4:
layout
modifier. Measure and set the correctText
height during the layout phase. - 🚫#5: Drawing Text on Canvas with
drawText
with the correct height, centred manually in both directions. - 🚫#6: Subcomposition. Measuring ”\n” in subcomposition, and then using this measurement to set Text height.
The best option was #1. Why? Read below!
We add two Text
composables, one on top of the other using a Box
. One has the text to display, and the other has the text “\n”, which is always 2 lines high and does not modify anything visually on the screen.
Pros
- Achieves the required design by adding a constant padding.
- Short code, easy to understand and maintain.
Cons
- We’re always adding this extra text composable. You wouldn’t need it when the text is 2 lines high.
To avoid always adding an extra composable like we do in option #1, we can get the height of “\n” in composition and use this height for one single Text.
To do this we need to be able to measure text somehow. Luckily we have a very useful and powerful API for this: TextMeasurer
, responsible for measuring a text so that it’s ready to be drawn.
We can create a TextMeasurer with the rememberTextMeasurer()
function. Then we use it to measure “\n” and set this height
to the text we want to display.
Notice how we’re using the same text style to measure “\n” as the Text
below, so we make sure we measure with the same constraints. Failing to do this might cause getting a height that is different than the required.
TextMeasurer
has an internal cache. As long as the text content, text style and constraints do not change, it will return the same measurement result, so it is fine to call it in composition (or whatever phase you need). We can even go a step further in our case and use remember
to store the result of the measurement as an optimization, so the calculation survives recompositions and we don’t need to hit its cache. In general though, we should be mindful when it would make sense to invalidate the remember
cache. Here it is not critical as the text is static.
Pros
- Achieves the required design.
- Code is easy to understand and maintain.
- Compose idiomatic.
Cons
- Requires measuring with the same style you’re drawing to avoid a design difference
- Requires considering to invalidate cache manually or not during the lifecycle of this composable.
To prevent an extra composable (option #1), an alternative to measuring the text explicitly (option #2) is using Text
’s onTextLayout
lambda which returns TextLayoutResult
. This object contains measurement information obtained during the layout phase about the text being rendered, like the information we need: line count and line height.
You can check the Compose Phases documentation and the Compose phases episode of the MAD skills series on Layouts & Modifiers to learn more about Compose phases. But as a quick recap, Compose has three main phases to transform data into UI:
In the onTextLayout
block we can query if the text has 1 line with lineCount
. If it does, we duplicate the height
, change the height mutable state which triggers a recomposition. If the text already has 2 lines we don’t need to do this. This processing happens during the layout phase.
This solution has 2 downsides.
First, this process will make us lose a frame for texts where line count is 1. The frame is not skipped (it won’t impact the frozen frame rate), but because we’re making a change in state before the drawing phase, there is a gap where nothing has been drawn. Compose needs to wait till the next frame to execute its 3 phases. Depending on the device, your users may notice a blink. You might also see other composables affected, as the parent composable recalculates where to place the children, after one of them has just changed its size.
And second, the result is not perfect:
Notice the text on the left for which we made the calculation is taller than the one on the right. To understand why we need to quickly revisit text metrics.
At the time of writing, by default in Compose, there’s padding added on top of the first line and bottom of the last line of the text, due to a legacy configuration called includeFontPadding
. By measuring one line of text:
And multiplying it by 2 you’re adding this font padding twice (left result), when in reality a two lined text would have a shorter resulting height (right result).
The resulting height of stacking 2 lines of text is larger than one single text with two lines.
These values are determined by the font metrics, meaning this difference might be more obvious or not depending on which font you use.
One interesting thing to try is setting
includeFontPadding
to false to remove the extra padding on top of the first and bottom of the last line of text. We won’t go into details of this solution here. You can check the gist and the Fixing Font Padding in Compose Text blog post if you’re interested to learn more about font metrics and text padding in Compose.
Let’s fix our code so we get the required design. Instead of getting the height of the single line and multiplying it by two, we can measure the text “\n” using onTextLayout
on the first frame, store this value and use it for all other frames as our height for the actual text.
We’re still losing a frame but the design is the one we want. The code is still simple to maintain and we’re not adding an extra Text
composable when we don’t need to.
Pros
- Achieves the required design.
- Code is easy to maintain.
- Compose idiomatic.
Cons
- The initial mistake was easy to make, if you are unaware of the text metrics.
- We lose a frame by changing state before the drawing phase. It might be noticeable depending on the layout, device you’re running, etc.
You can use the layout
modifier to modify how an element is measured and laid out, in our case, to measure and set the height to be 2 lines of text. Then we can place the text in the center of the container.
We cannot use remember
function inside the layout
block as we are not in composition at this point. As mentioned before, TextMeasurer
comes with an internal cache, so measuring multiple times should not be a problem, but it will take a toll.
We can also use the TextMeasurer
to measure in composition, remember
the result of the calculation, and then use the result during the layout phase, similar to what we did in option 2 to get a better performance.
To learn more about the layout
modifier, see the Use the layout modifier page in our documentation and the Advanced layout concepts episode of the MAD Skills series on Layouts and Modifiers.
Pros
- Achieves the required design.
- Compose- idiomatic, by leveraging the power of the drawing phase.
Cons
- Manual vertical alignment by calculating relative position.
- Measuring and remembering this measure in composition is more efficient than using
TextMeasurer
internal cache.
While drawing the text in Canvas
directly is possible, this option is not suitable for our use case, as it skips over the material and foundation layers of Compose text, which implements core features like text selection and accessibility.
Drawing text in Canvas
is primarily for creative purposes, such as decorative text or custom animated typography.
For all this we won’t add it to the final shortlist of options to consider. We’ll write it just for fun.
The perfect API to draw Compose Text
on Canvas
is drawText
. This API receives a TextLayoutResult
as obtained from measuring text with TextMeasurer
with the necessary configurations.
The first step will be to obtain a “\n”. Then we can use drawWithCache
modifier in a Box
, to access a cached draw scope. While in this scope, we can perform the second measurement on the text we need to display, using the constraints of the Canvas
.
Finally during the onDrawBehind
block we call drawText
, with the result of the second measurement. We use the topLeft
parameter to place our text so it is vertically and horizontally centered, using the canvas measures.
Again we use TextMeasurer
to measure “\n” to get the height of our container like we did in the TextMeasurer
step, and use remember
so we don’t calculate the measurement in each recomposition.
Pros
- Achieves the required design.
- Compose- idiomatic, by manually drawing text on Canvas during the drawing phase.
Cons
- Drawing directly on Canvas is unsuitable for complex uses, as your
Text
will lack basic core features like a11n and selection. - Manual alignment by calculating relative positions.
As an iteration of option #1, instead of adding the composable Text
(\n
) always on the screen, we can just compose the element, get its height through subcomposition and apply the obtained height to the next Text composable. We technically can do this using SubcomposeLayout
. But, as we will see a bit later, this option is not suitable for our use case.
By using SubcomposeLayout
you’re able to do a measurement pass first for a given child composable and then use that information to determine whether to compose other children or not.
If this is how the 3 phases of Compose go for the majority of layouts:
Notice that the layout phase involves measurement and placement steps.
In SubcomposeLayout
there is a fundamental change like this:
With SubcomposeLayout
you’re able to condition the composition of a child during the layout phase of a main composable, with the measurement information obtained.
Back to our code, we can create a composable that receives mainText
which is the text we want to display. By using SubcomposeLayout
, we can subcompose and measure the height of “\n”. Then we set the height we obtained as the max height in the layout
block of the SubcomposeLayout
. We can place mainText
vertically in the center by doing a calculation between the max height and the current text’s height.
This code might look like this:
This code is harder to read compared to our previous solution. Also because we’re deferring one composition after another child’s layout phase, we anticipate a poorer performance result for the benchmark.
Finally, our use case does not fit the usage of SubcomposeLayout
here. We’re composing both “\n” and the real text, deferring the composition of the real text and only placing one of them always.
As mentioned, we use SubcomposeLayout
when at least one child’s composition depends on the result of another child’s measurement, e.g. using the values calculated during the measurement as params for the composition of the children.
What we really need is one child’s measurement to measure other children, which is why using the layout
modifier is enough, as explored in option #4.
See the Advanced layout concepts episode of the MAD Skills series on Layouts and Modifiers to learn more about SubcomposeLayout
and its usage.
We did not consider this option in our final comparison, but thought it was interesting to explore.
Pros
- Achieves the required design.
- Prevents adding an extra Text to the composition for alignment purposes.
Cons
- Suboptimal usage of
SubcomposeLayout
. - Complex code, harder to understand.
- Worst performing solution.
We wrote and ran a macrobenchmark
test on a real Pixel 7 Pro device. The test consists of a LazyColumn
containing a fixed amount of items (the composable we are testing), and then scrolling it a fixed amount of times from top to bottom. The benchmark results include this overhead.
While we expect all solutions to perform well, we aim to assess the effect of utilising different APIs by comparing them against each other.
We analysed two metrics: frameDurationCpuMs
and frameOverrunMs
.
frameDurationCpuMs
— How much time the frame took to be produced on the CPU, the smaller this number, the better as they indicate that the UI is rendering more efficiently.frameOverrunMs
— How much time a given frame missed its deadline by. We’ll be getting negative numbers here, the more negative the better as they indicate how much faster than the deadline a frame was.
For more on these metrics, see the Capture Macrobenchmark Metrics documentation.
The results of the benchmark for the “default behavior” (without using any specific API) are:
+--------------------+----------+---------+---------+--------+
| | P50 | P90 | P95 | P99 |
+--------------------+----------+---------+---------+--------+
| frameDurationCpuMs | 5.1 | 6.6 | 7.1 | 8.5 |
| frameOverrunMs | -10.1 | -8.6 | -7.9 | -5.9 |
+--------------------+----------+---------+---------+--------+
Macrobenchmark gives us percentile benchmark results. To get P90 for instance, the framework collects the results from running the test a fixed number of iterations, orders them in ascending order, and discards the bottom 90% of them. P90 is the first result left after doing this, e.g. frameDurationCpuMs
were 6.6 ms or higher for 90% of the runs.
This result is our baseline, and we compared all the performance solutions below to it and against one another.
These are the results for frameDurationCpuMs
:
+--------------------+----------+---------+---------+--------+
| frameDurationCpuMs | P50 | P90 | P95 | P99 |
+--------------------+----------+---------+---------+--------+
| default | 5.1 | 6.6 | 7.1 | 8.5 |
| Two texts | 5.4 | 7.1 | 7.8 | 9.6 |
| Text measurer | 5.3 | 6.8 | 7.4 | 8.7 |
| TextLayoutResult | 5.3 | 7.1 | 8.0 | 9.6 |
| layout modifier | 5.4 | 7.1 | 7.8 | 9.6 |
| drawText | 5.3 | 7.0 | 7.7 | 9.5 |
| Subcomposition | 4.8 | 7.0 | 8.2 | 11.1 |
+--------------------+----------+---------+---------+--------+
These are the results for frameOverrunMs
:
+--------------------+----------+---------+---------+--------+
| frameOverrunMs | P50 | P90 | P95 | P99 |
+--------------------+----------+---------+---------+--------+
| default | -10.1 | -8.6 | -7.9 | -5.9 |
| Two texts | -9.8 | -8.0 | -7.0 | -4.2 |
| TextMeasurer | -9.7 | -8.1 | -7.5 | -5.3 |
| TextLayoutResult | -9.8 | -7.8 | -7.0 | -4.2 |
| layout modifier | -9.8 | -8.0 | -7.0 | -4.7 |
| drawText | -9.7 | -7.9 | -7.1 | -4.6 |
| Subcomposition | -10.1 | -7.7 | -5.9 | -2.4 |
+--------------------+----------+---------+---------+--------+
From taking a look at these 2 metrics we can extract few key conclusions:
TextMeasurer
solution (option #2) seems to perform the closest to the default behavior across the board.- The rest of them, except the subcomposition option, perform similarly. Using all these different APIs doesn’t seem to affect the median frame time compared to the default behavior, however it had a greater effect on frames in P99.
- Using subcomposition is the worst performing solution compared to the rest. This is unsurprising because this layout defers the composition of a child composable till after the result of a main composition.
Now we will revisit our shortlist and pick the best one according to the criteria we defined: readability, performance, and Compose idiomacy. It’s important to remember that each solution has strengths and weaknesses.
Our options:
For our use case we decided to go for the intuitive solution, or option #1 🎉 as it is the most easy to read and maintain, and performed well compared to almost all the rest of the alternatives 🚀
Phew, we made it! We explored a real problem we had, proposed multiple options to tackle it, compared and chose the one that suits best, given a criteria. We also did a performance analysis of all solutions for research purposes.
Our problem? Vertically align a text in a container with a height determined by the measure of another text.
We picked the solution that maximizes intuition, readability, simplicity, has a good performance, considering all tradeoffs and learnt about shortcomings of each approach.
Now, how would you have implemented this component? Do you agree with our pick? Which would’ve been yours and why? Let us know here or catch me on Twitter with your comments and questions.
Happy composing! 🚀
[ad_2]
Source link
Leave a Reply