UiScrollable scrollIntoView stops just before scrolling into view - android

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.

Related

Unexplained Snapping Behaviour In A Programmatically Scrolled "Lazy List""

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.

Why is the Android ViewPager2 class final?

While using ViewPager2 in my current project, I wanted to use setOnTouchListener, but I was unable to get any events.
I decided to extend the ViewPager2 class to override the touch event methods, but was unable to as the class definition is final.
Why is this class final and is there any way to extend it so that I can override the touch events?
Well I think they f'd up the whole viewpager thing from the getgo. Most liky because all the fragment in fragment code was also messed up back than.
They even ask us to not use the normal viewpager from brand new androidx because it has errors. I think some errors that are reported are now older code trying to fix issues from old viewpager implementations. I think they now got the code stable enough to be useable and don't want us to implement all the now-unnessessary and maybe counter-productive fixes when transitioning to viewpager2.
I am quite mad at how lazy google handled the whole fragments and viewpager stuff in the past. It speaks volumes they never got it working in the main codebase and always suggested using the compat packages.
So in short. Now after years they got it working and they want you to drop all your fixes that are now not needed anymore..
Likely it is final, because it is not the idea to extend on it
... and that keyword is extremely effective in preventing that.
You can use onTouch of recyclerView which inside of viewPage2.
fun ViewPager2.getRecyclerView(): RecyclerView {
val recyclerViewField = ViewPager2::class.java.getDeclaredField("mRecyclerView")
recyclerViewField.isAccessible = true
return recyclerViewField.get(this) as RecyclerView
}
Usage
pager2.getRecyclerView().setOnTouchListener { view, motionEvent ->
false
}

Setting RecyclerViews itemAnimator to null does not remove animations

