Compose: placement of measurables in Layout based on measuredWidth - kotlin

I am trying to implement a SegmentedControl composable, but allow for segments to be of different sizes if one of them needs more space. So far I've achieved basic implementation, where all segments are equal in width:
But as you can see, Foo and Bar segments can easily occupy less space to make room for Some very long string.
So my requirements are:
When the sum of desired widths of every child is less than width of incoming constraints, distribute children evenly
Otherwise shrink children that can be shrinked until all children are visible
If it is not possible, find a configuration in which maximum amount of content can be showed.
When trying to implement the first requirement I quickly remembered that it is not possible with default Layout composable since only one measurement per measurable per layout pass is allowed, and for good reasons.
Layout(
content = {
// Segments
}
) { segmentsMeasurables, constraints ->
var placeables = segmentsMeasurables.map {
it.measure(constraints)
}
// In case every placeable has enough space in the layout,
// we divide the space evenly between them
if (placeables.sumOf { it.measuredWidth } <= constraints.maxWidth) {
placeables = segmentsMeasurables.map {
it.measure( // <- NOT ALLOWED!
Constraints.fixed(
width = constraints.maxWidth / state.segmentCount,
height = placeables[0].height
)
)
}
}
layout(
width = placeables.sumOf { it.width },
height = placeables[0].height
) {
var xOffset = 0
placeables.forEachIndexed { index, placeable ->
xOffset += placeables.getOrNull(index - 1)?.width ?: 0
placeable.placeRelative(
x = xOffset,
y = 0
)
}
}
}
I also looked into SubcomposeLayout, but it doesn't seem to do what I need (my use-case doesn't need subcomposition).
I can imagine a hacky solution in which I force at least two layout passes to collect children`s sizes and only after that perform layout logic, but it will be unstable, not performant, and will generate a frame with poorly layed-out children.
So how is it properly done? Am I missing something?

You have to use intrinsic measurements,
#Composable
fun Tiles(
modifier: Modifier = Modifier,
content: #Composable () -> Unit,
) {
Layout(
modifier = modifier,
content = content,
) { measurables, constraints ->
val widths = measurables.map { measurable -> measurable.maxIntrinsicWidth(constraints.maxHeight) }
val totalWidth = widths.sum()
val placeables: List<Placeable>
if (totalWidth > constraints.maxWidth) {
// do not fit, set all to same width
val width = constraints.maxWidth / measurables.size
val itemConstraints = constraints.copy(
minWidth = width,
maxWidth = width,
)
placeables = measurables.map { measurable -> measurable.measure(itemConstraints) }
} else {
// set each to its required width, and split the remainder evenly
val remainder = (constraints.maxWidth - totalWidth) / measurables.size
placeables = measurables.mapIndexed { index, measurable ->
val width = widths[index] + remainder
measurable.measure(
constraints = constraints.copy(
minWidth = width,
maxWidth = width,
)
)
}
}
layout(
width = constraints.maxWidth,
height = constraints.maxHeight,
) {
var x = 0
placeables.forEach { placeable ->
placeable.placeRelative(
x = x,
y = 0
)
x += placeable.width
}
}
}
}
#Preview(widthDp = 360)
#Composable
fun PreviewTiles() {
PlaygroundTheme {
Surface(
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(all = 16.dp),
) {
Tiles(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
) {
Text(
text = "Foo",
textAlign = TextAlign.Center,
modifier = Modifier.background(Color.Red.copy(alpha = .3f))
)
Text(
text = "Bar",
textAlign = TextAlign.Center,
modifier = Modifier.background(Color.Blue.copy(alpha = .3f))
)
}
Tiles(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
.height(40.dp)
) {
Text(
text = "Foo",
textAlign = TextAlign.Center,
modifier = Modifier.background(Color.Red.copy(alpha = .3f))
)
Text(
text = "Bar",
textAlign = TextAlign.Center,
modifier = Modifier.background(Color.Blue.copy(alpha = .3f))
)
Text(
text = "Some very long text",
textAlign = TextAlign.Center,
modifier = Modifier.background(Color.Red.copy(alpha = .3f))
)
}
Tiles(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
.height(40.dp)
) {
Text(
text = "Foo",
textAlign = TextAlign.Center,
modifier = Modifier.background(Color.Red.copy(alpha = .3f))
)
Text(
text = "Bar",
textAlign = TextAlign.Center,
modifier = Modifier.background(Color.Blue.copy(alpha = .3f))
)
Text(
text = "Some even much longer text that doesn't fit",
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.background(
Color.Red.copy(alpha = .3f)
)
)
}
}
}
}
}

Related

Jetpack Compose - layouting reusable components

for practicing with reusable components in Jetpack Compose, I started a little exercise.
See picture below.
As I imagine the green row, the input row, and the rows between have the same construction.
The first element got the available space, the second takes 50.dp, and the last one got 70.dp.
I tried to seperate the width into variables an pass this vars as modifiers to the single elements in the row. I thought if I need additionally fields, the I can extend it whitout any problem.
CODE DOESN'T WORK!
#Composable
fun groundComponent(
modifier: Modifier = Modifier,
spaceBetween: Dp = 0.dp,
color: Color,
content: #Composable () -> Unit
) {
Surface(
color = color
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(spaceBetween)
) {
content()
}
}
}
#Composable
fun inputSection() {
val firstRowWidth = 1F
val secondRowWidth = 70.dp
val thirdRowWidth = 50.dp
Text("Add Ingredient")
groundComponent(color = Color.Green){
Text( text="Ingredient", modifier = Modifier.weight(firstRowWidth ))
Text( text="Amount", modifier = Modifier.widthIn(secondRowWidth ))
Text( text="Unit", modifier = Modifier.widthIn(thirdRowWidth ))
}
groundComponent{
Text( text="Sugar", modifier = Modifier.weight(firstRowWidth ))
Text( text="500", modifier = Modifier.widthIn(secondRowWidth ))
Text( text="gr", modifier = Modifier.widthIn(thirdRowWidth ))
}
groundComponent{
Text( text="Carrot", modifier = Modifier.weight(firstRowWidth ))
Text( text="1.5", modifier = Modifier.widthIn(secondRowWidth ))
Text( text="kg", modifier = Modifier.widthIn(thirdRowWidth ))
}
groundComponent{
TextField(
value = "newIngredient",
onValueChange = {},
modifier = Modifier.weight(firstRowWidth ))
TextField(
value = "newAmount",
onValueChange = {},
modifier = Modifier.widthIn(secondRowWidth )
)
TextField(
value = "newUnit",
onValueChange = {},
modifier = Modifier.widthIn(thirdRowWidth )
)
}
Button(onClick={}){Text("add")}
}
I got several errors with the .weight modifier.
So how is the right aproach to solve such a situation.
Thanks!
Modifier.weight is a Modifier that defined in specific scopes such as RowScope and ColumnScope. To be able to use modifiers that are defined in specific scopes you need to add Receiver to your content. BoxScope as Modifier.align() that is defined for instance, you can define your scopes either.
#Composable
fun GroundComponent(
modifier: Modifier = Modifier,
spaceBetween: Dp = 0.dp,
color: Color=Color.Unspecified,
content: #Composable RowScope.() -> Unit
) {
Surface(
color = color
) {
// Can't call content here because it has RowScope as receiver
// content()
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(spaceBetween)
) {
content()
}
}
}
Also in InputSection you define weight fractions as
val firstRowWidth = 1F
val secondRowWidth = 70.dp
val thirdRowWidth = 50.dp
these values should be proportionate to each other
if you set 1/5/6 for instance. or between 0f-1f
And by convention you can name Composable with capital initial letter since they are considered as widgets.
Thanks for your reply and your pretty good explanation!
With your help I solved my problem this way.
#Composable
fun InputRowGroundComponent(
modifier: Modifier = Modifier,
spaceBetweenElements: Dp = 0.dp,
color: Color,
content: #Composable RowScope.() -> Unit
) {
Surface(
color = color
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(spaceBetweenElements),
verticalAlignment = Alignment.CenterVertically
) {
content()
}
}
}
#Composable
fun OverviewHeader(
modifier: Modifier = Modifier,
text: String
) {
Text(
modifier = modifier,
text = text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center
)
}
#Composable
fun OverviewContent(
modifier: Modifier = Modifier,
text: String
) {
Text(
modifier = modifier,
text = text,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
#Preview(showBackground = true, widthDp = 460)
#Composable
fun testPrev() {
val rowWeights = listOf(6F,3F,2F)
val rowSpacing = 8.dp
val indentation = 10.dp
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(rowSpacing)
) {
InputRowGroundComponent(
modifier = Modifier.heightIn(45.dp),
spaceBetweenElements = rowSpacing,
color = Color.Green
) {
OverviewHeader(text = "Ingredient", modifier = Modifier.weight(rowWeights[0]))
OverviewHeader(text = "Amount", modifier = Modifier.weight(rowWeights[1]))
OverviewHeader(text = "Unit", modifier = Modifier.weight(rowWeights[2]))
}
InputRowGroundComponent(
modifier = Modifier.heightIn(30.dp),
spaceBetweenElements = rowSpacing,
color = Color.Unspecified
) {
OverviewContent(text = "Sugar", modifier = Modifier.weight(rowWeights[0]).padding(start=indentation))
OverviewContent(text = "500", modifier = Modifier.weight(rowWeights[1]).padding(start=indentation))
OverviewContent(text = "gr", modifier = Modifier.weight(rowWeights[2]).padding(start=indentation))
}
InputRowGroundComponent(
modifier = Modifier.heightIn(30.dp),
spaceBetweenElements = rowSpacing,
color = Color.Unspecified
) {
OverviewContent(text = "Carrot", modifier = Modifier.weight(rowWeights[0]).padding(start=indentation))
OverviewContent(text = "1.5", modifier = Modifier.weight(rowWeights[1]).padding(start=indentation))
OverviewContent(text = "kg", modifier = Modifier.weight(rowWeights[2]).padding(start=indentation))
}
InputRowGroundComponent(
spaceBetweenElements = rowSpacing,
color = Color.Unspecified
) {
TextField(value = "", onValueChange = {}, modifier = Modifier.weight(rowWeights[0]))
TextField(value = "", onValueChange = {}, modifier = Modifier.weight(rowWeights[1]))
TextField(value = "", onValueChange = {}, modifier = Modifier.weight(rowWeights[2]))
}
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { /*Todo*/ },
content = {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "Add Ingredient"
)
Text(
text = "Add"
)
}
}
)
}
}
Is this approach now right?

Draw a circle behind BadgeBox to make it more visible, circle getting too small

My code:
IconButton(onClick = { /*TODO*/ }) {
BadgedBox(
modifier = Modifier.drawBehind
{
drawCircle(
color = Color.LightGray,
center = Offset(
this.size.maxDimension / 2,
this.size.maxDimension / 2
),
radius = 10f
)
},
badge = { Badge(modifier = Modifier.size(12.dp)) {} },
) {
Icon(imageVector = Icons.Outlined.Notifications, contentDescription = "")
}
Current results:
The light-gray circle, shall cover in background behind and look like a circular button. But, currently, it doesn't look like that. I tried with maxDimension. I am doing this in Kotlin with Jetpack Compose.
Desired:
EDIT: Added this line,
modifier = Modifier.clip(CircleShape).background(Color.LightGray)
Now it looks like this:
However, this looks very strange and does not provide the desired results.
You can do it using one box that covers both content and notification circle
#Composable
private fun MyBadgeBox(
badge: #Composable () -> Unit,
notificationRadius: Dp = 8.dp,
notification: Boolean
) {
Box {
Box(modifier = Modifier.padding(notificationRadius)) {
badge()
}
Box(
modifier = if (notification) Modifier
.padding(notificationRadius / 2)
.align(Alignment.TopEnd)
.size(notificationRadius * 2)
.drawBehind {
drawCircle(Color.Red)
} else Modifier)
}
}
Usage
val modifier = Modifier
.size(40.dp)
.background(
color = Color.LightGray.copy(.5f),
shape = CircleShape
)
.clip(CircleShape)
.clickable { }
.padding(6.dp)
MyBadgeBox(
badge = {
Icon(
modifier = modifier,
imageVector = Icons.Outlined.Notifications,
contentDescription = ""
)
}, notification = false
)
MyBadgeBox(
badge = {
Icon(
modifier = modifier,
imageVector = Icons.Outlined.Notifications,
contentDescription = ""
)
}, notification = true
)
}
Result
You can use something like:
BadgedBox(
badge = { Badge(modifier = Modifier.size(12.dp)) {} },
) {
val radius = 16.dp
val shape = RoundedCornerShape(radius)
Box(
contentAlignment= Alignment.Center,
modifier = Modifier
.defaultMinSize(minWidth = radius * 2, minHeight = radius * 2)
.background(
color = Color.LightGray,
shape = shape
)
.clip(shape)
,
) {
Icon(imageVector = Icons.Outlined.Notifications, contentDescription = "")
}
}

How to show the content of a dialog without shadow?

I am displaying a Dialog with Jetpack Compose in my application. The Dialog contains a Google map. The problem is that this map appears dark, as if it has a shadow on it:
If you notice, it is displayed in exactly the same way as what is below the Dialog. How can I make this map appear clear without any shadow?
My code:
Dialog:
#Composable
fun DialogCustom(
data: Data
onDismiss: () -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Card(
backgroundColor = Color.Green,
elevation = 20.dp,
shape = RoundedCornerShape(15.dp),
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 20.dp,
vertical = 8.dp
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
) {
CustomCardTitle(
data= data
color = Color.Green
)
CustomMap(
lat = data.lat,
lon = data.lon
)
}
}
}
}
Map:
#Composable
fun CustomMap(
lat: Double,
lon: Double
) {
val mapView = rememberMapViewWithLifeCycle()
Column(
modifier = Modifier
.background(Color.White)
) {
AndroidView(
{
mapView
}
) {
mapView.getMapAsync {
val map = it
map.uiSettings.isZoomControlsEnabled = false
map.addMarker(marker(title, lat, lon, 250f))
map.moveCamera(CameraUpdateFactory.newLatLngZoom(LatLng(lat, lon), 16f))
map.mapType = GoogleMap.MAP_TYPE_HYBRID
}
}
}
}

How to make BottomNavigationItem fill space available?

I want to make BottomNavigation with text appearing from right side of selected item. How can I make BottomNavigationItem fill available space or move other items, to prevent text from wrapping?
here's image
Tried this, but didn't work:
#Composable
fun BottomNavigationBar(
items: List<BottomNavItem>,
navController: NavController,
onItemClick: (BottomNavItem) -> Unit
) {
val backStackEntry = navController.currentBackStackEntryAsState()
BottomNavigation(
modifier = Modifier,
elevation = 0.dp,
backgroundColor = light
) {
items.forEach{
val selected = it.screen_route == backStackEntry.value?.destination?.route
BottomNavigationItem(
selected = selected,
selectedContentColor = primary_color,
unselectedContentColor = shaded,
onClick = { onItemClick(it) },
icon = {
Row(
modifier = if (selected) Modifier
.fillMaxWidth()
.padding(horizontal = 15.dp)
else Modifier
.padding(horizontal = 15.dp)
) {
Icon(
imageVector = it.icon,
contentDescription = it.title,
tint = if (selected) primary_color else shaded,
)
if (selected){
Text(
text = it.title,
color = primary_color,
textAlign = TextAlign.Center,
fontSize = 20.sp,
modifier = Modifier.padding(start = 2.dp).align(Alignment.CenterVertically),
overflow = TextOverflow.Visible
)
}
}
}
)
}
}
}
You can check solution from Jetsnack sample app. I think this is the same behavior you want to achieve.

Jetpack Compose - TextOverflow.Ellipsis doesn't work without specifying maxLines

I want to display a Text inside a Card with some inner padding and sometimes the text will not fit in. I want this thing to be marked with an ellipsis. But I can't make it work without maxLines.
#Composable
fun CardWithText() {
Card(
modifier = Modifier
.height(60.dp)
.width(100.dp)
.border(1.dp, Color.Black, RoundedCornerShape(0))
) {
Card(
modifier = Modifier
.padding(8.dp)
.fillMaxSize()
.border(1.dp, Color.Black, RoundedCornerShape(0))
) {
Text(
text = "One two three four five six seven eight nine ten eleven twelve",
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = Color.Black
)
}
}
}
With maxLines = 2
With maxLines = 3 or not using maxLines at all
This is a known issue, causing Ellipsis to ignore parent size constraints. Star it to bring more attention and follow the updates.
Meanwhile you can use this hacky solution: it'll calculate the real number of lines and pass the correct value for maxLines:
#Composable
fun TextEllipsisFixed(
text: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit,
style: TextStyle = LocalTextStyle.current,
) {
SubcomposeLayout(modifier = modifier) { constraints ->
var slotId = 0
fun placeText(
text: String,
onTextLayout: (TextLayoutResult) -> Unit,
constraints: Constraints,
maxLines: Int,
) = subcompose(slotId++) {
Text(
text = text,
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
textAlign = textAlign,
lineHeight = lineHeight,
softWrap = softWrap,
onTextLayout = onTextLayout,
style = style,
overflow = TextOverflow.Ellipsis,
maxLines = maxLines,
)
}[0].measure(constraints)
var textLayoutResult: TextLayoutResult? = null
val initialPlaceable = placeText(
text = text,
constraints = constraints,
onTextLayout = {
textLayoutResult = it
},
maxLines = maxLines,
)
val finalPlaceable = textLayoutResult?.let { layoutResult ->
if (!layoutResult.didOverflowHeight) return#let initialPlaceable
val lastVisibleLine = (0 until layoutResult.lineCount)
.last {
layoutResult.getLineBottom(it) <= layoutResult.size.height
}
placeText(
text = text,
constraints = constraints,
onTextLayout = onTextLayout,
maxLines = lastVisibleLine + 1,
)
} ?: initialPlaceable
layout(
width = finalPlaceable.width,
height = finalPlaceable.height
) {
finalPlaceable.place(0, 0)
}
}
}
Usage:
Card(
modifier = Modifier
.height(60.dp)
.width(100.dp)
.border(1.dp, Color.Black, RoundedCornerShape(0))
) {
Card(
modifier = Modifier
.padding(8.dp)
.fillMaxSize()
.border(1.dp, Color.Black, RoundedCornerShape(0))
) {
TextEllipsisFixed(
text = "One two three four five six seven eight nine ten eleven twelve",
color = Color.Black
)
}
}
Result: