keyboard: clamp Dart curve to native height; narrow close-edge flush

The Dart-side predictive curve plays a sampled LUT to lead the system IME
animation by `_headStart`, so the composer can render where the IME will
be by the time the Flutter frame paints. When the LUT hasn't yet adapted
to the actual device curve, the prediction runs above the IME's real
position and the composer overshoots — quite visibly in debug mode where
Flutter's lower frame rate amplifies per-frame divergence, milder in
release where the rate is higher and learning converges faster.

Cap each per-frame `h` against the system-reported value (which is
per-vsync fresh on Android R+ via WindowInsetsAnimation.onProgress and
iOS via CADisplayLink). The clamp is a no-op on Android pre-R because
the native value snaps to target up-front, so the Huawei pre-R fallback
keeps animating via the LUT alone. Net effect on adapted devices: the
predictive lead is sacrificed for a per-frame "track the IME exactly"
guarantee — no overshoot regardless of LUT freshness.

Also narrow the previous-commit's `scheduleFrame` after notifyListeners
to fire only at the close-edge (`!isIOS && _height == 0`). Mid-animation
the steady-state pump or curveActive condition already schedules; only
the down-to-zero transition needs the explicit flush.
This commit is contained in:
agra
2026-05-22 21:26:55 +03:00
parent de0a96b557
commit 96df891b9d

View File

@@ -207,12 +207,26 @@ class XKeyboard with ChangeNotifier {
final t = (elapsed / _animDuration).clamp(0.0, 1.0);
h = _animFrom + (_animTo - _animFrom) * _lerpSamples(_samples, t);
// Cap the Dart-side prediction against the system-reported height.
// The native value is per-vsync fresh on Android R+ (via
// WindowInsetsAnimation.onProgress) and iOS (via CADisplayLink); on
// Android pre-R it snaps to the target up-front, making this a no-op.
// This trades the LUT's predictive *lead* for robustness against
// overshoot when the LUT hasn't adapted to this device's actual
// curve — especially in debug mode where Flutter's lower frame rate
// amplifies per-frame divergence.
final native = _uxKeyboardHeight!();
if (_animTo > _animFrom && h > native) {
h = native;
} else if (_animTo < _animFrom && h < native) {
h = native;
}
// Collect observations for adaptive learning.
if (t < 1.0 && !_converged) {
final ffi = _uxKeyboardHeight!();
final range = _animTo - _animFrom;
if (range.abs() > 1) {
final p = ((ffi - _animFrom) / range).clamp(0.0, 1.0);
final p = ((native - _animFrom) / range).clamp(0.0, 1.0);
_obs.add((t: t, p: p));
}
}
@@ -229,16 +243,18 @@ class XKeyboard with ChangeNotifier {
if ((h - _height).abs() > 0.5) {
_height = h;
notifyListeners();
// notifyListeners triggers setState in listeners, which marks elements
// dirty. We're inside a persistent frame callback (after the build phase
// has already run), so `ensureVisualUpdate` is a no-op — without an
// explicit scheduleFrame the dirty marks never get flushed. This matters
// especially on the down-edge (h transitions to 0) where the steady-state
// pump below stops scheduling.
SchedulerBinding.instance.scheduleFrame();
// Close-edge flush: when _height transitions to 0, the steady-state
// pump below (h>0 || _height>0) stops scheduling, and
// ensureVisualUpdate is a no-op inside a persistent frame callback —
// so dirty marks enqueued during notifyListeners would never get
// flushed. Mid-animation transitions don't need this because the
// pump or curveActive condition already covers them.
if (!Platform.isIOS && _height == 0) {
SchedulerBinding.instance.scheduleFrame();
}
}
// Steady-state pump while the curve is still running or the keyboard is up.
// Schedule frames while the curve is still running.
final curveActive = _isAnimating &&
(ts - _animStartTime) < _animDuration;
if (curveActive || (!Platform.isIOS && (h > 0 || _height > 0))) {