scrollTo method

Future<void> scrollTo(
  1. dynamic target, {
  2. dynamic within,
  3. double step = 200,
  4. int maxScrolls = 50,
  5. Shot? shot,
  6. Settle? settle,
})

Scrolls until target is on screen, then captures it there.

The scrollable is the first one on screen unless within names one — a widget that is a Scrollable or contains one. A target that is built but behind the viewport is jumped to directly, whichever direction and axis that is. step is how far each drag travels toward the end of the list when the target is not built yet; make it negative to walk back toward the start.

A target already on screen is the step's point achieved — so the verb is safe inside a loop over pages of varying length, where which pages scroll depends on the device. On a page that cannot scroll that is a true no-op; on one that can, the trailing alignment may still bring the target to the viewport's edge, and the scrolled screen is captured as ever. A call that drew nothing skips its automatic capture: the step would repeat the previous picture byte for byte, and the defensive calls such a loop makes were measured on a consumer suite as half its duplicate warnings. The skipped shot still consumes its position — see _capture — and an explicit shot still captures: the author asked for a picture. Unlike the other verbs this one may start with a target that matches nothing — being off screen is the reason to call it — so it reports the miss itself when the scrolling never finds it, and when nothing scrolls and the target is absent or off screen.

Implementation

Future<void> scrollTo(
  dynamic target, {
  dynamic within,
  double step = 200,
  int maxScrolls = 50,
  Shot? shot,
  Settle? settle,
}) => _step(
  shot,
  settle,
  () async {
    // Guarded, because the walk evaluates it on every step and an absent
    // row is what a walk starts from: `find.text('Row 40').first` over a
    // list that has not built it yet would otherwise throw on the first
    // look.
    var finder = emptyWhenAbsent(finderForTarget(target));
    var scrollable = within == null
        ? find.byType(Scrollable)
        : find.descendant(
            of: finderForTarget(within),
            matching: find.byType(Scrollable),
            matchRoot: true,
          );
    // Held, not re-evaluated: a `Finder` caches nothing between calls, so
    // every `evaluate()` walks the element tree from the root. This verb
    // needs the same two answers three times over, and a walking scenario
    // pays for them per step.
    var scrollables = scrollable.evaluate();
    if (scrollables.isEmpty) {
      var refusal = refusalWhenNothingScrolls(
        finder,
        describeTarget(target),
        within,
        _messages,
      );
      // A target already on screen is the step's whole point achieved:
      // capture it there, scroll nothing. Which pages scroll varies with
      // the device, so a walking scenario cannot know statically. It is
      // still marked, because "already here" is what the verb did.
      if (refusal == null) {
        _aimAt(finder);
        return;
      }
      throw ScenarioTargetError(refusal.message);
    }
    var onstage = finder.evaluate();
    // Built but behind the viewport: the walk only drags one way, so a
    // target the list has already scrolled past is unreachable however
    // long it walks. `Scrollable.ensureVisible` reads the target's own
    // position and jumps — both directions, both axes. The walk stays for
    // what it was built for: a lazy list whose target is not built yet.
    //
    // Looked up here rather than after the mark, because the mark wants the
    // same element and this is the expensive lookup of the two: it ignores
    // `skipOffstage`, so it visits the whole tree rather than the onstage
    // part of it.
    var behind = onstage.isEmpty
        ? scrolledPastTarget(finder, scrollable)
        : null;
    _aimAtScroll(
      scrollables.first,
      step,
      at: behind ?? (onstage.length == 1 ? onstage.single : null),
    );
    if (behind != null) {
      await Scrollable.ensureVisible(behind);
      await tester.pump();
      // Not revealed means it was never in this viewport's reach — an
      // `Offstage` under the list, say. The walk's own exhaustion
      // message below is the one that says what to try.
      if (finder.evaluate().isNotEmpty) return;
    }
    try {
      // A film walks it with a thumb. Same walk, same step, same stopping
      // condition — and the same tail, so the alignment it ends on and the
      // refusal it throws are the SDK's rather than a second version of
      // them. See [ScenarioFilm.scroll].
      if ((_film, _boundsOf(scrollables.first.renderObject)) case (
        var film?,
        var pane?,
      )) {
        await film.scroll(
          tester,
          pane: pane,
          by: switch ((scrollables.first.widget as Scrollable)
              .axisDirection) {
            AxisDirection.up => Offset(0, step),
            AxisDirection.down => Offset(0, -step),
            AxisDirection.left => Offset(step, 0),
            AxisDirection.right => Offset(-step, 0),
          },
          until: () => finder.evaluate().isNotEmpty,
          maxScrolls: maxScrolls,
        );
        // `dragUntilVisible`'s own last line, and its own way of failing: an
        // empty `single` is the `StateError` the catch below turns into the
        // exhaustion message.
        await Scrollable.ensureVisible(finder.evaluate().single);
        await tester.pump();
        return;
      }
      await tester.scrollUntilVisible(
        finder,
        step,
        // The first, as `flutter_test` itself defaults to: nested scrollables
        // are ordinary, and `within` is how a scenario says which one.
        scrollable: scrollable.first,
        maxScrolls: maxScrolls,
      );
    } on StateError {
      throw ScenarioTargetError(
        _messages.scrollExhausted(maxScrolls, step, describeTarget(target)),
      );
    }
  },
  verb: 'scrollTo',
  target: describeTarget(target),
  autoShotNeedsChange: true,
);