https://kotlinlang.org logo
Title
g

galex

02/06/2022, 5:56 PM
What’s the best way to get a callback when an item is 100% visible in a
LazyColumn
and another callback when it becomes invisible? I’m at lost with the side effects… 😕
m

mattinger

02/06/2022, 6:24 PM
What you probably want is DisposableEffect. It's main body will execute when the composition is composed, and the onDispose part will execute when it goes away:
DisposableEffect(key1 = true) {
    // Do something when it appears
    onDispose { 
        // Do something when it goes away
    }
}
Keep in mind though that this is "approximate" with LazyColumn because it will keep a few compositions on either side of the currently visible scroll region. So if you first start the screen and you have 5 rows visible, you'll actually have composed around 6 or 7 rows and the DisposableEffect will fire. Likewise, if you scroll down so rows 2 - 6 on the screen, the dispose of row 1 probably won't have fired yet.
As for the "key1" attribute, if you haven't dealt much with these, that tells the DisposableEffect when it should restart if recomposition happens. So if you want something to only happen once despite it being recomposed, you should pass a static key to DisposableEffect (like the value true).
g

galex

02/06/2022, 7:21 PM
Hey @mattinger, thanks for helping here. The issue is that I do need to trigger the callbacks only on really visible items, so I am still looking how to manage that
m

mattinger

02/06/2022, 7:21 PM
If you find something let me know. I'
I've been able to solve this at the top level composition on an activity or fragment (i'll have to find the solution again if you need it), but i haven't been able to figure this out yet at the lower level compositions. I think the issue is that something like LazyColumn will actually run the composition and keep it offscreen so that it can easily be scrolled to. This helps reduce jank when scrolling (the same thing recyclerview does). It's also a limitation we've learned to live with when using RecyclerView as well, so this is not specifically a compose issue. It's more about the pattern of how and when the views are instantiated.
g

galex

02/06/2022, 7:45 PM
I’m on it! For already a full day 😕
f

FunkyMuse

