hi all, im trying to design a relatively complicat...
# compose
a
hi all, im trying to design a relatively complicated composable. it is essentially a tab row, where the tabs are little rounded corner shapes that stretch to fit a row. so far so good, but we want it to be such that if the size of the tabs end to end at their smallest exceeds the size of the row, the row becomes scrollable and the tabs' individual size is minimised. i'd say we're 50% of the way there, but its getting to be complicated now that we're trying to implement scrolling. we're currently using a subcompose layout to measure the children and give them a specific width based on the amount of space available, or leaving them at min width if the total space theyd take up meets or exceeda constraints. do we then have to implement our own scrolling logic, or is there a much simpler way we should be tackling this? i'd love to use a regular row instead, but the switching between tab items filling horizontally to wrapping horizontally and scrolling has us stumped. thanks in advance
1
lmk if more context or code sample is needed
s
Would it work with a custom layout? Get the available width from the max constraints, and measure the intrinsic width of the items with the given height, and see if the sum of all of those exceeds the maxWidth. If it does, just lay them out as a row would. Since you want to have the layout be horizontally scrollable, when you do that the max constraints horizontally will be infinite, so you gotta do smth like what I'm doing here https://github.com/HedvigInsurance/android/blob/develop/app%2Ffeature%2Ffeature-home%2Fsrc%2Fmain%2Fkotlin%2Fcom%2Fhedvig%2Fandroid%2Ffeature%2Fhome%2Fhome%2Fui%2FHomeDestination.kt#L318-L330 where I store the size manually before the content becomes scrollable to later use in the layout
a
sweet thanks so much!
s
Lmk if it works 🤗
a
man this is a tough one, we're designing this as a reuseable design element so id really prefer to avoid passing in the size manually if at all possible. feels odd that its so hard to merge these 2 behaviours
Copy code
if (space taken <= space available)
   fill children
if (space taken > space available)
   don't modify child size and instead scroll