My outer RecyclerView crashes either with
IllegalArgumentException: Scrapped or attached views may not be recycled. isScrap:false isAttached:true...
or
IllegalStateException: The specified child already has a parent. You must call removeView() on the child's parent first.
Like the title suggests I have an RecyclerView in the list item layout of the first RecyclerView. This layout is used to display messages and the
inner RecyclerView to display attachments that come with the message. The inner RecyclerViews visibility is set to either GONE or VISIBLE depending whether the message has any attachments or not. The simplified outer list item layout looks like this
ConstraintLayout
TextView
TextView
TextView
RecyclerView
And the part of the adapter that handles the inner RecyclerView looks like this
private fun bindFiles(message: Message?) = with(itemView) {
if (message != null && message.attachments.isNotEmpty())
{
sent_message_attachments.setAsVisible()
sent_message_attachments.layoutManager = GridLayoutManager(this.context,Math.min(message.attachments.size,3))
sent_message_attachments.adapter = AttachmentAdapter(message.attachments)
sent_message_attachments.itemAnimator = null
sent_message_attachments.setHasFixedSize(true)
}
else{
sent_message_attachments.setAsGone()
sent_message_attachments.adapter = null
sent_message_attachments.layoutManager = null
}
}
The bug has something to do with the way I fetch the attachments in the inner adapter since once I disable the part that start the download process, everything is fine. There's no problem when loading images from the device, but once I start the download process, everything goes to hell. This is the part that handles images and kicks off the download process in the inner adapter. I have functions for videos and for other file types that are pretty much the same exact thing but use slightly different layout.
private fun bindImage(item: HFile?) = with(itemView) {
if (item != null)
{
if (item.isOnDevice && !item.path.isNullOrEmpty())
{
if (item.isGif)
{
attachment_image.displayGif(File(item.path))
}
else
{
attachment_image.displayImage(File(item.path))
}
}
else
{
//TODO: Add option to load images manually
FileHandler(item.id).downloadFileAsObservable(false)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ progress ->
//TODO: Show download process
},
{ error ->
error.printStackTrace()
//TODO: Enable manual retry
},
{ notifyItemChanged(adapterPosition)} //onComplete
)
}
}
}
I use the same structure as above in my DiscussionListAdapter to load discussion portraits (profile pictures etc.) and it does not have the same issue.
These are the extensions functions used to inflate the viewHolders and to display the images
fun ViewGroup.inflate(layoutRes: Int): View
{
return LayoutInflater.from(context).inflate(layoutRes, this, false)
}
fun ImageView.displayGif(file:File){
GlideApp.with(context).asGif().load(file).transforms(CenterCrop(), RoundedCorners(30)).into(this)
}
fun ImageView.displayImage(file:File){
GlideApp.with(context).load(file).transforms(CenterCrop(), RoundedCorners(30)).into(this)
}
I've been on this for the past couple of days and just can't get my head around it. Any help in any direction is greatly appreciated. I know my explanations can be a bit all over the place so just ask for clarification when needed :)
UPDATE
I have now been able to produce this with a GridLayout as well as with RecyclerView. It's safe to assume that the nested RecyclerViews were not the culprit here. I even tried to ditch the Rx-piece that handled loading the images and created an IntentService for the process, but the same crashes still occur.
With GridLayout I mean that instead of having another adapter to populate the nested RecyclerView I use only one adapter to populate the message and to inflate and populate views for the attachments as well and to attach those views to the nested GridLayout.
The crash happens when I start to download a file and then scroll the view, that is supposed to show the downloaded file, out of the screen. That view should get recycled but for some reason the download process (which in my test cases only takes around 100ms-400ms) causes the app to throw one of the two errors mentioned in the original question. It might be worth noting that I'm using Realm and the adapter takes in a RealmResults<Message> list as it's dataset. My presenter looks for changes in the list and then notifies the adapter when needed (changed due to the implementation of IntentService).
This is how I'm capable to reproduce this time and time again:
Open a discussion that has messages with attachments
Start to scroll upwards for more messages
Pass a message with an attachment and scroll it off screen while it's still loading
Crash
There is no crash if I stop and wait for the download to complete and everything works as intended. The image/video/file gets updated with a proper thumbnail and the app wont crash if I scroll that out of view.
UPDATE 2
I tried swapping the nested ViewGroup for a single ImageView just to see is the problem within the nestedness. Lo and behold! It still crashes. Now I'm really confused, since the DiscussionListAdapter I mentioned before has the same exact thing in it and that one works like a charm... My search continues. I hope someone, some day will benefit from my agony.
UPDATE 3
I started to log the parent of every ViewHolder in the onBindViewHolder() function. Like expected I got nulls after nulls after nulls, before the app crashed and spew this out.
04-26 21:54:50.718 27075-27075/com.hailer.hailer.dev D/MsgAdapter: Parent of ViewHolder: android.view.ViewOverlay$OverlayViewGroup{82a9fbc V.E...... .......D 0,0-1440,2168}
There's a method to my madness after all! But this just poses more questions. Why is ViewOverlay used here? As a part of RecyclerView or as a part of the dark magicians plans to deprive me of my sanity?
Sidenote
I went digging into RecyclerViews code to check if I could find a reason for the ViewOverlaymystery. I found out that RecyclerView calls the adapters onCreateViewHolder() function only twice. Both times providing itself as the parent argument for the function. So no luck there... What the hell can cause the item view to have the ViewOverlay as it's parent? The parent is an immutable value, so the only way for the ViewOverlay to be set as the parent, is for something to construct a new ViewHolder and supply the ViewOverlay as the parent object.
UPDATE 4
Sometimes I amaze myself with my own stupidity. The ViewOverlay is used because the items are being animated. I didn't even consider this to be an option since I've set the itemAnimator for the RecyclerView as null, but for some odd reason that does not work. The items are still being animated and that is causing this whole charade. So what could be the cause of this? (How I chose to ignore the moving items, I do not know, but the animations became very clear when I forced the app to download same picture over and over again and the whole list went haywire.)
My DiscussionInstanceFragment contains the RecyclerView in question and a nested ConstraintLayout that in turn contains an EditText for user input and a send button.
val v = inflater.inflate(R.layout.fragment_discussion_instance, container, false)
val lm = LinearLayoutManager(context)
lm.reverseLayout = true
v.disc_instance_messages_list.layoutManager = lm
v.disc_instance_messages_list.itemAnimator = null
v.disc_instance_messages_list.adapter = mPresenter.messageAdapter
This is the piece that handles the initialization of the RecyclerView. I'm most definitely setting the itemAnimator as null, but the animations just wont stop! I've tried setting the animateLayoutChanges xml attribute on the root ConstraintLayout and on the RecyclerView but neither of them worked. It's worth mentioning that I also checked whether the RecyclerView had an itemAnimator in different states of the program, and every time I check the animator, it is null. So what is animating my RecyclerView?!
I have faced the same issue
Try this in your child RecyclerView it works for me
RecyclerView childRC = itemView.findViewById(R.id.cmol_childRC);
layoutManager = new LinearLayoutManager(context);
childRC.setItemAnimator(null);
childRC.setLayoutManager(layoutManager);
childRC.setNestedScrollingEnabled(false);
childRC.setHasFixedSize(true);
now set your Adapter like this
ArrayList<Model> childArryList = new ArrayList<>();
childArryList.addAll(arrayList.get(position).getArrayList());
ChildOrderAdapter adapter = new ChildOrderAdapter(context, childArryList);
holder.childRC.swapAdapter(adapter, true);
hope this helps
I finally figured out what was causing this. In my DiscussionInstanceView I have a small view that is animated into and out of view with ConstraintLayout keyframe animations. This view only shows the download progress of the chat history and is used only once, when the discussion is first opened. BUT since I had a call to hiding that view every time my dataset got updated, I was forcing the ConstraintLayout to fire of an animation sequence thus making everything animate during the dataset update. I just added a simple check whether I was downloading the history or not and this problem got fixed.

