I have a Composable
that is on a LazyColumn
but I'm trying to do the check of visibility out of the LazyListState
I used to use this
fun LazyListState.visibleItems(itemVisiblePercentThreshold: Float) =
layoutInfo
.visibleItemsInfo
.filter {
visibilityPercent(it) >= itemVisiblePercentThreshold
}
private 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)
}
But now I want it inside the Composable
since I do not have access to the list, others features use my Composables
and it could be inside a LazyList
, Surface
, whatever, so I'm using onGloballyPositioned
to determine if it's visible or not, but I'd like to know if it's at least 30% visible. Any idea?
I have a @Composable
that I want to provide to other features of my app, so for example :
Feature1 have a LazyColumn
that prints its items, but then they want to add my @Composable
to the top of the list, so I want to know when my @Composable
is visible (at least 30%) on this case if it's at the top normally it should be always 100% when load the list but for example in Feature2 they want to add my @Composable
at the middle of their list, so I should know when it start to be visible that's why I need the threshold to send some events.
It wont be always a LazyColumn
that's why I don't know if I want to be attached to a LazyListState
but if needed I could receive a LazyListState
on my @Composable
.
If you wish to use this with any Compoasble you can use Modifier.onGloballyPositioned{}
fun Modifier.isVisible(
parentCoordinates: LayoutCoordinates?,
threshold: Int,
onVisibilityChange: (Boolean) -> Unit
) = composed {
val view = LocalView.current
Modifier.onGloballyPositioned { layoutCoordinates: LayoutCoordinates ->
if (parentCoordinates == null) return@onGloballyPositioned
val layoutHeight = layoutCoordinates.size.height
val thresholdHeight = layoutHeight * threshold / 100
val layoutTop = layoutCoordinates.positionInRoot().y
val parentTop = parentCoordinates.positionInParent().y
val parentHeight = parentCoordinates.size.height
val parentBottom = (parentTop + parentHeight).coerceAtMost(view.height.toFloat())
println(
"layoutTop: $layoutTop, " +
" parentTop: $parentTop, " +
" parentBottom: $parentBottom, " +
"parentHeight: $parentHeight, " +
"SECTION: ${parentBottom - layoutTop}"
)
if (
parentBottom - layoutTop > thresholdHeight &&
(layoutTop - parentTop > thresholdHeight - layoutHeight)
) {
onVisibilityChange(true)
} else {
onVisibilityChange(false)
}
}
}
I posted a little bit complex example to show you can get position no matter where parent is positioned in its parent.
You can use it with LazyColumn or Column with vertical scroll.
@Preview
@Composable
private fun ScrollTest() {
var isVisible by remember {
mutableStateOf(false)
}
var coordinates by remember {
mutableStateOf<LayoutCoordinates?>(null)
}
val context = LocalContext.current
var visibleTime by remember {
mutableLongStateOf(0L)
}
LaunchedEffect(isVisible) {
if (isVisible) {
visibleTime = System.currentTimeMillis()
Toast.makeText(context, "😆 Item 30% threshold is passed $isVisible", Toast.LENGTH_SHORT)
.show()
} else if (visibleTime != 0L) {
val currentTime = System.currentTimeMillis()
val totalTime = currentTime - visibleTime
Toast.makeText(context, "🥵 Item was visible for $totalTime ms", Toast.LENGTH_SHORT)
.show()
}
}
Column {
Box(modifier = Modifier.height(100.dp))
LazyColumn(
modifier = Modifier
.onPlaced { layoutCoordinates: LayoutCoordinates ->
coordinates = layoutCoordinates
}
.weight(1f)
.fillMaxSize()
.border(2.dp, Color.Black)
) {
items(60) { index: Int ->
if (index == 15) {
Column(
modifier = Modifier.fillMaxWidth().height(300.dp)
.border(6.dp, if (isVisible) Color.Green else Color.Red)
.isVisible(parentCoordinates = coordinates, threshold = 30) {
isVisible = it
}
) {
Box(modifier = Modifier.fillMaxWidth().weight(3f).background(Color.Yellow))
Box(modifier = Modifier.fillMaxWidth().weight(4f).background(Color.Cyan))
Box(modifier = Modifier.fillMaxWidth().weight(3f).background(Color.Magenta))
}
} else {
Text(
text = "Row $index",
fontSize = 24.sp,
modifier = Modifier.fillMaxWidth().padding(8.dp)
)
}
}
}
// Column(
// modifier = Modifier
// .onPlaced { layoutCoordinates: LayoutCoordinates ->
// coordinates = layoutCoordinates
// }
// .weight(1f)
// .fillMaxSize()
// .border(2.dp, Color.Black)
// .verticalScroll(rememberScrollState())
// ) {
// repeat(60) { index ->
// if (index == 15) {
// Column(
// modifier = Modifier.fillMaxWidth().height(300.dp)
// .border(6.dp, if (isVisible) Color.Green else Color.Red)
// .isVisible(parentCoordinates = coordinates, threshold = 30) {
// isVisible = it
// }
// ) {
// Box(modifier = Modifier.fillMaxWidth().weight(3f).background(Color.Yellow))
// Box(modifier = Modifier.fillMaxWidth().weight(4f).background(Color.Cyan))
// Box(modifier = Modifier.fillMaxWidth().weight(3f).background(Color.Magenta))
// }
// } else {
// Text(
// text = "Row $index",
// fontSize = 24.sp,
// modifier = Modifier.fillMaxWidth().padding(8.dp)
// )
// }
// }
// }
Box(modifier = Modifier.height(100.dp))
}
}
fun Modifier.isVisible(
threshold: Int,
onVisibilityChange: (Boolean) -> Unit
) = composed {
Modifier.onGloballyPositioned { layoutCoordinates: LayoutCoordinates ->
val layoutHeight = layoutCoordinates.size.height
val thresholdHeight = layoutHeight * threshold / 100
val layoutTop = layoutCoordinates.positionInRoot().y
val layoutBottom = layoutTop + layoutHeight
// This should be parentLayoutCoordinates not parentCoordinates
val parent =
layoutCoordinates.parentLayoutCoordinates
parent?.boundsInRoot()?.let { rect: Rect ->
val parentTop = rect.top
val parentBottom = rect.bottom
if (
parentBottom - layoutTop > thresholdHeight &&
(parentTop < layoutBottom - thresholdHeight)
) {
onVisibilityChange(true)
} else {
onVisibilityChange(false)
}
}
}
}
If you want to use this for multiple Composables create a custom one as
@Composable
private fun MyCustomBox(
modifier: Modifier = Modifier,
threshold: Int = 30,
content: @Composable () -> Unit
) {
var isVisible by remember {
mutableStateOf(false)
}
val context = LocalContext.current
var visibleTime by remember {
mutableLongStateOf(0L)
}
LaunchedEffect(isVisible) {
if (isVisible) {
visibleTime = System.currentTimeMillis()
Toast.makeText(context, "😆 Item 30% threshold is passed $isVisible", Toast.LENGTH_SHORT)
.show()
} else if (visibleTime != 0L) {
val currentTime = System.currentTimeMillis()
val totalTime = currentTime - visibleTime
Toast.makeText(context, "🥵 Item was visible for $totalTime ms", Toast.LENGTH_SHORT)
.show()
}
}
Box(
modifier = modifier
.border(6.dp, if (isVisible) Color.Green else Color.Red)
.isVisible(threshold = threshold) {
isVisible = it
}
) {
content()
}
}
And you can use it as
@Preview
@Composable
private fun ScrollTest2() {
Column {
TopAppBar {
Text("TopAppbar")
}
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.border(2.dp, Color.Black)
.verticalScroll(rememberScrollState())
) {
repeat(60) { index ->
if (index == 15 || index == 22 || index == 35) {
MyCustomBox(
modifier = Modifier.fillMaxWidth().height(300.dp)
) {
Column {
Box(
modifier = Modifier.fillMaxWidth().weight(3f)
.background(Color.Yellow)
)
Box(
modifier = Modifier.fillMaxWidth().weight(4f).background(Color.Cyan)
)
Box(
modifier = Modifier.fillMaxWidth().weight(3f)
.background(Color.Magenta)
)
}
}
} else {
Text(
text = "Row $index",
fontSize = 24.sp,
modifier = Modifier.fillMaxWidth().padding(8.dp)
)
}
}
}
Box(modifier = Modifier.height(100.dp))
}
}