Files
ux/lib/src/view_padding.dart
agra de4925adf9 video_player + insets: native playback backend + animated viewPadding
- video_player: ExoPlayer (Android) / AVPlayer (iOS/macOS) backend with
  PixelBufferSink, method-channel adapter, Dart-side XVideoPlayer +
  testing fake.
- insets: XInsets singleton + XAnimatedInsets widget lerp the system
  viewPadding over 220ms so OS bar visibility toggles
  (immersiveSticky <-> edgeToEdge) slide bottom-/top-anchored UI into
  place instead of snapping by the nav-bar / status-bar height.
2026-05-23 15:57:15 +03:00

133 lines
4.1 KiB
Dart

import 'dart:ui' show FlutterView;
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
/// Smoothly-animated view padding (safe-area insets).
///
/// Replaces a raw `view.viewPadding` read with a value that lerps toward the
/// system inset over [duration] when it changes — so when the OS bars toggle
/// visibility (e.g. switching out of `SystemUiMode.immersiveSticky` back to
/// `edgeToEdge`) bottom- and top-anchored UI slides into place instead of
/// snapping by the nav-bar / status-bar height.
///
/// Use the singleton [instance] and listen via [addListener]; or wrap a
/// subtree in [XAnimatedInsets] so descendant `MediaQuery.viewPaddingOf`
/// reads pick up the animated value.
///
/// Tracks only `viewPadding` — keyboard insets (`viewInsets`) are handled by
/// [XKeyboard] and intentionally bypass this smoothing.
class XInsets with ChangeNotifier, WidgetsBindingObserver {
XInsets._() {
WidgetsBinding.instance.addObserver(this);
final view = WidgetsBinding.instance.platformDispatcher.implicitView;
_system = _read(view);
_current = _system;
}
/// Singleton instance. Constructed lazily on first access; requires
/// `WidgetsFlutterBinding.ensureInitialized()` to have been called.
static final XInsets instance = XInsets._();
EdgeInsets _system = EdgeInsets.zero;
EdgeInsets _current = EdgeInsets.zero;
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);
/// Lerp curve.
Curve curve = Curves.easeOut;
/// Smoothed view-padding in logical pixels.
EdgeInsets get viewPadding => _current;
/// Raw system view-padding (the un-lerped target). Use sparingly — most
/// callers want [viewPadding].
EdgeInsets get systemViewPadding => _system;
@override
void didChangeMetrics() {
final next = _read(WidgetsBinding.instance.platformDispatcher.implicitView);
if (next == _system) return;
_system = next;
if (duration == Duration.zero) {
_current = next;
_ticking = false;
notifyListeners();
return;
}
_from = _current;
_start = SchedulerBinding.instance.currentSystemFrameTimeStamp;
if (!_ticking) {
_ticking = true;
SchedulerBinding.instance.scheduleFrameCallback(_tick);
SchedulerBinding.instance.scheduleFrame();
}
}
void _tick(Duration ts) {
if (!_ticking) return;
final dt = (ts - _start).inMicroseconds / duration.inMicroseconds;
final t = curve.transform(dt.clamp(0.0, 1.0));
_current = EdgeInsets.lerp(_from, _system, t)!;
notifyListeners();
if (dt < 1.0) {
SchedulerBinding.instance.scheduleFrameCallback(_tick);
SchedulerBinding.instance.scheduleFrame();
} else {
_ticking = false;
}
}
static EdgeInsets _read(FlutterView? v) {
if (v == null) return EdgeInsets.zero;
final r = v.devicePixelRatio;
final p = v.viewPadding;
return EdgeInsets.fromLTRB(p.left / r, p.top / r, p.right / r, p.bottom / r);
}
}
/// Wraps [child] in a [MediaQuery] whose `viewPadding` is sourced from
/// [XInsets.instance] (smoothly animated). Use once at the app root so every
/// descendant `MediaQuery.viewPaddingOf(context)` read returns the animated
/// value.
class XAnimatedInsets extends StatefulWidget {
const XAnimatedInsets({super.key, required this.child});
final Widget child;
@override
State<XAnimatedInsets> createState() => _XAnimatedInsetsState();
}
class _XAnimatedInsetsState extends State<XAnimatedInsets> {
@override
void initState() {
super.initState();
XInsets.instance.addListener(_onTick);
}
@override
void dispose() {
XInsets.instance.removeListener(_onTick);
super.dispose();
}
void _onTick() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final mq = MediaQuery.of(context);
return MediaQuery(
data: mq.copyWith(viewPadding: XInsets.instance.viewPadding),
child: widget.child,
);
}
}