JetBrains / compose-multiplatform

Compose Multiplatform, a modern UI framework for Kotlin that makes building performant and beautiful user interfaces easy and enjoyable.
https://jetbrains.com/lp/compose-multiplatform
Apache License 2.0
15.96k stars 1.16k forks source link

Improve visibility of scroll state for SubcomposeLayout optimization #3285

Open dzirbel opened 1 year ago

dzirbel commented 1 year ago

TL;DR: to improve performance for very large lists of content which don't fit into out-of-the-box LazyLayouts I'd like to have more visibility into the scroll state upwards in the composition to allow accurate subcomposing of custom layouts like grids and tables

I'm working on a desktop project which has some pages that include potentially very large lists of content, generally as either (custom) tables or grids. Composing, laying out, and drawing these large lists can take up to a couple of seconds (during which the UI is entirely unresponsive). I'd like to ask for advice on how to improve performance and perhaps suggest some ways to make performant layouts like this possible, since I think they are particularly important for desktop/web applications (moreso than mobile).

I'm generally quite familiar with Compose and Compose Multiplatform but it's possible I've missed something obvious here. This may also be the wrong forum for this topic - apologies if so. There are aspects that might require changes deeper with Compose UI or Foundation to address, but I thought it would be relevant here since desktop applications are more likely to show large, in-depth pages of content like this, and I believe Compose Multiplatform already provides some custom scrolling functionality directly in VerticalScrollbar et al.

Here are the avenues I've gone down so far:

LazyLayout

First, the most obvious solution: LazyLayouts (LazyColumn/Grid or a custom LazyLayout). This is definitely the core of what I want, but there are a couple reasons that make them a non-starter for my use case:

SubcomposeLayout

The next candidate is the more generic SubcomposeLayout. This is almost exactly what I want, namely to be able to choose which items of my table/grid are actually composed during the layout phase. But this brings up the second problem: in order to calculate the set of items to display, I need to know the state of the "viewport", i.e. what section of the table/grid is actually visible on the screen. And here I got stuck in finding a good solution. In particular, any (vertical) scroll-aware SubcomposeLayout needs to know three things (I believe):

  1. the current scroll position
  2. viewport height: the height of the scroll container, i.e. the element with verticalScroll()
  3. the relative position of the SubcomposeLayout within the scroll container (i.e. if there is a header or other content above it, how much space that takes up)

(1) is easy to get: both a simple ScrollState and ScrollbarAdapter provide this, so they could either be passed down through the composition or perhaps provided as a CompositionLocal which I think conceptually makes sense as a LocalScrollContext that's relevant to the part of the Composition tree that's being scrolled.

(2) is more difficult: ScrollState has it as an internal property and ScrollbarAdapter exposes it, but in both cases it will be unknown (0) until the first layout of the scroll container has finished. If we passed it down in the same way as (1), we would requires at least a second composition/layout pass to start displaying actually content - potentially flashing empty content on the screen first from the initial composition. This appears to me to be a pretty fundamental limitation in Compose - the onGloballyPositioned Modifier being the other common way of accessing resolved sizes and relative positioning, which also requires a second layout pass if the layout logic depends on it. Conceptually I'm not sure why this is such a deep problem - the layout constraints already generally capture the max height available, but that information happens to be inaccessible within a scroll container since the scrolling content no longer has that limitation. But I could imagine augmenting the Constraints provided to include the size of the scroll viewport, or something similar. Along the same lines, if the layout phase had a construct similar to CompositionLocal I could imagine the scrolling container providing its max height to the layouts happening in the tree below it when it is measured.

(3) has stumped me pretty much entirely. The only access to relative positioning information like this appears to be from onGloballyPositioned, although any access to that feels hacky at best (and requires a re-layout). The LayoutCoordinates it provides can compute relative positioning to the parent, root, or window but not to the more nebulous "closest scroll container". The only solution I can think of is to have each scroll container provide a CompositionLocal of its position in the root/window (from its own call to onGloballyPositioned and do the same from the SubcomposeLayout within and compare them. If passing the scroll container's absolute position down as a CompositionLocal (i.e. LocalScrollCoordinates) then this requires (I believe) not only a re-layout but a recomposition since accessing the LocalScrollCoordinates would happen in the composition phase and not the layout phase. All of this together is a bit too much abuse of the onGloballyPositioned (which doesn't really feel like something meant to be used for core layout information in the first place).

