From 53784d64c35891d25d20028139176bad64d7d3e9 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 14 Jul 2026 08:59:37 +0700 Subject: [PATCH] feat(ui): crossfade the app content on orientation and rail-breakpoint changes Flutter reflows in one frame on rotation, so the new layout popped in; tablets change the most (bottom bar to rail, two-pane now-playing). A 300ms fade above the router runs on orientation flips and on width changes across the 600dp rail breakpoint (fold/unfold, split-screen). --- lib/app.dart | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/lib/app.dart b/lib/app.dart index 6384b21c..582215a1 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -89,6 +89,64 @@ class _FluidScrollBehavior extends MaterialScrollBehavior { const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()); } +/// Fades the app content in after an orientation change or a width change +/// across the shell's rail breakpoint (fold/unfold, split-screen resize) — +/// Flutter reflows the layout in a single frame, so without this the new +/// layout pops in. +class _OrientationFade extends StatefulWidget { + final Widget child; + + const _OrientationFade({required this.child}); + + @override + State<_OrientationFade> createState() => _OrientationFadeState(); +} + +class _OrientationFadeState extends State<_OrientationFade> + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + value: 1, + ); + // Matches the shell's NavigationRail breakpoint. + static const double _railBreakpoint = 600; + + Orientation? _lastOrientation; + double? _lastWidth; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final orientation = MediaQuery.orientationOf(context); + final width = MediaQuery.sizeOf(context).width; + final orientationChanged = + _lastOrientation != null && orientation != _lastOrientation; + final crossedRailBreakpoint = + _lastWidth != null && + (_lastWidth! < _railBreakpoint) != (width < _railBreakpoint); + if (orientationChanged || crossedRailBreakpoint) { + _controller.forward(from: 0); + } + _lastOrientation = orientation; + _lastWidth = width; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: CurvedAnimation(parent: _controller, curve: Curves.easeOut), + child: widget.child, + ); + } +} + class SpotiFLACApp extends ConsumerWidget { final bool disableOverscrollEffects; @@ -140,7 +198,9 @@ class SpotiFLACApp extends ConsumerWidget { // never register, so no flights run on any navigator. child: HeroMode( enabled: heroAnimationsEnabled, - child: child ?? const SizedBox.shrink(), + child: _OrientationFade( + child: child ?? const SizedBox.shrink(), + ), ), ); },