02/06/2022, 8:52 PM
val listState = rememberLazyListState() LazyColumn(state = listState) { // ... } // minimize unnecessary compositions val listHasItemsVisible by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }
I'm sorry for the weird alignment I'm posting from my phone, hope this helps you
g

galex

02/06/2022, 9:02 PM
Thanks, not sure it’s what I need 😕 What I want to a callback when I see an item at x % visibility, and another one when the visibility goes under that number, (testing with 100% just to check)
I’ve doing the following, but it doesn’t work as expected…. The screen itself
LazyColumn(
        scrollState = scrollState,
    ) {
        itemsIndexed(photos) { index, item: Photo ->

                scrollState.ItemTracker(
                    trackedIndex = index,
                    trackedItem = item,
                    onItemShown = { indexShown: Int, itemShown: Photo -> Log.d("TrackerList", "item shown - $indexShown - $itemShown") },
                    onItemHidden = { indexHidden: Int, itemHidden: Photo -> Log.d("TrackerList", "item hidden - $indexHidden - $itemHidden") },
                )

            PhotoItem(
                photo = item,
                onPhotoClick = onPhotoClick,
                modifier = Modifier.padding(2.dp),
            )
        }
    }
And the
ItemTracker
I am trying to build:
@Composable
fun <T> LazyListState.ItemTracker(
    trackedIndex: Int,
    trackedItem: T,
    onItemShown: (indexShown: Int, itemShown: T) -> Unit,
    onItemHidden: (indexHidden: Int, itemHidden: T) -> Unit
) {
    val visible = remember(layoutInfo.visibleItemsInfo) {
        val info = layoutInfo.visibleItemsInfo.getOrNull(trackedIndex)
        return@remember info?.let {
            Log.d("Tracker", "$trackedIndex has info at visibility = ${visibilityPercent(info)}")
            visibilityPercent(info) == 100f
        }
    }

    LaunchedEffect(visible) {
        Log.d("Tracker", "$trackedIndex is visible = $visible")
        when (visible) {
            true -> onItemShown(trackedIndex, trackedItem)
            false -> onItemHidden(trackedIndex, trackedItem)
        }
    }
}
And how it calculates the visibility:
fun LazyListState.visibilityPercent(info: LazyListItemInfo): Float {
    val cutTop = maxOf(0, layoutInfo.viewportStartOffset - info.offset)
    val cutBottom = maxOf(0, info.offset + info.size - layoutInfo.viewportEndOffset)
    return maxOf(0f, 100f - (cutTop + cutBottom) * 100f / info.size)
}
The results are weird… I am def missing something here, but it’s late so I’ll leave that for tomorrow…. 🤔
m

mattinger

02/07/2022, 5:46 AM
Have you tried something like this to track what's visible:
val state = rememberLazyListState()
val flow = snapshotFlow { state.layoutInfo }
LaunchedEffect(key1 = true) {
    flow.collect {
        val firstIndex = it.visibleItemsInfo.first().index
        val lastIndex = it.visibleItemsInfo.last().index
    }
}
Against, it's not "perfect", but it will let you know the range of items which are at least partially visible
g

galex

02/07/2022, 5:50 AM
Thanks, yes, I started to think about using a State list but I didn't try yet. I will try that ad soon SD possible
m

mattinger

02/07/2022, 6:04 AM
And you can wrap it up into a modifier nicely:
fun Modifier.trackVisibleItems(
    state: LazyListState,
    onVisibleItems: (Int, Int) -> Unit
) = composed {
    val flow = snapshotFlow { state.layoutInfo }
    LaunchedEffect(key1 = true) {
        flow.collect {
            val firstIndex = it.visibleItemsInfo.first().index
            val lastIndex = it.visibleItemsInfo.last().index
            onVisibleItems(firstIndex, lastIndex)
        }
    }
    Modifier
}
LazyColumn(
            modifier = Modifier
                .fillMaxSize()
                .trackVisibleItems(
                    state = state,
                    onVisibleItems = { firstIndex, lastIndex ->
                        Timber.tag("LazyColumn").d("$firstIndex $lastIndex")
                    }
                ),
g

galex

02/07/2022, 6:04 AM
What I need is a • Trigger when index shows up in layoutInfo.visibleItemsInfo and visibility is > than x % • Trigger when visibility goes under that x % That's all actually 😛erplexe:
m

mattinger

02/07/2022, 6:05 AM
well, if you can figure that out from the visibleItemsInfo, you should be golden
g

galex

02/07/2022, 6:05 AM
That looks good I'll try that 😊
m

mattinger

02/07/2022, 6:07 AM
Let me know what you find. it could be a very useful tool in the toolbar. I haven't really looked what else is in the LayoutInfo, but i imagine it may have what you want
g

galex

02/07/2022, 6:11 AM
The whole issue is that the reported visible items are not really visible, but some calculation with the viewport start and end offset should work, at least I'd like to think so
m

mattinger

02/07/2022, 6:16 AM
Timber.tag("LazyColumn").d("viewport=${it.viewportStartOffset} ${it.viewportEndOffset}")
            it.visibleItemsInfo.forEach {
                Timber.tag("LazyColumn").d("index=${it.index} offset=${it.offset}")
i'm able to get this far, and i'm getting a viewport of 0 - 1977
and then some offsets like this:
2022-02-07 01:16:07.149 7018-7018/com.example.myapplication D/LazyColumn: index=31 offset=1829
2022-02-07 01:16:07.149 7018-7018/com.example.myapplication D/LazyColumn: index=32 offset=1888
2022-02-07 01:16:07.149 7018-7018/com.example.myapplication D/LazyColumn: index=33 offset=1947
2
There is a "size" field too that might have what you want
g

galex

02/07/2022, 6:21 AM
Yup can't wait to sit in front of my computer and find a solution for this
m

mattinger

02/07/2022, 6:25 AM
fun Modifier.trackVisibleItems(
    state: LazyListState,
    onVisibleItems: (Int, Int) -> Unit
) = composed {
    val flow = snapshotFlow { state.layoutInfo }
    LaunchedEffect(key1 = true) {
        flow.collect { layoutInfo ->
            Timber.tag("LazyColumn").d("viewport=${layoutInfo.viewportStartOffset} ${layoutInfo.viewportEndOffset}")
            layoutInfo.visibleItemsInfo.forEach { item ->
                val isFullyVisible =
                    item.offset >= layoutInfo.viewportStartOffset &&
                            item.size + item.offset <= layoutInfo.viewportEndOffset

                Timber.tag("LazyColumn").d("index=${item.index} size=${item.size} offset=${item.offset} isFullyVisible=${isFullyVisible}")

            }

            val firstIndex = layoutInfo.visibleItemsInfo.first().index
            val lastIndex = layoutInfo.visibleItemsInfo.last().index
            onVisibleItems(firstIndex, lastIndex)
        }
    }
    Modifier
}
this should do what you want. just tweak the callback to send what you want up the chain
but this at least answers the question of when things are fully visible
g

galex

02/07/2022, 6:31 AM
Looks super good 👍
I wonder something, does it get triggered on every scroll or only when the visible items list is changed? You're killing me I can't test that right now, only in about 30min 🤣
I'm saying that because it might be the issue here, we do need to recompute the list of fully visible items on every scroll offset, not only when the visible items list is changing
Maybe the snapshot Flow does that indeed
m

mattinger

02/07/2022, 6:51 AM
i think i got it.
fun Modifier.trackVisibleItems(
    state: LazyListState,
    onNewlyVisible: (List<Int>) -> Unit,
    onNewlyHidden: (List<Int>) -> Unit
) = composed {
    val flow = snapshotFlow { state.layoutInfo }
    val visibleItemIndexes = remember { mutableStateOf(emptyList<Int>()) }

    LaunchedEffect(key1 = true) {
        flow.collect { layoutInfo ->
            val newVisibleItemIndexes = layoutInfo.visibleItemsInfo.filter { item ->
                item.offset >= layoutInfo.viewportStartOffset &&
                        item.size + item.offset <= layoutInfo.viewportEndOffset
            }.map { item ->
                item.index
            }

            val newlyVisible = newVisibleItemIndexes.filter {
                !visibleItemIndexes.value.contains(it)
            }

            val newlyHidden = visibleItemIndexes.value.filter {
                !newVisibleItemIndexes.contains(it)
            }

            if (newlyVisible.isNotEmpty()) {
                onNewlyVisible(newlyVisible)
            }

            if (newlyHidden.isNotEmpty()) {
                onNewlyHidden(newlyHidden)
            }

            visibleItemIndexes.value = newVisibleItemIndexes
        }
    }
    Modifier
}
and to answer your question, whenever the scroll state changes, the layoutInfo will change, and a new value will appear in the flow. THe trick then is to just keep track of the last list of items which were fully visible and see what was added and subtracted. I'm sure there's probably better operators than that filter (maybe the minus operator or something) but this does the trick
2022-02-07 01:49:50.635 8329-8329/com.example.myapplication D/LazyColumn: newlyHidden=[0]
2022-02-07 01:49:50.669 8329-8329/com.example.myapplication D/LazyColumn: newlyVisible=[33]
2022-02-07 01:49:50.746 8329-8329/com.example.myapplication D/LazyColumn: newlyHidden=[1]
2022-02-07 01:49:50.827 8329-8329/com.example.myapplication D/LazyColumn: newlyVisible=[34]
2022-02-07 01:49:51.014 8329-8329/com.example.myapplication D/LazyColumn: newlyHidden=[2]
2022-02-07 01:49:51.127 8329-8329/com.example.myapplication D/LazyColumn: newlyVisible=[35]
2022-02-07 01:49:51.309 8329-8329/com.example.myapplication D/LazyColumn: newlyHidden=[3]
2022-02-07 01:49:51.376 8329-8329/com.example.myapplication D/LazyColumn: newlyVisible=[36]
2022-02-07 01:49:51.441 8329-8329/com.example.myapplication D/LazyColumn: newlyHidden=[4]
2022-02-07 01:49:51.548 8329-8329/com.example.myapplication D/LazyColumn: newlyVisible=[37]
2022-02-07 01:49:51.578 8329-8329/com.example.myapplication D/LazyColumn: newlyHidden=[5]
2022-02-07 01:49:51.702 8329-8329/com.example.myapplication D/LazyColumn: newlyVisible=[38]
2022-02-07 01:49:51.743 8329-8329/com.example.myapplication D/LazyColumn: newlyHidden=[6]
2022-02-07 01:49:51.759 8329-8329/com.example.myapplication D/LazyColumn: newlyVisible=[39]
2022-02-07 01:49:51.818 8329-8329/com.example.myapplication D/LazyColumn: newlyHidden=[7]
2022-02-07 01:49:51.870 8329-8329/com.example.myapplication D/LazyColumn: newlyVisible=[40]
g

galex

02/07/2022, 7:05 AM
Ok I’m here on the computer finally, testing that 😄
@mattinger you’re golden man, that’s it 😄
Thank you so much!
Ah, there’s one missing piece. Upon leaving the screen, as all the visible items are hidden, I’d like to report those still visible as hidden. I guess replacing
LaunchedEffect
with
DisposableEffect
with a callback call in
onDispose
should work right?
It works @mattinger 😄
fun Modifier.trackVisibleItems(
    state: LazyListState,
    scope: CoroutineScope,
    onItemsVisible: (List<Int>) -> Unit,
    onItemsHidden: (List<Int>) -> Unit
) = composed {
    val flow = snapshotFlow { state.layoutInfo }
    val visibleItemIndexes = remember { mutableStateOf(emptyList<Int>()) }
    DisposableEffect(key1 = true) {
        scope.launch {
            flow.collect { layoutInfo ->
                val newVisibleItemIndexes = layoutInfo.visibleItemsInfo.filter { item ->
                    item.offset >= layoutInfo.viewportStartOffset && item.size + item.offset <= layoutInfo.viewportEndOffset
                }.map { item -> item.index }

                val newlyVisible = newVisibleItemIndexes.filter {
                    !visibleItemIndexes.value.contains(it)
                }

                val newlyHidden = visibleItemIndexes.value.filter {
                    !newVisibleItemIndexes.contains(it)
                }

                if (newlyVisible.isNotEmpty()) {
                    onItemsVisible(newlyVisible)
                }

                if (newlyHidden.isNotEmpty()) {
                    onItemsHidden(newlyHidden)
                }

                visibleItemIndexes.value = newVisibleItemIndexes
            }
        }

        onDispose {
            onItemsHidden(visibleItemIndexes.value)
        }
    }
    Modifier
}