Others

There may be other architectural pieces that would better fit this problem; the most relevant ones I'm aware of are AlignmentLines and LookaheadLayout. Neither of these seem to be directly related: AlignmentLines pass layout information up to parents while I mostly need parents to pass scrolling context down, and LookaheadLayout is more for animation (from what I gather) and doesn't support SubcomposeLayouts yet either - so using it to get some upfront LayoutCoordinates in some way won't work at the moment.

Another analogue is RowColumnParentData used by row/column weights as a general way of passing data up to parents from modifiers. I didn't quite follow it, but "parent data" appears to have direct support in NodeCoordinator. It looks like it can only pass data up to the direct parent, whereas ideally I'd like to allow these scroll-aware Subcomposeables to be embedded more deeply in the tree than direct children of the scroll container. Again, this seems to be going in the opposite direction than I need, but a similar solution could be plausible.

Suggestions

I don't have an airtight suggestion to make for this usecase, but I do think it's an important one. Generally what I'd like is to get straightforward information about the scrolling state of whatever the nearest scroll container is in the layout phase. I can think of a few ways to do this (none of which are perhaps very practical):

Appendix

Just to give the concrete examples: the project I'm working on is here, with my custom Table and Grid implementations. My ideal solution is to port these over to use SubcomposeLayout instead of Layout, with some access to information about how much of their content is actually visible on the screen.

Walingar commented 1 year ago

Hello @dzirbel thank you very much for your investigation, time and effort! This use case is not that easy indeed, so we will discuss it in a team, what we can do here.

m-sasha commented 1 year ago

@dzirbel Can you elaborate on the issues you're having with, say, LazyColumn? We can try to assist with that.

In general, I think the right way to do this is to try using LazyColumn, and if that lacks the functionality you need, to simply add it. If your app's licensing would allow it, you can copy/paste the LazyColumn source and modify it as needed. If your app's licensing doesn't allow it, then implement your own version.

Note that LazyColumn itself uses SubcomposeLayout internally, so whatever problems you think you will encounter when using it, LazyColumn has either solved them, or has them itself.

dzirbel commented 1 year ago

Sure - and yep, I'm aware that LazyColumn uses SubcomposeLayout itself - that's how I found it :) I could image re-implementing LazyColumn or using the base LazyLayout, for a simple page that just has a header on top of a table of items this would end up looking something like:

MyLazyPage {
    header {
        // regular compasable content in the header
    }

    // each column dictates how to render the Composable content of the item in that cell
    table(items = items, columns = columns)
}

Then MyLazyPage would either manually or via a custom LazyLayoutItemProvider and LazyLayoutMeasurePolicy expose this API to create full-width elements (header) and elevate the table elements to ultimately be direct children of the page via table.[1] My main concern is that this centralizes much of my application's layout logic into a the single layout step of MyLazyPage and takes a step back from the generality of Compose. For example, say I wanted one page to be a two-column layout with a table on one side and a block of text on the other. This would have to look like:

MyLazyPage {
    header {
        // regular compasable content
    }

    columns {
        table(items = items, columns = columns)

        text(text = text)
    }
}

and requires explicit support for columns in the layout logic for MyLazyPage. (There are other issues as well: table is no longer a generic Composable and must be used within a MyLazyPage, any configuration options passed to it must be passed through MyLazyPage so once we have a few types of sub-layouts like table/grid/list the parameter count will explode, etc.)

Ultimately, this could work but I believe it would end up being a very inflexible and centralized approach (and much more involved than I think this use case should require). I think the core issue is that all the elements of a LazyLayout have to be direct children created by either its non-Composable content or a LazyLayoutItemProvider and laid out by a single measurePolicy. This means it needs to understand how to lazily create the entire page at once - both the parts that really need to be lazy (the table) and everything else (the header, columns, etc).

My suggestion is to instead allow the table to be a generic Composable which is aware of the viewport it is being rendered in so it can have its own subcomposition logic individual from everything else on the page. Then the second example looks something like:

Column(Modifier.verticalScroll()) {
    Row { // header
        Image("url")
        Text("header")
    }

    Row {
        SubcomposeTable(items = items, columns = columns)

        Text("body")
    }
}

