flutter_keyboard_controller 1.0.4
flutter_keyboard_controller: ^1.0.4 copied to clipboard
Smooth, frame-by-frame keyboard animation tracking for Flutter. Includes KeyboardChatScrollView, KeyboardToolbar, KeyboardStickyView, KeyboardAwareScrollView, and KeyboardAvoidingView — everything you [...]
flutter_keyboard_controller #
Frame-by-frame keyboard tracking for Flutter — smooth animations, auto-scroll forms, chat UIs, sticky toolbars, and a full imperative API. Built on native CADisplayLink (iOS) and WindowInsetsAnimationCompat + OnApplyWindowInsetsListener (Android).
Preview #
1. Form auto-scroll #
KeyboardAwareScrollView auto-scrolls to the focused field including labels and error text.
2. Chat lift #
KeyboardChatScrollView lifts the message list with 4 configurable behaviors.
3. Sticky toolbar #
KeyboardToolbar slides in sync with the keyboard, pixel-perfect.
Why this library? #
Flutter's built-in keyboard handling #
Flutter ships two mechanisms:
Scaffold(resizeToAvoidBottomInset: true) (default)
- Scaffold body shrinks in one jump when keyboard animation ends — not per-frame.
TextField.scrollPaddingcallsScrollable.ensureVisible()on the rawTextFieldbounds — ignores labels or error text below the field.- Android: on some devices (Samsung) a keyboard-type switch causes a static layout pass with no animation, so Flutter's scroll simply doesn't fire.
MediaQuery.viewInsetsOf(context)
- Returns height only after animation ends.
- Every widget reading it rebuilds — cascades through the entire
InheritedWidgettree. - No progress value (0→1), no lifecycle events, no interactive-dismiss tracking.
Deep comparison #
| Flutter built-in | flutter_keyboard_controller | |
|---|---|---|
| Height timing | After animation ends | Every frame |
| Progress 0→1 | ❌ | ✅ progressNotifier |
| Lifecycle events | ❌ | ✅ willShow / didShow / willHide / didHide / move / interactive |
| iOS interactive dismiss | Estimated | ✅ CADisplayLink exact position |
| Android keyboard-type switch | ❌ Missed on Samsung | ✅ setOnApplyWindowInsetsListener safety net |
| Rebuild scope | Entire MediaQuery subtree |
Single ValueListenableBuilder |
| Auto-scroll to field | ✅ raw TextField only |
✅ full widget bounds (label + error) via KeyboardScrollBoundary |
Auto-scroll when resizeToAvoidBottomInset: false |
❌ | ✅ |
| Global dismiss (tap/drag) | Per-ScrollView only | ✅ KeyboardProvider.dismissBehavior |
| Chat lift behaviors | ❌ | ✅ KeyboardChatScrollView |
| Sticky widget above keyboard | ❌ | ✅ KeyboardStickyView |
| Prev/Next/Done toolbar | ❌ | ✅ KeyboardToolbar |
dismiss(keepFocus, animated) |
❌ | ✅ |
| Preload keyboard (iOS lag) | ❌ ~300 ms | ✅ KeyboardController.preload() |
| Android soft-input mode | Manifest only | ✅ setInputMode() at runtime |
Performance #
Flutter built-in: O(N) — rebuilds the entire MediaQuery subtree (Scaffold → theme → every consumer)
every time the keyboard opens or closes.
This library: O(1) — only the specific Padding/Transform wrapper updates via ValueListenableBuilder.
Heavy widgets (lists, cards, images) remain completely static.
Always pass stable content as child: to ValueListenableBuilder so it never rebuilds per-frame:
// ✅ child: never rebuilds — only Padding wrapper does (~60×/s during anim)
ValueListenableBuilder<double>(
valueListenable: context.keyboard.heightNotifier,
child: const MyList(),
builder: (_, height, child) => Padding(
padding: EdgeInsets.only(bottom: height),
child: child,
),
);
Which widget should I use? #
What are you building?
│
├─ Form with many text fields → KeyboardAwareScrollView
│
├─ Chat / AI-chat UI → KeyboardChatScrollView + KeyboardStickyView
│
├─ Fixed layout (compose, → KeyboardAvoidingView (adjusts whole container)
│ search bar, login page) OR KeyboardStickyView (just the input bar)
│
└─ Form + Prev/Next/Done → KeyboardToolbar (pair with KeyboardAwareScrollView)
KeyboardAvoidingView vs KeyboardStickyView — most common confusion #
KeyboardAvoidingView |
KeyboardStickyView |
|
|---|---|---|
| What adjusts | The entire layout container | One specific widget |
| Content behind keyboard | ✅ Never hidden | ❌ Keyboard overlaps it |
| Use case | Compose screen | Chat input bar |
Installation #
dependencies:
flutter_keyboard_controller: ^1.0.0
- Android: min SDK 24.
- iOS: min iOS 13.
Quick start #
import 'package:flutter_keyboard_controller/flutter_keyboard_controller.dart';
void main() {
runApp(
// ⚠️ KeyboardProvider must wrap your entire app (above MaterialApp /
// CupertinoApp). Placing it below Scaffold causes keyboard state to reset
// on every route push/pop and bottom-sheet open.
KeyboardProvider(
dismissBehavior: KeyboardDismissBehavior.onTapAndDrag,
child: MaterialApp(home: MyApp()),
),
);
}
KeyboardProvider #
Root widget. Required for all features.
⚠️ Important: Always place
KeyboardProvideraboveMaterialApp/CupertinoApp. If placed belowScaffold, keyboard state resets on every route push/pop and breaks overlay handling for dialogs and bottom sheets.
KeyboardProvider(
enabled: true,
dismissBehavior: KeyboardDismissBehavior.onTapAndDrag,
child: MaterialApp(...), // ✅ correct — wraps the entire app
)
KeyboardDismissBehavior |
Effect |
|---|---|
manual |
Never auto-dismiss (default) |
onTap |
Dismiss on tap outside |
onDrag |
Dismiss on scroll |
onTapAndDrag |
Both |
Reading keyboard state #
final animation = context.keyboard; // subscribes — widget rebuilds
final animation = context.keyboardOrNull; // no subscription
KeyboardAwareScrollView #
When to use: Multi-field forms (sign-up, profile, settings).
How it works:
- Adds bottom padding = keyboard height so
maxScrollExtentgrows → content is scrollable above keyboard. _onFocusChangedfires on every focus change →addPostFrameCallbacktriggers_scrollToFocusedInput.KeyboardGeometryServicecalculates the target scroll offset using the field'sRenderBoxcoordinates._isDismissing+ModalRoute.isCurrentprevent unwanted scrolls when a bottom sheet opens.- Android:
setOnApplyWindowInsetsListenercatches static keyboard-type switches thatWindowInsetsAnimationCompatmisses.
vs Flutter built-in:
- Flutter: body snaps, scrolls to raw
TextFieldbounds only, fails onresizeToAvoidBottomInset: false. - This: smooth per-frame padding, scrolls to full widget height (labels + errors), works independently of Scaffold.
Scaffold(
resizeToAvoidBottomInset: false,
body: KeyboardAwareScrollView(
padding: const EdgeInsets.all(16),
children: [
// Plain field — scrolled to raw TextField bounds
TextField(decoration: InputDecoration(labelText: 'Email')),
// Custom widget — KeyboardScrollBoundary tells the scroll view to
// include the label AND the red error text below the field.
// Without it, only the raw TextField bounds are used and the error
// text gets hidden behind the keyboard.
KeyboardScrollBoundary(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Password'),
const TextField(obscureText: true),
const Text(
'Must be at least 8 characters',
style: TextStyle(color: Colors.red, fontSize: 12),
),
],
),
),
FilledButton(onPressed: submit, child: const Text('Sign in')),
],
),
)
KeyboardScrollBoundary — for custom input widgets #
Wrap your custom widget's root once so KeyboardAwareScrollView measures the full widget height (label + input + error text). No per-screen config needed — every screen using KeyboardAwareScrollView benefits automatically.
// Inside AppTextInput.build() — wrap once, works everywhere:
return KeyboardScrollBoundary(
child: Column(children: [label, textField, errorText]),
);
Parameters #
| Param | Default | Description |
|---|---|---|
children |
required | Content widgets |
padding |
null |
Outer padding |
scrollPadding |
EdgeInsets.all(20) |
Gap between field and keyboard edge |
animationDuration |
300 ms |
Scroll animation duration |
animationCurve |
Curves.easeOut |
Scroll animation curve |
physics |
platform | ScrollPhysics |
scrollContextFinder |
null |
Override ancestor traversal |
KeyboardAvoidingView #
When to use: Fixed layouts where the entire container must adapt to the keyboard — login screen, compose screen, search overlay.
How it works:
LayoutBuildercaptures parent height once.ValueListenableBuilderonheightNotifierapplies the behavior per-frame.- Always returns the same wrapper widget type regardless of keyboard state, so Flutter never unmounts the subtree and
FocusNodes keep their state.
vs Flutter built-in:
- Flutter's
resizeToAvoidBottomInset: truesnaps the body in one step. KeyboardAvoidingViewfollows the keyboard smoothly every frame.
Scaffold(
resizeToAvoidBottomInset: false,
body: KeyboardAvoidingView(
behavior: KeyboardAvoidingBehavior.padding,
child: Column(children: [
Expanded(child: content),
inputBar, // lifts smoothly with keyboard
]),
),
)
Behaviors #
behavior |
Effect | Constraints |
|---|---|---|
padding |
Adds paddingBottom |
Works with any parent |
height |
Reduces maxHeight |
Parent must give loose constraints (Flexible, not Expanded) |
position |
Translates view upward | Content may go behind AppBar |
translateWithPadding |
Half translate + half padding | Middle ground |
⚠️
heightbehavior requires a parent with loose constraints. UseFlexibleinstead ofExpanded.Why?
Expandedpasses tight constraints (exact size) to its child. When the Flutter layout engine receives tight constraints it ignores any attempt to shrink the child —KeyboardAvoidingViewsimply can't reduce its height.Flexiblepasses loose constraints (maximum size), which allows the view to actually shrink to dodge the keyboard.
KeyboardChatScrollView #
When to use: Chat, messaging, AI assistant — reversed list with a floating input bar.
How it works:
ListView(reverse: true)withpadding.bottomadjusted per-frame viaheightNotifier.- No viewport resize — keyboard overlays. Lifting is pure padding change (no
jumpToneeded). - Four lift behaviors match popular apps.
vs Flutter built-in: No built-in equivalent. The naive resizeToAvoidBottomInset: true approach causes the list to flash/jump on keyboard open.
Scaffold(
resizeToAvoidBottomInset: false,
body: Stack(children: [
Positioned.fill(
child: KeyboardChatScrollView(
liftBehavior: KeyboardLiftBehavior.whenAtEnd,
extraBottomPadding: 72, // floating input bar height
safeAreaBottom: MediaQuery.of(context).viewPadding.bottom,
children: messages.map((m) => MessageBubble(m)).toList(),
),
),
ValueListenableBuilder<double>(
valueListenable: context.keyboard.heightNotifier,
child: InputBar(),
builder: (_, kbH, child) => Positioned(
left: 0, right: 0, bottom: kbH, child: child!,
),
),
]),
)
Lift behaviors #
liftBehavior |
When lifts | Inspired by |
|---|---|---|
always |
Every time keyboard opens | Telegram |
whenAtEnd |
Only when at newest message | WhatsApp, ChatGPT |
persistent |
Opens and stays lifted | Claude.ai |
never |
Never lifts | Perplexity |
KeyboardStickyView #
When to use: A single widget (input bar, send button) that must always sit just above the keyboard.
How it works:
Align(bottomCenter) + Padding(bottom: kbH + extraOffset)driven byheightNotifier.- Follows the keyboard pixel-perfectly every frame including iOS interactive swipe-dismiss.
vs Flutter built-in: MediaQuery.viewInsetsOf only updates after animation — widget teleports. KeyboardStickyView slides smoothly.
Stack(
children: [
Positioned.fill(child: chatList),
KeyboardStickyView(
offset: const KeyboardStickyOffset(closed: 0, opened: 8),
child: InputBar(),
),
],
)
KeyboardToolbar #
When to use: Forms with multiple fields where Prev/Next/Done navigation improves UX.
How it works:
_ToolbarVisibilitysubscribes tolastEventNotifier(notheightNotifier) so it reacts to lifecycle events, not per-frame height.- Slides in/out via
ClipRect + Align(heightFactor)in sync with keyboard animation. KeyboardToolbarScaffoldwithresizeToAvoidBottomInset: falseuses aStack + Positioned.fill(VLB)soPositionedinsideVLBissue is avoided.- Android:
_isSwitchingflag +_savedKbHkeeps toolbar at correct height during keyboard-type switches.
vs Flutter built-in: No built-in toolbar. Workarounds using MediaQuery cause the toolbar to teleport (appear/disappear in one frame) instead of sliding.
KeyboardToolbarScaffold(
resizeToAvoidBottomInset: false,
toolbar: KeyboardToolbar(
margin: const EdgeInsets.fromLTRB(8, 0, 8, 8),
borderRadius: BorderRadius.circular(100), // pill
doneLabel: 'Xong',
arrowColor: Colors.blue,
doneColor: Colors.blue,
onPrev: () => FocusScope.of(context).previousFocus(),
onNext: () => FocusScope.of(context).nextFocus(),
actions: [
KeyboardToolbarAction(icon: Icons.tag, onPressed: insertTag),
KeyboardToolbarAction(
icon: Icons.photo_camera_outlined,
onPressed: openCamera,
isSelected: _cameraMode,
selectedColor: Colors.blue,
),
],
),
body: KeyboardAwareScrollView(children: [...]),
)
KeyboardToolbar parameters #
| Param | Default | Description |
|---|---|---|
doneLabel |
'Done' |
Dismiss button text (multilang) |
arrowColor |
primary | Prev/Next arrow color |
doneColor |
primary | Done button color |
showArrows |
true |
Show Prev/Next arrows |
actions |
[] |
Custom icon buttons (≤ 5) |
margin |
null |
Outer margin for floating pill effect |
borderRadius |
null |
Corner radius for pill shape |
toolbarScrollClearance |
64 |
Extra scroll padding so fields clear the toolbar |
KeyboardAnimation — raw values #
final animation = context.keyboard;
// Only this widget rebuilds per frame
ValueListenableBuilder<double>(
valueListenable: animation.heightNotifier,
child: const MyFAB(),
builder: (_, height, fab) => Positioned(
right: 16, bottom: 16 + height, child: fab!,
),
);
| Notifier | Type | Description |
|---|---|---|
heightNotifier |
ValueNotifier<double> |
Keyboard height in dp (0 when hidden) |
progressNotifier |
ValueNotifier<double> |
Progress 0→1 |
isVisibleNotifier |
ValueNotifier<bool> |
Whether keyboard is visible |
lastEventNotifier |
ValueNotifier<KeyboardEventData?> |
Last native event |
Lifecycle events #
animation.lastEventNotifier.addListener(() {
switch (animation.lastEvent?.type) {
case KeyboardEventType.willShow: // about to appear
case KeyboardEventType.didShow: // fully visible
case KeyboardEventType.willHide: // about to hide
case KeyboardEventType.didHide: // fully hidden
case KeyboardEventType.move: // per-frame during animation
case KeyboardEventType.interactive: // iOS swipe-dismiss
}
});
KeyboardController — imperative API #
await KeyboardController.dismiss();
await KeyboardController.dismiss(keepFocus: true); // hide keys, cursor stays
await KeyboardController.dismiss(animated: false); // iOS: instant
final visible = await KeyboardController.isVisible();
final state = await KeyboardController.state(); // height, isVisible
KeyboardController.preload(); // iOS: eliminate cold-start lag
// Android only
KeyboardController.setInputMode(AndroidSoftInputMode.adjustNothing);
KeyboardController.setDefaultMode();
Architecture notes #
Android — two-layer keyboard tracking #
This library uses two complementary mechanisms on Android:
-
WindowInsetsAnimationCompat.Callback— tracks per-frame height during animated keyboard transitions. FireswillShow/move/didShow/willHide/didHideevents. -
setOnApplyWindowInsetsListener(safety net) — catches static keyboard-type switches (e.g. text → number keyboard) where Android performs a layout pass with no animation. Without this,heightNotifierwould stay at the old value and the toolbar/scroll would appear mispositioned.
The isAnimating flag prevents duplicate events when both fire for the same transition.
KeyboardGeometryService #
DOM traversal and scroll-offset math are extracted into KeyboardGeometryService (under lib/src/services/). This means:
KeyboardAwareScrollViewis a thin UI layer — only renders and triggers events.- Geometry logic is independently unit-testable without rendering.
KeyboardScrollBoundary #
A zero-overhead marker widget (build returns child unchanged). KeyboardGeometryService.resolveTargetContext walks ancestors looking for it. Wrap your custom input's root widget once — no per-screen config.
API summary #
Widgets #
| Widget | Purpose |
|---|---|
KeyboardProvider |
Root — required. Enables all features. |
KeyboardScrollBoundary |
Marks full bounds of a custom input (label + input + error) |
KeyboardAwareScrollView |
Form scroll — auto-scrolls focused field above keyboard |
KeyboardAvoidingView |
Adjusts entire container with 4 behaviors |
KeyboardChatScrollView |
Chat list with 4 lift behaviors |
KeyboardStickyView |
Sticks one widget to keyboard top edge |
KeyboardToolbar |
Prev/Next/Done toolbar above keyboard |
KeyboardToolbarScaffold |
Convenience scaffold wiring KeyboardToolbar |
Enums #
| Enum | Values |
|---|---|
KeyboardDismissBehavior |
manual, onTap, onDrag, onTapAndDrag |
KeyboardAvoidingBehavior |
padding, height, position, translateWithPadding |
KeyboardLiftBehavior |
always, whenAtEnd, persistent, never |
KeyboardEventType |
willShow, didShow, willHide, didHide, move, interactive |
AndroidSoftInputMode |
adjustResize, adjustPan, adjustNothing, adjustUnspecified |
Changelog #
See CHANGELOG.md.
License #
MIT © 2026 Tuan Nguyen Cong