Why does Appium's UiScrollable scrollIntoView find an Android row that findElement cannot?
answer
- one query versus a device-side loop
- recycled rows are not merely hidden
- the selector string runs on the device
- UiScrollable swipes and rescans
basics
~20 sUiScrollable runs on the device: the UiAutomator2 server swipes the Android list and rescans after each swipe. A plain findElement is one query against the hierarchy as it exists now, and a recycled off-screen row is not in it.
solid answer
~40 sA plain find is a **single query** against the accessibility hierarchy the device can see at that instant, and nothing in that path scrolls. Android list containers recycle their children, so a row that has not been reached yet was never inflated; it is not hidden, it is absent, and no query can match it. The `-android uiautomator` strategy does something different: it sends a `UiSelector` **expression as a string**, and the UiAutomator2 server on the device parses and runs it. `UiScrollable` matches a scrollable container, then loops - swipe, rescan, swipe, rescan - until a node matches or its search-swipe budget is spent, and finally aligns the match into view. The scroll therefore lives *inside* the find, which is why one call succeeds where the other cannot.
go deeper
Recall that a find in an Android session looks at the hierarchy as it is right now and never scrolls by itself, and that the -android uiautomator strategy is how you ask the device to scroll while it looks.
Explain the mechanism: a UiSelector expression is a string parsed and run by the UiAutomator2 server, and UiScrollable loops swipe-and-rescan on the device. Mention that recycled rows are absent from the hierarchy, not hidden in it.
Show you have felt the cost: a miss burns the whole swipe budget in one request with no progress visible to the client, and a long scan can trip a transport timeout instead of returning a clean not-found.
Weigh whether encoding scroll logic inside selector strings is a pattern you want spreading, given that it mixes locating and acting into one artefact only one platform's driver can even parse.
## What a plain find actually asks When a test calls a normal find in an Appium Android session, the driver asks the UiAutomator2 server on the device for nodes matching a criterion, evaluated against the **accessibility hierarchy as it exists at that moment**. If nothing matches, the answer is a no-such-element error. There is no scrolling anywhere in that path, and the driver does not add one for you. This catches people out because a browser behaves differently: a web page's whole document exists whether or not it is scrolled into the viewport, so a query can match something the user cannot see. An Android list is not like that. ## Why the recycled row is the whole story List containers such as `RecyclerView` and `ListView` reuse a small pool of child views. In a pet-grooming appointment app showing a hundred bookings: - Rows above the viewport have had their views **recycled** and rebound to newer rows. - Rows far below the viewport have **never been inflated** at all. - Only the visible band, plus a small buffer, exists as real nodes. So the row for `Bella - Full Groom` is not an invisible node that some setting could reveal. It is not a node. That is the difference between an element that is present but not displayed and an element that does not exist yet, and it is the reason people reach for a scroll command in the first place. ## What the -android uiautomator string actually sends The `-android uiautomator` strategy does not carry a predicate for the driver to evaluate. Its value is a **program**: a `UiSelector` expression written in UiAutomator's own syntax, sent as a string, parsed by the UiAutomator2 server and executed on the device. ```python # one query against the hierarchy that exists right now driver.find_element(AppiumBy.ID, 'com.example.grooming:id/appointment_row') # a program the on-device server runs, swiping as it searches driver.find_element( AppiumBy.ANDROID_UIAUTOMATOR, 'new UiScrollable(new UiSelector().resourceId("com.example.grooming:id/schedule"))' '.scrollIntoView(new UiSelector().text("Bella - Full Groom"))') ``` `UiScrollable` first resolves the scrollable container from its own selector. `scrollIntoView` then runs a loop on the device: swipe the container, rescan for the inner selector, repeat until a match appears or the search-swipe budget runs out. When it matches, it also nudges the container so the row sits inside the visible area, which is what makes the result immediately clickable. ## What the one-round-trip design costs you Because the loop runs on the device, the client sees a single request that takes as long as the whole scan: - No intermediate progress, and no per-swipe line from the client's side. - The wall-clock cost is roughly the search budget multiplied by one swipe, so a miss is expensive. - A long scan can outlive the HTTP client's own read timeout, which surfaces as a transport error rather than a clean not-found. - The budget is tunable through `UiScrollable`'s own API, for example `setMaxSearchSwipes`, expressed inside the same selector string. ## Where it stops working The mechanism has real edges, and knowing them is what separates using it from understanding it: 1. The container must actually report as scrollable. If the outer selector matches nothing scrollable, the whole expression fails before any swiping happens. 2. Nested or sibling scrollable areas need the outer `UiSelector` narrowed, for example by resource id, or the loop may scroll the wrong list. 3. Horizontal lists have to be told so, through `UiScrollable`'s horizontal-list mode, or the swipes go the wrong way. 4. Only what `UiSelector` can express - text, description, resource id, class, index, instance - can be a scroll target. A row you normally locate by XPath must be re-expressed for this call. ## The takeaway to say out loud The difference is not that one command is smarter. It is that a plain find asks a question about the present, while the `-android uiautomator` expression ships a small program that keeps changing the present until the answer exists. The same insight explains the UiAutomator2 driver's `mobile: scroll` execute method, which searches while it scrolls in the same way with the container and the target passed as separate arguments. It also explains why iOS cannot copy the trick: XCUITest's scroll-to-element command is handed an element rather than a search.
- What happens on Android if the outer `UiSelector` in a `UiScrollable` expression matches nothing scrollable?The expression fails before any swiping happens, and it surfaces as a no-such-element error from the find. It looks identical to a missing row, which is why narrowing the container selector - by resource id, for instance - is worth doing even when a screen appears to have only one list.
- Why can a target you normally locate by XPath be unusable as a `scrollIntoView` target on Android?The inner argument is a `UiSelector`, not an XPath expression, so only what `UiSelector` can express - text, content description, resource id, class name, index, instance - is available. A structural XPath path has to be re-expressed as a `UiSelector` criterion, or the scroll has to be driven another way.
saying these in an interview costs you the question
- Thinks findElement scrolls the list on its own
- Believes the off-screen row is merely hidden, not absent
- Says the UiSelector string is evaluated by the Appium server
- Expects any XPath target to work as a scrollIntoView target
- Assumes the container is scrollable without checking