where we can use the usual Column/Row/etc to create the page and still have the table content be optimized. This does have the downside compared to the single LazyLayout of not making the header be lazy (it is still composed even when scrolled past it), but that's not an issue for my use case - and if it were I could write it as a simple SubcomposeLayout that also depends on the viewport state to render some, all, or none of the header as necessary.

[1] I haven't gotten too deep into how this would work, but I image it would be done by making different LazyLayoutItemProvider.getContentType() types for the header and table row/cells and retrieving this in the measure scope. This gets very complicated - but probably solvable - when there are multiple different tables in the page or when adjusting them via another sub-scope like columns in the next example.

m-sasha commented 1 year ago

I'm not sure I understand what a "page" is in your example, and why it needs to be lazy, rather than just the table inside it. Can you maybe attach a screenshot so it's easier to visualize what you're trying to achieve?

kirill-grouchnikov commented 1 year ago

Depending on how flexible you are envisioning your table to be, it will not be enough to assume that only the header part is sticky. See https://autodesk.github.io/react-base-table/examples/sticky-rows for an example of a table configured with every fifth row to be sticky during scrolling, and having sticky columns as well.

dzirbel commented 1 year ago

I'm not sure I understand what a "page" is in your example, and why it needs to be lazy, rather than just the table inside it. Can you maybe attach a screenshot so it's easier to visualize what you're trying to achieve?

Good idea, here's two from my application now (the second slightly scrolled on the page):

image image

Might be a little bit hard to see, but the cover image, "Background", etc at the top are the "header" and the (large, 561 rows - but it could be up to thousands) table is below. By "page" I just mean the entire scrolling area, in my app that's a page like this one to view a playlist (there are others with a grid of albums by a particular artist, for example). Unless I'm misunderstanding something very badly (which is possible), I can't make the table its own LazyTable and still have the entire page (including the header, which is not sticky) scroll together, which is what I need. (Currently, nothing in the page is lazy - the entire table is composed, laid out, and drawn.)

Here's another example (not my application, this is Spotify):

image

The entire page scrolls, but if we imagine the table to be very large we'd want to lazily compose rows as we scroll (and Spotify does indeed do something like this - they have a briefly visible shimmer loading when scrolling large tables), while still having the header and footer as part of the scrolling content.

m-sasha commented 1 year ago

Why can't the layout in the screenshots be done with a simple LazyColumn?

By the way, here's the Sticky Rows example you linked, implemented in Compose.

import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.singleWindowApplication

const val ROWS = 1000
const val COLS = 10

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FlexTable(modifier: Modifier) {
    val verticalListState = rememberLazyListState()
    val rowsState = rememberScrollState()
    Box(modifier) {
        LazyColumn(
            modifier = modifier,
            state = verticalListState
        ) {
            repeat(ROWS / 5) { section ->
                stickyHeader {
                    FlexRow(
                        row = section * 5,
                        cols = COLS,
                        scrollState = rowsState,
                        backgroundColor = Color.LightGray
                    )
                }

                items(4) {
                    FlexRow(
                        row = section * 5 + it + 1,
                        cols = COLS,
                        scrollState = rowsState,
                        backgroundColor = Color.White
                    )
                }
            }
        }

        VerticalScrollbar(
            adapter = rememberScrollbarAdapter(verticalListState),
            modifier = Modifier
                .fillMaxHeight()
                .align(Alignment.CenterEnd)
        )

        HorizontalScrollbar(
            adapter = rememberScrollbarAdapter(rowsState),
            modifier = Modifier
                .fillMaxWidth()
                .align(Alignment.BottomCenter)
        )
    }
}

@Composable
fun FlexRow(
    row: Int,
    cols: Int,
    scrollState: ScrollState,
    backgroundColor: Color
) {
    Row {
        FlexCell(row, 0, backgroundColor)
        FlexCell(row, 1, backgroundColor)

        Row(
            modifier = Modifier
                .weight(1f)
                .horizontalScroll(scrollState)
        ) {
            repeat(cols - 3) {
                FlexCell(row, it + 2, backgroundColor)
            }
        }

        FlexCell(row, cols - 1, backgroundColor)
    }
}

