How to detect scroll behaviour in LazyColumn? - android

I'm trying to detect three scenarios :
1.- User scroll vertically (down) and notify to hide a button
2.- User stop scrolls and notify to hide button
3.- User scroll vertically (up) and notify to show the button
4.- User is in the bottom of the list and there are no more items and notify to show the button.
What I've tried is :
First approach is to use nestedScrollConnection as follows
val isVisible = remember { MutableTransitionState(false) }
.apply { targetState = true }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset {
return Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
isVisible.targetState = false
return super.onPostScroll(consumed, available, source)
}
}
}
LazyColumn(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, top = 16.dp)
.nestedScroll(nestedScrollConnection),
verticalArrangement = Arrangement.spacedBy(16.dp),
)
What I've tried is when y > 0 is going up, else is going down, but the others I don't know how to get them.
Another approach I followed is :
val scrollState = rememberLazyListState()
LazyColumn(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, top = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
state = scrollState,
But I don't know how to get if it's last item or not, I can get if the scroll is in progress.
Note
Answer from Skizo works but with this it's a bit weird because if you scroll up slowly the Y sometimes is not what I want and then hide it again, is there any way to leave some scroll to start reacting to this? For instance, scroll X pixels to start showing / hiding.
What I want is to hide/show is a Float Action Button depending on the scroll (the scenarios are the ones from above)
I've found this way, but it is using the offset and I'd like to animate the FloatActionButton instead of appearing from the bottom like a fade in/fade out I was using the Animation Visibility and I got this working with fade in/fade out but now, how can I adapt the code from github to use Animation Visibility? And also add this when the user ends scrolling that from now in the code is just while scrolling

Here's how you'd go about achieving these,
1.) Detect The Scrolling Direction (Vertically Up, or Vertically Down)
#Composable
private fun LazyListState.isScrollingUp(): Boolean {
var previousIndex by remember(this) { mutableStateOf(firstVisibleItemIndex) }
var previousScrollOffset by remember(this) { mutableStateOf(firstVisibleItemScrollOffset) }
return remember(this) {
derivedStateOf {
if (previousIndex != firstVisibleItemIndex) {
previousIndex > firstVisibleItemIndex
} else {
previousScrollOffset >= firstVisibleItemScrollOffset
}.also {
previousIndex = firstVisibleItemIndex
previousScrollOffset = firstVisibleItemScrollOffset
}
}
}.value
}
Now, just create a listState variable and use it with this Composable to retrieve the scrolling dierection.
val listState = rememberLazyListState()
val scrollingUp = listState.isScrollingUp()
Then, as you say, you'd like to get notified if the user stops scrolling, so for that you can just create a variable known as restingScrollPosition, I'll name it rsp for short. Now that you are familiar with the required helper methods, all that is required is devising a mechanism to trigger an event based on the value of the current scroll position that triggers the code in concern if the value has been the same for a particular amount of time (the definition of the scroll being "at rest").
So, here it is
var rsp by remember { mutableStateOf(listState.firstVisibleItemScrollOffset) }
var isScrolling by remember { mutableStateOf(false) } // to keep track of the scroll-state
LaunchedEffect(key = listState.firstVisibleItemScrollOffset){ //Recomposes every time the key changes
/*This block will also be executed
on the start of the program,
so you might want to handle
that with the help of another variable.*/
isScrolling = true // If control reaches here, we're scrolling
launch{
isScrolling = false
delay(100) //If there's no scroll after a hundred seconds, update rsp
if(!isScrolling){
rsp = listState.firstVisibleItemScrollOffset
/* Execute your trigger here,
this denotes the scrolling has stopped */
}
}
}
I don't think I would be explaining the workings of the last code here, please analyze it yourself to gain a better understanding, it's not difficult.
Ah yes to achieve the last objective, you can just use a little swashbuckling with the APIs, specifically methods like lazyListState.layoutInfo. It has all the info you'll require about the items currently visible on-screen, and so you can also use it to implement your subtle need where you wish to allow for a certain amount to be scrolled before triggering the codeblock. Just have a look at the availannle info in the object and you should be able to start it up.
UPDATE:
Based on the info provided in the comments added below as of yesterday, this should be the implementation,
You have a FAB somewhere in your heirarchy, and you wish to animate it's visibility based on the isScrollingUp, which is bound to the Lazy Scroller defined somewhere else in the heirarchy,
In that case, you can take a look at state-hoisting, which is a general best practice for declarative programming.
Just hoist the isScrollingUp() output up to the point where your FAB is declared, or please share the complete code if you need specific instructions based on your use-case. I would require the entire heirarchy to be able to help you out with this.

