2

I'm new to jetpack compose and trying to implement a scrollable data table using jetpack LazyColumn/LazyRow. The first row and first column of the entire table shall be sticky.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BaseTheme {
                ExceptionsSpreadSheet(exceptions = generateData())
            }
        }
    }
}

This is my activity, the BaseTheme is just the default theme android studio generated for me.

@OptIn(ExperimentalFoundationApi::class)
@Preview(showBackground = true, widthDp = 960, heightDp = 540)
@Composable
fun ExceptionSpreadSheetHeader(
    exception: DropBoxException = DropBoxException(),
    state: LazyListState = rememberLazyListState()
) {
    LazyRow(
        modifier = Modifier.background(color = Color.DarkGray),
        state = state
    ) {
        stickyHeader {
            ExceptionSpreadSheetCell(text = "id")
        }
        items(1) {
            ExceptionSpreadSheetCell(text = "packageName")
            ExceptionSpreadSheetCell(text = "processName")
            ExceptionSpreadSheetCell(text = "packageVersionCode")
            ExceptionSpreadSheetCell(text = "packageVersionName")
            ExceptionSpreadSheetCell(text = "activity")
            ExceptionSpreadSheetCell(text = "romVersion")
            ExceptionSpreadSheetCell(text = "platformId")
            ExceptionSpreadSheetCell(text = "productName")
            ExceptionSpreadSheetCell(text = "modelName")
            ExceptionSpreadSheetCell(text = "exceptionSummary")
            ExceptionSpreadSheetCell(text = "exceptionType")
            ExceptionSpreadSheetCell(text = "exceptionDigest")
            ExceptionSpreadSheetCell(text = "androidId")
            ExceptionSpreadSheetCell(text = "occurTime")
            ExceptionSpreadSheetCell(text = "callStack")
        }
    }
}

@OptIn(ExperimentalFoundationApi::class)
@Preview(showBackground = true, widthDp = 960, heightDp = 540)
@Composable
fun ExceptionSpreadSheetRow(
    exception: DropBoxException = DropBoxException(),
    state: LazyListState = rememberLazyListState()
) {
    LazyRow(state = state) {
        stickyHeader {
            ExceptionSpreadSheetCell(
                text = exception.id.toString(),
                modifier = Modifier.background(color = Color.DarkGray)
            )
        }
        items(1) {
            ExceptionSpreadSheetCell(text = exception.packageName)
            ExceptionSpreadSheetCell(text = exception.processName)
            ExceptionSpreadSheetCell(text = exception.packageVersionCode)
            ExceptionSpreadSheetCell(text = exception.packageVersionName)
            ExceptionSpreadSheetCell(text = exception.activity)
            ExceptionSpreadSheetCell(text = exception.romVersion)
            ExceptionSpreadSheetCell(text = exception.platformId.toString())
            ExceptionSpreadSheetCell(text = exception.productName)
            ExceptionSpreadSheetCell(text = exception.modelName)
            ExceptionSpreadSheetCell(text = exception.exceptionSummary)
            ExceptionSpreadSheetCell(text = exception.exceptionType)
            ExceptionSpreadSheetCell(text = exception.exceptionDigest)
            ExceptionSpreadSheetCell(text = exception.androidId)
            ExceptionSpreadSheetCell(text = exception.occurTime.toString())
            ExceptionSpreadSheetCell(text = exception.callStack)
        }
    }
}

I created a ExceptionSpreadSheetHeader for the sticky title, which will display some static names for columns, and a ExceptionSpreadSheetRow for rows below the header, each one has a sticky id column.

Then I combined these two components together as ``

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ExceptionsSpreadSheet(exceptions: List<DropBoxException>) {
    val sharedScrollState = rememberLazyListState()
    if (sharedScrollState.isScrollInProgress) {
        sharedScrollState.firstVisibleItemIndex
        sharedScrollState.firstVisibleItemScrollOffset
    }
    LazyColumn {
        stickyHeader {
            ExceptionSpreadSheetHeader(exception = exceptions.first())
            ExceptionSpreadSheetHeader(exception = exceptions.first(), state = sharedScrollState)
            ExceptionSpreadSheetHeader(state = sharedScrollState)
            ExceptionSpreadSheetRow(exception = exceptions.first(), state = sharedScrollState)
            ExceptionSpreadSheetRow(state = sharedScrollState)
            ExceptionSpreadSheetHeaderTest(state = sharedScrollState)
            ExceptionSpreadSheetHeaderTest(exception = exceptions.first(), state = sharedScrollState)
        }
        items(exceptions) {
            ExceptionSpreadSheetRow(exception = it, state = sharedScrollState)
        }
    }
}

I used sharedScrollState to sync horizontal scroll state across columns, then I found 2 strange behavior, any help or explanation would be great for me, thanks in advance.

  1. in the items section, the shared scroll state does not work at first, only when i added the if (sharedScrollState.isScrollInProgress) part, the rows in items would sync, otherwise it looks like items_not_sync. I would love to know why it works after I added the if part, or why it does not work before

  2. in the stickyHeaders section of ExceptionsSpreadSheet, I added a lot of rows for testing, and the result looks like header_not_sync. The first 6 lines are headers I added, including the 2 red one as ExceptionSpreadSheetHeaderTest:

