Tutorial · 15 min · intermediate

Search done right

One AppBar.searchBar — Apple's in-place search choreography on iOS 26, the genuine Material 3 search app bar on Android, live Dart suggestions inside both.

What you'll build — running on device.

Search-in-the-bar is the sharpest demonstration of DartNative’s adaptive-first idea: you declare what search is (part of the bar), and each platform lowers it structurally — not a lowest common denominator, but both platforms at their best. On iOS 26 the pill is a real UISearchBar in the Liquid Glass bar running Apple’s in-place choreography (the App Store pattern: bar items fade out, the field stretches edge to edge, Cancel appears). On Android it’s the genuine Material 3 SearchBar + SearchView pair with the native expand morph. Same file.

The app you’ll build is a small inbox with a search pill for a bar — plus a toggle that flips the bar live between the two sanctioned arrangements: back + search (frame 1 on a pushed screen) and the Gmail-style leading + search + avatar frame.

What you need

  • A project from Your first DartNative app
  • Best experienced on an iOS 26 device and an Android device — the whole point is what one declaration becomes on each

Step 1 — The state: a query and its matches

Before any bar exists, decide what search does in your app: it turns a query string into a filtered list. That’s plain Dart — a field the native field will stream into, and a getter that derives the matches:

String _query = '';

List<(String, String)> get _matches {
  final q = _query.trim().toLowerCase();
  if (q.isEmpty) return _mail;
  return [
    for (final m in _mail)
      if (m.$1.toLowerCase().contains(q) || m.$2.toLowerCase().contains(q)) m,
  ];
}

_mail is a const list of (from, subject) records. Nothing here knows about platforms yet — the native surfaces will feed _query, and everything that renders _matches refilters for free.

Step 2 — Declare search as part of the bar

Here is the whole trick. You don’t place a search widget somewhere on screen — you tell the AppBar that search is its content:

appBar: AppBar(
  searchBar: SearchBar(
    hintText: 'Search in mail',
    onChanged: (q) => setState(() => _query = q),
    suggestions: _suggestions(),
  ),
  backgroundColor: kBarBg,
),

(One M3 copy rule worth keeping: the hint text always includes the word “Search”.)

Three things worth noticing:

  • searchBar replaces title — the pill owns the bar region on both platforms (any title is skipped while it’s set). This screen is pushed, so frame 1 keeps the auto back button and the pill lays out after it; the pure pill-only frame, edge to edge, belongs to root screens that have nothing to pop to.
  • onChanged streams per keystroke from the real platform field — native autocorrect, loupe, emoji included. A plain setState refilters.
  • suggestions is a live Dart subtree hosted inside the native search surface — the iOS overlay, the Android SearchView content. It’s not a snapshot: while the surface is open, every setState rebuilds it.

Declaring search at the bar level — rather than as a widget you position — is what gives the framework license to lower it structurally: into the navigation bar’s UISearchBar on iOS, into the M3 search app bar on Android.

Step 3 — The suggestions tree

The suggestions builder is ordinary widget code; the only rule is the opaque root:

Widget _suggestions() => Container(
      color: kHomeBg, // opaque root — the native surface brings its own background
      child: _suggestionsList(),
    );

Widget _suggestionsList() => ListView(
      padding: const EdgeInsets.symmetric(vertical: 8),
      children: [
        for (final (from, subject) in _matches)
          // …the same rounded mail rows the body list uses
      ],
    );

The native search surfaces (Android’s SearchView, the iOS suggestions overlay) supply their own platform background. Give your tree an opaque root and your theme rides on top of either — no per-platform branching.

Step 4 — Compose the bar around the pill

searchBar composes with the other bar slots. The demo keeps a _full flag and flips the bar live between the two arrangements — which also exercises the AppBar update path: mutating slots on a mounted bar, not rebuilding a screen.

bool _full = false; // false = search only; true = leading + search + avatar
leading: _full
    ? GestureDetector(
        onTap: () => _barTap('menu'),
        child: Platform.isAndroid
            ? Container(
                width: 40,
                height: 48,
                padding: const EdgeInsets.only(left: 16, top: 12),
                child: Icon(
                  MaterialSymbolsRounded.menu,
                  color: kTextPrimary,
                  size: 24,
                ),
              )
            : Icon(
                MaterialSymbolsRounded.menu,
                color: kTextPrimary,
                size: 24,
              ),
      )
    : null,
actions: _full ? [ /* a 32dp (Android) / 42pt (iOS) avatar circle */ ] : null,
searchBar: SearchBar(/* …as above */),
// The avatar is a self-designed circle — no framework capsule behind it.
actionsGlassBackground: false,

Why the platform branch? Bar slots are under native custody — the OS bar owns their placement, so the rules differ:

  • Android: give the glyph a real sized container whose own padding provides the M3 16dp inset (a bare Padding wrapper’s margins don’t survive slot custody), and let it hug the glyph so the slot gap becomes the icon-to-pill rhythm.
  • iOS 26: hand over the bare glyph — the bar wraps leading buttons in its own 44pt glass capsule, centered with the search field; any app-side inset would just sit off-center inside that capsule.

The avatar is ordinary app code (a colored Container with a letter), not a framework widget. On iOS 26 the bar would normally put a glass capsule behind actions too; actionsGlassBackground: false is the designed opt-out for actions that bring their own shape.

And when the search expands? Leading and actions vanish under the native surface and return on collapse — you write zero choreography.

Step 5 — Run it on both platforms

iOS 26: tap the pill — the bar’s other items fade, the field stretches over the whole bar with Cancel beside it, the keyboard rises on the system curve, and your suggestions overlay the content. Cancel restores the bar, animated. That’s Apple’s own in-place search pattern — the one from the App Store and Safari — with the real glass.

Android: tap the pill — the Material 3 expand morph plays (the actual com.google.android.material transition, not an imitation), the search view covers the screen, the same suggestions tree renders inside.

Then tap the frame toggle: the mounted bar recomposes in place — menu glyph and avatar in, back out — and the taps land in your Dart handlers (the body shows a “Bar tap verified” line naming the slot you hit).

You wrote one declaration.

Why this is native

Flutter’s SearchAnchor re-draws a material search experience on every platform — one look, neither platform’s. Here iOS gets a real UISearchBar with the OS’s in-place choreography (a pattern that literally cannot be faked over glass), and Android gets the real MDC pair with its morph, back handling and keyboard behavior. Declaring search as structure (AppBar.searchBar) rather than as a widget you place is what makes that lowering possible.

The finished code

In the public repo — dn create ., dn run. The screen (lib/screens/material3/m3_search_appbar_demo.dart) is a byte-identical copy of the playground’s search app bar demo, and lib/main.dart is only a thin entry that pushes it — improvements to the playground screen flow into this tutorial by copying the file again.

Open the finished code →