2398c8ad35ce3ddf0129512e3e200aa1bc3bb842
`_start` was assigned from `currentSystemFrameTimeStamp` (raw, since-boot) while the frame callback's `ts` parameter is epoch-adjusted (since the binding's first observed frame). The two live in different time domains; `dt = (ts - _start) / animDuration` came out massively negative, `clamp(0, 1)` pinned `t = 0`, and `_current` never moved past `_from` no matter how many frames fired. The bug was masked on most flows because `_current` happened to coincide with the post-animation target value already, but it surfaced on Android EMUI 12 after dismissing a gallery from a keyboard-open chat: the bottom inset stayed at 0 instead of animating back to the nav-bar height, leaving the composer flush with the screen edge and the system nav bar painted on top of it. Opening the keyboard again forced an unrelated metric pump that finally drove `_tick` to completion. Fix: capture `_start` lazily from the first `_tick`'s `ts`, so both sides of the subtraction live in the same epoch-adjusted domain. Regression test asserts viewPadding advances past `_from` across pumped frames; pre-fix it stayed at 0.
ux
A Flutter toolkit for building fluid, native-feeling UIs.
XKeyboard
Frame-accurate keyboard height tracking for iOS and Android, with interactive dismiss.
Flutter's built-in MediaQuery.viewInsets.bottom lags behind the actual keyboard position
and doesn't support interactive dismiss. XKeyboard reads the keyboard height directly
from the native layer via FFI — zero channel latency, every frame.
Features
- Real-time height — reads the keyboard's actual position each frame via FFI (iOS) / JNI (Android)
- Native animation curves — sampled from
CADisplayLink(iOS) andWindowInsetsAnimation(Android), with adaptive learning that refines the curve from observations - Interactive dismiss — swipe the keyboard down like iMessage/Telegram, with snap-back or dismiss
- Scroll freeze —
isTrackingflag lets you freeze scrolling during interactive dismiss
Quick start
final keyboard = XKeyboard.instance;
// Enable swipe-to-dismiss. trackingInset is the height of your input bar.
keyboard.enableInteractiveDismiss(trackingInset: 56);
Use ListenableBuilder to rebuild when the keyboard height changes:
Scaffold(
resizeToAvoidBottomInset: false, // we handle it ourselves
body: ListenableBuilder(
listenable: keyboard,
builder: (context, _) {
final keyboardHeight = keyboard.height;
final safeBottom = MediaQuery.viewPaddingOf(context).bottom;
final bottom = max(keyboardHeight, safeBottom);
return Column(
children: [
Expanded(
child: ListView.builder(
reverse: true,
// Freeze scrolling during interactive dismiss
physics: keyboard.isTracking
? NeverScrollableScrollPhysics()
: null,
// ...
),
),
Container(
padding: EdgeInsets.only(bottom: 8 + bottom),
// your input bar
),
],
);
},
),
);
API
| Member | Description |
|---|---|
XKeyboard.instance |
Singleton instance |
.height |
Current keyboard height in logical pixels |
.systemHeight |
Last system-reported keyboard height |
.isOpen |
Whether the keyboard is visible |
.isTracking |
Whether a dismiss pan gesture is active |
.enableInteractiveDismiss({trackingInset}) |
Enable swipe-to-dismiss |
.disableInteractiveDismiss() |
Disable swipe-to-dismiss |
addListener / removeListener |
Standard ChangeNotifier API |
Key points
- Set
resizeToAvoidBottomInset: falseon yourScaffold— otherwise Flutter's built-in resize fights withXKeyboard - Use
MediaQuery.viewPaddingOf(context).bottomfor the safe area (notpaddingOf, which is consumed byScaffold) - Use
max(keyboardHeight, safeBottom)for bottom padding — the keyboard height includes the safe area when open, andsafeBottomcovers the home indicator when closed
Other utilities
- BendBox — a flexible layout widget
- Bezier — bezier curve utilities
Languages
Dart
40.2%
Swift
29.1%
Kotlin
18.4%
Java
5.2%
C++
3.1%
Other
4%