insets: lazy _start capture matches frame-callback ts domain
`_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.
This commit is contained in:
@@ -33,7 +33,10 @@ class XInsets with ChangeNotifier, WidgetsBindingObserver {
|
||||
EdgeInsets _system = EdgeInsets.zero;
|
||||
EdgeInsets _current = EdgeInsets.zero;
|
||||
EdgeInsets _from = EdgeInsets.zero;
|
||||
Duration _start = Duration.zero;
|
||||
// Lazy: captured on the first _tick of an animation so the value lives in
|
||||
// the same time domain as the frame-callback `ts` parameter (epoch-adjusted),
|
||||
// not the raw `currentSystemFrameTimeStamp` which is since-boot.
|
||||
Duration? _start;
|
||||
bool _ticking = false;
|
||||
Duration? _nextAnimationDuration;
|
||||
|
||||
@@ -77,19 +80,18 @@ class XInsets with ChangeNotifier, WidgetsBindingObserver {
|
||||
}
|
||||
_from = _current;
|
||||
_animDuration = animDuration;
|
||||
_start = SchedulerBinding.instance.currentSystemFrameTimeStamp;
|
||||
if (!_ticking) {
|
||||
_ticking = true;
|
||||
SchedulerBinding.instance.scheduleFrameCallback(_tick);
|
||||
SchedulerBinding.instance.scheduleFrame();
|
||||
}
|
||||
_start = null;
|
||||
_ticking = true;
|
||||
SchedulerBinding.instance.scheduleFrameCallback(_tick);
|
||||
SchedulerBinding.instance.scheduleFrame();
|
||||
}
|
||||
|
||||
Duration _animDuration = Duration.zero;
|
||||
|
||||
void _tick(Duration ts) {
|
||||
if (!_ticking) return;
|
||||
final dt = (ts - _start).inMicroseconds / _animDuration.inMicroseconds;
|
||||
_start ??= ts;
|
||||
final dt = (ts - _start!).inMicroseconds / _animDuration.inMicroseconds;
|
||||
final t = curve.transform(dt.clamp(0.0, 1.0));
|
||||
_current = EdgeInsets.lerp(_from, _system, t)!;
|
||||
notifyListeners();
|
||||
@@ -98,6 +100,7 @@ class XInsets with ChangeNotifier, WidgetsBindingObserver {
|
||||
SchedulerBinding.instance.scheduleFrame();
|
||||
} else {
|
||||
_ticking = false;
|
||||
_start = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
43
test/view_padding_test.dart
Normal file
43
test/view_padding_test.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ux/src/view_padding.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('animated viewPadding advances past _from on subsequent frames', (tester) async {
|
||||
final binding = TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final insets = XInsets.instance;
|
||||
|
||||
addTearDown(tester.view.resetViewPadding);
|
||||
|
||||
// Seed: snap to a known starting padding (no pending animation).
|
||||
tester.view.viewPadding = FakeViewPadding.zero;
|
||||
binding.handleMetricsChanged();
|
||||
await tester.pump();
|
||||
expect(insets.viewPadding.top, 0);
|
||||
|
||||
// Arm a 200ms animation, then change the system padding.
|
||||
unawaited(insets.setSystemUiMode(
|
||||
SystemUiMode.edgeToEdge,
|
||||
animate: const Duration(milliseconds: 200),
|
||||
));
|
||||
tester.view.viewPadding = const FakeViewPadding(top: 100);
|
||||
binding.handleMetricsChanged();
|
||||
|
||||
// Drive the lerp chain across a handful of frames. Pre-fix, `_start`
|
||||
// was assigned `currentSystemFrameTimeStamp` (raw since-boot) while
|
||||
// the frame-callback `ts` is epoch-adjusted — different time domains
|
||||
// gave `dt` a massively negative value, `clamp` pinned `t = 0`, and
|
||||
// `_current` never moved past `_from`. The expectation below would
|
||||
// fail with `viewPadding.top == 0`.
|
||||
for (var i = 0; i < 4; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
}
|
||||
expect(insets.viewPadding.top, greaterThan(0));
|
||||
|
||||
// Drain the animation chain so no transient callback leaks past
|
||||
// teardown.
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user