Tutorial · 25 min · beginner
Build the chat screen
The screen every cross-platform team gives up on — a reverse message list on a real UITableView, a growing input bar, and a keyboard that moves like the OS.
What you'll build — running on device.
Chat is the honest benchmark for a UI framework. It needs a list anchored to the bottom, an input bar that grows as you type, and — the part that breaks everyone — a keyboard that moves the UI with the system’s own animation curve. Flutter reacts to the keyboard a frame or two late; React Native round-trips through JavaScript. DartNative’s input bar is committed in the same compositor transaction as the keyboard slide, so what you’ll build here is indistinguishable from a hand-written UIKit app.
You’ll build it in six steps. Everything is the Flutter widget API — the only DartNative-specific line in the finished app is the import.
What you need
- The
dnCLI (get it on dartpub.dev — building and testing is free) - An iPhone, Android phone, or simulator
- Ten minutes of Flutter familiarity (widgets,
setState)
Create the project:
dn create chat_screen
cd chat_screen
dn run
Step 1 — The message model
A chat message is text plus a side. Keep the newest message first:
index 0 will render at the bottom of a reverse list, so “receiving” or
sending a message is just an insert at 0 — no scroll math, ever. Each
message also gets an auto-assigned id, because later we’ll scroll to a
specific message, and indexes shift every time something is inserted.
class _Message {
_Message({required this.text, required this.isMe}) : id = _nextId++;
static int _nextId = 0;
final int id;
final String text;
final bool isMe;
}
final _kSeedMessages = [
// Newest message first — index 0 = newest (matched by ListView(reverse: true))
_Message(text: 'This conversation is long enough to scroll 😄', isMe: false),
_Message(text: 'Tap scrollTo label to scroll to this message.', isMe: true),
// …fifteen more in the finished file — enough to actually scroll.
];
// Target message number 2 (second-to-last in newest-first ordering).
final int _kScrollToMessageId = _kSeedMessages[_kSeedMessages.length - 2].id;
The colors come from the playground’s shared UI kit
(lib/screens/home/demo_ui.dart, shipped next to the screen): getters like
kHomeBg, kBarBg, kTileBg, kRowBg, kTextPrimary, kTextSecondary
read the active palette — dark by default — so every surface below names a
role, not a hex value.
Step 2 — The scaffold
Three properties do the heavy lifting; the rest is a normal AppBar:
return Scaffold(
// The system handles the keyboard: the body resizes with the exact
// keyboard curve, committed in the same compositor transaction as the
// keyboard slide — this is the line Flutter apps fight and lose.
resizeToAvoidBottomInset: true,
backgroundColor: kHomeBg,
// iOS 26: run the body under the Liquid Glass bars.
extendBody: isIOS26 ? true : false,
appBar: AppBar(
// Avatar + name + presence: a plain Row —
// Image.asset('assets/avatar.jpg') in a ClipRRect, 'Lisa' with a
// verified badge, and a green 'Online' line. See the finished file.
title: Row(/* … */),
// Solid bar on every generation (a translucent color — alpha < 1 —
// opts iOS 26 into the Liquid Glass frost instead).
backgroundColor: kBarBg,
actions: [
// A real native bar button (UIBarButtonItem on iOS).
BarButtonItem(
title: 'ScrollTo #2',
titleStyle: TextStyle(
color: kTextPrimary, fontSize: 15, fontWeight: FontWeight.w500),
onPressed: () {
dnLog('[ChatDemo] ScrollTo action tapped');
_jumpToScrollToMessage();
},
),
],
),
body: SafeArea(
top: false, // AppBar handles the top safe area
bottom: true,
child: Stack(
children: [
Positioned.fill(child: _buildList()),
// …an iOS-26 fade-out gradient + the scroll-to-bottom FAB — step 6.
],
),
),
// A new field in Scaffold for the bottom input bar. It automatically handles
// keyboard lift with native animation and interactive keyboard dismissal.
bottomInputBar: _BottomBar(controller: _controller, onSend: _onSend),
);
Two concepts are new here. BarButtonItem is an app-bar action that lowers
to the platform’s real bar button — a UIBarButtonItem on iOS, not a
tappable rectangle drawn to look like one. And bottomInputBar is a
Scaffold slot for exactly this screen: whatever you put there stays glued
to the keyboard. resizeToAvoidBottomInset is where other frameworks fight
physics — DartNative doesn’t intercept the keyboard, it joins it: the
notification’s own curve, one Core Animation transaction, zero per-frame
Dart work. (extendBody + the isIOS26 getter are the screen’s iOS 26
awareness: on Liquid Glass devices the message list runs under the
translucent bars; everywhere else the flag is a no-op.)
Step 3 — The list is a real UITableView
The messages render in a FastList — DartNative’s high-performance list,
backed by UITableView on iOS and RecyclerView on Android, with O(1)
native cell recycling. The screen actually keeps a ListMode enum with
five ways to render the same chat — listView, listViewBuilder,
customScrollViewSliverList, customScrollViewMixed, and fastList — and
a _buildList() switch dispatches on a single const flag, so you can flip
implementations and re-run:
const ListMode _activeListMode = ListMode.fastList;
Every mode is the same native UIScrollView scroll; fastList adds cell
recycling and direct native row APIs. Here’s that branch:
Widget _buildFastList() {
// iOS 26 only: extendBody runs the list behind the glass bar, so anchor the
// newest message ~10px above it — reserve the bar's height + gap, plus the
// bottom safe-area inset the bar sits above. iOS 18 / Android use a solid
// bar above the body, so a plain 8px suffices.
final double bottomPad =
isIOS26 ? 66 + MediaQuery.paddingOf(context).bottom : 8;
return FastList(
controller: _fastListController,
reverse: true,
itemCount: _messages.length,
// FastList has no ScrollController; drive the FAB from onScroll instead.
onScroll: (offset, maxExtent, viewport, dragging) =>
_updateFab(offset, maxExtent),
itemBuilder: (_, i) => _ChatBubble(
message: _messages[i],
messageNumber: _messages.length - i,
isHighlighted: _messages[i].id == _highlightedMessageId,
),
padding: EdgeInsets.only(
left: 16, right: 16, top: _listTopPad, bottom: bottomPad),
);
}
reverse: true anchors the list to the BOTTOM — the chat convention: index
0 renders at the anchor, scrolling up reads history. _listTopPad is the
same iOS 26 story as the padding above: on glass devices the oldest
messages need the bar’s height reserved so they can scroll out from under
the frost.
Run it, fling the list, and pull past the end. That momentum and rubber-band
aren’t imitated — this is the platform’s scroll view. It matters more
than it sounds: your users have scrolled the real thing for a decade, and
when a re-implementation decelerates a beat early or overshoots the
rubber-band, they can’t name what’s wrong — the app just feels alien.
These are nuances you can’t abstract and re-create; here they’re simply the
platform’s own. The two extras in the builder — messageNumber prefixes
each bubble so you can see jumps land, isHighlighted flashes the jump
target — pay off in step 6.
Step 4 — Bubbles
The classic tail-less bubble: the corner nearest the sender stays tight.
final Color bubbleColor;
if (isHighlighted) {
bubbleColor = const Color(0xFF17A1FF); // highlight blue
} else {
bubbleColor = message.isMe ? const Color(0xFF007AFF) : kTileBg;
}
return Align(
alignment: message.isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 3),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
constraints: BoxConstraints(
minWidth: 120,
maxWidth: MediaQuery.of(context).size.width * 0.72,
),
decoration: BoxDecoration(
color: bubbleColor,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(18),
topRight: const Radius.circular(18),
bottomLeft: Radius.circular(message.isMe ? 18 : 4),
bottomRight: Radius.circular(message.isMe ? 4 : 18),
),
),
// Text → UILabel. CoreText rendering, Dynamic Type, correct baseline.
child: Text(
messageNumber != null
? '$messageNumber. ${message.text}'
: message.text,
style: TextStyle(
// White on the blue bubbles (sent/highlight); themed on incoming.
color: message.isMe || isHighlighted
? const Color(0xFFFFFFFF)
: kTextPrimary,
fontSize: 17,
height: 1.15,
),
),
),
);
My messages hug the right edge, theirs the left; the number prefix makes the scroll-to jump verifiable at a glance.
The rounded rectangle is a CALayer corner radius — a layer property, not a
draw pass — and the text inside is real platform text, CoreText on iOS, not
glyphs drawn on a canvas.
Step 5 — The input bar
A multiline TextField in a capsule, next to a send button. Because the
field is a real UITextField, everything users unconsciously test is just
there: long-press for the native selection handles and the magnifier loupe,
autocorrect and spell-check underlines, emoji rendered at their correct size
(a thing Flutter text famously gets wrong), crisp platform text at any size.
Canvas-drawn fields re-implement a few of these and visibly miss the rest —
and text input is where users stare the longest.
// TextField → UITextField. Correct keyboard animation, loupe, native return.
final Widget textField = Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: 'Message…',
hintStyle: TextStyle(color: kTextSecondary),
border: InputBorder.none,
contentPadding: EdgeInsets.only(top: 8, bottom: 10),
),
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
minLines: 1,
maxLines: 6,
style: TextStyle(color: kTextPrimary, fontSize: 17),
),
);
minLines: 1, maxLines: 6 makes the field grow as you type. The capsule
around it is where the file earns its iOS 26 polish: isIOS26 is a
framework getter that’s true on devices with Apple’s Liquid Glass material,
and GlassEffectContainer wraps any child in the real glass — pre-26
devices and Android take the solid branch of the same expression:
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isIOS26 ? Colors.transparent : kRowBg,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Input capsule — iOS 26: Liquid Glass; else solid grey.
Expanded(
child: isIOS26
? GlassEffectContainer(
borderRadius: BorderRadius.circular(18),
// Follow the playground theme (also sets the wrapped
// textfield's keyboard appearance).
brightness: playgroundPalette.brightness,
child: textField,
)
: Container(
decoration: BoxDecoration(
color: kTileBg,
borderRadius: BorderRadius.circular(18),
),
child: textField,
),
),
const SizedBox(width: 10),
// Send button — iOS 26: blue-tinted interactive glass; else solid blue.
GestureDetector(
onTap: onSend,
behavior: HitTestBehavior.opaque,
// 40pt matches the single-line field capsule (17pt text +
// 8/10 content padding), so the Row's CrossAxisAlignment.end
// aligns them flush — and the button stays bottom-anchored when
// the field grows multiline.
child: isIOS26
? GlassEffectContainer(
borderRadius: BorderRadius.circular(20),
tint: const Color(0xFF007AFF),
interactive: true,
brightness: Brightness.dark,
child: const SizedBox(
width: 40,
height: 40,
child: Center(child: sendGlyph),
),
)
: Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: Color(0xFF007AFF),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: sendGlyph,
),
),
],
),
);
(sendGlyph is a const white CupertinoIcons.arrow_up icon, declared
just above; the input capsule’s glass follows playgroundPalette.brightness
— the UI kit’s active theme — which also sets the wrapped field’s keyboard
appearance.)
Now tap the field. The keyboard rises with the input bar attached — no gap, no stutter, no lag. Drag the list down and the keyboard follows your finger out. That’s the part no amount of Flutter widget optimization can reproduce, because it isn’t a widget trick: it’s the system animating its own views.
Step 6 — Send, snap, and jump
Sending is the reverse-list payoff — one insert, and one post-frame snap so the new message is revealed even if the reader had scrolled up into history:
void _onSend() {
final text = _controller.text.trim();
if (text.isEmpty) return;
setState(() {
_messages.insert(0, _Message(text: text, isMe: true));
_controller.clear();
});
// Reveal the sent message even if the user had scrolled up — POST-frame:
// FastList's scrollToItem is an immediate native call, so pre-insert it
// targets the OLD list (and its programmatic-jump semantics mark
// userScrolled, muting the reverse auto-anchor for the insert). After the
// frame, item 0 IS the new message. The ListController/ScrollController
// modes are equally correct post-frame (re-arm + snap / new maxExtent).
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom());
}
Scroll control goes through FastListController — a handle whose
scrollToItem calls the native row-scrolling APIs (NSIndexPath on iOS)
directly, no offset estimation. alignment says where the row should land
in the viewport: 0.0 the top, 1.0 the bottom. (The other ListModes use
the equivalent ListController.animateToItem — same idea over
UIScrollView.setContentOffset.) The onScroll feed from step 3 drives a
scroll-to-bottom FAB, and the app bar’s ScrollTo #2 button jumps to a
specific message and flashes it — shown here with the finished file’s
dnLog diagnostics trimmed:
/// Jump to message #2 (second oldest) and highlight it for 1 second.
void _jumpToScrollToMessage() {
final targetIndex = _messages.indexWhere(
(m) => m.id == _kScrollToMessageId,
);
if (targetIndex < 0) {
// (the demo logs the miss here)
return;
}
if (_activeListMode == ListMode.fastList) {
_fastListController.scrollToItem(targetIndex, alignment: 0.0);
} else {
_listController.animateToItem(targetIndex, alignment: 0.0);
}
_highlightTimer?.cancel();
setState(() => _highlightedMessageId = _messages[targetIndex].id);
_highlightTimer = Timer(const Duration(milliseconds: 700), () {
if (mounted) setState(() => _highlightedMessageId = null);
});
}
/// Shared FAB toggle. Reverse lists put the newest message at [maxExtent]
/// (bottom) on BOTH families, so the same "how far up from newest" test works
/// for ScrollView lists and FastList alike.
void _updateFab(double offset, double maxExtent) {
final show = (maxExtent - offset) > _kFabZone;
if (_showFab.value != show) _showFab.value = show;
}
/// FAB tap → snap to the newest message (bottom of a reverse list).
void _scrollToBottom() {
if (_activeListMode == ListMode.fastList) {
// Dart index 0 = newest = bottom row in a reverse FastList.
// alignment 1.0 → UITableView .bottom, which respects the list's
// bottom contentInset (the glass-bar clearance) — the default 0.0
// maps to .top, which UIKit clamps to max offset and leaves the row
// flush with the RAW viewport bottom, i.e. hidden behind the bar.
_fastListController.scrollToItem(0, alignment: 1.0);
} else if (_activeListMode == ListMode.customScrollViewSliverList ||
_activeListMode == ListMode.customScrollViewMixed) {
// CustomScrollView has no native scrollToBottom wiring (only ListView
// does); animate the ScrollController to the bottom instead.
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
);
}
} else {
// listView / listViewBuilder — resets userScrolled + native bottom snap,
// re-enabling the reverse-list auto-anchor.
_listController.scrollToBottom();
}
}
The FAB itself is the placeholder from step 2’s Stack — a
ValueListenableBuilder so only the button rebuilds, never the list:
Positioned(
// right 12 = the send button's inset (the input bar's 12pt
// horizontal padding), so FAB and send button align vertically.
right: 12,
bottom: isIOS26 ? 61 + MediaQuery.paddingOf(context).bottom : 16,
child: ValueListenableBuilder<bool>(
valueListenable: _showFab,
builder: (_, show, __) => show
? FloatingActionButton(
mini: true,
backgroundColor: Colors.white,
// Fixed dark grey — the FAB stays white in both themes.
foregroundColor: const Color(0xFF48484A),
onPressed: _scrollToBottom,
child:
const Icon(CupertinoIcons.chevron_down, size: 20),
)
: const SizedBox.shrink(),
),
),
(The isIOS26 bottom offset is the extendBody bill coming due: on glass
devices the Stack runs to the physical bottom, so the FAB must clear the
pinned input bar + home indicator.)
One thing to feel rather than read: every touch in the app is delivered by the platform’s own recognizers, with the OS’s exact timing — none of that faint softness that makes taps in cross-platform apps land a frame behind. Tap the send button a few times and you’ll know what this means. Hard to describe, impossible to miss — and it’s true of every component and gesture you’ll ship.
Why this is native
Every piece you just composed is the platform’s real component: the list is a
UITableView/RecyclerView(system physics, native cell recycling,NSIndexPathjumps), the field is aUITextField(autocorrect, loupe, selection), the bar button is aUIBarButtonItem, the glass isUIGlassEffect, and the keyboard choreography is the OS compositor’s own transaction. Flutter re-draws imitations of these on a canvas; React Native drives them from JS. Here, your widgets are the views — and the difference is many small nuances nobody can name and every user notices when they’re missing: the scroll settle, the tap timing, the loupe, the keyboard sync.
The finished code
The complete app is in the public repo at tutorials/chat-screen: grab it,
dn create ., dn run. The screen (lib/screens/chat_screen_demo.dart) is
a byte-identical copy of the playground’s chat demo — with the playground’s
shared UI kit (lib/screens/home/demo_ui.dart) and avatar asset alongside,
also verbatim — so improvements to the playground screen flow straight into
this tutorial; lib/main.dart is just the thin entry that registers the
bindings and runs it.