keyboard_actions_bar 2.0.0
keyboard_actions_bar: ^2.0.0 copied to clipboard
A fully customizable keyboard toolbar for Flutter with zero FocusNode boilerplate, Material 3 theming, tap-outside dismiss, and 6 built-in actions.
keyboard_actions_bar
A fully customizable Flutter keyboard toolbar — Done / Next / Prev / Clear / InsertAt / Custom actions above the soft keyboard, with zero FocusNode boilerplate, tap-outside dismiss, footer widgets, custom keyboard panels, smooth animation, haptic feedback, and Material 3 theming out of the box.
Key Features • Installation • Migrating from 1.x • Quick Start • Which Pattern? • Actions • KeyboardField • KeyboardBarItem • Footer Widget • Custom Keyboard Panel • KeyboardConfig • License
Key Features #
- Auto-discovery — plain
TextField/TextFormFieldwidgets get a toolbar with zero wrapping, zero config - Smart Prev/Next — hides Prev on the first field and Next on the last, instead of a dead button
- Zero FocusNode boilerplate — wrap a
TextFieldinKeyboardFieldwhen you need per-field customization - 6 built-in actions —
done,next,prev,clear,insertAt,custom - Done callback — fire logic before the keyboard closes
- Per-field toolbar control — hide bar, align buttons, skip disabled fields
- Tap-outside dismiss — opaque or translucent overlay
- Global Done override — custom widget or text for the Done button
- Footer widget — suggestion chips, emoji picker, live character counter
- Custom keyboard panels — replace the system keyboard with your own UI
- Smooth slide animation — configurable duration & curve
- Haptic feedback on every button tap
- Material 3 themed — auto-adapts to your
ColorScheme, dark mode aware - Samsung / OEM keyboard fix — deferred focus-loss prevents IME bounce bugs
- Android
adjustResizesupport — works with the default Flutter manifest - One shared focus listener per bar (not per field) — scales to large forms without per-field listener overhead
- Advanced:
KeyboardBarItemconfig-list style, and thekbField()record helper — see Which pattern should I use?
Installation #
dependencies:
keyboard_actions_bar: ^2.0.0
flutter pub get
Import #
import 'package:keyboard_actions_bar/keyboard_actions_bar.dart';
Migrating from 1.x #
Two breaking changes in 2.0.0. Most apps can just bump the version — nothing below needs a code change unless you were relying on the old default.
| Old (1.x) | New (2.0.0) |
|---|---|
KeyboardField required around every field for a toolbar |
Usually unnecessary: bare TextField/TextFormField auto-discovered |
| Unwrapped field → no toolbar | Unwrapped field → toolbar by default. Opt out with KeyboardField(enabled: false, ...) |
showOnAndroid: false (default) |
showOnAndroid: true (default). Pass showOnAndroid: false to keep iOS-only |
| Prev/Next always shown together once there are 2+ fields | Smart Prev/Next — hidden on the first/last field automatically |
| (no shortcut) | KeyboardActionsBar.done(...) — Done-only preset |
Everything else — KeyboardField, KeyboardBarItem, kbField(), all 6 actions, KeyboardConfig
— is unchanged and fully backward compatible.
Quick Start #
Wrap your Scaffold body in KeyboardActionsBar — that's it, no per-field wrapping required:
Scaffold(
body: KeyboardActionsBar(
child: ListView(
children: [
TextField(decoration: InputDecoration(labelText: 'Name')),
TextField(decoration: InputDecoration(labelText: 'Email')),
],
),
),
)
That's it. Prev / Next / Done appear automatically above the keyboard on iOS and Android,
with Prev/Next hidden on the first/last field. KeyboardField is still there for when you need
to customize or exclude a specific field — see Which pattern should I use?.
Just want a Done button, no navigation, regardless of how many fields are on screen?
KeyboardActionsBar.done(
child: ListView(children: [...]),
)
Note:
showOnWebdefaults tofalse(browsers/desktop don't always have a predictable soft-keyboard inset). To also show on Web/desktop:KeyboardActionsBar( config: KeyboardConfig.allPlatforms, child: ..., )
Note: the toolbar reads its colors from
Theme.of(context), so it follows your app's light/dark mode automatically — but only if yourMaterialAppis actually set up to switch. Make sure you have both adarkThemeandthemeMode: ThemeMode.system(the default), e.g.:MaterialApp( theme: ThemeData(useMaterial3: true, colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo)), darkTheme: ThemeData( useMaterial3: true, colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo, brightness: Brightness.dark), ), themeMode: ThemeMode.system, home: ..., )With only a
theme:set, your app (and the toolbar) stays in light mode regardless of the phone's setting. See the example app for a working setup.
Which Pattern Should I Use? #
A plain TextField/TextFormField anywhere inside KeyboardActionsBar already gets a
toolbar — no wrapper needed. The patterns below are for when you want more than the default:
| Pattern | Use it when | FocusNode handling |
|---|---|---|
| (none) ✅ default | Almost always. Just use TextField/TextFormField directly. |
Whatever the field creates for itself — auto-discovered, no FocusNode touches your code. |
KeyboardField |
You want to customize a field's actions/footer, or exclude it entirely (enabled: false). |
Auto-created for you — none of your code touches a FocusNode. |
KeyboardBarItem — advanced |
You're migrating from keyboard_actions' config-list API, or you need a FocusNode reference outside the widget tree (e.g. to call .requestFocus() from a controller/bloc). |
Auto-created, but you must pass item.focusNode to the matching TextField yourself. |
kbField() — advanced |
You're already using KeyboardBarItem (or passing an explicit focusNode) and want a FocusNode + TextEditingController pair declared/disposed in one line instead of two. |
You own and dispose both. |
If you're not sure, use a plain TextField and reach for KeyboardField only for the one
field that needs customizing or excluding — everything below it is opt-in for specific needs.
Actions #
All 6 built-in actions #
KeyboardAction.done() // closes keyboard
KeyboardAction.next() // move focus to next field ↓
KeyboardAction.prev() // move focus to previous field ↑
KeyboardAction.clear(controller) // clears a TextEditingController
KeyboardAction.insertAt(controller) // inserts '@' at cursor (email helper)
KeyboardAction.custom(...) // fully custom — label, icon, or widget
| Factory | Default UI | Behaviour |
|---|---|---|
done() |
"Done" text |
node.unfocus() |
next() |
keyboard_arrow_down |
moves focus forward |
prev() |
keyboard_arrow_up |
moves focus backward |
Note: when you don't pass
actions:and rely on the built-in default ([prev, next, done]), Prev/Next only appear once there are 2 or more navigable fields — a lone field just shows Done. Smart Prev/Next also hides Prev on the first field and Next on the last, so you never see a button with nowhere to go — this counts every navigable field in the bar, both auto-discovered andKeyboardField-wrapped. |clear(ctrl)|backspace_outlined| clears controller | |insertAt(ctrl)|"Insert @"| inserts char at cursor | |custom(...)| your choice | any callback |
Override defaults #
KeyboardAction.done(label: 'Submit')
KeyboardAction.done(onTap: () => submitForm()) // fires BEFORE keyboard closes
KeyboardAction.next(icon: Icons.arrow_forward_ios)
KeyboardAction.prev(icon: Icons.arrow_back_ios)
KeyboardAction.clear(ctrl, icon: Icons.delete_outline)
KeyboardAction.insertAt(ctrl, char: '#', label: 'Hashtag')
Precedence: a per-field
KeyboardAction.done(label: ...)always wins overKeyboardConfig.defaultDoneButtonText. Leavelabelunset to fall back to the global config default.
Custom action examples
// Text label
KeyboardAction.custom(
label: 'Save draft',
onTap: () => saveDraft(),
)
// Icon button
KeyboardAction.custom(
icon: Icons.emoji_emotions_outlined,
onTap: () => controller.text += ' 😊',
)
// Fully custom widget (receives the FocusNode)
KeyboardAction.custom(
onTap: () {},
builder: (context, focusNode) => GestureDetector(
onTap: () {
focusNode.unfocus();
submitForm();
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'Submit',
style: TextStyle(color: Theme.of(context).colorScheme.onPrimary),
),
),
),
)
kbField() — Field Record #
Advanced. Skip this if you're using plain
KeyboardField(the default, recommended path) — it already handles theFocusNodefor you.kbField()is for when you need an explicitFocusNode+TextEditingControllerpair, typically alongsideKeyboardBarItem.
kbField() couples a FocusNode and TextEditingController into a single Dart 3 record so they always travel together.
// Declare
final _name = kbField(debugLabel: 'name');
final _email = kbField(debugLabel: 'email');
final _phone = kbField(debugLabel: 'phone');
// Dispose — one loop for all fields
@override
void dispose() {
for (final f in [_name, _email, _phone]) {
f.focus.dispose();
f.ctrl.dispose();
}
super.dispose();
}
// Use
TextField(focusNode: _name.focus, controller: _name.ctrl)
Without kbField() you need 8 separate declarations + 8 dispose calls for 4 fields. With it — 4 declarations + one loop.
KeyboardActionBuilders #
Advanced. Companion to
kbField()— only relevant if you're using that pattern.
Helper actions that work directly with KbField records:
KeyboardActionBuilders.insertAt(_email) // inserts '@' using _email.ctrl
KeyboardActionBuilders.insertAt(_email, char: '#') // any character
KeyboardActionBuilders.clear(_phone) // clears _phone.ctrl
KeyboardField #
Per-field wrapper — co-locates toolbar config with the field itself. Not required for a
field to get a toolbar at all (see auto-discovery) — use it when you want
to customize a specific field's actions/footer, or exclude it with enabled: false.
KeyboardField(
actions: [
KeyboardAction.prev(),
KeyboardAction.next(),
KeyboardAction.insertAt(ctrl),
KeyboardAction.clear(ctrl),
KeyboardAction.done(onTap: () => validate()),
],
footer: MyFooterWidget(), // optional widget below toolbar
displayActionBar: true, // false = hide toolbar row
toolbarAlignment: MainAxisAlignment.end, // button alignment
child: TextField(controller: ctrl),
)
KeyboardField properties #
| Property | Type | Default | Description |
|---|---|---|---|
actions |
List<KeyboardAction>? |
config default | Buttons shown in the toolbar |
footer |
Widget? |
null |
Widget below the toolbar row |
focusNode |
FocusNode? |
auto-created | Provide your own node if needed |
enabled |
bool |
true |
false = skip, no toolbar shown |
displayActionBar |
bool |
true |
Show/hide the toolbar row |
toolbarAlignment |
MainAxisAlignment |
end |
Button row alignment |
KeyboardBarItem #
Advanced. Skip this if
KeyboardFieldcovers your case (it does, for almost everyone). UseKeyboardBarItemwhen migrating fromkeyboard_actions' config-list API, or when you need aFocusNodereference outside the widget tree.
For when you prefer a separate config list (similar to keyboard_actions pub.flutter-io.cn style).
Each item owns a FocusNode — pass item.focusNode to its TextField.
final _nameItem = KeyboardBarItem(
actions: [KeyboardAction.prev(), KeyboardAction.next(), KeyboardAction.done()],
);
final _emailItem = KeyboardBarItem(
actions: [
KeyboardAction.prev(),
KeyboardAction.insertAt(_emailCtrl),
KeyboardAction.done(),
],
);
Full KeyboardBarItem example
// Declare items
final _nameItem = KeyboardBarItem(
actions: [KeyboardAction.prev(), KeyboardAction.next(), KeyboardAction.done()],
);
final _emailItem = KeyboardBarItem(
actions: [
KeyboardAction.prev(),
KeyboardAction.insertAt(_emailCtrl),
KeyboardAction.done(),
],
toolbarAlignment: MainAxisAlignment.spaceBetween,
);
final _phoneItem = KeyboardBarItem(
actions: [KeyboardAction.prev(), KeyboardAction.clear(_phoneCtrl), KeyboardAction.done()],
);
final _noteItem = KeyboardBarItem(
actions: [KeyboardAction.prev(), KeyboardAction.done()],
enabled: false, // skip this field — no toolbar shown when focused
displayActionBar: false, // hide toolbar row (footer still shows if set)
);
// Dispose
@override
void dispose() {
for (final item in [_nameItem, _emailItem, _phoneItem, _noteItem]) {
item.dispose();
}
super.dispose();
}
// Wire up
KeyboardActionsBar(
config: KeyboardConfig.allPlatforms,
items: [_nameItem, _emailItem, _phoneItem, _noteItem],
child: ListView(children: [
TextField(focusNode: _nameItem.focusNode, controller: _nameCtrl),
TextField(focusNode: _emailItem.focusNode, controller: _emailCtrl),
TextField(focusNode: _phoneItem.focusNode, controller: _phoneCtrl),
TextField(focusNode: _noteItem.focusNode, controller: _noteCtrl),
]),
)
KeyboardBarItem properties #
| Property | Type | Default | Description |
|---|---|---|---|
focusNode |
FocusNode? |
auto-created | Pass to the matching TextField |
actions |
List<KeyboardAction>? |
config default | Toolbar buttons for this field |
footer |
Widget? |
null |
Widget below the toolbar row |
enabled |
bool |
true |
false = skip, no toolbar shown |
displayActionBar |
bool |
true |
Show/hide the toolbar row |
toolbarAlignment |
MainAxisAlignment |
end |
Button row alignment |
Footer Widget #
Attach any widget below the toolbar row.
Live character counter
KeyboardField(
actions: [KeyboardAction.prev(), KeyboardAction.done()],
footer: ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, value, _) {
final count = value.text.length;
return Container(
width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Text(
'$count / 120',
textAlign: TextAlign.end,
style: TextStyle(
color: count > 100
? Theme.of(context).colorScheme.error
: Theme.of(context).colorScheme.onSurface,
fontSize: 12,
),
),
);
},
),
child: TextField(controller: controller, maxLines: 3, maxLength: 120),
)
Suggestion chips
KeyboardField(
footer: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: ['flutter', 'dart', 'mobile', 'ios', 'android']
.map((tag) => Padding(
padding: const EdgeInsets.only(right: 6),
child: ActionChip(
label: Text('#$tag'),
visualDensity: VisualDensity.compact,
onPressed: () => controller.text += ' #$tag',
),
))
.toList(),
),
),
child: TextField(controller: controller),
)
Custom Keyboard Panel #
Replace the system keyboard with your own UI using KeyboardCustomInput<T>.
final _dateNotifier = ValueNotifier<DateTime>(DateTime.now());
final _dateItem = KeyboardBarItem(
displayActionBar: false,
footer: MyDatePickerPanel(notifier: _dateNotifier),
);
KeyboardActionsBar(
items: [_dateItem],
child: KeyboardCustomInput<DateTime>(
focusNode: _dateItem.focusNode,
notifier: _dateNotifier,
height: 48,
builder: (context, value, hasFocus) => Text(
'${value.day}/${value.month}/${value.year}',
style: TextStyle(
fontSize: 16,
color: hasFocus
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
),
),
)
Build a custom keyboard panel
class MyDatePickerPanel extends StatefulWidget {
final ValueNotifier<DateTime> notifier;
const MyDatePickerPanel({required this.notifier});
@override
State<MyDatePickerPanel> createState() => _MyDatePickerPanelState();
}
class _MyDatePickerPanelState extends State<MyDatePickerPanel>
with KeyboardCustomPanelMixin<DateTime> {
@override
ValueNotifier<DateTime> get notifier => widget.notifier;
@override
Widget build(BuildContext context) => CalendarDatePicker(
initialDate: notifier.value,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
onDateChanged: (date) => updateValue(date), // pushes to notifier
);
}
KeyboardConfig #
Controls the toolbar appearance and behaviour globally.
KeyboardActionsBar(
config: const KeyboardConfig(
showOnAndroid: true,
showOnIOS: true,
showOnWeb: false,
height: 48,
backgroundColor: Colors.white,
showSeparator: true,
separatorColor: Colors.grey,
elevation: 2,
animationDuration: Duration(milliseconds: 200),
animationCurve: Curves.easeOut,
defaultDoneButtonText: 'Submit',
defaultDoneWidget: MyDoneButton(),
tapOutsideBehavior: TapOutsideBehavior.translucentDismiss,
),
child: ...,
)
All KeyboardConfig properties
| Property | Type | Default | Description |
|---|---|---|---|
showOnAndroid |
bool |
true |
Show on Android |
showOnIOS |
bool |
true |
Show on iOS |
showOnWeb |
bool |
false |
Show on Web / Desktop |
height |
double |
44 |
Toolbar row height (px) |
backgroundColor |
Color? |
surfaceContainerHigh |
Toolbar background |
showSeparator |
bool |
true |
Thin line above toolbar |
separatorColor |
Color? |
outlineVariant |
Separator line color |
elevation |
double |
0 |
Drop shadow above toolbar |
animationDuration |
Duration |
150ms |
Slide-in / slide-out speed |
animationCurve |
Curve |
Curves.easeOut |
Animation easing |
defaultActions |
List<KeyboardAction>? |
[prev, next, done]* |
Fallback when a field has no actions |
defaultDoneButtonText |
String |
'Done' |
Rename the Done button globally |
defaultDoneWidget |
Widget? |
null |
Replace Done button widget globally |
tapOutsideBehavior |
TapOutsideBehavior |
none |
Tap-outside dismiss behaviour |
* prev/next are only included once there are 2+ navigable fields; a single-field form falls back to [done].
TapOutsideBehavior #
KeyboardConfig(tapOutsideBehavior: TapOutsideBehavior.none) // default — no overlay
KeyboardConfig(tapOutsideBehavior: TapOutsideBehavior.opaqueDismiss) // tap dismisses, blocks gestures below
KeyboardConfig(tapOutsideBehavior: TapOutsideBehavior.translucentDismiss) // tap dismisses, gestures pass through
| Value | Behaviour |
|---|---|
none |
Default Flutter behaviour — no tap-outside handling |
opaqueDismiss |
Tapping outside closes keyboard. Blocks scroll/tap below |
translucentDismiss |
Tapping outside closes keyboard. Scrolling and taps still work |
Dismissal only triggers on a resolved tap — the finger has to go down and back up in roughly the same spot. Starting a scroll or drag outside the field does not close the keyboard.
Requirements #
| Version | |
|---|---|
| Flutter | >= 3.16.0 |
| Dart | >= 3.0.0 |
No Android manifest changes needed. Works with the default android:windowSoftInputMode="adjustResize".
License #
MIT