Why does the Scroll event unnecessarily fire when a ListView is loaded in (Xamarin.)Android?

I am working on a Xamarin.Android app. I have a ListView that has the following code:
OnCreate
{
//other code here
listView.Scroll += ListView_Scroll;
}
private void ListView_Scroll(object sender, AbsListView.ScrollEventArgs e)
{
throw new NotImplementedException();
}
(I simply added the Scroll += ... and the Empty Project to the Custom Row Views project of Xamarin Android.
Immediately when the list is loaded, it throws the NotImplementedException. Even when Adapter is null, it still scrolls!
Why does it scroll when it does not need to? The ScrollState also changes to Fling.
Can someone explain why this happens? I am working on an app that uses this event and this is very annoying to work around.
I don't know if this also happens in Android but I assume it does, that's why I have tagged Android, as well.
ListView in Android doesn't inherit from Scrollview but is very similar to the same
The list-view module is using android.widget.ListView for its Android implementation while behind ScrollView lies android.widget.HorizontalScrolView (or vertical) so basically we have two different native controls with different implementations. Comparing to HorizontalScrollView, the ListView for Android presets much more functionality for listing data and also has specific optimisations so this is the logical choice to implement in nativeScript as well.
Secondly it throws that exception because you have the following line in your scroll event :
throw new NotImplementedException();
Removing this should solve your problem for the most part.
Why does it scroll when it does not need to?
What actually happens here is that the listview Scrolls To the current Position
Goodluck Happy Coding!

Testing ViewPager with multiple fragments using android espresso

I am trying to test my app which uses ViewPager. Each page contains fragments but these fragments are not always visible. I want to check visibility of a fragment in the currently visible page.
onView(withId(R.id.container_weather))
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)));
But the problem is that espresso looks are all the pages not just the current page and I get the following error:
android.support.test.espresso.AmbiguousViewMatcherException: 'with id: eu.airpatrol.android:id/container_weather' matches multiple views in the hierarchy...
I had the same problem, however using the condition isCompletelyDisplayed() solved this problem as it only takes into account the on-screen views.
So, something like this should work:
onView(allOf(withId(R.id.container_weather), isCompletelyDisplayed()))
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)));
Note: isDisplayed() works too in some cases but it also takes views off-screen into account and won't work if the ViewPager has any other page pr fragment loaded with the same view id.
Your tests are failing because of multiple elements with the same id. You can combine conditions using allOf(...). Then use isDisplayed() to check that matched view is visible on the screen. Below example can work:
onView(allOf(
withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE),
withId(R.id.container_weather)))
.check(matches(isDisplayed()));
Ran into this exact same problem. I was fortunate because the view hierarchies in my ViewPager can be easily identified by their siblings, so I was able to solve this using the hasSibling matcher, like so:
onView(
allOf(
hasSibling(withId(R.id.some_sibling)),
withId(R.id.field_to_test)
)
).perform(replaceText("123"));
Not a perfect solution as it can be slightly brittle, but in my case I think it was an acceptable compromise.
I had similar problem, where I was reusing the button layout and it was giving me a matches multiple views in the hierarchy exception.
So the easy work around I did was to create 2 different screens and have 2 different methods with different text.
Withdraw Screen:
public WithdrawScreen clickWithdraw() {
onView(allOf(withId(R.id.save_button), withText("Withdraw")))
.perform(click());
return this;
}
Deposit Screen:
public DepositScreen clickDeposit() {
onView(allOf(withId(R.id.save_button), withText("Deposit")))
.perform(click());
return this;
}
and in my tests, I create a new instance of both screens and call the above methods based on screen reference which is a bit easy to test for.
WithdrawScreen withdrawInstance = new WithdrawScreen();
withdrawInstance.clickWithdraw();
DepositScreen depositInstance = new DepositScreen();
depositInstance.clickDeposit();
The point was they were using same id - R.id.save_button for button and I was replacing text of button based on visibility of the fragment we are on.
Hope it helps.

Categories

Resources