skip to content

A Detox test cannot tap a meal card far down a React Native FlatList — why, and how do scroll, scrollTo and whileElement reach it?

level: seniorimportance: should knowfreq 30%

answer

  1. not rendered vs not visible
  2. scroll the list's own testID
  3. scroll offset vs scrollTo edge
  4. waitFor().whileElement().scroll()
  5. fails at the edge if never found

basics

~20 s

A FlatList renders only rows near the viewport, so a distant card does not exist yet, and a nearer one exists but is off screen. Scroll the list element: waitFor(card).toBeVisible().whileElement(by.id('list')).scroll(200, 'down') scrolls until the card shows, then tap it.

solid answer

~30 s

Two things stop the tap: `FlatList` virtualization means a card far down has not been rendered, so no matcher can find it, and a rendered card below the fold exists but is not visible. The fix is to scroll the scrollable itself, so the `FlatList` needs its own `testID`. `scroll(offset, direction)` moves a known number of points, `scrollTo('bottom')` jumps to an edge, and `swipe` is a gesture for carousels or dismissals. For "somewhere down the list" I use `waitFor(element(by.id('dish.card.miso-salmon'))).toBeVisible().whileElement(by.id('plans.list')).scroll(200, 'down')`, which scrolls and re-checks until the card is visible and fails if it hits the edge first. Then I tap it.

code

tsx · 18 lines
tsx
import { FlatList, Pressable, Text } from 'react-native';

type Dish = { slug: string; name: string };

export function PlanList({ dishes, onPick }: { dishes: Dish[]; onPick: (slug: string) => void }) {
  return (
    <FlatList
      testID="plans.list"
      data={dishes}
      keyExtractor={(d) => d.slug}
      renderItem={({ item }) => (
        <Pressable testID={`dish.card.${item.slug}`} onPress={() => onPick(item.slug)}>
          <Text>{item.name}</Text>
        </Pressable>
      )}
    />
  );
}

go deeper

for a junior

Recall that off-screen list rows need scrolling first and that scroll is called on the list, which needs a testID.

for a middle

Explain scroll, scrollTo and swipe, and why virtualization means a distant row does not exist yet.

for a senior

Use waitFor().whileElement() with a safe step size, avoid index-based picks, and handle nested scrollables and sticky headers.

for a principal

Decide how much list-navigation logic belongs in shared test helpers so screen redesigns do not break many suites at once.

## The problem: the card is not where the test looks On the meal-kit app's plan picker, the dishes are a React Native `FlatList`. A test that goes straight to `element(by.id('dish.card.miso-salmon')).tap()` works for the first few cards and fails for one far down the list. Two different facts are at play: - **Virtualization.** `FlatList` renders only a window of rows around the viewport. A row far enough down has **not been rendered yet**, so it does not exist in the native hierarchy and no matcher can find it. - **Visibility.** A row that is rendered but below the fold **exists** but is **not visible**, so `toBeVisible()` fails and a tap on it is unreliable. Either way, the test must scroll first — the way a user would. ## The scrolling actions All of these are called on the **scrollable element itself**, so the `FlatList` or `ScrollView` needs its own `testID` (for example `plans.list`). On iOS, calling `scroll` on a non-scrolling view fails with an error that the view is not a scroll view. | Action | What it does | When to use it | |---|---|---| | `scroll(offset, direction)` | scrolls by `offset` points `up`, `down`, `left` or `right` | step through content by a known amount | | `scrollTo(edge)` | scrolls to `top`, `bottom`, `left` or `right` | jump to the end of the list (footer, "Load more") | | `swipe(direction, speed?, normalizedOffset?)` | performs a swipe gesture; `speed` defaults to `fast` | gesture-driven UI: carousels, swipe-to-dismiss, pull-to-refresh | | `scrollToIndex(index)` | Android only; scrolls a React Native scroll view to a child index | rarely portable; avoid in shared tests | Both `scroll` and `swipe` accept optional normalized start positions (0.0–1.0 within the element), useful when the default start point lands on a control that swallows the gesture. ## Scroll until it appears: `waitFor(...).whileElement(...)` The robust pattern for "somewhere down the list" combines polling with scrolling: ```js await waitFor(element(by.id('dish.card.miso-salmon'))) .toBeVisible() .whileElement(by.id('plans.list')) .scroll(200, 'down'); await element(by.id('dish.card.miso-salmon')).tap(); ``` Detox scrolls `plans.list` by 200 points, re-checks the expectation, and repeats. If it reaches the edge of the list without the expectation passing, the step fails. Choose an offset smaller than the viewport height so a row cannot be skipped between checks. ## Why not the alternatives 1. **`scrollTo('bottom')` then search** — fine for the last row, but it overshoots anything in the middle and may trigger `onEndReached` pagination the test did not intend. 2. **Tapping by `atIndex`** — indexes count matched elements, which change as rows render and differ between iOS and Android. 3. **A fixed number of `scroll` calls** — breaks when the dish list or screen size changes. 4. **`swipe` for list navigation** — works, but a fast swipe flings the list an unpredictable distance; `scroll` moves a known offset. ## Getting `swipe` right when it is the right tool - **Speed** — `fast` (the default) or `slow`. Pull-to-refresh tests typically use a downward swipe on the list while it is at the top, and `slow` gives a steadier pull than a fling. - **`normalizedOffset`** — how far to swipe relative to the screen, 0.0–1.0; leave it as `NaN` to let Detox choose. - **Start point** — `normalizedStartingPointX` and `normalizedStartingPointY` relative to the element, for when the default start lands on a nested control. ```js await element(by.id('plans.carousel')).swipe('left', 'fast', 0.75); await element(by.id('plans.list')).swipe('down', 'slow'); // pull to refresh from the top ``` ## Platform and layout notes - **Screen size matters.** The same list shows more rows on a tablet, which is another reason to scroll until visible rather than a fixed count. - **Nested scrollables.** A horizontal carousel inside a vertical list needs its own `testID`; scroll or swipe the carousel element, not the page. - **Headers covering rows.** A sticky header can cover the row's activation point on iOS after scrolling; scroll a little further, or assert `toBeVisible()` before tapping so the failure message is clear. A candidate who knows why the row was missing (virtualization vs visibility), which element receives the scroll, and the `whileElement` pattern has answered what interviewers ask about Detox and long lists.

  • Why must the scroll action target the list rather than the card?
    Scrolling is an action on a scrollable view, and the card is not one; on iOS Detox fails with an error that the view is not a scroll view. Give the `FlatList` or `ScrollView` a `testID` and call `scroll`, `scrollTo` or `whileElement` on that element.
  • When is swipe a better choice than scroll?
    When the UI responds to a gesture rather than to scroll position — paging a carousel, swipe-to-delete on a row, pull-to-refresh at the top. `swipe` takes a direction, a speed that defaults to `fast` and an optional normalized offset, but its travel distance is less predictable than `scroll`'s fixed point offset.

saying these in an interview costs you the question

  • Detox scrolls to an element automatically before tapping it.
  • Every FlatList row exists in the hierarchy from the first render.
  • Calling scroll on the card element scrolls its parent list.
  • atIndex is a portable way to pick the Nth dish card.
  • whileElement keeps scrolling past the edge until timeout.