Measuring string width to properly size Text() composable - android-jetpack-compose

I am working on a Jetpack Compose based app which shows a simple list with 3 columns.
Things are working in principle, but what I am struggling with is to automatically determine the size of the columns.
For example, the width requirement of the date column will differ significantly depending on user's locale settings and font size.
24.12.2021 - 20:00 requires a lot less screen space than
12/14/2021 - 08:00 PM.
What I was hoping I can do is to work with a sample date, measure it up based on current locale settings and font size and then set the width for all list entries accordingly.
Something similar to this:
val d = Date( 2021,12,30 ,23,59,59) // Sample date
val t = dateFormat.format(d) + " - " + timeFormat.format(d) // Build the output string
val dateColumnWidth = measureTextWidth(t, fontSize) // This is what I need
…
LazyColumn {
…
Row {
Text(text = t, Modifier.width(dateColumnWidth.dp), fontSize = fontSize.sp))
Text(text = value)
Text(text = comment)
}
}
…
I have been on this for weeks but a function like my "measureTextWidth" above doesn't seem to exist.
Is there any way to achieve this?

You can use SubcomposeLayout like this:
#Composable
fun MeasureUnconstrainedViewWidth(
viewToMeasure: #Composable () -> Unit,
content: #Composable (measuredWidth: Dp) -> Unit,
) {
SubcomposeLayout { constraints ->
val measuredWidth = subcompose("viewToMeasure", viewToMeasure)[0]
.measure(Constraints()).width.toDp()
val contentPlaceable = subcompose("content") {
content(measuredWidth)
}[0].measure(constraints)
layout(contentPlaceable.width, contentPlaceable.height) {
contentPlaceable.place(0, 0)
}
}
}
Then use it in your view:
MeasureUnconstrainedViewWidth(
viewToMeasure = {
Text("your sample text")
}
) { measuredWidth ->
// use measuredWidth to create your view
}

Related

Jetpack Compose - avoid unnecessary recomposition

