I stumbled upon this issue while trying to solve a case. To summarise, the patient needed complete control over the 'speed' of the scroll. Now, Android's sensible defaults have the item-scroll follow the user's finger at all times, but apparently an un-shared condition/requirement of this patient lead them to want the user scrolling further than the item actually scrolls. So, I suggested, as originally proposed by David in his solution, to disable the user's direct control over the lazy list, and manually scroll it by observing events from a touch detection callback, like the ones provided by the pointerInput(...) Modifier. Hence, the implementation:
#Composable
fun LazyStack(sdd: Int = 1) { // Scroll-Distance-Divisor
val lazyStackState = rememberLazyListState()
val lazyStackScope = rememberCoroutineScope()
LazyColumn(
modifier = Modifier.pointerInput(Unit) {
detectVerticalDragGestures { _, dragAmount ->
lazyStackScope.launch {
lazyStackState.scrollBy(-dragAmount / sdd)
}
}
},
state = lazyStackState,
userScrollEnabled = false
) {
items((0..500).map { LoremIpsum(randomInt(5)) }) {
Text(text = it.values.joinToString(" "))
}
}
}
Now, the treatment actually worked, except a .. tiny side-effect, apparently.
You see, the list actually scrolls perfectly as intended for the most part. For example, if I pass the sdd parameter as 2, the scroll distance is reduced to half its original value. But, the side-effect is "snapping".
Apparently while scrolling smoothly through the list, there are certain "points" on both sides of which, the list "snaps", WHILE THE USER HAS THIER INPUT POINTER IN USE (a finger, a mouse, or any other pointers, are actively "holding" the list). Now, this is a point, which, if you cross in either direction, the list snaps, and apparently by the exact same value every single time.
The list above uses Lorem Ipsum as a convention, but perhaps a numbered list would help the diagnose the problem.
So, a synopsis of the symptoms:
There seem to be certain "points" in the list, occurring at inconsistent intervals/positions throughout the list/screen which tend to make the list instantly scroll through a lot of pixels. Once discovered, the points are consistently present and can be used to re-produce the symptom unless recomposed. The value of the "snap" is apparently not returned to be a massive float when continuously logging the value returned by the scrollBy method. This makes the issue untraceable by regular diagnostic techniques, which is why we, the diagnosticians are here.
You have the history, you have the symptoms. Let the differential diagnosis begin.
The snapping behavior is caused by incorrectly used randomInt(5), within a composable. List generation should be inside the viewmodel then there is no regeneration of the list during scrolling and everything works perfectly.
Also using detectVerticalDragGestures does not work as smoothly as my original suggestion of using modfiier.scrollable(...) but maybe that's a desired effect.
Related
I've been messing around with Jetpack Compose and currently looking at different ways of creating/managing/updating State.
The full code I'm referencing is on my github
I have made a list a piece of state 3 different ways and noticed differences in behavior. When the first list button is pressed, it causes all 3 buttons to be recomposed. When either of the other 2 lists are clicked though they log that the list has changed size, update their UI but trigger no recompose of the buttons ?
To clarify my question, why is that when I press the button for the firsList I get the following log messages, along with size updates:
Drawing first DO list button
Drawing List button
Drawing second DO list button
Drawing List button
Drawing third DO list button
Drawing List button
But when I press the buttons for the other 2 lists I only get the size update log messages ?
Size of list is now: 2
Size of list is now: 2
var firstList by remember{mutableStateOf(listOf("a"))}
val secondList: SnapshotStateList<String> = remember{ mutableStateListOf("a") }
val thirdList: MutableList<String> = remember{mutableStateListOf("a")}
Row(...) {
println("Drawing first DO list button")
ListButton(list = firstList){
firstList = firstList.plus("b")
}
println("Drawing second DO list button")
ListButton(list = secondList){
secondList.add("b")
}
println("Drawing third DO list button")
ListButton(list = thirdList){
thirdList.add("b")
}
}
When I click the button, it adds to the list and displays a value. I log what is being re-composed to help see what is happening.
#Composable
fun ListButton(modifier: Modifier = Modifier,list: List<String>, add: () -> Unit) {
println("Drawing List button")
Button(...,
onClick = {
add()
println("Size of list is now: ${list.size}")
}) {
Column(...) {
Text(text = "List button !")
Text(text = AllAboutStateUtil.alphabet[list.size-1])
}
}
}
I'd appreciate if someone could point me at the right area to look so I can understand this. Thank you for taking the time.
I'm no expert (Well,), but this clearly related to the mutability of the lists in concern. You see, Kotlin treats mutable and immutable lists differently (the reason why ListOf<T> offers no add/delete methods), which means they fundamentally differ in their functionality.
In your first case, your are using the immutable listOf(), which once created, cannot be modified. So, the plus must technically be creating a new list under the hood.
Now, since you are declaring the immutable list in the scope of the parent Composable, when you call plus on it, a new list is created, triggering recompositions in the entire Composable. This is because, as mentioned earlier, you are reading the variable inside the parent Composable's scope, which makes Compose figure that the entire Composable needs to reflect changes in that list object. Hence, the recompositions.
On the other hand, the type of list you use in the other two approaches is a SnapshotStateList<T>, specifically designed for list operations in Compose. Now, when you call its add, or other methods that alter its contents, a new object is not created, but a recomposition signal is sent out (this is not literal, just a way for you to understand). The way internals of recomposition work, SnapshotStateList<T> is designed to only trigger recompositions when an actual content-altering operation takes place, AND when some Composable is reading it's content. Hence, the only place where it triggered a recomposition was the list button that was reading the list size, for logging purposes.
In short, first approach triggers complete recompositions since it uses an immutable list which is re-created upon modification and hence the entire Composable is notified that something it is reading has changed. On the other hand, the other two approaches use the "correct" type of lists, making them behave as expected, i.e., only the direct readers of their CONTENT are notified, and that too, when the content (elements of the list) actually changes.
Clear?
EDIT:
EXPLANATION/CORRECTION OF BELOW PROPOSED THEORIES:
You didn't mention MutableListDos in your code, but I'm guessing it is the direct parent of the code you provided. So, no, your theory is not entirely correct, as in the immutable list is not being read in the lambda (only), but the moment and the exact scope where you are declaring it, you send the message that this value is being read then and there. Hence, even if you removed the lambda (and modified it from somewhere else somehow), it will still trigger the recompositions. The Row still does have a Composable scope, i.e., it is well able to undergo independent recompositions, but the variable itself is being declared (and hence read) in the parent Composable, outside the scope of the Row, it causes a recomp on the entire parent, not just the Row Composable.
I hope we're clear now.
I was implementing the outlinedTextField in android using the new compose library. But strangely the input data was not updated in the text field.
So I searched and found a topic called Recomposition in android compose. I didn't get it completely.
However, I did find the solution:
#Composable
fun HelloContent(){
var name:String by remember {mutableStateOf("")}
OutlinedTextField(
value = name,
onValueChange = {name = it},
label = {Text("Name")}
)
}
and I also read on the concept of State in jetpack compose. But I wasn't able to get it completely.
Can someone explain it in simple words?
Basically, recomposition is just an event in Compose, in which the Composable in concern is re-executed. In declarative coding, which is what Compose is based on, we write UI as functions (or methods, more commonly). Now, a recomposition is basically an event in which the UI is re-emitted, by executing the body of the said Composable "function" all over again. This is what recomposition is, at its core. Now on to when it is triggered.
Ok, so in order to trigger recompositions, we need a special type of variable. This type is built into compose and was specifically designed to let it know when to recompose. And the mentioned type is MutableState. As the name suggests, it is State, that can Mutate, i.e., change; vary.
So, we have a variable of type MutableState, what's next? Guess what, you DON'T have a variable of type MutableState because I didn't teach you how to create one! The most common assignment you will use in Compose is the mutableStateOf helper. This is a pre-defined method that returns a value of type MutableState, well, MutableState<T>, actually. T is the type of State here, see below
var a = mutableStateOf(999)
Above, as you can see, 999 is an Int, and so, mutableStateOf here will return a MutableState<Int> type value. Easy enough.
Now, we have a MutableState<Int> value, but honestly, that's kinda ugly. Every time you need to get the value out of the MutableState<T>, you would need to refer to a property conveniently named .value.
So, to get the 999 out of the above var a, you would need to call a.value. Now, this is fine for use at one or two places but calling this every time seems like a mess. That is where Kotlin Property Delegation Come In (I did not need to Capitalize the last two words, I know). We use the by keyword to retrieve the value out of the state, and assign that to our variable - That's all you should care about.
So, var a by mutableStateOf(999) will actually return 999 of type Int, and not of type MutableState<Int>, but the brilliant part is that Compose will still know that the variable a is a State-Holder. So basically mutableStateOf can be thought of as a registering-counter, which you just need to pass through once, in order to get registered in the list of State-Holders. As of when, a recomposition will trigger every time the value of one of the state-holders is changed. This is the rough idea, but let's get technical; Now on to the "how" of recomposition.
To trigger a recomposition, all you need to ensure is two things:
The Composable should be reading a variable, that is also a state-holder
The state-holder should experience a change in its current value
Everything's better with Perry Examples:-
var a by mutableStateOf(999)
Case 1: A Composable receives a as a parameter value, MyComposable(a), then I run a = 0,
Outcome 1: Recomposition Triggered
Case II: This declaration of variable a is actually inside a Composable itself, then I run a = 12344
Outcome II: Recomposition Triggered
Case III: I repeat cases 1 & II, but with a different variable, as follows: var b = 999
Outcome III: No Recompositions Triggered; Reason: b is not a state-holder
Great, we got the basics down now. So, this is the last phase of this lecture.
REMEMBER!!
You see when I say during recomposition, the entire Composable is re-executed, I mean the entire Composable is re-executed, that is, every single line and every single assignment, without exceptions. You see anything wrong with this yet? Lemme demonstrate
Let's say I want to have a Text Composable that displays a number, and increases that number when I click on it.
I could implement something as simple as this
#Composable
fun CountingText(){
var n = mutableStateOf(0) //Starts at 0
Text(
value = n.toString(), //The Composable only accepts strings, while n is of Int type
modifier = Modifier
.clickable { n++ }
)
}
Ok so this is the implementation that we might think would work. If you are unfamiliar with Modifiers, just leave that for now and trust me that it just triggers the code inside the clickable braces, when you actually click on the Text. Now, let's picture how this will be executed.
Firstly Compose will register the variable n as a state-holder. Then it will render the Text Composable with the initial value 0 of n.
Now, the Text is actually clicked. The block inside clicakble will be executed, which in this case is just n++, which will update the value of n. Compose sees that the value of n is updated, and runs through the list of state-holders. Compose finds that n is indeed a state-holder, and then decides to trigger a recomposition. Now, the entire Composable reading the value of n will be recomposed. In this case, that Composable is CountingText since a Text inside it is reading the value of n (To display it). Hence, CountingText will be "re-executed". Let's walk through the re-execution here.
First line in the Composable,
var n = mutableStateOf(0)
n became 0.
Next lines:-
Text(
value = n.toString(), //Just displays 0
modifier = Modifier
.clickable { n++ } //Just tells it to increase n upon click
)
So you see, the catch here is that upon re-execution, n is completely created from scratch as if it never existed before. It was removed from the Composable's memory. To counter this, we need the Composable to remember n. That way, Compose knows that this is a state-holder AND holds a value that needs to be re-assigned to it upon recomposition. So, here's the updated first line (the rest is the same, just the initialization is updated)
var n by remember { mutableStateOf(0) }
Now, upon first execution, n will receive 0, since it is actually the very first time n is created. Thanks to remember, n now has access to the Composable's memory, and thus will be stored in the memory for future usage.
So, during recomposition, this is what happens - When the executor (???) reaches the line where n is assigned,
var n by remember { mutableStateOf(0) }
remember actually acts as a gatekeeper, and does not allow the executor to enter the block contained in it. Instead, it passes it the previously remembered value and asks it to move on. Since when the user clicked the Text, it already incremented the value of n to 1, this was retained in memory and so, now this works as expected.
This is the same case for your TextField problem. The field initially reads an empty value and the value is updated every time the user types a letter, triggering a recomposition and finally displaying the correct value on the screen.
Could it get simpler enough? Let me know I spent half an hour typing this.
Recomposition is used in Compose to recompose (reload the parts that changed) the screen. In your example you have user input which changes the state of the screen. You have to use var name:String by remember {mutableStateOf("")}, by the way, you can leave the :String out because you set the type here: mutableStateOf("") anyway, you need to use remember that the composable remembers the old content and can then add the new content.
If you type in h e l l o everytime you type one letter it recomposes, it remembers the value h, then he, then hel and so on.
Try this
I know there is not difference but leaving imports
Compose_version 1.0.4
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
#Composable
fun YourAnswer() {
var text by remember { mutableStateOf("") }
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text("Name") }
)
}
I have a variable whose value can change as frequently as the device frame-rate. Now, actually it is an angle, but perhaps can be thought of as a regularly incrementing value. What I wish, is to calculate how many 360 loops have been made. I have a Composable, which I have hooked up to this value. So the user is basically rotating an indicator, and I update the value based on the angle. Now, I already have a system put in place which performs a calculation on the indicator position and renders an angle ranging from 0 to 359 (well 360 actually, but non-inclusive). So, now if the user is spinning the indicator, I want to know when he actually hits the 0f Angle. I tried it with a conditional but this does not work if the user is going even at a reasonable speed. He must be incredibly slow in order to capture that value.
Here's a sample
val angle = ...//Already Got this Value
if(angle == 0f) { /*One cycle Complete*/ } // But this is not detected mostly
Now, remember that this is Compose, so we must take recompositions into account. I have tried with a list, by storing some of the latest values of the variable, but it just rotates too fast. All the values end up being the same, unless the size is crazy like 500 or so.
Can my approach be better? Any known way of accomplishing this without changing the approach. Basically any help would be appreciable.
This is what I ended up doing and it works at any given speed
var cycles by remember { mutableStateOf(0) } // Holds cycles state
val startCheck by mutableStateOf(angle in 300..360) //The Hot-Zone for Active Monitoring
LaunchedEffect(angle) { //This executes every time angle changes, i.e, at frame rate
if (startCheck) { //Only if indicator is in the hot zone
if (angle <300 ) cycles++ //Increment the Cycles
}
}
I'm making an API call getData(forPage: Int): Response which returns a page-worth of data (10 items max) and thereIsMoreData: Boolean.
The recyclerView is implemented that by scrolling, the scroll listener automatically fetches more data using that API call:
val scrollListener = object : MyScrollListener() {
override fun loadMoreItems() {
apiFunctionForLoading(currentPage + 1)
}
}
The problem is that with longer screen devices that have more space for items (let's say 20), the RV receives 10 items and then doesn't allow scrolling, because there's no more items to scroll to. Without scrolling, more data cannot be loaded.
My naive solution:
load first set of data
if thereIsMoreData == true I load another page of data
now I have more data than the screen can display at once hence allowing scroll
Is there a more ellegant solution?
Android has this Paging Library now which is about displaying chunks of data and fetching more when needed. I haven't used it and it looks like it might be a bit of work, but maybe it's worth a look?
Codepath has a tutorial on using it and I think their stuff is pretty good and easy to follow, so maybe check that out too. They also have this older tutorial that's closer to what you're doing (handling it yourself) so there's that too.
I guess in general, you'd want your adapter to return an "infinite" number for getItemCount() (like Integer.MAX_VALUE). And then in your onBindViewHolder(holder, position) method you'd either set the item at position, or if you don't have that item yet you load in the next page until you get it.
That way your initial page will always have the right amount of content, because it will be full of ViewHolders that have asked for data - if there's more than 10, then item 11 will have triggered the API call. But actually handling the callback and all the updating is the tricky part! If you have that working already then great, but it's what the Paging library was built to take care of for you (or at least make it easier!)
An elegant way would be to check whether the view can actually scroll down:
recyclerView.canScrollVertically(1)
1 means downwards -> returns true if it is possible tro scroll down.
So if it returns false, your page is not fully filled yet.
I have created the following wrap of UiScrollable for android testing:
protected fun UiScrollable.ensureScrolledIntoView(elementToScrollTo: UiObject) {
val elementPresent = scrollIntoView(elementToScrollTo)
if (!elementPresent) {
Assert.fail("Expected element ${elementToScrollTo.selector} not found in scroll view")
}
}
I believe the wrapper itself is not the problem, but sometimes the scrollIntoView fails to make the last needed scroll swipe. Best is to demonstrate with example:
Gives the error:
java.lang.AssertionError: Expected element
UiSelector[CLASS=android.widget.LinearLayout, DESCRIPTION=May,
CHILD=UiSelector[TEXT=7,
RESOURCE_ID=com.maypackage.android:id/calendar_day_text_view]] not
found in scroll view
And it was, obviously tasked to scroll to May 7th.
The same thing sometimes happens when I search for a date that will be found with up scroll: the method returns just one scroll short and the element is not found.
Has anyone encountered such problem? How to overcome it?
EDIT
After much more experimentation I found out the issue to be elements on the screen consuming certain scrolling attempts. This is most easily experienced in case of floating buttons, but the situation of the question shows more obscure example of the same. In most cases it turned out that setSwipeDeadZonePercentage on the UiScrollable did the trick for me. This methods tells the scrollable that there is certain area in which no swiping attempts should be made. This solved the issues in cases of overflow buttons. For the more obscure cases I use the method below, which is still not guaranteed to work all the time, but is stable in my cases.
OLD, original answer:
Eventually, after much trials I found out that the framework error is more generic and does not necessary trigger a stop one swipe before the element is in the view. Frankly, I believe the problem is with the unstable behavior of scrollForward method which is used internally in the scrollIntoView method. Eventually I re-wrote my method as follows:
protected fun UiScrollable.ensureScrolledIntoView(elementToScrollTo: UiObject) {
// this method used to use scrollIntoView, but it proved unstable
if (elementToScrollTo.exists()) return
while (scrollBackward()) {
// no body needed
}
// very complex construct, because scroll forward seems to return false on first call
var consecutiveFailingScrolls = 0
while (consecutiveFailingScrolls < 2) {
if (elementToScrollTo.exists()) return
if (!scrollForward()) {
consecutiveFailingScrolls++
} else {
consecutiveFailingScrolls = 0
}
}
Assert.fail("Expected element ${elementToScrollTo.selector} not found in scroll view")
}
This is basically trying to address the framework instability and, up to the moment it has not failed me. By the way, I suspect the instability of scrollIntoView manifests only when we are scrolling lazily loading components like recycler views. For the moment I intend to continue using the library methods for non-lazy loading lists and see if more errors are encountered.