Guide to make custom ViewAction: Solving problem of NestedScrollView in Espresso

How to deal with scrolling inside NestedScrollView in automation tests.

Kamil Krzyk
AzimoLabs

--

In this article you can find a solution - ViewAction created by us, which will allow you to scroll to views which are a children of NestedScrollView. This ViewAction will handle CoordinatorLayout with CollapsingToolbarLayout scroll problem. If you want only the result, code is there:

However if you are interested in how ViewActions work and would like to learn how to create one by yourself then go ahead and read this article.

Unpleasant surprise

We are sure that many of you who write automation tests with usage of Android Espresso came across Activity that uses NestedScrollView in it’s layout. And when you were writing your first test for that Activity, you probably didn’t expect problems you will be soon needed to face.

Imagine you create automation test for Activity which uses NestedScrollView. You need to scroll to one of views on the screen which is not currently visible. How can you do it?

Espresso provides you with only one way to do this. You have to use ViewAction scrollTo() which can be found in ViewActions.java file. Your view is inside ScrollView after all. It should work… but then you get error stack trace containing messages like those below:

android.support.test.espresso.PerformException: Error performing ‘scroll to’ on view ‘with id: com.your.app:id/tvTextView’.

or

Action will not be performed because the target view does not match one or more of the following constraints:(view has effective visibility=VISIBLE and is descendant of a: (is assignable from class: class android.widget.ScrollView or is assignable from class: class android.widget.HorizontalScrollView))

So what you can see is that Espresso allows you to scroll only to views inside ScrollView and HorizontalScrollView. NestedScrollView is neither of them and inherits from different classes.

Conclusion? You CANNOT use built in Espresso ViewActions to scroll to views inside NestedScrollView.

In search of a solution

  • Documentation of Espresso did not provide any example or explanation.
  • First seven top StackOverflow questions we had read, didn’t bring us much closer to what we needed. There were a lot of propositions but most of them were, workarounds or custom implementations of scrollTo() ViewAction which didn’t work when NestedScrollView was a child of CoordinatorLayout and some of them even modified behaviour of tested view. In those questions there were usually no accepted answers .
  • Issue on Google Issue Tracker was created over one year ago and was not responded or closed yet.

We were quite surprised how problematic it was to reach any solution for so common problem. That’s why we wanted to share ours.

No real solution found? Let’s create one!

Our Azimo application uses NestedScrollView very often. What’s more it is sometimes a child of CoordinatorLayout which brings even more complexity when it comes to Espresso scrolling.

Fortunately Espresso allows developers to extend it’s possibilities by creating custom ViewActions, ViewAssertions or ViewMatchers. We wanted to create code which could be REUSABLE in any project.

Let’s create our own nestedScrollTo() ViewAction.

Any ViewAction in Espresso (like scrollTo(), click() etc.) is a static method that returns a ViewAction object. So we have to create one ourselves.

ViewAction forces us to implement following methods:

getConstraints()

It is a ViewMatcher which contains conditions that our matched view (view to which we will want to scroll to) has to meet. In case of built into Espresso scrollTo() ViewAction it looks like that:

As we can see, view which is inside of NestedScrollView won’t pass those conditions so we have to modify it. We want to make sure view is visible so we will leave :

  • withEffectiveVisibility(Visibility.VISIBLE)

Method withEffectiveVisibility(…) checks if the view of interest and all of his parents has visibility set to value sent by parameter.

Next we need to change parent from which our view have to be assignable:

  • isAssignableFrom(ScrollView.class)
  • isAssignableFrom(HorizontalScrollView.class)

to:

  • isAssignableFrom(NestedScrollView.class)

The result will look like that:

getDescription()

There is no need for example. You simply need to return string that will be visible to test author in case of errors to explain what didn’t work.

perform(UiController uiController, View view)

The core of our ViewAction. We are provided with two parameters:

  • view - to which we will want to scroll to. You can measure it’s position or request it to appear on the screen
  • uiController - core class of the whole Espresso. It performs actions on views (scrolls, clicks etc.) and handles synchronisation between tasks sent from test thread to UI thread (by waiting until UI thread is idle before executing test code)

If you peek how it is done in Espresso scrollTo ViewAction, code responsible for scrolling looks like that:

We are requesting for our view of interest to be drawn on the screen which results with focus being changed to it. We won’t use this code as it won’t properly work with CoordinatorLayout with CollapsingToolbarLayout child.

