fix(shell): rail overflow on short viewports and tab reset on rotation

- NavigationRail scrolls inside its SafeArea when the viewport is too
  short for four labeled destinations (landscape phone with the mini
  player), instead of overflowing by its natural height
- the PageView subtree carries a GlobalKey so swapping the body
  between rail and bottom-bar layouts at the 600dp breakpoint moves
  the element instead of remounting it - rotation no longer snaps
  back to the home tab and kept-alive tab state survives
This commit is contained in:
zarzet
2026-07-14 09:10:17 +07:00
parent a7f9aae0a9
commit 5a1e898795
+61 -38
View File
@@ -42,6 +42,9 @@ class MainShell extends ConsumerStatefulWidget {
class _MainShellState extends ConsumerState<MainShell>
with SingleTickerProviderStateMixin {
int _currentIndex = 0;
// Preserves the PageView element (and its kept-alive tabs) when the body
// structure swaps between rail and bottom-bar layouts on rotation.
final GlobalKey _pageViewKey = GlobalKey();
late final PageController _pageController;
late final AnimationController _tabJumpTransitionController;
bool _hasCheckedUpdate = false;
@@ -588,33 +591,36 @@ class _MainShellState extends ConsumerState<MainShell>
// bottom NavigationBar on phones.
final useNavigationRail = MediaQuery.sizeOf(context).width >= 600;
final pageView = AnimatedBuilder(
animation: _tabJumpTransitionController,
child: PageView.builder(
controller: _pageController,
itemCount: tabs.length,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
// TickerMode mutes animations and lets visibility-aware widgets
// (e.g. MotionHeaderBanner) pause when their tab is hidden —
// kept-alive pages otherwise keep running offscreen.
itemBuilder: (context, index) => _KeepAliveTabPage(
key: ValueKey('page-$index'),
child: TickerMode(
enabled: index == _currentIndex,
child: tabs[index],
final pageView = KeyedSubtree(
key: _pageViewKey,
child: AnimatedBuilder(
animation: _tabJumpTransitionController,
child: PageView.builder(
controller: _pageController,
itemCount: tabs.length,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
// TickerMode mutes animations and lets visibility-aware widgets
// (e.g. MotionHeaderBanner) pause when their tab is hidden —
// kept-alive pages otherwise keep running offscreen.
itemBuilder: (context, index) => _KeepAliveTabPage(
key: ValueKey('page-$index'),
child: TickerMode(
enabled: index == _currentIndex,
child: tabs[index],
),
),
),
builder: (context, child) {
final t = Curves.easeOutCubic.transform(
_tabJumpTransitionController.value,
);
return Opacity(
opacity: t,
child: Transform.scale(scale: 0.985 + (0.015 * t), child: child),
);
},
),
builder: (context, child) {
final t = Curves.easeOutCubic.transform(
_tabJumpTransitionController.value,
);
return Opacity(
opacity: t,
child: Transform.scale(scale: 0.985 + (0.015 * t), child: child),
);
},
);
return BackButtonListener(
@@ -624,27 +630,44 @@ class _MainShellState extends ConsumerState<MainShell>
},
child: Scaffold(
extendBody: true,
// The page view keeps one element across the rail<->bar structure
// swap via _pageViewKey; without it a rotation past the 600dp
// breakpoint remounts the PageView and snaps back to the first tab.
body: useNavigationRail
? Row(
children: [
SafeArea(
right: false,
bottom: false,
child: NavigationRail(
selectedIndex: _currentIndex.clamp(0, maxIndex),
onDestinationSelected: _onNavTap,
labelType: NavigationRailLabelType.all,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainer,
destinations: [
for (final destination in destinations)
NavigationRailDestination(
icon: destination.icon,
selectedIcon: destination.selectedIcon,
label: Text(destination.label),
// The rail needs ~300dp of height for four labeled
// destinations; on short viewports (landscape phone with
// the mini player showing) it must scroll, not overflow.
child: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
],
child: IntrinsicHeight(
child: NavigationRail(
selectedIndex: _currentIndex.clamp(0, maxIndex),
onDestinationSelected: _onNavTap,
labelType: NavigationRailLabelType.all,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainer,
destinations: [
for (final destination in destinations)
NavigationRailDestination(
icon: destination.icon,
selectedIcon: destination.selectedIcon,
label: Text(destination.label),
),
],
),
),
),
),
),
),
const VerticalDivider(width: 1),