DEV Community

Cover image for The Find-in-Page Bug That Only Shows Up on Long Lists
Yusuf İhsan Görgel
Yusuf İhsan Görgel

Posted on

The Find-in-Page Bug That Only Shows Up on Long Lists

Flutter has no built-in Ctrl+F. If you want a find-in-page feature, the obvious approach is to wrap each piece of visible text in a widget that registers itself with a search controller when it gets built and unregisters when it gets disposed. That works fine on a short screen. It fails in a specific, predictable way once that text lives inside a ListView.builder, and manual testing during development will not catch it.

Here is why, and how the published package find_in_page handles it with a FindableListView widget.

Why ListView.builder is lazy in the first place

ListView.builder only builds the rows near the current viewport, plus a small cache extent on either side. That is the entire reason to reach for ListView.builder instead of ListView(children: [...]): a 10,000-row list should not hold 10,000 widgets and 10,000 render objects in memory at once. Rows outside the built range do not exist as widgets. There is no Text instance, no Element, no RenderBox for them until you scroll them into range.

That is a good design. It is also the thing that breaks a naive search implementation.

The naive approach

Say you have a FindableText widget (from find_in_page) that registers its string with a FindInPageController in didChangeDependencies and unregisters in dispose. You drop it into each row of a ListView.builder:

import 'package:find_in_page/find_in_page.dart';
import 'package:flutter/material.dart';

class NaiveSearchableList extends StatelessWidget {
  const NaiveSearchableList({super.key, required this.items});

  final List<String> items;

  @override
  Widget build(BuildContext context) {
    return FindInPageScope(
      child: ListView.builder(
        itemExtent: 40,
        itemCount: items.length,
        itemBuilder: (context, index) {
          return ListTile(
            title: FindableText(items[index]),
          );
        },
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This looks correct, and if you test it by typing a query for something visible on screen, it works. The problem is what "registered with the controller" actually means here: only rows that are currently built have a FindableText instance, so only rows that are currently built are registered. Everything else was never told to the controller at all, because it does not exist yet.

Scroll the list and the registered set changes continuously as rows build and dispose. Search for a term that only appears in row 480 of a 500-row list while you are sitting at the top, and the controller has never heard of row 480, because no widget for that row was ever built. The match is absent from the search domain rather than ranked low or filtered out of it.

I confirmed this concretely rather than reasoning about it from the widget lifecycle alone: 500 rows, 40px item extent, 400px viewport, a string reading "needle far below the fold" placed only in row 480. Before searching, find.text('needle far below the fold') returns nothing, matching the fact that row 480 has never been built at that scroll position. Run a search through this naive setup and controller.matchCount comes back as 0. The match is entirely invisible to the search, with no error, no warning, and nothing in the UI that says "some rows were not checked."

Diagram comparing two find-in-page approaches on the same 500-row list scrolled to the top. Top half, labeled

What FindableListView does differently

FindableListView skips the widget tree for search entirely. It requires itemCount, itemExtent, findableTextOf, and itemBuilder:

import 'package:find_in_page/find_in_page.dart';
import 'package:flutter/material.dart';

class FixedSearchableList extends StatelessWidget {
  const FixedSearchableList({super.key, required this.items});

  final List<String> items;

  @override
  Widget build(BuildContext context) {
    return FindInPageScope(
      child: FindableListView(
        itemCount: items.length,
        itemExtent: 40,
        findableTextOf: (index) => items[index],
        itemBuilder: (context, index, matches, activeMatchIndex) {
          return ListTile(
            title: Text(
              items[index],
              style: matches.isNotEmpty
                  ? const TextStyle(fontWeight: FontWeight.bold)
                  : null,
            ),
          );
        },
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

findableTextOf is a plain function that reads a string straight from your backing data by index. FindableListView calls it for every index up front, bounded by itemCount, not by whatever happens to be built at the time. Each result gets wrapped as a FindableRecord and registered with the controller. The search domain is the full dataset from the start, independent of scroll position.

The ListView.builder that FindableListView renders internally is only there for scrolling and layout. Its itemBuilder looks up whatever matches were already computed for that row and hands them to your itemBuilder, along with activeMatchIndex telling you which one (if any) is the currently active match. Deciding how to draw the highlight is your job, same as it would be with a plain ListView.builder. FindableListView only tells you where the matches are, not how to render them.

Rerun the same scenario through FindableListView: row 480 is still unbuilt before the search (find.text('needle far below the fold') still returns nothing), but after controller.search('needle'), controller.matchCount is 1 immediately. No scroll required. The package's own test suite (test/findable_list_view_test.dart) covers this directly, including a test that scrolls the list back and forth and asserts the match count never drifts, which is exactly the failure mode the naive version has.

The itemExtent tradeoff

FindableListView requires a fixed itemExtent because of how revealing an off-screen match works: it has to convert a match's index into a scroll offset, and there may be no live widget or RenderBox for that row to ask, since it might never have been built. Internally, FindableListView computes that offset like this:

final target = index * widget.itemExtent - position.viewportDimension * 0.3;
Enter fullscreen mode Exit fullscreen mode

index * itemExtent is only an exact scroll offset when every row is the same height. That is the same constraint ListView.builder(itemExtent: ...) already imposes on you if you use it for performance; FindableListView just inherits it for the same reason. Variable-height rows are not supported, which the package documents explicitly rather than leaving as a surprise.

There's a second consequence of this design worth naming: a FindableListView row has no BuildContext to hand to something like Scrollable.ensureVisible, because it may not be built. Internally each row is a FindableRecord, a kind of FindableSource whose findableContext getter returns null. The controller's reveal logic checks for that case: if a source was registered with its own reveal callback, as every FindableListView row is, it calls that callback to scroll the match into view instead of trying to walk a widget tree that does not have the row in it yet.

Search domain and render domain are not the same thing

The same failure shows up anywhere lazy building meets a feature that assumes every item is live: counting total results, jumping to a bookmark, an accessibility announcement that reports "item 480 of 500." Any feature that needs to reason about the full dataset will quietly fail on whatever fraction of that dataset is not currently built, unless it is written against the data directly instead of against whatever widgets happen to exist at a given scroll position.

find_in_page 0.3.0 ships FindableListView as a direct answer to this for the search case, with itemCount, itemExtent, and findableTextOf doing the work of decoupling what gets searched from what gets rendered. It is on pub.dev under an MIT license, and the source, including the test suite referenced above, is on GitHub at Yusufihsangorgel/find_in_page.

Top comments (0)