I've faced same problem some days ago and I did a mix of what you say.
To show or hide then scrolling up or down with the Y is enough.
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset {
val delta = available.y
isVisible.targetState = delta > 0
return Offset.Zero
}
}
}
And to detect there's no more items you can use
fun isLastItemVisible(lazyListState: LazyListState): Boolean {
val lastItem = layoutInfo.visibleItemsInfo.lastOrNull()
return lastItem == null || lastItem.size + lastItem.offset <= layoutInfo.viewportEndOffset
}

Related

Jetpack Compose: LazyRow onScrollListener

I was wondering if there is a way or a resource I could refer to in order to achieve a side effect on a LazyRow when an item is scrolled?
The side effect is basically to call a function in the viewModel to alter the state of the list's state.
The side effect should be only executed only if the current firstVisibleItemIndex after
scroll is different than before
The side effect should not be executed the item is not fully scrolled
I am implementing a fullscreen LazyRow items with a snap behavior
So far I have tried NestedScrollConnection
class OnMoodItemScrolled : NestedScrollConnection {
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
viewModel.fetchItems()
return super.onPostFling(consumed, available)
}
}
The issue with the above is that the side effect is going to be executed anyway even-though the item displayed after the scroll is the same as before the scroll.
I also tried to collecting the listState interaction as the following
val firstVisibleItem: Int = remember { sectionItemListState.firstVisibleItemIndex }
sectionItemListState.interactionSource.collectIsDraggedAsState().let {
if (firstVisibleItem != sectionItemListState.firstVisibleItemIndex) {
viewModel.fetchItems()
}
}
The issue with the above is that the side effect is going to be executed the second the composable is composed for the first time.
You can use the LazyListState#firstVisibleItemIndex to get the information about the first visible item and store this value. When the value changes the item is scrolled up.
Something like:
#Composable
private fun LazyListState.itemIndexScrolledUp(): Int {
var previousIndex by remember(this) { mutableStateOf(firstVisibleItemIndex) }
return remember(this) {
derivedStateOf {
if (firstVisibleItemIndex > previousIndex) {
//scrolling up
previousIndex
} else {
- 1
}.also {
//Update the previous index
previousIndex = firstVisibleItemIndex
}
}
}.value
}
and then:
val state = rememberLazyListState()
var index = state.itemIndexScrolledUp()
DisposableEffect(index){
if (index != -1) {
//...item is scrolled up
}
onDispose { }
}
LazyColumn(
state = state,
){
//...
}
I solved my issue using a LaunchedEffect with 2 keys.
val sectionItemListState = rememberLazyListState()
val flingBehavior = rememberSnapFlingBehavior(sectionItemListState)
var previousVisibleItemIndex by rememberSaveable {
mutableStateOf(0)
}
val currentVisibleItemIndex: Int by remember {
derivedStateOf { sectionItemListState.firstVisibleItemIndex }
}
val currentVisibleItemScrollOffset: Int by remember {
derivedStateOf { sectionItemListState.firstVisibleItemScrollOffset }
}
LaunchedEffect(currentVisibleItemIndex, currentVisibleItemScrollOffset) {
if (previousVisibleItemIndex != currentVisibleItemIndex && currentVisibleItemScrollOffset == 0) {
// The currentVisible item is different than the previous one && it's fully visible
viewModel.fetchItems()
previousVisibleItemIndex = currentVisibleItemIndex
}
}
Using both currentVisibleItemIndex and currentVisibleItemScrollOffset as keys will make sure that the LaunchedEffect will be triggered whenever one of them changes. Moreover, checking if the previousVisibleItemIndex is different than the currentVisibleItemIndex will ensure that we only trigger this effect only if the visible item is changing.
However, this condition will true also if the use has partially scrolled and since I have a snapping effect it will go back to the previous position. Which will result in triggering the effect twice.
In order to make sure that we only trigger the effect only in case were we actually scrolled to the next/previous fully visible position we need to rely on the scrollOffset.