In this layout we have 20 TextViews wrapped in NestedScrollView. That means all views are present in view hierarchy. If we match the view on last position then it’s reference will be returned in perform(UiController uiConctroller, View view). We can scroll to it by calling scrollTo(int x, int y) on it’s parent. We can receive position of our view by invoking getTop(). It returns relative position of view to it’s parent - it doesn’t matter view is outside of the the screen now. What is missing is reference to the parent itself. But we can retrieve it from the view by recurrently calling getParent() until we reach NestedScrollView. This code can look like this:

So you insert view as as starting point for search operation and end-point which is a class. It will iterate over view hierarchy until it reaches end-point class. We can’t nest ScrollView inside other ScrollView so we can be sure that NestedScrollView will have only one instance.

Ok! We have our puzzles ready, now we need to connect them and implement perform(UiController uiController, View view) method:

This is it. We find the NestedScrollView parent of our matched view, if it exists then we can perform scroll by calling scrollTo(int x, int y) on that parent. If parent was not found then we have to throw an exception witch will be consumed by catch block. It contains Espresso code for adding current view hierarchy to error stack trace for easier debugging. Notice we are calling uiController.loopMainThreadUntilIdle() for synchronisation purposes. It ensures that next Espresso code in test thread won’t run until our ui operations are not finished.

Easier case is done. Place it in your test and it works!

In this case we also have 20 TextViews in NestedScrollView. Problem with this layout is caused by CollapsingToolbarLayout (blue area on the screen which is extended Toolbar). Either scrollTo(int x, int y) of NestedScrollView or requestRectangleOnScreen(Rect rect) are not taking into calculation current size of CollapsingToolbarLayout. The result of your scroll to last TextView in our layout will look like this then:

The idea of placing NestedScrollView inside CoordinatorLayout is that both views are aware of each other. Scrolling feedback from NestedScrollView will affect CollapsingToolbarLayout in CoordinatorLayout. But we need to use different scrolling method of NestedScrollView which is dispatchNestedPreScroll (int dx, int dy, int[] consumed, int[] offsetInWindow) that will handle scrolling of CollapsingToolbarLayout for us, but again we need to know it’s size to be able to hide it. Consequently we need reference to CollapsingToolbarLayout but it’s not accessible from our matched view (view to which we want to scroll to). CollapsingToolbarLayout is not a parent of our matched view but it’s a child of CoordinatorLayout that is a parent of every view on the screen. What we can do?

  1. Match view to which we want to scroll.
  2. Get reference to CoordinatorLayout parent from matched view.
  3. From CoordinatorLayout parent get reference to CollapsingToolbarLayout and measure it.
  4. Get reference to NestedScrollView from matched view.
  5. Perform nested scroll to hide CollapsingToolbarLayout.
  6. Perform normal scroll to view of interest.

We can use implemented before findFirstParentLayoutOfClass(View view, Class<? extends View> parentClass) to get CoordinatorLayout reference. Now we need to perform hierarchy search on CoordinatorLayout to find CollapsingToolbarLayout. We need to implement it ourselves:

We recurrently check children of every view and return result only if child instance is CollapsingToolbarLayout. With this we can try to re-assemble our perform(UiController uiController, View view) method again and that’s the result:

Notice it’s only an extension to our previous solution. Perform method will check now if CoordinatorLayout is in view hierarchy, if not then simple scrollTo(int x, int y) will be used. Otherwise we need to make sure CollapsingToolbarLayout is inside of Coordinator Layout, if we find it then we can trigger nested scroll for dealing with scrolling of ToolBar area. And it works CollapsingToolbarLayout is fully collapsed and our scroll was a success:

That’s it. CoordinatorLayout with CollapsingToolbarLayout causes problems very often in UiAutomation tests. The hardest case might be probably with RecyclerView. Not all of your ViewHolders are present in view hierarchy so you cannot match and scroll to them easily the same way presented above. But that’s story for some other time.

We hope that this article helped you in some way.

Source code

Full code can be found in our sample project.

Towards financial services available to all

We’re working throughout the company to create faster, cheaper, and more available financial services all over the world, and here are some of the techniques that we’re utilizing. There’s still a long way ahead of us, and if you’d like to be part of that journey, check out our careers page.

--

--

Kamil Krzyk
AzimoLabs

📊 🤖 4 years of cumulative ML Engineer experience, programming background ( 💻📱Full Stack Engineer for 4+ years at #Android #iOS teams), 🎓#lifelonglearning