@OptIn(ExperimentalFoundationApi::class)
@Preview(showBackground = true, widthDp = 960, heightDp = 540)
@Composable
fun ExceptionSpreadSheetHeaderTest(
    exception: DropBoxException = DropBoxException(),
    state: LazyListState = rememberLazyListState()
) {
    LazyRow(
        modifier = Modifier.background(color = Color.Red),
        state = state
    ) {
        stickyHeader {
            ExceptionSpreadSheetCell(text = "id")
        }
        items(1) {
            ExceptionSpreadSheetCell(text = "packageName")
            ExceptionSpreadSheetCell(text = "processName")
            ExceptionSpreadSheetCell(text = "packageVersionCode")
            ExceptionSpreadSheetCell(text = "packageVersionName")
            ExceptionSpreadSheetCell(text = "activity")
            ExceptionSpreadSheetCell(text = "romVersion")
            ExceptionSpreadSheetCell(text = "platformId")
            ExceptionSpreadSheetCell(text = "productName")
            ExceptionSpreadSheetCell(text = "modelName")
            ExceptionSpreadSheetCell(text = "exceptionSummary")
            ExceptionSpreadSheetCell(text = "exceptionType")
            ExceptionSpreadSheetCell(text = "exceptionDigest")
            ExceptionSpreadSheetCell(text = "androidId")
            ExceptionSpreadSheetCell(text = exception.occurTime.toString())
            ExceptionSpreadSheetCell(text = "callStack")
        }
    }
}

In the gif, only the 4th and 7th header is synced with contents below, so here are the strange behavior(s):

  • in the code, the 2nd header (ExceptionSpreadSheetHeader(exception = exceptions.first(), state = sharedScrollState)) and the 7th header (ExceptionSpreadSheetHeaderTest(exception = exceptions.first(), state = sharedScrollState)) has nothing different but between the second last ExceptionSpreadSheetCell in there items section, it seems that I have to somehow use the exception passed in then the sync would work, that looks really strange to me.
  • the 6th and 7th header differs in constructor parameters, 6th header uses default value, 7th header uses outer assigned value, but they result in different sync behavior.

The DropBoxException class is a simple data class

data class DropBoxException(
    var id: Int = 0,
    var packageName: String = "",
    var processName: String = "",
    var packageVersionCode: String = "",
    var packageVersionName: String = "",
    var activity: String = "",
    var romVersion: String = "",
    var platformId: Int = -1,
    var productName: String = "",
    var modelName: String = "",
    var exceptionSummary: String = "",
    var exceptionType: String = "",
    var exceptionDigest: String = "",
    var androidId: String = "",
    var occurTime: Int = 0,
    var callStack: String = "",
)

and generateData() create some dummy data for me

fun generateData(): List<DropBoxException> = (1..300).map {
    DropBoxException(
        id = it,
        packageName = "$it packageName",
        processName = "$it processName"
    )
}

If you beleived I mis-used some of the interfaces or the question is asked somewhere else before, feel free to post links and I'll look into them, thanks again!

Icarus Xu
  • 41
  • 4
  • You can't make two lazy views be in sync just by using the same state. Check out [this example](https://stackoverflow.com/a/69613111/3585796) of syncing two states. – Phil Dukhov Feb 22 '22 at 04:29
  • 1
    @PhilipDukhov thanks for replying, I got inspired and posted my current solution below, hope it would help others – Icarus Xu Feb 22 '22 at 06:52

1 Answers1

2

After I checked bunch of examples, it's now clear to me that each row/column shall has its own state. So here is my current solution, by making each row's lazyListState listen to a mutable value, and change that value when any row scrolls

class MainActivity : ComponentActivity() {
    // ...
}

val horizontalFirstVisibleItemIndex = mutableStateOf(0)
val horizontalFirstVisibleItemScrollOffset = mutableStateOf(0)

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ExceptionsSpreadSheet(exceptions: List<DropBoxException>) {
    // ...
}

@OptIn(ExperimentalFoundationApi::class)
@Preview(showBackground = true, widthDp = 960, heightDp = 540)
@Composable
fun ExceptionSpreadSheetHeader() {
    val scrollState = rememberLazyListState()
    if (scrollState.isScrollInProgress) {
        horizontalFirstVisibleItemIndex.value = scrollState.firstVisibleItemIndex
        horizontalFirstVisibleItemScrollOffset.value = scrollState.firstVisibleItemScrollOffset
    }
    LazyRow(modifier = Modifier.background(color = Color.DarkGray), state = scrollState) {
        // ...
    }
    LaunchedEffect(horizontalFirstVisibleItemIndex.value, horizontalFirstVisibleItemScrollOffset.value) {
        Log.v("test", "launch title $horizontalFirstVisibleItemScrollOffset")
        scrollState.scrollToItem(horizontalFirstVisibleItemIndex.value, horizontalFirstVisibleItemScrollOffset.value)
    }
}

@OptIn(ExperimentalFoundationApi::class)
@Preview(showBackground = true, widthDp = 960, heightDp = 540)
@Composable
fun ExceptionSpreadSheetRow(
    exception: DropBoxException = DropBoxException(),
) {
    val scrollState = rememberLazyListState()
    if (scrollState.isScrollInProgress) {
        horizontalFirstVisibleItemIndex.value = scrollState.firstVisibleItemIndex
        horizontalFirstVisibleItemScrollOffset.value = scrollState.firstVisibleItemScrollOffset
    }
    LazyRow(state = scrollState) {
        // ...
    }
    LaunchedEffect(horizontalFirstVisibleItemIndex.value, horizontalFirstVisibleItemScrollOffset.value) {
        scrollState.scrollToItem(horizontalFirstVisibleItemIndex.value, horizontalFirstVisibleItemScrollOffset.value)
    }
}

hope it could help anyone looking for solutions. It occurs to me with some animation lagging but good enough for my case, so I'm not digging more into this problem, but further optimization is welcomed.

Icarus Xu
  • 41
  • 4
  • @lcarcus I have tried this approach for my very similar use case but this causes sluggish scrolling and causes lots of recompositions. have u found any other alternative? – anshul Feb 28 '23 at 19:51