Jetpack Compose: Focus Styling

I am making custom components and with my custom components I am required to cover the focus state.
At the top of my component hierarchy I am tracking focus with this.
val focused = remember { mutableStateOf(false) }
val focusModifier = modifier.onFocusEvent {
focused.value = it.hasFocus || it.isFocused
}
Component(modifier = focusModifier, focused = focused.value)
The component is basically this:
#Composable
fun Component(
modifier: Modifier = Modifier,
focused: Boolean = false
) {
...
val colorStuff = if(focused) focusColors else otherColors
var focusModifier = modifier
if(focused) {
focusModifier = modifier.border(BorderStroke(2.dp, Color.Red)).padding(16.dp)
}
NextComponent(
focusModifier,
colorStuff,
etc
)
}
If I leave the code with colorStuff and focused without the focusModifier code the focus state is done correctly and the colors for the component change appropriately. But when I add the focusModifier code and do a border and padding the focus state will trigger, but then instantly be lost. I'm assuming this is because the addition of modifier code changes the build order of the component and makes it discard focus. But that doesn't make full sense either.
I essentially need to add borders/shadows around components when they are focused so this will be something I have to do multiple times. Right now I can't get it done once. Any idea what I need to do to overcome this?
This is not an answer to your question, but I'll share my use-case, maybe it could help. Consider the codes below
data class PersonItem(
val id : Int,
val isActive: Boolean = false
)
List Item
#Composable
fun PersonInfoItem() {
Column {
// First Name
TextField(
modifier = Modifier.onFocusEvent {
// report to view model this personItem received focus events
},
value = "",
onValueChange = {
}
)
// Last Name
TextField(
modifier = Modifier.onFocusEvent {
// report to view model this personItem received focus events
},
value = "",
onValueChange = {
}
)
}
VieModel
class ViewModel {
val currentFocusedItem : PersonItem? = null
fun onPersonItemFocused(personItem: PersonItem) {
// this is where I validate that if the personItem argument
// is not the same with the currentFocusedPerson, if they are not then the last one is not on focus anymore (set !isActive)
// otherwise if they are the same, we are still on the same PersonItem
}
Regardless of which TextField received or lost its focus, as long as I'm keeping track of the Id of the PersonItem being reported to the ViewModel I'm guaranteed that if its the same PersonItem, it will retain what ever state I put into it during its Focus events
(say, the item will change background color when either of the TextField received focus, and revert back to its original background when none of the TextField is focused).
Another thing to consider, these kinds components (i.e TextFields) report multiple inactive focus events during its initial composition, and it's kind of annoying.

Avoid initial scrolling when using Jetpack Compose ScrollableTabRow

I'm using a ScrollableTabRow to display some 60 Tabs.
At the very beginning, the indicator should start "in the middle".
However, this results in an unwanted scrolling animation when the composable is drawn - see video. Am i doing something wrong or is this component buggy?
#Composable
#Preview
fun MinimalTabExample() {
val tabCount = 60
var selectedTabIndex by remember { mutableStateOf(tabCount / 2) }
ScrollableTabRow(selectedTabIndex = selectedTabIndex) {
repeat(tabCount) { tabNumber ->
Tab(
selected = selectedTabIndex == tabNumber,
onClick = { selectedTabIndex = tabNumber },
text = { Text(text = "Tab #$tabNumber") }
)
}
}
}
But why would you like to do that?
I'm writing a calendar-like application and have a day-detail-view.
From there want a fast way to navigate to adjacent days. A Month into the future and a month into the past - relative to the selected month - is what i'm aiming for.
No, you are not doing it wrong. Also the component is not really buggy, rather the behaviour you are seeing is an implementation detail.
If we check the implementation of the ScrollableTabRow composable we see that the selectedTabIndex is used in two places inside the composable:
inside the default indicator implementation
as an input parameter for the scrollableTableData.onLaidOut call
The #1 is used for positioning the tabs inside the layout, so it is not interesting for this issue.
The #2 is used to scroll to the selected tab index.
The code below shows how the initial scroll state is set up, followed by the call to scrollableTabData.onLaidOut
val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()
val scrollableTabData = remember(scrollState, coroutineScope) {
ScrollableTabData(
scrollState = scrollState,
coroutineScope = coroutineScope
)
}
// ...
scrollableTabData.onLaidOut(
density = this#SubcomposeLayout,
edgeOffset = padding,
tabPositions = tabPositions,
selectedTab = selectedTabIndex // <-- selectedTabIndex is passed here
)
And this is the implementation of the above call
fun onLaidOut(
density: Density,
edgeOffset: Int,
tabPositions: List<TabPosition>,
selectedTab: Int
) {
// Animate if the new tab is different from the old tab, or this is called for the first
// time (i.e selectedTab is `null`).
if (this.selectedTab != selectedTab) {
this.selectedTab = selectedTab
tabPositions.getOrNull(selectedTab)?.let {
// Scrolls to the tab with [tabPosition], trying to place it in the center of the
// screen or as close to the center as possible.
val calculatedOffset = it.calculateTabOffset(density, edgeOffset, tabPositions)
if (scrollState.value != calculatedOffset) {
coroutineScope.launch {
scrollState.animateScrollTo( // <-- even the initial scroll is done using an animation
calculatedOffset,
animationSpec = ScrollableTabRowScrollSpec
)
}
}
}
}
}
As we can see already from the first comment
Animate if the new tab is different from the old tab, or this is called for the first time
but also in the implementation, even the first time the scroll offset is set using an animation.
coroutineScope.launch {
scrollState.animateScrollTo(
calculatedOffset,
animationSpec = ScrollableTabRowScrollSpec
)
}
And the ScrollableTabRow class does not expose a way to control this behaviour.

Animate the Content Changes for a particular Composable,

I have a simple Composable that shows some data to the user, and that data needs to be updated periodically. Now, I wish to add a smooth transition between data changes instead of just snapping the new data in, so that is the prime focus of this question.
Now, for a mere example, we could take a simple Image Composable. I have this
#Composable
fun ImageFrame(imagePainter: Painter){
Box{
Image(
imagePainter,
... //Modifications, etcetra
)
}
}
If I want the painter to change every three seconds, how should I go about animating this change in reference to the Box with a slide-in and slide-out motion?
Found a great experimental API, built into Compose for the exact same thing, it's called AnimatedContent. Here's an implementation for my use-case
#Composable
fun AnimatedImageFrame(image: Painter){
AnimatedContent(
targetState = image,
transitionSpec = {
(slideInHorizontally { -it } with slideOutHorizontally { it })
.using(
// Disable clipping since the faded slide-in/out should
// be displayed out of bounds.
SizeTransform(clip = false)
)
}
) {
Image(
painter = it,
contentDescription = ""
)
}
}
Every time you update the parameter passed to the AnimatedImageFrame, the image displayed would animate by doing a sliding motion.
To read more, refer to this doc.
I had a similar issue, and as far as I know, the only out-of-the-box animation in Compose for content changes is Crossfade.
I ended up implementing a custom animation based on the Crossfade implementation, as it effectively will take care of managing the state for you, you'll just need to swap out the alpha value animation for slide in/out animation. Inspect the Crossfade implementation, and you'll see how it works.
The bit where Crossfade composes the new content with the fading animation is this:
CrossfadeAnimationItem(key) {
val alpha by transition.animateFloat(
transitionSpec = { animationSpec }
) { if (it == key) 1f else 0f }
Box(Modifier.graphicsLayer { this.alpha = alpha }) {
content(key)
}
}
So for sliding in/out instead, you may end up with something like:
SlideContentInAnimationItem(key) {
transition.AnimatedVisibility(
visible = { it == key },
enter = slideInHorizontally( /* your animation config*/ ),
exit = slideOutHorizontally( /* your animation config*/ )
) {
content(key)
}
}
The targetState property and the key value are one and the same - in your example it would represent your ImagePainter - if that value changes due to a state change in the composable, the Crossfade or your new custom sliding animation will be triggered.

Jetpack Compose: Mimicking spinner.setSelection() inside of a DropDownMenu

The use case is that you have 10s or 100s of items inside of a dropdown menu, the dropdown options have some ordering - as with number values or alphabetical listing of words - and selections are made in succession.
When the user reopens the menu, you'd like for it to open in the same region as their last selection, so that for instance you don't jump from "car" to "apple" but rather from "car" to "cat". Or if they just opted to view order number 358, they can quickly view order number 359.
Using views, you could create a Spinner and put all of your items in an ArrayAdapter and then call spinner.setSelection() to scroll directly to the index you want.
DropdownMenu doesn't have anything like HorizontalPager's scrollToPage(). So what solutions might exist to achieve this?
So far, I've tried adding verticalScroll() to the DropdownMenu's modifier and trying to do arithmetic with the scrollState. But it crashes at runtime with an error saying the component has infinite height, the same error you get if you try to nest scrollable components like a LazyColumn inside of a Column with verticalScroll.
It's a known issue.
DropdownMenu has its own vertical scroll modifier inside, and there is no API to work with it.
Until this problem is fixed by providing a suitable API, the only workaround I can think of is to create your own view - you can take the source code of DropdownMenu as reference.
I'll post a more detailed answer here because I don't want to mislead anyone with my comment above.
If you're in Android Studio, click the three dots on the mouse-hover quick documentation box and select "edit source" to open the source for DropdownMenu in AndroidMenu.android.kt. Then observe that it uses a composable called DropdownMenuItemContent. Edit source again and you're in Menu.kt.
You'll see this:
#Composable
internal fun DropdownMenuContent(
...
...
...
{
Column(
modifier = modifier
.padding(vertical = DropdownMenuVerticalPadding)
.width(IntrinsicSize.Max)
.verticalScroll(rememberScrollState()),//<-We want this
content = content
)
}
So in your custom composable just replace that rememberScrollState() with your favorite variable name for a ScrollState.
And then chain that reference all the way back up to your original view.
Getting Access to the ScrollState
#Composable
fun MyCustomDropdownMenu(
expanded:Boolean,
scrollStateProvidedByTopParent:ScrollState,
...
...
)
{...}
#Composable
fun MyCustomDropdownMenuContent(
scrollStateProvidedByTopParent:ScrollState,
...
...
)
{...}
//And now for your actual content
#Composable
fun TopParent(){
val scrollStateProvidedByTopParent=rememberScrollState()
val spinnerExpanded by remember{mutableStateOf(false)}
...
...
Box{
Row(modifier=Modifier.clickable(onClick={spinnerExpanded=!spinnerExpanded}))//<-More about this line in the sequel
{
Text("Options")
Icon(imageVector = Icons.Filled.ArrowDropDown, contentDescription = "")
MyCustomDropdownMenu(
expanded = spinnerExpanded,
scrollStateProvidedByTopParent=scrollStateProvidedByTopParent,
onDismissRequest = { spinnerExpanded = false })
{//your spinner content}
}
}
}
The above only specifies how to access the ScrollState of the DropdownMenu. But once you have the ScrollState, you'll have to do some arithmetic to get the scroll position right when it opens. Here's one way that seems alright.
Calculating the scroll distance
Even after setting the contents of the menu items explicitly, the distance was never quite right if I relied on those values. So I used an onTextLayout callback inside the Text of my menu items in order to get the true Text height at the time of rendering. Then I use that value for the arithmetic. It looks like this:
#Composable
fun TopParent(){
val scrollStateProvidedByTopParent=rememberScrollState()
val spinnerExpanded by remember{mutableStateOf(false)}
val chosenText:String by remember{mutableStateOf(myListOfSpinnerOptions[0])
val height by remember{mutableStateOf(0)}
val heightHasBeenChecked by remember{mutableStateOf(false)}
val coroutineScope=rememberCoroutineScope()
...
...
Box{
Row(modifier=Modifier.clickable(onClick={spinnerExpanded=!spinnerExpanded
coroutineScope.launch{scrollStateProvidedByTopParent.scrollTo(height*myListOfSpinnerOptions.indexOf[chosenText])}}))//<-This gets some arithmetic for scrolling distance
{
Text("Options")
Icon(imageVector = Icons.Filled.ArrowDropDown, contentDescription = "")
MyCustomDropdownMenu(
expanded = spinnerExpanded,
scrollStateProvidedByTopParent=scrollStateProvidedByTopParent,
onDismissRequest = { spinnerExpanded = false }) {
myListOfSpinnerOptions.forEach{option->
DropdownMenuItem(onClick={
chosenText=option
spinnerExpanded=false
}){
Text(option,onTextLayout={layoutResult->
if (!heightHasBeenChecked){
height=layoutResults.size.height
heightHasBeenChecked=true
}
}
)
}
}
}
}
}

Categories

Resources