I'm creating a custom slider control for my app but I can't avoid unnecessary recomposition without adding some ugly hacks...
CustomSlider1 is a component that recomposes all its children when the value changes; CustomSlider2 is what I came up with that does not... but the code doesn't seem right, so could anyone tell me what I'm doing wrong in CustomSlider1 and if CustomSlider2 is indeed correct?
The difference between the 2 components is basically that I read the value through a lambda and also added the Slider component inside a Scoped composable.
I'm using recomposeHighlighter to show recompositions.
Here's a gif showing how both behaves when I change its value:
Here's the code:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TestTheme {
Column {
var value by remember {
mutableStateOf(50f)
}
CustomSlider1("Custom Slider", value, 50f, true, { value = it }, 0f..100f, 5)
Spacer(modifier = Modifier.padding(10.dp))
CustomSlider2("Custom Slider 2", { value }, 50f, true, { value = it }, 0f..100f, 5)
}
}
}
}
}
#Composable
fun CustomSlider1(
label: String,
value: Float,
defaultValue: Float,
enabled: Boolean = true,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>,
steps: Int = 0,
) {
Column(
modifier = Modifier.recomposeHighlighter()
) {
Text(
text = label,
color = if (enabled) Color.Unspecified else LocalContentColor.current.copy(alpha = 0.5f),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.recomposeHighlighter()
)
Row {
Slider(
value = value,
valueRange = valueRange,
steps = steps,
enabled = enabled,
onValueChange = onValueChange,
modifier = Modifier
.recomposeHighlighter()
.weight(1f)
)
IconButton(
onClick = { onValueChange(defaultValue) },
enabled = enabled,
colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.primary),
modifier = Modifier.recomposeHighlighter()
) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = null,
modifier = Modifier.recomposeHighlighter()
)
}
}
}
}
#Composable
fun CustomSlider2(
label: String,
value: () -> Float,
defaultValue: Float,
enabled: Boolean = true,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>,
steps: Int = 0,
) {
Column(
modifier = Modifier.recomposeHighlighter()
) {
Text(
text = label,
color = if (enabled) Color.Unspecified else LocalContentColor.current.copy(alpha = 0.5f),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.recomposeHighlighter()
)
Row {
Scoped { //had to do this to avoid recompositions...
Slider(
value = value.invoke(),
valueRange = valueRange,
steps = steps,
enabled = enabled,
onValueChange = onValueChange,
modifier = Modifier
.recomposeHighlighter()
.weight(1f)
)
}
IconButton(
onClick = { onValueChange(defaultValue) },
enabled = enabled,
colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.primary),
modifier = Modifier.recomposeHighlighter()
) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = null,
modifier = Modifier.recomposeHighlighter()
)
}
}
}
}
#Composable
fun Scoped(content: #Composable () -> Unit) = content()
First thing you do to prevent recompositions creating Scope to create recomposition scope to limit recomposition since Column and Row are inline functions that do not create scopes.
Second thing with lambdas. In compose lambdas are unique they defer state read from composition phase of frame to layout or draw phases that's why you don't have recompositions.
Composition->Layout( measure and Layout)->Draw are the phases when a (re)composition is triggered by using lambdas you don't invoke composition phase.
For lambdas and state deferring you can check out official document or question below
// Here, assume animateColorBetween() is a function that swaps between
// two colors
val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(Modifier.fillMaxSize().background(color))
Here, the box's background color is switching rapidly between two
colors. This state is thus changing very frequently. The composable
then reads this state in the background modifier. As a result, the box
has to recompose on every frame, since the color is changing on every
frame.
To improve this, we can use a lambda-based modifier–in this case,
drawBehind. That means the color state is only read during the draw
phase. As a result, Compose can skip the composition and layout phases
entirely–when the color changes, Compose goes straight to the draw
phase.
val color by animateColorBetween(Color.Cyan, Color.Magenta) Box( Modifier
.fillMaxSize()
.drawBehind {
drawRect(color)
} )
Android Jetpack Compose - Composable Function get recompose each time Text-field value changes
For scoped composition you can check out this question or other answer linked to it

Why is MediumTopAppBar (and Large) showing two TextField in compose?

I am trying to make the title of a screen editable.
MediumTopAppBar(
title = {
val name: String? = "Some Title"
var input by remember { mutableStateOf(name ?: "") }
when (state.isEditingTitle) {
true ->
TextField(
value = input,
onValueChange = { input = it },
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
callbacks.onEditTitleChange(editTitle = false, updatedTitle = input)
})
)
false -> {
Text(
modifier = Modifier.clickable { callbacks.onEditTitleChange(true, null) },
text = name ?: "(No Title)"
)
}
}
},
... more app bar parameters
}
When I click on the title Text(...) and the view gets recomposed the AppBar shows two TextFields
How do I ignore the top one and only show the one in the bottom, like the Text() is only shown in the bottom?
(Fyi: the two TextInputs have their own remembered state and calls the callback with their own respective value)
Bonus question: How do I handle the remembered state "input" so that it resets every time the onDone keyboard action is triggered? Instead of val name: String? = "Some Title" it would of course be something in the line of val name: String? = state.stateModel.title
I found out why it does this, but I have no idea how to solve it (except for just making my own views and placing it close by)
It's easy to see when looking at the function for the MediumTopBar
// androidx.compose.material3.AppBar.kt
#ExperimentalMaterial3Api
#Composable
fun MediumTopAppBar(
title: #Composable () -> Unit,
modifier: Modifier = Modifier,
navigationIcon: #Composable () -> Unit = {},
actions: #Composable RowScope.() -> Unit = {},
windowInsets: WindowInsets = TopAppBarDefaults.windowInsets,
colors: TopAppBarColors = TopAppBarDefaults.mediumTopAppBarColors(),
scrollBehavior: TopAppBarScrollBehavior? = null
) {
TwoRowsTopAppBar(
modifier = modifier,
title = title,
titleTextStyle = MaterialTheme.typography.fromToken(TopAppBarMediumTokens.HeadlineFont),
smallTitleTextStyle = MaterialTheme.typography.fromToken(TopAppBarSmallTokens.HeadlineFont),
titleBottomPadding = MediumTitleBottomPadding,
smallTitle = title, // <- this thing, right here
navigationIcon = navigationIcon,
actions = actions,
colors = colors,
windowInsets = windowInsets,
maxHeight = TopAppBarMediumTokens.ContainerHeight,
pinnedHeight = TopAppBarSmallTokens.ContainerHeight,
scrollBehavior = scrollBehavior
)
}
There's some internal state shenanigans going on, probably checking for a Text being shown in the 2nd TopAppBarLayout (more digging required to find that), but not for any other view.
TwoRowsTopAppBar and TopAppBarLayout are not public, and can't be used directly.
This is explains why, but it would be interesting to see how to solve it (still using Medium or Large -TopAppBar)
it is stupid thing devs overlooked and should be warned against, at least. The answer is do not give default colors to your Typography TextStyles.
private val BodySmall = TextStyle(
fontSize = 10.sp,
lineHeight = 12.sp,
fontWeight = FontWeight.SemiBold,
fontFamily = Manrope,
color = Color.Black // REMOVE THIS
)
val OurTypography = Typography(
...
bodySmall = BodySmall
)

