is there a good grid layout already written by som...
# compose
t
is there a good grid layout already written by someone else that I can just pilfer? I have spent the half a day or so trying to get a working one myself and it's hard to come up with something which both works and where I'm happy with the API...
the API I was trying to get was something like this...
Copy code
GridLayout(columnCount = 2) {
                        val indent = Modifier.padding(start = 8.dp)
                        row {
                            Text(text = "Added in:", fontWeight = FontWeight.Bold)
                            Text(text = "${description.versionInfoSummary.versionString} (${description.versionInfoSummary.versionDateString})")
                        }
                        row {
                            Text(text = "Location:", fontWeight = FontWeight.Bold)
                        }
                        row(modifier = indent) {
                            Text(text = "Block:", fontWeight = FontWeight.Bold)
                            Text(text = description.blockName)
                        }
                        row(modifier = indent) {
                            Text(text = "Plane:", fontWeight = FontWeight.Bold)
                            Text(text = description.planeName)
                        }
                        row {
                            Text(text = "Script:", fontWeight = FontWeight.Bold)
                            Text(text = description.scriptName)
                        }
                        row {
                            Text(text = "Category:", fontWeight = FontWeight.Bold)
                            Text(text = description.codePointCategory)
                        }
                    }
the bit where it's hard to get working is that I don't have a nice way to capture all these content rows so that I can measure them together at the end
I guess I want to somehow record what's emitted from those blocks as a list
because as far as order of operations goes, you have to do all the cells in the same column as the current cell at the same time, to know how much space is left for the next column
so I can't place the second cell on the row until all the other rows have had their first cells measured
I guess I can delay calling the content() for all the rows until whenever, the bit I can't figure out is how to mark the rows with the row they belong to so that later I can know which ones belong together
e
> I don’t have a nice way to capture all these content rows so that I can measure them together at the end Have you looked in the source? Thats literally what LazyXX components do. (And I just did the same for our custom UI stories component also)
TLDR; You create a derived state of your “scoping API class” and on construction run the content lambda. (Of course its more involved than that, let me get the link)
t
yeah that much is fine but I still don't have any way to mark the things being added so that I can determine which column is which later
e
if you really want to do it yourself, a custom layout for each row that produces and consumes https://developer.android.com/develop/ui/compose/layouts/alignment-lines would do
t
I did wonder if it was possible using alignment lines too yeah....
e
the bit I can’t figure out is how to mark the rows with the row they belong to so that later I can know which ones belong together
You could always add
parentData
aka
layoutId
, which you can access from your custom layout measure policy block. (you will need the custom measurement / layout anyways for the alignment lines)
t
alignment lines seem very magical and it has always been too hard to figure out how to adapt that one very specific example in the docs to do anything more general
e
Example of providing a custom DSL to build the actual composables / items. This one is how LazyColumn|Row builds its content … https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/[…]n/lazy/LazyListItemProvider.kt;l=45-64?q=LazyListItemProvider
t
atm it seems like I abuse(?) the alignmentLines map to store my list of column offsets
it's confusing at the moment because as far as I can tell, row 1 could affect the alignment of things in row 2, as well as the reverse
e
alignment lines seem very magical
Personally I think of alignment lines as specific (horizontal or vertical) lines within any layout node, the position of which is measured from (0, 0) of that node ie
topLeft
corner A row, column or even custom parent can then read these alignment lines from any of its children and, quite literally, line them up. So I could for instance add an alignment line right in the middle of all my children and when I align them by that line, their middles will be lined up. Less useful as you dont need an
AlignmentLine
for this, its already possible with normal
Alignment
parameter.
t
so when the parent does this it somehow .. magically? .. affects the positions of what was in the child
e
I could however do something more useful like:
t
because as far as I can tell by this point my child's layout already happened
e
Alignment Lines are set on children/descendants(!) & used by parents/ancestors (that support them)
t
right, so maybe I can't use them then
because the child needs to use the line to position its columns correctly
e
Right now I am just explaining ALs, I am not completely informed of your use case so I dont know if they fit or not. But personally I wouldn’t reach for alignment lines for custom layouts yet. I would try just plain old measurement and layout together with
parentData
first.
t
the basic use case atm is something like property sheets, two columns with labels on the left and descriptions on the right
so LazyVerticalGrid is pretty close to doing the job except the column sizes of the two columns won't be the same
e
Okay so you need a grid, but just not lazy? (because you need to at least measure all the items on the left side to know what the maximum would be?)
t
yeah it doesn't need to be lazy
laziness would be difficult to say the least, for that reason
it's just that when I go looking for grids, the lazy one is the only one
and the only thing stopping me from using Column { Row{} Row{} } is that the rows don't have the descriptions line up
and a similar deal with Row { Column[} Column{} }
e
Understandable. I think a custom layout is the way to go. And it doesnt sound as complex too.
t
it seemed ok until I started getting into the weeds
I wanted to add a row which had only one cell which spanned
so then it wasn't possible to trivially determine which cells belonged to which column anymore
e
Looking at the API you want I would go with multi content custom layout (<- its much simpler than it sounds)
basically its when you pass into
Layout
a list of composables and for each one in the list, in your measurement block you will get a list of measurables Its basically the one with a
List<List<Measurable>>
Since the API you want, has a single composable for each row it works perfectly. • Your DSL will build a list of “`CustomRow` “s • Each of those will have a single composable lambda parameter eg
CustomRow.content
• You will map your list of CustomRows into a
List<@Composable () -> Unit>
and pass it into the multi content
Layout
• MeasurePolicy will have
List<List<Measurable>>
with each item in the outer list representing the list of “items” / nodes composed within a single row
From that point you are free to do measurement/layout’y stuff, eg • You can filter each individual row list for just the first
n
items based on columnCount. • You can take the first (index=0) item for each rows list as the “label” OR you can add a
layoutId
to specific composables to denote which one is label & which one is description • You can say, if this row has only one cell (ie one item) do not use its size to determine label column size, just measure it after ALL other nodes have been measured or whatever
t
ok, so I feed it a List<()->Unit> but I get back a List<List<Measurable>> where the number inside each list varies
e
Yup
t
but they'll correspond to my cells for the row I hope
heh
e
Yes exactly
t
so yeah I just need to transpose that list of lists
measure() seems like it eats all available height
I expected it to actually measure the thing I gave it and allocate that much height but it seems the thing took all the space
e
You have to intentionally configure the constraints you want for each one.
t
tough because I don't know how much space it needs until it tells me
e
Yeah but you know what the maximum it could be is? Eg you can say each first child of every row should measure with a max of HALF the space that the outer grid gets
t
in this case the problem is the height
it uses 100% of the available height on the first row
I could say it is limited to 50% of the available but that's a little arbitrary
I'd rather have it set it to the actual required height I guess
but yeah it's messy
e
Do you have a max height you want it to take? If you dont then how would that even work
t
not... even sure
😅 1
how does it normally work in the naive case where I just throw three Text into a Column
the first of those doesn't eat 100% of the available height normally
but it does here...
e
They would unless you constrain them:)
t
usually I don't
usually I just call Text("blah")
someone constrains them perhaps
that someone is not me
e
Yeah, and I am saying it would 100% consume all available space as long as the text has content unless you constrain it, with for example
weight(...)
t
yet .. it doesn't
the whole column might maybe, but the first row at least does not
Copy code
fun main() = singleWindowApplication {
    Surface {
        Column {
            Row {
                Text("a")
            }
            Row {
                Text("b")
            }
            Row {
                Text("c")
            }
        }
    }
}
for example, each row takes up only the space required to fit the text
e
CleanShot 2024-07-23 at 10 .05.55@2x.png,CleanShot 2024-07-23 at 10 .06.15@2x.png
Yes that is because your row has basically no content … 🤔
t
yeah but with my layout it takes all the available space despite that
lol
e
Oh? Okay. Nice.
t
it just goes "oh you have this much space available? I will use all of it then"
like Chromium with available system memory
e
Yeah you need to look into what constraints you are passing then
t
basically, whatever ones I was passed
Copy code
val columnConstraints = constraints.copy(
    maxWidth = (constraints.maxWidth - xOffset).coerceAtLeast(constraints.minWidth)
)
e
Ah yeah don’t do that.
t
the width though I subtract the previous columns
right, so we have a catch-22 here where I need to measure something to determine how much space it needs so that I can measure it
e
If you have
fillMaxSize()
for instance like i have in my screen shot it sets the constraints to minW = maxW = MAX_WIDTH_AVAILABLE minH = maxH = MAX_HEIGHT_AVAILABLE
t
and, if there is a scrollbar around it, then Int.MAX_VALUE is probably the available height too
e
right, so we have a catch-22 here where I need to measure something to determine how much space it needs so that I can measure it
Not really, you just need new constraints for the children
t
yeah but what I'm saying is I won't know the right values to pass to that without first measuring the thing
e
(changed max value so you dont confuse it with Int.MAX_VALUE)
t
or I can't see how I possibly would
e
Okay lets take a step back. What you wnat is that every first item in a row is measured and the column size be the maximum one?
t
pretty much
column width is the max of the widths of the cells for that column
row height is the max of the heights?
👍🏾 1
presumably
e
So therefore you measure every first item of every row, with new Constraints ( minW = 0, maxW = constraints.maxW, minH = 0, maxH = constraints.maxH )
Now if you want, for instance, the first column can be as wide as its widest item, just not wider than half of the width, then that becomes: new Constraints ( minW = 0, maxW = constraints.maxW / 2, minH = 0, maxH = constraints.maxH )
t
I guess I have to worry about that when I come to it
I'm not sure how much it should take if it's fighting with the other columns over the available width
specifying the min sizes as 0 appears to have made it behave itself
until it runs in the full app, and then suddenly an exception. Exception in thread "main" java.lang.IllegalStateException: Size(586 x 2147483647) is out of range. Each dimension must be between 0 and 16777215.
now that is a magic number
e
I’m not sure how much it should take if it’s fighting with the other columns over the available width
This is dependent on you unfortunately. You have limited space: eg 400 wide. You have n number of columns You want, say, first column to be as wide as its widest child You see the issue? Its widest child could just want to take up all the space, and you will need to decide how to constrain it from doing so. You do that with the constraints you pass in for it to measure with. If, for example, you have 3 column but your 1st column is your priority column and the max width it can be is half entire width, and the rest of the columns will share the remaining space equally; you’ll measure all the first items with
Copy code
new Constraints (
  minW = 0, maxW = constraints.maxW / 2,
  minH = 0, maxH = constraints.maxH
)
which means their width can be anywhere from 0 - halfFullWidth. Iterate through all, and find the max width. Subtract that from the main constraints max width and then divide by 2 to get the other columns’ max width that should be used to measure them
t
yeah, I think that's fine, it was just mystifying before because it seemed I had to also specify the max size, when I didn't know either
I guess in the real app it's inside a scrollable area so the max height is MAX_VALUE
but then when I pass that for the max height it's "too large"
e
specifying the min sizes as 0 appears to have made it behave itself
yes because like I said earlier, when you tell a parent to fillMaxWidth (or fillMaxSize), its a directive to set the constraints at that point like so minW = maxW = MAX_WIDTH_AVAILABLE minH = maxH = MAX_HEIGHT_AVAILABLE So if you pass this to a node to measure, youre basically telling it that the minimum space it should take is the entire space … and it has to respect that (usually)
In a (vertically) scrollable parent, maxHeight is Constraint.Infinity
… and you cannot and should not
fillMaxHeight()
because, well, it doesnt make sense 😅
t
ah well
it does sort of have that because if it's smaller, it probably should
e
The point of a scrollable container is to allow its children measure at their desired content heights of which the parent can then … scroll
t
but yeah
still not out of the woods with this one, apparently. the lambda passed in seems to be getting called, but the second time it's called is not from where I expect, and the code to do the layout doesn't seem to get called when the contents change
Copy code
val scope = GridLayoutScope()
    println("About to call GridLayout scope lambda")    <- only gets here once
    scope.content()
    println("Call to scope lambda returned, row count = ${scope.rows.size}")     <- only gets here once
    Layout(
        contents = scope.rows.map { r -> r.content },
        modifier = modifier
    ) { measurables: List<List<Measurable>>, constraints: Constraints ->
        println("Inside Layout MeasureScope, measurables = (size ${measurables.size}) $measurables")    <- does call this twice
e
> val scope = GridLayoutScope() > … > scope.content() > … > Layout ( … ) This looks … problematic 🤔, looks like you are calling your scope lambda within a composable? Is that the intended behavior? What I thought was that you have a custom DSL such as
Copy code
GridLayout(
  …
  content: GridLayoutScope.() -> Unit // Note: NOT composable
)
Copy code
interface GridLayoutScope {
  fun row(
    …
    content: @Composable () -> Unit
  )
}
In which case you’d do a trick like LazyItemProvider does (linked somewhere above)
t
that is pretty close to what I had, but I do have @Composable on the content param
so that's probably half the issue
e
Yeah the point is to have separate content lambdas for each row
t
which I do have
e
I mean yeah but not really, you don’t. Because your root content lambda is composable so in reality it has the ability to emit an entire tree of layout nodes
An alternative to what I proposed would just be
Copy code
GridLayout(
  ...
  rows: List<@Composable () -> Unit>
)
t
it's still a different root for the second call though, so it's complete magic how the old data somehow still gets used
e
Or
Copy code
GridLayout(
  ...
  rowCount: Int,
  content: List<@Composable (Int) -> Unit>,
)
t
as in, a different content lambda is passed the second time
and I can see it being different when I print it out
but then somehow the old rows get displayed despite that haha
so yeah magical caching is working against me
I think maybe slapping something around the thing that calls the content will be the rest of the fix though
e
Okay riddle me this, what if someone puts a
Scaffold
in your GridLayout content lambda and does NOT call
row { ... }
, what would that sort of layout mean?
t
like derivedStateOf... even though there aren't any obvious state vars in scope for it to think it's depending on
yeah that is a fair question, I have no idea either
e
There is no
magical caching
in compose. Its all well defined behavior
t
well defined .. to some core group of wizards
lol
e
I have no idea either
Your API should not allow such a case
t
the rest of us mostly have no idea how it works
it's like how Swing threading is well defined
😅 1
new devs still don't get it and wonder why the rest of us think it's totally obvious how to use it
e
Okay fair enough 😄
t
yeah derivedStateOf does seem to make it work here too
it just smells a bit because, derived state of what exactly
e
So my rec: GridLayout content should not be composable. The goal of it is to build the list of rows
t
answer is, "whatever state is being checked inside that scope.content() call"
👌 1
yeah, I mean, the current version is already
Copy code
class GridLayoutScope {
    val rows = mutableListOf<CustomRow>()

    fun row(content: @Composable () -> Unit) {
        rows.add(CustomRow(content = content))
    }
}
e
Nice, so lose the compose annotation on it.
t
Copy code
val rowContents by derivedStateOf {
    val scope = GridLayoutScope()
    scope.content()
    scope.rows.map { r -> r.content }
}

Layout(
    contents = rowContents,
    modifier = modifier
) { ... }
and that behaves
e
Yup exactly.
t
still not entirely sure what the right place for that map call is honestly
I could equally well call it outside
it seems to work both ways
e
That would call it every composition. It works but thats definitely not behavior you want
t
I guess it depends on how frequently it's being recomposed (and for what reasons)
I have a long enough list of bugs to keep me busy for a week at this point but it's mostly dumb edge cases rendering text
sometimes I just wish Unicode had some reliable way to frame some text to stop crap leaking out of it
someone puts a single RLO character somewhere in a string and suddenly the whole UI is wrong lol
e
this is entirely untested outside of preview but I don't see what's hard to use about alignment lines
t
try adding a row where the number of things inside it is 1 or 2
but yeah, it's probably possible to get this to work. to answer "what's so hard about using alignment lines", observe that the amount of code in this example is about double what I currently use 🙂
e
well a lot of it comes from handling arrangement/alignment similar to the way that the standard LazyVerticalGrid does
also fixed jagged row counts
t
I even wondered whether it was possible to make a custom implementation of GridCells and then use LazyVerticalGrid as-is, because having columns of different widths was the only customisation I really needed
I haven't tried that one yet
e
if that's all you need then yes it would be very much easier to use the built-in lazy grid
and you could have skipped this 100+ message thread
lazy wouldn't help if you wanted to measure all the cells before deciding how big a column is, but if you can decide that up front then it'll work just fine. (also if you can decide that up front, you can just use columns and rows…)