I have a strange behaviour with jetpack compose row.
This is the demo code: display a row with X items and a vertical divider. Colors are for demo purpose :)
#Composable
fun StackOverflowDemo(itemList: List<Int>) {
Row(
modifier = Modifier
.height(IntrinsicSize.Min)
.background(Color.Blue)
.horizontalScroll(rememberScrollState())
) {
itemList.forEach {
Text(
it.toString(),
modifier = Modifier
.background(Color.Yellow)
.padding(horizontal = 4.dp)
)
Divider(
color = Color.Red,
modifier = Modifier
.width(1.dp)
.fillMaxHeight()
)
}
}
}
With few elements (10), this is the result
But with more elements (30), the result is wrong: the row height is not equal to the single item height, and the vertical divider is too high
Why there is such a different behaviour? What is missing in this simple setup?
If you need a emergency workaround, you can use SubcomposeLayout in order to calculate the max height and use it as Divider height.
#Composable
fun StackOverflowDemo(itemList: List<Int>) {
Box(
Modifier
.background(Color.Blue)
.horizontalScroll(rememberScrollState())
) {
SubcomposeLayout { constraints ->
// Measuring each Text
val placeables = subcompose("SomeRandomId") {
itemList.forEach {
Text(
it.toString(),
modifier = Modifier
.background(Color.Yellow)
.padding(horizontal = 4.dp)
)
}
}.map { it.measure(constraints) }
// Getting the max height to use in the divider
val maxHeight = placeables.maxOf { it.height }
// Now the Dividers are measured
val dividers = subcompose("SomeDividerId") {
(0 until itemList.lastIndex).map {
Divider(
color = Color.Red,
modifier = Modifier
.height(with(LocalDensity.current) { maxHeight / density }.dp)
.width(1.dp)
)
}
}.map { it.measure(constraints) }
// Calculating the total width of the components
val width = placeables.sumOf { it.width } + dividers.sumOf { it.width }
// Placing Text and dividers into the layout
layout(width, maxHeight) {
var x = 0
placeables.forEachIndexed { index, placeable ->
placeable.placeRelative(x, 0)
x += placeable.width
// the divider is not displayed after the last item
if (index < placeables.lastIndex) {
dividers[index].place(x, 0)
x += dividers[index].width
}
}
}
}
}
}
Here's the result:
Related
I know how to align text in Jetpack Compose to the baseline.
But now I would need to align two differently sized texts that follow each other in a Row by the ascent of the larger of these two fonts. I would like to think of this as aligning two texts "by the top baseline" if that makes sense. Modifier.align(Alignment.Top) does not work as it will not align by the ascent but by the layout's top and then the texts are not aligned correctly at the top.
I have tried to look how to do this, but apparently there's no ready made function or modifier for this? I didn't even find a way to access Text's ascent property etc in Compose. So not sure how this would be possible?
Thanks for any hints! :)
Edit: This is what it looks when Alignment.Top is used. But I would like the two texts to align at the top.
All information about text layout can be retrieved with onTextLayout Text argument. In this case you need a line size, which can be retrieved with getLineBottom, and an actual font size, which can be found in layoutInput.style.fontSize.
I agree that it'd be easier if you could use some simple way to do that, so I've starred your feature request, but for now here's how you can calculate it:
onTextLayout = { textLayoutResult ->
val ascent = textLayoutResult.getLineBottom(0) - textLayoutResult.layoutInput.run {
with(density) {
style.fontSize.toPx()
}
}
},
Full example of aligning two texts:
val ascents = remember { mutableStateMapOf<Int, Float>() }
val texts = remember {
listOf(
"Big text" to 80.sp,
"Small text" to 20.sp,
)
}
Row(
Modifier
.drawBehind {
ascents.maxOfOrNull { it.value }?.let {
drawLine(Color.Red, Offset(0f, it), Offset(size.width, it))
}
}
) {
texts.forEachIndexed { i, info ->
Text(
info.first,
fontSize = info.second,
onTextLayout = { textLayoutResult ->
ascents[i] = textLayoutResult.getLineBottom(0) - textLayoutResult.layoutInput.run {
with(density) {
style.fontSize.toPx()
}
}
},
modifier = Modifier
.alpha(if (ascents.count() == texts.count()) 1f else 0f)
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val maxAscent = ascents.maxOfOrNull { it.value } ?: 0f
val ascent = ascents[i] ?: 0f
val yOffset = if (maxAscent == ascent) 0 else (maxAscent - ascent).toInt()
layout(placeable.width, placeable.height - yOffset) {
placeable.place(0, yOffset)
}
}
)
}
}
Result:
One workaround would be you can adjust y-axis offset modifier according to your need.
Text(text = "Second", modifier = Modifier.offset(x = 0.dp, y = 5.dp))
you can have negative value for offset as well if you like to up your first text according to your need.
Another option is to use ConstraintLayout. You can simply constrain the tops of the two texts https://developer.android.com/jetpack/compose/layouts/constraintlayout
in addition to one of the previous answers
ascent and descent
Text(
modifier = modifier,
text = text,
onTextLayout = { result ->
val layoutInput = result.layoutInput
val fontSize = with(layoutInput.density) { layoutInput.style.fontSize.toPx() }
val lineHeight = with(layoutInput.density) { layoutInput.style.lineHeight.toPx() }
var baseline = result.firstBaseline
(0 until result.lineCount).forEach { index ->
val top = result.getLineTop(index)
val bottom = result.getLineBottom(index)
val ascent = bottom - fontSize
val descent = bottom - (baseline - fontSize - top)
baseline += lineHeight
}
}
)
I want to make a ScrollableTabRow that goes past the width of the screen to make it clearer that there are more tabs to scroll through. It should look something like this:
When scrolled all the way to the left:
When scrolled somewhere in the middle:
When scrolled all the way to the right:
However, I cannot achieve it using the Material component ScrollableTabRow because the ScrollableTabRow is filling the remaining width of the screen, instead of fully wrapping its content.
When scrolled all the way to the left:
When scrolled all the way to the right:
Here is my code using the ScrollableTabRow composable:
Note: 1.unit is equal to 4.dp
Row {
HorizontalSpacer(width = 4.unit)
ScrollableTabRow(
modifier = Modifier.clip(CircleShape).wrapContentSize(),
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
SpecsheetTabIndicator(tabPositions = tabPositions, pagerState = pagerState)
},
edgePadding = 0.dp,
divider = {},
containerColor = MaterialTheme.colorScheme.primaryContainer,
) {
for ((index, tab) in tabs.withIndex()) {
val textColor by animateColorAsState(
targetValue = when (pagerState.currentPage) {
index -> MaterialTheme.colorScheme.onPrimary
else -> MaterialTheme.colorScheme.onPrimaryContainer
},
)
Tab(
modifier = Modifier
.zIndex(6f)
.clip(CircleShape),
selected = pagerState.currentPage == index,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
text = {
Text(
text = tab.text(),
color = textColor,
)
},
)
}
}
HorizontalSpacer(width = 4.unit)
}
Also, as you can see in my code above, the Row is not really scrolling because I am not sure how to implement nested scrolling. Adding a horizontalScroll modifier to the Row makes the app crash because of the unhandled nested scrolling.
I have achieved the behavior I wanted as demonstrated in the first three images using regular rows:
Row(
modifier = Modifier.horizontalScroll(scrollState),
) {
HorizontalSpacer(width = 4.unit)
Row(
modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer)
) {
for ((index, tab) in tabs.withIndex()) {
val containerColor by animateColorAsState(
targetValue = when (pagerState.currentPage) {
index -> MaterialTheme.colorScheme.primary
else -> Color.Transparent
},
)
val textColor by animateColorAsState(
targetValue = when (pagerState.currentPage) {
index -> MaterialTheme.colorScheme.onPrimary
else -> MaterialTheme.colorScheme.onPrimaryContainer
},
)
Surface(
modifier = Modifier.clip(CircleShape),
color = containerColor,
) {
Tab(
modifier = Modifier
.zIndex(6f)
.clip(CircleShape)
.defaultMinSize(minWidth = 24.unit),
selected = pagerState.currentPage == index,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
text = {
Text(
text = tab.text(),
color = textColor,
)
},
)
}
}
}
HorizontalSpacer(width = 4.unit)
}
However, I don't know how to reimplement a lot of the behavior that are built into the ScrollableTabRow like the proper rendering of the indicator and the proper scrolling of the tab row to the selected tab when the selected tab is not visible. Can anyone help me make this work for me?
I want to do custom swipeable switch, but I want the switch to be only swiped from the main part (the dark grey box). The problem is that I can swipe the box from anywhere, even the light gray part(when the dark grey box is in the other side) in the row.
How can I make it to get gestures only from dark grey box.
val width = 96.dp
val squareSize = 48.dp
val swipeableState = rememberSwipeableState(0)
val sizePx = with(LocalDensity.current) { squareSize.toPx() }
val anchors = mapOf(0f to 0, sizePx to 1) // Maps anchor points (in px) to states
Box(
modifier = Modifier
.width(width)
.swipeable(
state = swipeableState,
anchors = anchors,
thresholds = { _, _ -> FractionalThreshold(0.3f) },
orientation = Orientation.Horizontal
)
.background(Color.LightGray)
) {
Box(
Modifier
.offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) }
.size(squareSize)
.background(Color.DarkGray)
)
}
Move swipeable modifier to inner Box
Box(
modifier = Modifier
.width(width)
.background(Color.LightGray)
) {
Box(
Modifier
.swipeable(
state = swipeableState,
anchors = anchors,
thresholds = { _, _ -> FractionalThreshold(0.3f) },
orientation = Orientation.Horizontal
)
.offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) }
.size(squareSize)
.background(Color.DarkGray)
)
}
The following picture shows what I want to achieve. I want the tab indicator to be a short rounded bar.
I looked up the implementation of TabRowDefaults.Indicator(), and just made my own one. I just tried to add the clip() modifier, but it didn't work. And I tried to change the order of the modifiers, but still no luck.
And here is my code implementation:
#Composable
fun TabLayout(
tabItems: List<String>,
content: #Composable () -> Unit
) {
var tabIndex by remember { mutableStateOf(0) }
Column {
ScrollableTabRow(
selectedTabIndex = tabIndex,
edgePadding = 0.dp,
backgroundColor = MaterialTheme.colors.background,
contentColor = Blue100,
indicator = { tabPositions ->
Box(
modifier = Modifier
.tabIndicatorOffset(tabPositions[tabIndex])
.height(4.dp)
.clip(RoundedCornerShape(8.dp)) // clip modifier not working
.padding(horizontal = 28.dp)
.background(color = AnkiBlue100)
)
},
divider = {},
) {
tabItems.forEachIndexed { index, item ->
Tab(
selected = tabIndex == index,
onClick = { tabIndex = index },
selectedContentColor = Blue100,
unselectedContentColor = Gray200,
text = {
Text(text = item, fontFamily = fontOutfit, fontSize = 18.sp)
}
)
}
}
Divider(
color = Gray50,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
)
content()
}
}
You applied Modifier.padding between Modifier.clip and Modifier.background, so the rounding is actually applied to the transparent padding. You need to move the padding in front of the clip, or specify the shape with the background:
.background(color = AnkiBlue100, shape = RoundedCornerShape(8.dp))
Read more about why the order of the modifiers matters in this answer
Is it possible to do weights in Jetpack Compose with lazy column?
I'd like to set it menu item is weighted as 1/n (n = number of menus) of a layout, and the other takes up the remaining 1/n, also.
I want to list it at the same height as the number of menus.
MenuList
#Composable
fun MenuList(
loading: Boolean,
menus: List<Menu>,
onNavigateToMenuDetailScreen: (String) -> Unit
) {
Box(modifier = Modifier
.background(color = MaterialTheme.colors.surface)
.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
if (loading && menus.isEmpty()) {
LoadingShimmer(imageHeight = 800.dp)
}
else if (menus.isEmpty()) {
NothingHere()
}
else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.weight(1F)
) {
itemsIndexed(
items = menus
) { index, menu ->
MenuCard(
menu = menu,
onClick = {
}
)
}
}
}
}
}
}
MenuCard
#Composable
fun MenuCard(
menu: Menu,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Card(
shape = MaterialTheme.shapes.small,
modifier = Modifier
.padding(
bottom = 6.dp,
top = 6.dp
)
.fillMaxWidth()
.clickable(onClick = onClick),
elevation = 8.dp
) {
Column {
Text(
text = menu.name,
fontSize = 30.sp,
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally)
.wrapContentHeight(Alignment.CenterVertically),
style = MaterialTheme.typography.h3
)
}
}
}
In summary, MenuCards are created as many as the number of menu on the MenuList screen, and I hope the height of each MenuCard will be 1/n.
(n = number of menu)
Like, when number of menu is 8,
Just same height to each menu.
Replace your LazyColumn code with BoxWithConstraints and regular Column.
BoxWithConstraints for getting the minHeight or in this case you can say screen height.
Change to something like the below.
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val height = minHeight/menus.size
Column(
modifier = Modifier
.fillMaxSize()
) {
menus.forEachIndexed { index, menu ->
MenuCard(
modifier = Modifier.height(height),
menu = menu,
onClick = {
}
)
}
}
}
MenuCard
#Composable
fun MenuCard(
menu: Menu,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Card(
shape = MaterialTheme.shapes.small,
modifier = modifier
.padding(
bottom = 6.dp,
top = 6.dp
)
.fillMaxWidth()
.clickable(onClick = onClick),
elevation = 8.dp
) {
Column(
modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = menu.name,
fontSize = 30.sp,
style = MaterialTheme.typography.h3
)
}
}
}
You will get this
3 items and 6 items
If all you want is a way to divide the screen height in equal proportions to all the n items, you can simply go for a column.
val items = ... //I assume you have access to this
val screenHeight = LocalConfiguration.current.screenHeightDp
Column{
items.forEach{
MenuCard(modifier = Modifier.height(1f / items.size * screenHeight))
}
}
Column and LazyColumn are fundamentally different. LazyColumn's sole purpose is to deal with large datasets by loading and caching only a small window off that set. Now, if there is nothing to be scrolled, LazyColumn is useless, in fact, it is worse.
EDIT:
You could use BoxWithConstraints to get the screen dimensions as well, but it, in this context, would be more of a workaround than a solution. It adds an extra Composable and decreases the readability of the code too, so for getting the screen dimensions, always use the API that was specifically built for the job.