Is CompositionLocalProvider the only approach for custom Compose Theme?

I'm working on the Theme of an application which has its own design system. After reading the official documentation about custom theming I have some doubts.
Is there any reason why the different specs are provided as a CompositionLocals? I understand in some cases like colors where there is a chance we need to trigger recomposition for something like (dark/light) but for Typographies why bother providing it through CompositionLocal and not access them directly from a static variable? Having a custom Typography spec means using Material Components "won't work" anyway, right?
For context the Typography spec would be something like XXL, XL, L, M, S..(insted of h1, h2..) with a prefidined sizes (weights, etc). It could be modeled has a global object that could be accessed from everywhere instead of a class that is provided... does it make sense?
I use composition local for getting access to custom spacing anywhere as follows
Spacing.kt
data class Spacing(
val default : Dp = 0.dp,
val extraSmall: Dp = 4.dp,
val small: Dp = 8.dp,
val large: Dp = 12.dp,
val extraLarge: Dp = 16.dp,
)
val LocalSpacing = compositionLocalOf { Spacing() }
val MaterialTheme.spacing: Spacing
#Composable
#ReadOnlyComposable
get() = LocalSpacing.current
In theme.kt
#Composable
fun PokedexTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: #Composable () -> Unit) {
val colors = if (darkTheme) {
DarkColorPalette
} else {
LightColorPalette
}
CompositionLocalProvider(
LocalSpacing provides Spacing()
) {
MaterialTheme(
colors = colors,
typography = Typography,
shapes = Shapes,
content = content
)
}
}
Access it as:
MaterialTheme.Spacing.large

How to make a lazycolumn scroll to the end when using bottomsheetscaffold?

I am going to design a layout with a bottomsheetscaffold with sheetPeekHeight to be 100 dp in order to show the sheet content. I also need to put a lazyColumn for the main content of the bottomsheetscaffold. But when the lazy column scrolls to the end, the final item will be behind the bottom sheet. How can I make the final item of the column be above the sheet?
Here is the code for the bottom sheet:
#ExperimentalMaterialApi
#Composable
fun HomeScreen() {
val bottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = BottomSheetState(BottomSheetValue.Collapsed)
)
val coroutineScope = rememberCoroutineScope()
BottomSheetScaffold(
scaffoldState = bottomSheetScaffoldState,
sheetContent = {
Text("this is test", modifier = Modifier.fillMaxWidth().height(60.dp))
},
sheetPeekHeight = 100.dp,
sheetShape = RoundedCornerShape(topEnd = 52.dp, topStart = 52.dp),
backgroundColor = Color.White
) {
MainContent()
}
}
#Composable
fun MainContent() {
LazyColumn {
items(count = 5) { itemIndex ->
when (itemIndex) {
0 -> {
Image(modifier = Modifier
.fillMaxWidth()
.height(100.dp), contentDescription = "test",
painter = painterResource(id = R.drawable.image))
}
}
}
}
}
Spacer(modifier=Modifier.height(100.dp)) I think fits better than a box here.
In your case it is easier to use fixed height, but if your content is dynamic you can also calculate bottomSheet height based on screenheight - bottomSheetOffset
fun YourComposable{
...
val bottomSheetHeight =
configuration.screenHeightDp.dp - bottomSheetScaffoldState.bottomSheetState.offset.value.pxToDp
...
}
private val Float.pxToDp: Dp
get() = (this / Resources.getSystem().displayMetrics.density).dp
As a workaround, I added an empty box with the same height as peekheight to the end of the lazycloumn. But I'm still interested in a better solution.

Grid Layout with equally height rows in Jetpack Compose

