- 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.
133 lines
4.1 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|