s
Yeah you wouldn't need to pass any fixed size in there. You can get the real size from the onPlaced as I do in the link, and in the layout logic use that to know what constraints to pass to the children
Can you share some code of how you expect to use this layout? Just the call site perhaps. Would it just be?
Copy code
ThisCustomScrollableRow {
  Item(...)
  Item(...)
  FooComposable()
  ...
  ...
}}
a
i thik i understand your first point, i was focused on how you pass fullScreenSize into the HomeLayout but i realise i can do that from within my composable to begin with. the item is essentially
Copy code
ScrollableTabRow(listOfTabDatas, selected, onTabSelected)
👍 1
let me get the inside
Copy code
RowOfTabs(
        modifier = Modifier
            .background(Color.Red)
            .padding(vertical = DP_4),
    ) {
        tabs.forEachIndexed { index, tab ->
            TabItem(
                leadingIcon = tab.leadingIcon,
                title = tab.title,
                onSelect = {
                    onSelect(it)
                },
                selected = selectedIndex == index,
                index = index
            )
        }
    }
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RowOfTabs(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {

    val scrollState = rememberScrollState()
    val overscrollEffect = ScrollableDefaults.overscrollEffect()
    // dont mind this, just experimenting

    SubcomposeLayout(
s
You want basically something like this right? Take max space if there is leftover space, otherwise just place as-is and allow scroll
I wrote this real quick, I am forcing non-empty list for the tab data, but you probably want some other data type there I assume. And doing an extra
&& maxIntrinsicWidthTaken != 0
check in there, for if all the items are still empty, but I assume this should not be possible at all in your case anyway. In any case, you should probably be able to grab this and move forward towards what you want to achieve.
AbbicItem
is gonna be your individual tab item which actually makes use of the index for selected and the onClick and such
Copy code
@Composable
fun AbbicLayout(
  listOfTabDatas: NonEmptyList<String>,
  selected: Int,
  onTabSelected: (Int) -> Unit,
  modifier: Modifier = Modifier,
) {
  var layoutCoordinates: LayoutCoordinates? by remember { mutableStateOf(null) }
  Box(
    modifier = modifier
      .fillMaxWidth()
      .onPlaced { layoutCoordinates = it }
      .horizontalScroll(rememberScrollState()),
  ) {
    if (layoutCoordinates != null) {
      AbbicLayout(
        componentSize = layoutCoordinates!!.size,
        listOfTabDatas = listOfTabDatas,
        selected = selected,
        onTabSelected = onTabSelected,
      )
    }
  }
}

@SuppressLint("ModifierParameter")
@Composable
fun AbbicLayout(
  componentSize: IntSize,
  listOfTabDatas: NonEmptyList<String>,
  selected: Int,
  onTabSelected: (Int) -> Unit,
  modifier: Modifier = Modifier,
) {
  Layout(
    content = {
      for (data in listOfTabDatas) {
        AbbicItem(data)
      }
    },
    modifier = modifier
  ) { measurables, constraints ->
    val realLayoutWidth = componentSize.width
    val maxIntrinsicWidthTaken = measurables.fastSumBy { measurable ->
      measurable.maxIntrinsicWidth(constraints.maxHeight)
    }
    val itemConstraints: Constraints
    if (maxIntrinsicWidthTaken < realLayoutWidth && maxIntrinsicWidthTaken != 0) {
      val fixedWidthPerItem = realLayoutWidth / measurables.size
      itemConstraints = constraints.copy(minWidth = fixedWidthPerItem, maxWidth = fixedWidthPerItem)
    } else {
      itemConstraints = constraints.copy(minWidth = 0, maxWidth = constraints.maxWidth)
    }
    val placeables = measurables.fastMap { measurable ->
      measurable.measure(itemConstraints)
    }
    layout(realLayoutWidth, placeables.fastMaxOfOrNull { it.height } ?: 0) {
      var x = 0
      placeables.fastMap {
        it.place(x, 0)
        x += it.width
      }
    }
  }
}

@Composable
fun AbbicItem(itemContent: String, modifier: Modifier = Modifier) {
  val color = (0xFF000000..0xFFFFFFFF).random()
  Text(itemContent, modifier.background(Color(color = color)))
}
I previewed this with
Copy code
@Preview(device = "id:pixel_8")
@Composable
private fun PreviewAbbic() {
  HedvigTheme {
    Surface(color = MaterialTheme.colorScheme.background) {
      val listOfTabDatas = nonEmptyListOf(
        "Home", "Books", "Profile",
        "Home", "Books", "Profile",
        "Home", "Books", "Profile",
      )
      AbbicLayout(
        listOfTabDatas = listOfTabDatas,
        selected = 0,
        onTabSelected = {},
      )
    }
  }
}
You can do the same to see it in action. If you don't use arrow, just replace
NonEmptyList
with just
List
a
Thank you so much! plenty to dig into. one reason i'll note is that we use SubcomposeLayout because we dont want each tab to be the same size, but to grow by the same amount. so we measure the placeables first and calculate how much bigger they need to be based on the excess space. not sure if that'll be incompatible with this but definitely going to give it a good stab, tysm for your time and effort
s
Oh not the same size? Care to share a screenshot of how that may look like? Inside the layout you do get access to each individual intrinsic measured width, so you could do what you are saying I think instead of blindly giving them the same width
a
this is tough, my layout is now in a box with horizontal scroll, but this happens
Copy code
java.lang.IllegalArgumentException: Can't represent a size of 715827970 in Constraints   at androidx.compose.ui.unit.Constraints$Companion.bitsNeedForSize(Constraints.kt:403)   at androidx.compose.ui.unit.Constraints$Companion.createConstraints-Zbe2FdA$ui_unit_release(Constraints.kt:366)   at androidx.compose.ui.unit.ConstraintsKt.Constraints(Constraints.kt:433)   at androidx.compose.ui.unit.ConstraintsKt.Constraints$default(Constraints.kt:418)
i think i may be misusing constraints though so lets forget about it for now
Screenshot 2024-06-10 at 14.35.25.png
doing something like
Copy code
val excessSpaceSize = constraints.maxWidth - minWidth
                        val excessSpacePerPlaceable = excessSpaceSize / mainPlaceables.size
                        placeable.measure(
                            Constraints(
                                minWidth = excessSpacePerPlaceable + mainPlaceables[index].width
                            )
                        )
s
Yeap, but grab
realLayoutWidth
as
constraints.maxWidth
will be infinite inside there since you're inside a horizontally scrollable container
a
yup, will massage that
s
Copy code
Layout(
  content = {
    for (data in listOfTabDatas) {
      Text(data)
    }
  },
  modifier = modifier,
) { measurables, constraints ->
  val realLayoutWidth = componentSize.width
  val maxIntrinsicWidths = measurables.fastMap { measurable ->
    measurable.maxIntrinsicWidth(constraints.maxHeight)
  }
  val maxIntrinsicWidthTaken = maxIntrinsicWidths.sum()
  val leftoverWidth = realLayoutWidth - maxIntrinsicWidthTaken
  val measurablesSize = measurables.size
  val placeables = measurables.fastMapIndexed { index, measurable ->
    val itemConstraints: Constraints = if (maxIntrinsicWidthTaken < realLayoutWidth && maxIntrinsicWidthTaken != 0) {
      val fixedExtraWidth = (realLayoutWidth - leftoverWidth) / measurablesSize
      val fixedWidth = maxIntrinsicWidths[index] + fixedExtraWidth
      constraints.copy(minWidth = fixedWidth, maxWidth = fixedWidth)
    } else {
      constraints.copy(minWidth = 0, maxWidth = constraints.maxWidth)
    }
    measurable.measure(itemConstraints)
  }
  layout(realLayoutWidth, placeables.fastMaxOfOrNull { it.height } ?: 0) {
    var x = 0
    placeables.fastMap {
      it.place(x, 0)
      x += it.width
    }
  }
}
Smth like this? Deleted my test code so I couldn't preview now, but this is where I'd start I think
a
oooh, intrinsic width..
yup, turns out that's exactly what we were looking for! i guess SubcomposeLayouts dont play well with scrollable containers
thank you, need to look into intrinsic size a little more cause it's still not well understood by me
s
Perhaps subcompose layout would work here too. This layout does have the same problem where you gotta do the measuring in the first pass to get the size, and one the next pass you get the real result on the screen. I suppose with subcomposition you can do the same? I am not sure, I don't think I've ever used it that way. But yes, the intrinsic width is just a quick way to ask the measurable what its width would be provided it had a fixed height (constraints.maxHeight in our case), and it gives back a result. All this without actually measuring the measurable twice which is not allowed in compose. I am not 100% sure of the internals of this either, but to make use of it this works just fine. With all that said, if there is a way to build this same thing, but without the onPlaced 1 frame delay I would be super happy to hear it too, because I haven't found of it yet either.
a
final implementation thus far is really very close to what you described at the end, but the layout doesnt seem to scroll
s
Was that with Subcompose layout?
a
nope, with layout they way you described
just had to fiddle with some of the spacing values, but definitely trying with a set of tabs that is going off the screen
s
In the
layout()
block I think I did it wrong in the snippet I shared. Look at what I do here https://github.com/HedvigInsurance/android/blob/0ea7547d4e2271b28cb6c0ff1067719d99[…]in/kotlin/com/hedvig/android/feature/home/home/ui/HomeLayout.kt where the layout itself does need to be bigger than just the constraints if there are enough items. You'll probably need to do the same so that the contents are actually scrollable
For you you'll have to get the max between the realSize or the sum of all the item widths
a
ah of course!
makes sense
final thing left is making that green selected indicator animate like a regular material tab indicator, but im 80% confident i can study the material3 TabRow implementation and figure it out from there
👍 1