Jetpack Compose:
I want to create a grid layout like UI with equally height rows, but I can't find a function for getting the current usable screen size (The application should look like that)
How the result should look like:
Have a look at the link mentioned above
What I have tried:
I tried to use Modifier.fillMaxHeight() inside of LazyVerticalGrid for generating equally sized rows, but it didn't work. Beside of that, I also had the idea of setting the height manually for every row item, but I couldn't find a function for getting the usable screen size in jetpack compose (I then would have divided the screen height by the number of rows).
val numbers = (0..34).toList()
LazyVerticalGrid(
cells = GridCells.Fixed(7)
) {
items(numbers.count()) {
Column(horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "#"+numbers.get(it))
}
}
}
You can use the aspectRatio modifier:
LazyVerticalGrid(
columns = GridCells.Adaptive(100.dp),
) {
items(100) {
Box(
modifier = Modifier.aspectRatio(1f)
) { }
}
}
Full example:
#Composable
fun Body(modifier: Modifier) {
Column (
modifier = modifier,
) {
Text(
text = "Without aspect ratio",
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.h5,
)
LazyVerticalGrid(
columns = GridCells.Adaptive(100.dp),
modifier = Modifier.weight(1f),
) {
items(100) {
Surface (
modifier = Modifier
.padding(12.dp),
color = Color(0xff2187bb),
) {
RandomSizeBox()
}
}
}
Divider()
Text(
"With aspect ratio",
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.h5,
)
LazyVerticalGrid(
columns = GridCells.Adaptive(100.dp),
modifier = Modifier.weight(1f),
) {
items(100) {
Surface (
modifier = Modifier
.aspectRatio(1f)
.padding(12.dp),
color = Color(0xffbb2187),
) {
RandomSizeBox()
}
}
}
}
}
#Composable
fun RandomSizeBox() {
Box(modifier = Modifier.height((10 + Math.random() * 90).dp))
}
For what it's worth, you can measure screen height this way:
val screenHeight = LocalConfiguration.current.screenHeightDp
But it might be worth your making your grid scrollable using a scroll modifier:
https://developer.android.com/jetpack/compose/gestures#scroll-modifiers
You can use subcompose layout to precalculate the max height of the biggest view, and then use that height for all of the LazyVerticalGrid items.
For example:
How does this work?
SubcomposeLayout gives you constraints, i.e. the max width and height the view could be. You can use this to calculate how many views will fit for a given min width. I will use 128 dp:
val longestText = exampleStringList.maxBy { it.length }
val maxWidthDp = constraints.maxWidth/1.dp.toPx()
val width = maxWidthDp/floor(maxWidthDp/128)
Then you can measure the height of a test view, in my case CategoryButton passing the calculated width from above to the modifier:
val measuredHeight = subcompose("viewToMeasure") {
CategoryButton(
longestText,
modifier = Modifier.width(width.dp)
)
}[0]
.measure(Constraints()).height.toDp()
Next you compose your actual LazyVerticalGrid, ensuring that the items use the height you calculated above:
val contentPlaceable = subcompose("content") {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 128.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(DataStore.categories) { category ->
CategoryButton(
category,
onClick = { onCategorySelected(category) },
modifier = Modifier.height(measuredHeight)
)
}
}
}[0].measure(constraints)
And finally, layout the LazyVerticalGrid:
layout(contentPlaceable.width, contentPlaceable.height) {
contentPlaceable.place(0, 0)
}
Full code:
SubcomposeLayout { constraints ->
val longestText = DataStore.categories.maxBy { it.length }
val maxWidthDp = constraints.maxWidth/1.dp.toPx()
val width = maxWidthDp/floor(maxWidthDp/128)
val measuredHeight = subcompose("viewToMeasure") {
CategoryButton(
longestText,
modifier = Modifier.width(width.dp)
)
}[0]
.measure(Constraints()).height.toDp()
val contentPlaceable = subcompose("content") {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 128.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(DataStore.categories) { category ->
CategoryButton(
category,
onClick = { onCategorySelected(category) },
modifier = Modifier.height(measuredHeight)
)
}
}
}[0].measure(constraints)
layout(contentPlaceable.width, contentPlaceable.height) {
contentPlaceable.place(0, 0)
}
}
there is another perhaps simple way to do it. You could keep tab of which item is the highest at the moment and update all the other one accordingly. Here is an example:
var desiredItemMinHeight by remember {
mutableStateOf(0.dp)
}
val density = LocalDensity.current
LazyVerticalGrid(
modifier = Modifier.fillMaxSize(),
columns = GridCells.Fixed(count = 2),
) {
items(state.items) {
ItemBox(
modifier = Modifier
.onPlaced {
with(density) {
if (desiredItemMinHeight < it.size.height.toDp()) {
desiredItemMinHeight = it.size.height.toDp()
}
}
}
.defaultMinSize(minHeight = desiredItemMinHeight),
)
}
}

Resources