insets: explicit-intent setSystemUiMode replaces always-on lerp

Animating every metric change baked in a boot race: XInsets._() ran
during App.production() (before runApp), and on Android cold starts
the activity hadn't been laid out yet — view.viewPadding read zero.
The next didChangeMetrics then lerped 0 → real over 220ms, leaving
the safe-area collapsed for the first ~13 frames. Visible as content
under the status bar + home indicator on launch.

- didChangeMetrics now snaps by default. _from / _start / _ticking
  state only spins up when a caller explicitly opted in.
- XInsets.setSystemUiMode(mode, {animate}) wraps
  SystemChrome.setEnabledSystemUIMode. The opt-in flag and the
  trigger live together — no caller-side ordering risk, no
  one-shot side-channel callers can misuse.
- Removed the public `duration` knob (tests no longer need to pin
  it; nothing animates without an explicit setSystemUiMode call).
This commit is contained in:
agra
2026-05-25 23:04:51 +03:00
parent de4925adf9
commit ff520be971

View File

@@ -1,6 +1,7 @@
import 'dart:ui' show FlutterView;
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
/// Smoothly-animated view padding (safe-area insets).
@@ -34,14 +35,26 @@ class XInsets with ChangeNotifier, WidgetsBindingObserver {
EdgeInsets _from = EdgeInsets.zero;
Duration _start = Duration.zero;
bool _ticking = false;
/// Lerp duration. Set to [Duration.zero] in tests for deterministic
/// frame-by-frame goldens.
Duration duration = const Duration(milliseconds: 220);
Duration? _nextAnimationDuration;
/// Lerp curve.
Curve curve = Curves.easeOut;
/// Wraps [SystemChrome.setEnabledSystemUIMode]. The resulting metric
/// change (Android relaying out the activity for / against immersive)
/// animates `viewPadding` from the current value to the new system value
/// over [animate]. Pass `null` (or omit) to snap. Use this rather than
/// calling `SystemChrome.setEnabledSystemUIMode` directly when entering
/// or exiting immersive — otherwise the underlying chrome (NavBar /
/// BottomBar / composer) snaps into place by ~status-bar height.
Future<void> setSystemUiMode(
SystemUiMode mode, {
Duration? animate,
}) async {
_nextAnimationDuration = animate;
await SystemChrome.setEnabledSystemUIMode(mode);
}
/// Smoothed view-padding in logical pixels.
EdgeInsets get viewPadding => _current;
@@ -54,13 +67,16 @@ class XInsets with ChangeNotifier, WidgetsBindingObserver {
final next = _read(WidgetsBinding.instance.platformDispatcher.implicitView);
if (next == _system) return;
_system = next;
if (duration == Duration.zero) {
final animDuration = _nextAnimationDuration;
_nextAnimationDuration = null;
if (animDuration == null || animDuration == Duration.zero) {
_current = next;
_ticking = false;
notifyListeners();
return;
}
_from = _current;
_animDuration = animDuration;
_start = SchedulerBinding.instance.currentSystemFrameTimeStamp;
if (!_ticking) {
_ticking = true;
@@ -69,9 +85,11 @@ class XInsets with ChangeNotifier, WidgetsBindingObserver {
}
}
Duration _animDuration = Duration.zero;
void _tick(Duration ts) {
if (!_ticking) return;
final dt = (ts - _start).inMicroseconds / duration.inMicroseconds;
final dt = (ts - _start).inMicroseconds / _animDuration.inMicroseconds;
final t = curve.transform(dt.clamp(0.0, 1.0));
_current = EdgeInsets.lerp(_from, _system, t)!;
notifyListeners();