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:
searchBarreplacestitle— the pill owns the bar region on both platforms (anytitleis 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.onChangedstreams per keystroke from the real platform field — native autocorrect, loupe, emoji included. A plainsetStaterefilters.suggestionsis 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, everysetStaterebuilds 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
Paddingwrapper’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
SearchAnchorre-draws a material search experience on every platform — one look, neither platform’s. Here iOS gets a realUISearchBarwith 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.