@Composable
fun FlexCell(
    row: Int,
    col: Int,
    backgroundColor: Color,
) {
    Text(
        text = "Row $row - Col $col",
        modifier = Modifier
            .background(backgroundColor)
            .border(1.dp, Color.Gray)
            .padding(16.dp)
            .width(120.dp)
    )
}

fun main() = singleWindowApplication {
    FlexTable(Modifier.fillMaxSize())
}

The columns aren't lazy (only the rows are), because unfortunately there's no good way to "sync" LazyListStates (to make them scroll together) and you can't reuse the same one for multiple lists, because LazyList has no way of knowing that the lists have the same structure. But if you really needed to, you could implement a lazy list that allows its state to be shared.

Sorry, the last part is meant for @kirill-grouchnikov. I haven't noticed the comment with the react example wasn't by @dzirbel .

m-sasha commented 1 year ago

@dzirbel

Every Composable in a LazyLayout is arranged according to the lazy specifications (i.e. in a column/row/etc). Most of my pages have at least a header and footer with the large table/grid in between, so using a LazyColumn/etc out of the box won't work. Even writing a custom LazyTable would still only allow scrolling within the table. It may be possible to do this with complicated content types provided by the LazyLayoutItemProvider but this requires specific layout logic for any change to the page rather than just arranging content as generic Composables.

I apologize if I misunderstood, but is it possible you're under the impression that all items in a LazyColumn have to be the same?

If so, then that isn't the case. You can easily have a (non-sticky) header and a footer like this:

    LazyColumn {
        item {
            Text("Header", Modifier.background(Color.Red))
        }
        items(1000) {
            Text("Item $it")
        }
        item {
            Text("Footer", Modifier.background(Color.Red))
        }
    }
dzirbel commented 1 year ago

I apologize if I misunderstood, but is it possible you're under the impression that all items in a LazyColumn have to be the same?

Yeah, I'm aware that the items can be different - but this is complicated when the items not only have different content but need to be laid out in different ways (rather than all in a linear column or a single table/grid).

Maybe the table example isn't the best because it isn't immediately obvious that the rows of the table can't just be laid out as Rows within a (Lazy)Column. I need a custom Table layout in order to ensure the columns line up. Hardcoded column width and RowScope.weight() could be used for simple column sizing, but my layout also allows other ColumnWidth settings. In particular, either having the column match the size of the header text (which would be a separate LazyColumn item{}) or "fill" where its width is based on the size of the content (lazy-loading makes this difficult, but it may still be possible to e.g. only measure the text of each row in a column to determine this without doing an entire composition/layout pass).

Perhaps a more clear example is this page with a large grid (and header):

image

Currently my Grid does layout in a different way than LazyGrid: it calculates the number of columns of the grid based on the largest item (that's again more difficult for lazy loading but may still be possible without doing a full composition, or I may limit the Grid to only support cases where all the items are the same size as here). So it would be hard to fit the rows of the grid into a plain LazyColumn since we would need to determine how many items to put in each row.

It might still be possible (especially if we limit Grid to requiring all items to be of the same size, known in advance) to fit each grid row into a LazyColumn item{}, but to clarify the problem further, imagine if the grid did not take up the entire horizontal space, but had a sidebar that scrolled along with it:

image

Adding this was as simple as putting the Grid in a Row with some weights, but nearing (I believe) impossibility with LazyColumn.

As a final complication, I allow opening an "inset" into the grid when right-clicking an item:

image

Implementing this into LazyColumn again seems impossible, but if my Grid used a SubcomposeLayout and had access to the scroll state and viewport information, it could accommodate all these factors.

@m-sasha Thanks for the help and advice :) I appreciate you taking the time to deal with these unusual layouts that don't fit into the usual boxes.

m-sasha commented 1 year ago

@dzirbel I'll continue looking into this, but in the meanwhile I can suggest the #compose channel in our kotlinlang slack. The questions you're asking aren't specific to Compose Multiplatform/Desktop, and there are members of Google's Compose team in that channel that have more expertise than me. Also, if we do end up needing to expose more scroll state, that will have to go through Google anyway.

dzirbel commented 1 year ago

Will do, thank you! I'll keep thinking about it and experimenting as well and post here with any other ideas / thoughts that I have.

okushnikov commented 2 months ago

Please check the following ticket on YouTrack for follow-ups to this issue. GitHub issues will be closed in the coming weeks.