flutter_keyboard_controller 0.0.2
flutter_keyboard_controller: ^0.0.2 copied to clipboard
Smooth, frame-by-frame keyboard animation tracking for Flutter. Includes KeyboardChatScrollView, KeyboardToolbar, KeyboardStickyView, KeyboardAwareScrollView, and KeyboardAvoidingView — everything you [...]
flutter_keyboard_controller #
A Flutter plugin for smooth, frame-by-frame keyboard animation tracking on iOS and Android.
Flutter's built-in MediaQuery.viewInsetsOf fires at the end of the keyboard animation. This library fires a native event on every frame, so your UI can follow the keyboard pixel-perfectly with zero jank.
Why this library? #
| Flutter built-in | flutter_keyboard_controller | |
|---|---|---|
| Keyboard height updates | Fires at animation end only | Every vsync frame during animation |
| Smooth animation | ❌ Jumps to final position | ✅ Follows keyboard pixel-by-pixel |
| Chat UI component | ❌ | ✅ KeyboardChatScrollView |
| Toolbar above keyboard | ❌ | ✅ KeyboardToolbar |
| Sticky widget | ❌ | ✅ KeyboardStickyView |
| iOS frame interpolation | ❌ | ✅ CADisplayLink |
| Android per-frame events | ❌ | ✅ WindowInsetsAnimationCompat |
Features #
- 🎞 Frame-by-frame keyboard height & progress (0–1) on every vsync
- 💬
KeyboardChatScrollView— chat list withalways / whenAtEnd / persistent / neverlift behaviors - 📌
KeyboardStickyView— any widget that sticks to the keyboard top edge - 🔧
KeyboardToolbar— Prev / Next / Done bar with customisable labels and colours - 📜
KeyboardAwareScrollView— auto-scrolls to keep focusedTextFieldvisible - 🛡
KeyboardAvoidingView— fine-grained avoidance with 4 modes - ⚡
KeyboardController— dismiss, query state, set Android soft-input mode, preload on iOS - 🎯
ValueNotifier-based — only subscribed widgets rebuild, zero overhead elsewhere
Installation #
dependencies:
flutter_keyboard_controller: ^0.0.1
flutter pub get
Android #
Minimum SDK 24. No extra configuration needed.
iOS #
Minimum iOS 13. No extra steps.
Quick start #
Wrap your app once with KeyboardProvider. All child widgets then have access to live keyboard data:
import 'package:flutter_keyboard_controller/flutter_keyboard_controller.dart';
void main() {
runApp(
KeyboardProvider(
child: MaterialApp(home: MyApp()),
),
);
}
KeyboardProvider #
KeyboardProvider is the required root widget that initialises native keyboard tracking and exposes a KeyboardAnimation to all descendants via InheritedNotifier.
Place it as high as possible in the tree — typically wrapping MaterialApp:
KeyboardProvider(
enabled: true, // set false to pause tracking without removing the widget
child: MaterialApp(home: MyApp()),
)
Reading keyboard state in descendants #
// Subscribes — widget rebuilds on every keyboard event
final animation = KeyboardControllerScope.of(context);
// Does NOT subscribe — safe for use inside listeners / callbacks
final animation = KeyboardControllerScope.maybeOf(context); // nullable
// Extension shorthand
final animation = context.keyboard; // subscribes, throws if absent
final animation = context.keyboardOrNull; // nullable, does not subscribe
KeyboardChatScrollView #
The centerpiece for chat / AI-chat UIs. Uses a Stack with a floating input bar so the viewport never shrinks — lifting is done via padding.bottom changes instead:
Scaffold(
resizeToAvoidBottomInset: false,
body: Stack(
children: [
Positioned.fill(
child: KeyboardChatScrollView(
liftBehavior: KeyboardLiftBehavior.whenAtEnd,
extraBottomPadding: 72, // fixed height of your input bar
safeAreaBottom: MediaQuery.of(context).viewPadding.bottom,
onEndVisible: () { /* user scrolled to bottom */ },
children: messages.map((m) => MessageBubble(m)).toList(),
),
),
// Input bar floats above keyboard, driven by plugin animation
ValueListenableBuilder<double>(
valueListenable: KeyboardControllerScope.of(context).heightNotifier,
child: InputBar(),
builder: (_, keyboardH, child) => Positioned(
left: 0, right: 0, bottom: keyboardH, child: child!,
),
),
],
),
)
Lift behaviors #
liftBehavior |
What it does | Matches |
|---|---|---|
always |
Lifts content on every keyboard open | Telegram |
whenAtEnd |
Lifts only when scrolled to the bottom | ChatGPT, WhatsApp |
persistent |
Lifts on open, stays lifted after close | Claude.ai |
never |
No automatic lift | Perplexity |
Parameters #
| Param | Default | Description |
|---|---|---|
liftBehavior |
whenAtEnd |
When to lift content |
extraBottomPadding |
0 |
Fixed space for the floating input bar |
safeAreaBottom |
0 |
MediaQuery.of(context).viewPadding.bottom — corrects SafeArea shrink inside input bar when keyboard opens |
onEndVisible |
null |
Called when user scrolls to the newest message (offset ≤ 20 dp in reverse:true) |
controller |
auto | Custom ScrollController |
padding |
null |
Extra EdgeInsetsGeometry added to the list |
physics |
default | ScrollPhysics |
clipBehavior |
Clip.hardEdge |
Clipping behaviour |
KeyboardToolbar #
Prev / Next / Done navigation bar that appears above the keyboard:
// Option A — convenience scaffold (recommended for forms)
KeyboardToolbarScaffold(
appBar: AppBar(title: const Text('Profile')),
toolbar: KeyboardToolbar(
doneLabel: 'Xong', // multilang — any String
prevLabel: 'Trước',
nextLabel: 'Tiếp',
arrowColor: Colors.blue, // colour for ‹ › arrows
doneColor: Colors.blue, // colour for Done label
onPrev: () => FocusScope.of(context).previousFocus(),
onNext: () => FocusScope.of(context).nextFocus(),
showArrows: true, // set false to show Done only
content: const Text('2/5'), // optional centre widget
),
body: MyForm(),
)
// Option B — manual placement in a Stack
KeyboardStickyView(
child: KeyboardToolbar(),
)
KeyboardToolbarScaffold uses resizeToAvoidBottomInset: true by default so Flutter's automatic scroll-to-focused-field still works inside the body. The toolbar slides in/out in sync with the keyboard animation — no lag between keyboard and toolbar disappearing.
KeyboardToolbarScaffold parameters #
| Param | Default | Description |
|---|---|---|
toolbar |
KeyboardToolbar() |
The toolbar widget |
body |
required | Main content |
appBar |
null |
Optional app bar |
resizeToAvoidBottomInset |
true |
Set false only when managing layout manually |
backgroundColor |
theme | Scaffold background |
floatingActionButton |
null |
FAB |
bottomNavigationBar |
null |
Bottom nav |
KeyboardStickyView #
Any widget that sticks to the top of the keyboard and moves with it frame-by-frame:
Stack(
children: [
Positioned.fill(child: content),
KeyboardStickyView(
offset: const KeyboardStickyOffset(closed: 0, opened: 0),
child: InputBar(),
),
],
)
KeyboardStickyOffset |
Description |
|---|---|
closed |
Extra dp offset when keyboard is hidden (e.g. safe-area bottom or tab-bar height) |
opened |
Extra dp offset when keyboard is visible |
KeyboardAwareScrollView #
Auto-scrolls to keep the focused TextField visible when the keyboard opens:
KeyboardAwareScrollView(
padding: const EdgeInsets.all(24),
scrollPadding: const EdgeInsets.all(20), // gap between field and keyboard edge
animationDuration: const Duration(milliseconds: 300),
animationCurve: Curves.easeOut,
children: [
TextField(decoration: const InputDecoration(labelText: 'First name')),
TextField(decoration: const InputDecoration(labelText: 'Last name')),
TextField(decoration: const InputDecoration(labelText: 'Email')),
const SizedBox(height: 24),
FilledButton(onPressed: submit, child: const Text('Save')),
],
)
Parameters #
| Param | Default | Description |
|---|---|---|
children |
required | Widgets inside the scroll view |
scrollController |
auto | Custom ScrollController |
padding |
null |
Outer padding of the scroll view |
scrollPadding |
EdgeInsets.all(20) |
Extra gap between the focused field and keyboard edge |
animationDuration |
Duration(ms: 300) |
Duration of the scroll animation |
animationCurve |
Curves.easeOut |
Curve of the scroll animation |
physics |
default | ScrollPhysics |
reverse |
false |
Reverse scroll direction |
shrinkWrap |
false |
Shrink-wrap content |
Fallback: If no
KeyboardProvideris in the tree,KeyboardAwareScrollViewfalls back toWidgetsBindingObserver.didChangeMetrics— it still auto-scrolls, just without frame-accurate timing.
KeyboardAvoidingView #
Fine-grained control over how a widget adjusts its layout when the keyboard appears:
Scaffold(
resizeToAvoidBottomInset: false, // required — let KeyboardAvoidingView handle it
body: KeyboardAvoidingView(
behavior: KeyboardAvoidingBehavior.padding,
keyboardVerticalOffset: 0,
enabled: true,
child: Column(
children: [
Expanded(child: MessageList()),
InputBar(),
],
),
),
)
Parameters #
| Param | Default | Description |
|---|---|---|
behavior |
padding |
Avoidance mode (see table below) |
keyboardVerticalOffset |
0.0 |
Extra dp added on top of keyboard height |
enabled |
true |
Set false to temporarily disable without removing the widget |
Behaviors #
behavior |
Effect |
|---|---|
padding |
Adds paddingBottom equal to keyboard height |
height |
Reduces the widget's maxHeight |
position |
Translates the widget upward |
translateWithPadding |
Half translate + half padding (for bottom sheets) |
Layout stability:
KeyboardAvoidingViewalways wraps the child in the same widget type regardless of keyboard state (Padding/SizedBox/Transform). This prevents Flutter from unmounting the subtree, soTextFields never lose theirFocusNode.
KeyboardAnimation — raw values #
Access live keyboard height and progress from any widget:
final animation = KeyboardControllerScope.of(context);
// ValueListenableBuilder — only this widget rebuilds per frame
ValueListenableBuilder<double>(
valueListenable: animation.heightNotifier,
child: const MyFAB(),
builder: (context, height, child) {
return Positioned(bottom: 16 + height, right: 16, child: child!);
},
);
Available notifiers #
| Notifier | Type | Description |
|---|---|---|
heightNotifier |
ValueNotifier<double> |
Keyboard height in dp (0 when hidden) |
progressNotifier |
ValueNotifier<double> |
Animation progress 0.0 → 1.0 |
isVisibleNotifier |
ValueNotifier<bool> |
Whether keyboard is currently visible |
lastEventNotifier |
ValueNotifier<KeyboardEventData?> |
Last raw event from native layer |
Convenience getters: animation.height · animation.progress · animation.isVisible · animation.lastEvent
Listening to keyboard events #
final animation = KeyboardControllerScope.of(context);
animation.lastEventNotifier.addListener(() {
final event = animation.lastEvent;
if (event == null) return;
switch (event.type) {
case KeyboardEventType.willShow:
print('Keyboard about to show, final height: ${event.height}');
case KeyboardEventType.didShow:
print('Keyboard fully visible');
case KeyboardEventType.willHide:
print('Keyboard about to hide');
case KeyboardEventType.didHide:
print('Keyboard hidden');
case KeyboardEventType.move:
print('Animating: ${event.height} dp, progress: ${event.progress}');
case KeyboardEventType.interactive:
print('User gesture dismiss: ${event.height} dp');
}
});
KeyboardEventData fields #
| Field | Type | Description |
|---|---|---|
type |
KeyboardEventType |
Event category |
height |
double |
Current keyboard height in dp |
progress |
double |
0.0 (hidden) → 1.0 (fully visible) |
duration |
double |
Native animation duration in ms |
timestamp |
double |
Unix ms when the event was fired |
isVisible |
bool |
height > 0 |
KeyboardController — imperative API #
// Dismiss keyboard
KeyboardController.dismiss();
KeyboardController.dismiss(keepFocus: true); // cursor stays, keyboard hides
KeyboardController.dismiss(animated: false); // iOS: instant dismiss
// Query state
final visible = await KeyboardController.isVisible();
final state = await KeyboardController.state();
print(state.height); // double — current height in dp
print(state.isVisible); // bool
print(state.progress); // double
// Android: change soft-input mode
KeyboardController.setInputMode(AndroidSoftInputMode.adjustNothing);
KeyboardController.setDefaultMode(); // restore to adjustResize
// iOS: preload keyboard to avoid first-show lag
KeyboardController.preload();
AndroidSoftInputMode values #
| Value | Effect |
|---|---|
adjustResize |
Scaffold body shrinks (Flutter default) |
adjustPan |
Window pans upward, body does not shrink |
adjustNothing |
Keyboard overlaps content, nothing moves |
adjustUnspecified |
System decides |
FocusedInputLayout #
Data models for retrieving information about the currently focused text input:
// FocusedInputLayout — position of the focused field on screen
FocusedInputLayout layout = FocusedInputLayout(
absoluteRect: Rect.fromLTWH(x, y, width, height),
isFocused: true,
);
print(layout.absoluteRect); // Rect in global screen coordinates
print(layout.isFocused); // bool
// FocusedInputLayout.empty() — default when nothing is focused
final empty = FocusedInputLayout.empty();
// FocusedInputTextChangedEvent — text change event
FocusedInputTextChangedEvent(text: 'Hello');
// FocusedInputSelectionChangedEvent — cursor / selection change
final sel = FocusedInputSelectionChangedEvent(start: 0, end: 5);
print(sel.isCollapsed); // true when start == end (cursor, not selection)
Migration from MediaQuery #
Before — fires once at end of animation, content jumps:
final height = MediaQuery.viewInsetsOf(context).bottom;
After — fires every frame, content follows keyboard smoothly:
final animation = KeyboardControllerScope.of(context);
ValueListenableBuilder<double>(
valueListenable: animation.heightNotifier,
builder: (_, height, child) {
return Padding(padding: EdgeInsets.only(bottom: height), child: child);
},
child: myContent,
);
Performance tips #
- Use
animation.heightNotifierin aValueListenableBuilderand pass your widget aschild— only thePositioned/Paddingwrapper rebuilds per frame, not the child widget itself. - Use
KeyboardControllerScope.maybeOf(context)when the provider might be absent — returns null gracefully, never throws. KeyboardChatScrollViewonly rebuilds its internalListViewpadding per frame — message bubble children are never touched.- Avoid reading
KeyboardControllerScope.of(context)at the root of a heavy widget — subscribe only at the leaf that actually needs the value.
API Reference #
Widgets #
| Widget | Key props | Defaults |
|---|---|---|
KeyboardProvider |
child, enabled |
enabled: true |
KeyboardAvoidingView |
child, behavior, keyboardVerticalOffset, enabled |
behavior: padding, offset: 0, enabled: true |
KeyboardAwareScrollView |
children, padding, scrollPadding, animationDuration, animationCurve, physics, scrollController |
scrollPadding: 20, duration: 300ms, curve: easeOut |
KeyboardStickyView |
child, offset: KeyboardStickyOffset(closed, opened) |
closed: 0, opened: 0 |
KeyboardToolbar |
doneLabel, prevLabel, nextLabel, arrowColor, doneColor, onPrev, onNext, onDone, content, showArrows, backgroundColor, borderColor |
showArrows: true |
KeyboardToolbarScaffold |
toolbar, body, appBar, resizeToAvoidBottomInset, backgroundColor |
resizeToAvoidBottomInset: true |
KeyboardChatScrollView |
children, liftBehavior, extraBottomPadding, safeAreaBottom, onEndVisible, controller, padding, physics |
liftBehavior: whenAtEnd, extraBottomPadding: 0, safeAreaBottom: 0 |
Models & Enums #
| Type | Values / Fields |
|---|---|
KeyboardAnimation |
heightNotifier, progressNotifier, isVisibleNotifier, lastEventNotifier |
KeyboardEventData |
height, progress, duration, timestamp, isVisible, type |
KeyboardEventType |
willShow, didShow, willHide, didHide, move, interactive |
KeyboardState |
height, isVisible, progress · KeyboardState.hidden() |
KeyboardLiftBehavior |
always, whenAtEnd, persistent, never |
KeyboardAvoidingBehavior |
padding, height, position, translateWithPadding |
AndroidSoftInputMode |
adjustResize, adjustPan, adjustNothing, adjustUnspecified |
FocusedInputLayout |
absoluteRect: Rect, isFocused: bool · .empty() |
FocusedInputTextChangedEvent |
text: String |
FocusedInputSelectionChangedEvent |
start: int, end: int, isCollapsed: bool |
KeyboardControllerScope #
| Method | Returns | Description |
|---|---|---|
KeyboardControllerScope.of(context) |
KeyboardAnimation |
Subscribes — widget rebuilds on every event |
KeyboardControllerScope.maybeOf(context) |
KeyboardAnimation? |
Does NOT subscribe — returns null if no provider |
context.keyboard |
KeyboardAnimation |
Shorthand for .of() |
context.keyboardOrNull |
KeyboardAnimation? |
Shorthand for .maybeOf() |
Changelog #
See CHANGELOG.md.
License #
MIT © 2025 Tuan Nguyen Cong