Fix status-bar overlap on scroll, and reposition/elevate the avatar menu
- Every tab now wraps its CustomScrollView in SafeArea(top: true) - the pinned sort/filter row was riding up underneath the status bar once the floating top bar above it fully collapsed, since that bar's own internal SafeArea only reserved the inset while it had some height left to put it in. Reserving it outside the scrolling region entirely fixes this regardless of the floating header's state. Caught live on device and backed by a new regression test. - The avatar menu now covers the top bar (title included) instead of sitting below it - positioned off just the status-bar inset rather than the top bar's full height on top of that. - The avatar menu's card now carries a second, tighter contact shadow alongside nooDialogShadow - that shadow alone was essentially invisible on a small card over a dark theme's near-black background. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -297,13 +297,21 @@ bare system prompt gives no context on its own.
|
|||||||
matching `Scaffold.appBar`'s old `pickRequest == null` guard - see
|
matching `Scaffold.appBar`'s old `pickRequest == null` guard - see
|
||||||
`buildAppTabView`'s doc comment. It sits above each tab's own pinned
|
`buildAppTabView`'s doc comment. It sits above each tab's own pinned
|
||||||
in-content header (the sort/filter controls row, or Files/Photos'
|
in-content header (the sort/filter controls row, or Files/Photos'
|
||||||
selection bar - see below); the two float/scroll independently.
|
selection bar - see below); the two float/scroll independently - each
|
||||||
`BottomNavBar` (`widgets/bottom_nav_bar.dart`) adapts
|
tab also wraps its whole `CustomScrollView` in `SafeArea(top: true,
|
||||||
the pinned `AppTab`s onto `NooBottomBar`. `AppDrawer`
|
bottom: false, ...)` so that pinned header stays clear of the status bar
|
||||||
|
once the floating top bar above it fully collapses (see
|
||||||
|
`topBarSliver`'s own doc comment for why that reservation can't live
|
||||||
|
inside the top bar itself). `BottomNavBar` (`widgets/bottom_nav_bar.dart`)
|
||||||
|
adapts the pinned `AppTab`s onto `NooBottomBar`. `AppDrawer`
|
||||||
(`widgets/app_drawer.dart`) builds a `NooDrawer`: account block, storage
|
(`widgets/app_drawer.dart`) builds a `NooDrawer`: account block, storage
|
||||||
meter, a "More" list of the hidden tabs, Settings, and an "Edit tabs"
|
meter, a "More" list of the hidden tabs, Settings, and an "Edit tabs"
|
||||||
link (opens Settings - there's no in-page anchor to scroll to its Tabs
|
link (opens Settings - there's no in-page anchor to scroll to its Tabs
|
||||||
section yet).
|
section yet). `SettingsController.navMenuStyle` (Settings → Appearance →
|
||||||
|
"Navigation menu") offers an alternative to the hamburger/drawer pair:
|
||||||
|
the avatar button opens `showAvatarMenu` (`widgets/avatar_menu.dart`)
|
||||||
|
instead, a dropdown holding the same hidden-tabs + Settings content -
|
||||||
|
see `styling.md`'s Gotchas for the wiring.
|
||||||
- **Desktop:** a `NooSidebar` (account card, pinned tabs, divider,
|
- **Desktop:** a `NooSidebar` (account card, pinned tabs, divider,
|
||||||
remaining tabs, storage meter, Settings) sits beside a `NooToolbar`
|
remaining tabs, storage meter, Settings) sits beside a `NooToolbar`
|
||||||
(tab title, search, an "Upload" action on Files/Photos) over the same
|
(tab title, search, an "Upload" action on Files/Photos) over the same
|
||||||
|
|||||||
@@ -236,7 +236,16 @@ blocks are noted where they matter:
|
|||||||
not a modal flow) - there's no existing anchored-popup primitive here
|
not a modal flow) - there's no existing anchored-popup primitive here
|
||||||
(`PopupMenuButton`'s own width doesn't stretch to a full content column),
|
(`PopupMenuButton`'s own width doesn't stretch to a full content column),
|
||||||
so don't reach for `showNooSheet`/`showNooDialog` for something shaped
|
so don't reach for `showNooSheet`/`showNooDialog` for something shaped
|
||||||
like this. Also added `NooTopBar.androidTitleReplacement`: Android has no
|
like this. It's positioned just past the status bar (`SafeArea`'s own
|
||||||
|
inset, not the top bar's full height on top of that) so it covers the
|
||||||
|
top bar - including the tab title - rather than sitting below it, and
|
||||||
|
its card carries two stacked `boxShadow`s rather than just
|
||||||
|
`nooDialogShadow` alone: that one shadow's blur is wide and soft enough
|
||||||
|
to read as basically invisible on a small card over a dark theme's
|
||||||
|
near-black `bg` (a dark, diffuse shadow needs real density close to the
|
||||||
|
edge to be visible against an already-dark backdrop), so a second,
|
||||||
|
tighter, more opaque contact shadow underneath it gives real elevation
|
||||||
|
in both themes. Also added `NooTopBar.androidTitleReplacement`: Android has no
|
||||||
large title to put a second search row under the way iOS's `search:`
|
large title to put a second search row under the way iOS's `search:`
|
||||||
slot does, so an inline search bar (`AppTopBar` passes a plain
|
slot does, so an inline search bar (`AppTopBar` passes a plain
|
||||||
`ShellSearchLauncher()` when search isn't in the bottom bar) replaces the
|
`ShellSearchLauncher()` when search isn't in the bottom bar) replaces the
|
||||||
|
|||||||
@@ -100,12 +100,19 @@ class ActivityView extends StatelessWidget {
|
|||||||
...tabBottomInsetSlivers(context),
|
...tabBottomInsetSlivers(context),
|
||||||
];
|
];
|
||||||
|
|
||||||
final scrollView = CustomScrollView(
|
// See files_view.dart's identical fix - without this, the sticky
|
||||||
|
// controls row rides up under the status bar once the floating top bar
|
||||||
|
// above it fully collapses.
|
||||||
|
final scrollView = SafeArea(
|
||||||
|
top: true,
|
||||||
|
bottom: false,
|
||||||
|
child: CustomScrollView(
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
// See files_view.dart's identical fix - without this, pull-to-
|
// See files_view.dart's identical fix - without this, pull-to-
|
||||||
// refresh can't be triggered on an empty or single-item feed.
|
// refresh can't be triggered on an empty or single-item feed.
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
slivers: contentSlivers,
|
slivers: contentSlivers,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
|
|||||||
@@ -658,6 +658,12 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
onRefresh: favoritesController.fetchAll,
|
onRefresh: favoritesController.fetchAll,
|
||||||
|
// See files_view.dart's identical fix - without this, the sticky
|
||||||
|
// controls row rides up under the status bar once the floating
|
||||||
|
// top bar above it fully collapses.
|
||||||
|
child: SafeArea(
|
||||||
|
top: true,
|
||||||
|
bottom: false,
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
controller: widget.scrollController,
|
controller: widget.scrollController,
|
||||||
// See files_view.dart's identical fix - without this, pull-to-
|
// See files_view.dart's identical fix - without this, pull-to-
|
||||||
@@ -667,6 +673,7 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -853,17 +853,33 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
unawaited(sync.syncOnPull());
|
unawaited(sync.syncOnPull());
|
||||||
return browser.reload();
|
return browser.reload();
|
||||||
},
|
},
|
||||||
|
// `topBarSliver`'s floating header can collapse all the way to
|
||||||
|
// zero height (fully scrolled away), at which point the sticky
|
||||||
|
// controls row right below it in `contentSlivers` would otherwise
|
||||||
|
// ride up underneath the status bar instead of stopping below it
|
||||||
|
// - the floating top bar used to be the only thing reserving that
|
||||||
|
// space (via its own internal `SafeArea`), and that reservation
|
||||||
|
// disappears along with it once it's fully hidden. Wrapping the
|
||||||
|
// whole scroll view keeps the inset outside the scrolling region
|
||||||
|
// entirely, so it's never implicated in the floating header's own
|
||||||
|
// collapse/reveal math - safe to apply unconditionally, since
|
||||||
|
// desktop's `MediaQuery.padding.top` is 0 anyway (no topBar / no
|
||||||
|
// status bar there).
|
||||||
|
child: SafeArea(
|
||||||
|
top: true,
|
||||||
|
bottom: false,
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
controller: widget.scrollController,
|
controller: widget.scrollController,
|
||||||
// Pull-to-refresh needs a scroll physics that allows dragging
|
// Pull-to-refresh needs a scroll physics that allows dragging
|
||||||
// past the edge even when content doesn't fill the viewport -
|
// past the edge even when content doesn't fill the viewport -
|
||||||
// an empty or single-item list otherwise can't be pulled at all
|
// an empty or single-item list otherwise can't be pulled at
|
||||||
// under the platform default physics.
|
// all under the platform default physics.
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
slivers: contentSlivers,
|
slivers: contentSlivers,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -369,6 +369,12 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
onRefresh: photosController.fetchAllMedia,
|
onRefresh: photosController.fetchAllMedia,
|
||||||
|
// See files_view.dart's identical fix - without this, the sticky
|
||||||
|
// controls row rides up under the status bar once the floating
|
||||||
|
// top bar above it fully collapses.
|
||||||
|
child: SafeArea(
|
||||||
|
top: true,
|
||||||
|
bottom: false,
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
controller: widget.scrollController,
|
controller: widget.scrollController,
|
||||||
// See files_view.dart's identical fix - without this, pull-to-
|
// See files_view.dart's identical fix - without this, pull-to-
|
||||||
@@ -378,6 +384,7 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,12 @@ class _RecentViewState extends State<RecentView> {
|
|||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
onRefresh: recent.fetchAll,
|
onRefresh: recent.fetchAll,
|
||||||
|
// See files_view.dart's identical fix - without this, the sticky
|
||||||
|
// controls row rides up under the status bar once the floating top
|
||||||
|
// bar above it fully collapses.
|
||||||
|
child: SafeArea(
|
||||||
|
top: true,
|
||||||
|
bottom: false,
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
controller: widget.scrollController,
|
controller: widget.scrollController,
|
||||||
// See files_view.dart's identical fix - without this, pull-to-
|
// See files_view.dart's identical fix - without this, pull-to-
|
||||||
@@ -96,6 +102,7 @@ class _RecentViewState extends State<RecentView> {
|
|||||||
slivers: contentSlivers,
|
slivers: contentSlivers,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,12 @@ class _SharesViewState extends State<SharesView> {
|
|||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
onRefresh: sharesController.fetchAll,
|
onRefresh: sharesController.fetchAll,
|
||||||
|
// See files_view.dart's identical fix - without this, the sticky
|
||||||
|
// controls row rides up under the status bar once the floating top
|
||||||
|
// bar above it fully collapses.
|
||||||
|
child: SafeArea(
|
||||||
|
top: true,
|
||||||
|
bottom: false,
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
controller: widget.scrollController,
|
controller: widget.scrollController,
|
||||||
// See files_view.dart's identical fix - without this, pull-to-
|
// See files_view.dart's identical fix - without this, pull-to-
|
||||||
@@ -142,6 +148,7 @@ class _SharesViewState extends State<SharesView> {
|
|||||||
slivers: contentSlivers,
|
slivers: contentSlivers,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,12 @@ class _TrashViewState extends State<TrashView> {
|
|||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
onRefresh: trashController.fetchAll,
|
onRefresh: trashController.fetchAll,
|
||||||
|
// See files_view.dart's identical fix - without this, the sticky
|
||||||
|
// controls row rides up under the status bar once the floating top
|
||||||
|
// bar above it fully collapses.
|
||||||
|
child: SafeArea(
|
||||||
|
top: true,
|
||||||
|
bottom: false,
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
controller: widget.scrollController,
|
controller: widget.scrollController,
|
||||||
// See files_view.dart's identical fix - without this, pull-to-
|
// See files_view.dart's identical fix - without this, pull-to-
|
||||||
@@ -111,6 +117,7 @@ class _TrashViewState extends State<TrashView> {
|
|||||||
slivers: contentSlivers,
|
slivers: contentSlivers,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import '../theme/design_tokens.dart';
|
|||||||
import 'noo/core/noo_avatar.dart';
|
import 'noo/core/noo_avatar.dart';
|
||||||
import 'noo/core/noo_badge.dart';
|
import 'noo/core/noo_badge.dart';
|
||||||
import 'noo/lists/noo_settings_row.dart';
|
import 'noo/lists/noo_settings_row.dart';
|
||||||
import 'noo/nav/noo_top_bar.dart';
|
|
||||||
import 'noo/noo_layout.dart';
|
|
||||||
import 'shell/shell_common.dart';
|
import 'shell/shell_common.dart';
|
||||||
|
|
||||||
/// The dropdown [ShellAvatarButton] opens when
|
/// The dropdown [ShellAvatarButton] opens when
|
||||||
@@ -24,14 +22,13 @@ import 'shell/shell_common.dart';
|
|||||||
/// app to reuse (`PopupMenuButton`'s own width doesn't stretch to the full
|
/// app to reuse (`PopupMenuButton`'s own width doesn't stretch to the full
|
||||||
/// content column the way this needs to).
|
/// content column the way this needs to).
|
||||||
Future<void> showAvatarMenu(BuildContext context) {
|
Future<void> showAvatarMenu(BuildContext context) {
|
||||||
final navStyle = NooLayout.navStyle(context);
|
|
||||||
return showGeneralDialog<void>(
|
return showGeneralDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierColor: Colors.transparent,
|
barrierColor: Colors.transparent,
|
||||||
barrierDismissible: true,
|
barrierDismissible: true,
|
||||||
barrierLabel: 'Close menu',
|
barrierLabel: 'Close menu',
|
||||||
transitionDuration: NooMotion.fast,
|
transitionDuration: NooMotion.fast,
|
||||||
pageBuilder: (context, _, _) => _AvatarMenuContent(navStyle: navStyle),
|
pageBuilder: (context, _, _) => const _AvatarMenuContent(),
|
||||||
transitionBuilder: (context, animation, _, child) => FadeTransition(
|
transitionBuilder: (context, animation, _, child) => FadeTransition(
|
||||||
opacity: animation,
|
opacity: animation,
|
||||||
child: ScaleTransition(
|
child: ScaleTransition(
|
||||||
@@ -47,9 +44,7 @@ Future<void> showAvatarMenu(BuildContext context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AvatarMenuContent extends StatelessWidget {
|
class _AvatarMenuContent extends StatelessWidget {
|
||||||
final NooNavStyle navStyle;
|
const _AvatarMenuContent();
|
||||||
|
|
||||||
const _AvatarMenuContent({required this.navStyle});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -60,15 +55,6 @@ class _AvatarMenuContent extends StatelessWidget {
|
|||||||
final hiddenTabs = settings.tabOrder
|
final hiddenTabs = settings.tabOrder
|
||||||
.where((t) => settings.hiddenTabs.contains(t))
|
.where((t) => settings.hiddenTabs.contains(t))
|
||||||
.toList();
|
.toList();
|
||||||
// The row/avatar it opens from is always this tall + the status bar
|
|
||||||
// above it (the button that opens this can't be tapped while its own
|
|
||||||
// top bar is scrolled away, so it's always on-screen at this exact
|
|
||||||
// position when that happens) - matches `NooTopBar.preferredSize`
|
|
||||||
// exactly rather than a guessed constant.
|
|
||||||
final topBarHeight = NooTopBar(
|
|
||||||
style: navStyle,
|
|
||||||
title: '',
|
|
||||||
).preferredSize.height;
|
|
||||||
|
|
||||||
void closeAndOpenSettings() {
|
void closeAndOpenSettings() {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
@@ -77,15 +63,15 @@ class _AvatarMenuContent extends StatelessWidget {
|
|||||||
|
|
||||||
return Align(
|
return Align(
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
|
// Just the status-bar inset, not the top bar's own height on top of
|
||||||
|
// it - the card covers the top bar (title included) rather than
|
||||||
|
// sitting below it, so opening it reads as the avatar growing into
|
||||||
|
// this instead of a separate element appearing underneath the row
|
||||||
|
// it came from.
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.symmetric(horizontal: NooSpace.md),
|
||||||
NooSpace.md,
|
|
||||||
topBarHeight,
|
|
||||||
NooSpace.md,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -94,7 +80,24 @@ class _AvatarMenuContent extends StatelessWidget {
|
|||||||
color: colors.surface,
|
color: colors.surface,
|
||||||
border: Border.all(color: colors.line),
|
border: Border.all(color: colors.line),
|
||||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||||
boxShadow: const [nooDialogShadow],
|
// `nooDialogShadow` alone is a wide, soft, fairly faint
|
||||||
|
// shadow - built for a desktop dialog with plenty of room
|
||||||
|
// to fall off into. On a small card over a dark theme's
|
||||||
|
// near-black `bg`, that falloff is too gradual to read as
|
||||||
|
// elevation at all (a dark shadow needs real density close
|
||||||
|
// to the edge to be visible against an already-dark
|
||||||
|
// backdrop). A second, tighter, more opaque contact shadow
|
||||||
|
// underneath it gives an immediate value-step right at the
|
||||||
|
// card's edge in both themes, with the soft one still
|
||||||
|
// doing the wider ambient falloff on top.
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x40000000),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: Offset(0, 4),
|
||||||
|
),
|
||||||
|
nooDialogShadow,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
|
|||||||
@@ -133,14 +133,31 @@ List<Widget> tabBottomInsetSlivers(BuildContext context) => [
|
|||||||
/// [SliverFloatingHeader] sizes itself from [topBar]'s own natural layout
|
/// [SliverFloatingHeader] sizes itself from [topBar]'s own natural layout
|
||||||
/// (like `SliverToBoxAdapter`) rather than a fixed extent declared up
|
/// (like `SliverToBoxAdapter`) rather than a fixed extent declared up
|
||||||
/// front - so [topBar]'s own internal `SafeArea` (see `NooTopBar`'s doc
|
/// front - so [topBar]'s own internal `SafeArea` (see `NooTopBar`'s doc
|
||||||
/// comment) already accounts for the status-bar inset correctly, with no
|
/// comment) already accounts for the status-bar inset correctly while
|
||||||
/// extra height math needed here (unlike building this on the general-
|
/// [topBar] itself is visible, with no extra height math needed here
|
||||||
/// purpose `SliverPersistentHeader` would have required).
|
/// (unlike building this on the general-purpose `SliverPersistentHeader`
|
||||||
|
/// would have required).
|
||||||
///
|
///
|
||||||
/// Sits above a tab's own pinned in-content header (built with
|
/// Sits above a tab's own pinned in-content header (built with
|
||||||
/// [StickyHeaderDelegate] - the sort/filter controls row, or the selection
|
/// [StickyHeaderDelegate] - the sort/filter controls row, or the selection
|
||||||
/// bar that replaces it) - put this sliver first in `contentSlivers` so
|
/// bar that replaces it) - put this sliver first in `contentSlivers` so
|
||||||
/// that header stays exactly where it already is, independent of whether
|
/// that header stays exactly where it already is, independent of whether
|
||||||
/// [topBar] is currently shown or scrolled away.
|
/// [topBar] is currently shown or scrolled away.
|
||||||
|
///
|
||||||
|
/// That pinned header needs its OWN protection from the status bar too,
|
||||||
|
/// though: [topBar]'s `SafeArea` only reserves space while [topBar] has
|
||||||
|
/// some height to put it in - once it's fully collapsed (0 height, [topBar]
|
||||||
|
/// scrolled all the way away), that reservation disappears with it, and
|
||||||
|
/// the pinned header would ride up underneath the status bar instead of
|
||||||
|
/// stopping below it (a real bug this shipped with once already - caught
|
||||||
|
/// by `tab_state_slivers_test.dart`'s regression test for it). Every tab
|
||||||
|
/// view wraps its whole `CustomScrollView` (this sliver, the pinned header,
|
||||||
|
/// and everything else) in `SafeArea(top: true, bottom: false, ...)` to
|
||||||
|
/// fix this - that reserves the inset outside the scrolling/collapsing
|
||||||
|
/// region entirely, so it's never implicated in this sliver's own
|
||||||
|
/// collapse math regardless of [topBar]'s current state. Flutter's
|
||||||
|
/// `SafeArea` nesting means this doesn't double the inset: the outer one
|
||||||
|
/// zeroes `MediaQuery.padding.top` for everything below it, so [topBar]'s
|
||||||
|
/// own inner `SafeArea` sees nothing left to add.
|
||||||
Widget topBarSliver(PreferredSizeWidget topBar) =>
|
Widget topBarSliver(PreferredSizeWidget topBar) =>
|
||||||
SliverFloatingHeader(child: topBar);
|
SliverFloatingHeader(child: topBar);
|
||||||
|
|||||||
@@ -32,6 +32,38 @@ class _FakeTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
/// tree is currently pumped.
|
/// tree is currently pumped.
|
||||||
Finder _header() => find.byType(SliverFloatingHeader, skipOffstage: false);
|
Finder _header() => find.byType(SliverFloatingHeader, skipOffstage: false);
|
||||||
|
|
||||||
|
/// A minimal stand-in for each tab's own pinned sort/filter row
|
||||||
|
/// (`StickyHeaderDelegate`) - just needs to be a `SliverPersistentHeader`
|
||||||
|
/// with `pinned: true` below `topBarSliver`, same contract every real tab
|
||||||
|
/// view uses.
|
||||||
|
class _FakeStickyHeader extends StatelessWidget {
|
||||||
|
const _FakeStickyHeader();
|
||||||
|
|
||||||
|
static const double height = 48;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => const SizedBox(
|
||||||
|
height: height,
|
||||||
|
child: ColoredBox(color: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeStickyHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||||
|
@override
|
||||||
|
double get minExtent => _FakeStickyHeader.height;
|
||||||
|
@override
|
||||||
|
double get maxExtent => _FakeStickyHeader.height;
|
||||||
|
@override
|
||||||
|
Widget build(
|
||||||
|
BuildContext context,
|
||||||
|
double shrinkOffset,
|
||||||
|
bool overlapsContent,
|
||||||
|
) => const _FakeStickyHeader();
|
||||||
|
@override
|
||||||
|
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) =>
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _pumpHost(WidgetTester tester, ScrollController controller) {
|
Future<void> _pumpHost(WidgetTester tester, ScrollController controller) {
|
||||||
return tester.pumpWidget(
|
return tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
@@ -140,4 +172,82 @@ void main() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'a pinned header below topBarSliver stays clear of the status bar once '
|
||||||
|
'the floating bar fully collapses (regression: it used to ride up '
|
||||||
|
'underneath the status bar once the only thing reserving that space '
|
||||||
|
"disappeared along with the bar's own height)",
|
||||||
|
(tester) async {
|
||||||
|
final controller = ScrollController();
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
const statusBarHeight = 40.0;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MediaQuery(
|
||||||
|
data: const MediaQueryData(
|
||||||
|
padding: EdgeInsets.only(top: statusBarHeight),
|
||||||
|
),
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: nooTheme(Brightness.light),
|
||||||
|
home: Scaffold(
|
||||||
|
// The fix under test: wrapping the scroll view (not just the
|
||||||
|
// top bar) in `SafeArea(top: true)` reserves the status-bar
|
||||||
|
// inset outside the scrolling/collapsing region entirely, so
|
||||||
|
// it's never implicated in `topBarSliver`'s own collapse math.
|
||||||
|
body: SafeArea(
|
||||||
|
top: true,
|
||||||
|
bottom: false,
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: controller,
|
||||||
|
slivers: [
|
||||||
|
topBarSliver(const _FakeTopBar()),
|
||||||
|
SliverPersistentHeader(
|
||||||
|
pinned: true,
|
||||||
|
delegate: _FakeStickyHeaderDelegate(),
|
||||||
|
),
|
||||||
|
SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) =>
|
||||||
|
SizedBox(height: 60, child: Text('Item $index')),
|
||||||
|
childCount: 40,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fully visible before any scroll - right below the reserved inset.
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(find.byType(_FakeStickyHeader)).dy,
|
||||||
|
_FakeTopBar.height + statusBarHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Scroll well past the top bar's own height so it collapses fully.
|
||||||
|
final gesture = await tester.startGesture(const Offset(200, 300));
|
||||||
|
await gesture.moveBy(const Offset(0, -300));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
tester.renderObject<RenderSliver>(_header()).geometry!.paintExtent,
|
||||||
|
0,
|
||||||
|
reason:
|
||||||
|
'the top bar should be fully collapsed for this check to '
|
||||||
|
'mean anything',
|
||||||
|
);
|
||||||
|
// The regression: without the fix, this would be 0 (or negative,
|
||||||
|
// scrolled up under the status bar) instead of sitting right at the
|
||||||
|
// reserved inset.
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(find.byType(_FakeStickyHeader)).dy,
|
||||||
|
statusBarHeight,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user