Merge: make the shell top bar hide/reveal with scroll, per tab
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -284,7 +284,21 @@ bare system prompt gives no context on its own.
|
||||
other tab has a create/upload flow), and the account avatar; Android
|
||||
gets a compact title row with `search`/avatar actions, relying on an
|
||||
extended `NooFab` ("Upload", Files/Photos only) instead of a top-bar
|
||||
icon for upload. `BottomNavBar` (`widgets/bottom_nav_bar.dart`) adapts
|
||||
icon for upload. Rather than one shared instance in `Scaffold.appBar`,
|
||||
`MainShellView` builds a separate `AppTopBar` per tab (labelled for that
|
||||
tab) and each tab plants its own as the first sliver in its own
|
||||
`CustomScrollView` (`topBarSliver` in `widgets/tabs/tab_state_slivers.dart`,
|
||||
wrapping it in the framework's `SliverFloatingHeader`) instead of passing
|
||||
it to `Scaffold.appBar` - see that file's doc comment for why (Material's
|
||||
native "floating" app bar behavior, tied to that tab's own
|
||||
`ScrollController`: scrolls away as the list scrolls down and reappears
|
||||
the moment the drag reverses, not only once scrolled back to the top).
|
||||
`topBar` is null (no top bar rendered) on desktop and while picking,
|
||||
matching `Scaffold.appBar`'s old `pickRequest == null` guard - see
|
||||
`buildAppTabView`'s doc comment. It sits above each tab's own pinned
|
||||
in-content header (the sort/filter controls row, or Files/Photos'
|
||||
selection bar - see below); the two float/scroll independently.
|
||||
`BottomNavBar` (`widgets/bottom_nav_bar.dart`) adapts
|
||||
the pinned `AppTab`s onto `NooBottomBar`. `AppDrawer`
|
||||
(`widgets/app_drawer.dart`) builds a `NooDrawer`: account block, storage
|
||||
meter, a "More" list of the hidden tabs, Settings, and an "Edit tabs"
|
||||
|
||||
@@ -143,6 +143,12 @@ blocks are noted where they matter:
|
||||
`lib/widgets/tabs/` (`tab_state_slivers.dart`, `tab_day_groups.dart`,
|
||||
`tab_location.dart`) — the loading/error/empty-state slivers and
|
||||
day/month grouping helpers shared by Recent/Activity/Trash/Shares.
|
||||
`tab_state_slivers.dart` also has `topBarSliver`, which every regular
|
||||
tab's view uses to plant its own `AppTopBar` instance as the first sliver
|
||||
in its `CustomScrollView` (a thin wrapper around the framework's
|
||||
`SliverFloatingHeader`) instead of the shell passing one shared instance
|
||||
to `Scaffold.appBar` - see its doc comment and `architecture.md`'s
|
||||
"Mobile" bullet for why.
|
||||
`lib/widgets/settings/` — Settings' 8 section widgets plus
|
||||
`settings_section.dart`'s `SettingsSection`/`showSettingsPicker` and
|
||||
`settings_dialogs.dart`'s `confirmRemoveAccount`.
|
||||
|
||||
+39
-15
@@ -381,15 +381,51 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
String? trashBadge(AppTab tab) =>
|
||||
tab == AppTab.trash && trashCount > 0 ? '$trashCount' : null;
|
||||
|
||||
final isDesktop = NooLayout.isDesktop(context);
|
||||
final navStyle = NooLayout.navStyle(context);
|
||||
// Picking/offline already override the tab set itself (see pinnedTabs
|
||||
// above) - Search doesn't belong in either: there's nothing to search
|
||||
// for a file-picking flow, and Search needs the network Offline mode
|
||||
// doesn't have.
|
||||
final showBottomBarSearch = !overrideActive && settings.searchInBottomBar;
|
||||
|
||||
// Each tab renders its own sticky selection toolbar inline (right under
|
||||
// its sort/filter row) instead of the shell swapping in a shared one,
|
||||
// so the nav chrome stays put and usable regardless of selection state.
|
||||
//
|
||||
// Each tab also builds its own [AppTopBar] (labelled for that tab, not
|
||||
// just whichever is currently selected) and plants it as that tab's own
|
||||
// first sliver - see `buildAppTabView`'s doc comment and `topBarSliver`
|
||||
// in `tabs/tab_state_slivers.dart` - instead of one shared instance
|
||||
// living in `Scaffold.appBar`. That's what gives each tab's top bar
|
||||
// Material's native floating-away-on-scroll-down/reappear-on-scroll-up
|
||||
// behavior, tied to that tab's own `ScrollController`: a fixed
|
||||
// `Scaffold.appBar` can't do that (no per-tab scroll signal reaches
|
||||
// it), and a shared single instance can't show 7 different tab labels
|
||||
// at once now that every tab keeps its own independent scroll state.
|
||||
// Null on desktop (which shows `NooToolbar` instead - embedding it here
|
||||
// too, since `tabStack` is shared by both layouts below, would double
|
||||
// up the top chrome there) and while picking (no top bar at all, same
|
||||
// as this screen's old `Scaffold.appBar: pickRequest == null ? ... :
|
||||
// null`).
|
||||
final tabStack = Stack(
|
||||
children: [
|
||||
IndexedStack(
|
||||
index: selectedIndex,
|
||||
children: displayTabs
|
||||
.map((tab) => buildAppTabView(tab, _scrollControllers[tab]!))
|
||||
.map(
|
||||
(tab) => buildAppTabView(
|
||||
tab,
|
||||
_scrollControllers[tab]!,
|
||||
topBar: isDesktop || pickRequest != null
|
||||
? null
|
||||
: AppTopBar(
|
||||
style: navStyle,
|
||||
tab: tab,
|
||||
searchInBottomBar: showBottomBarSearch,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
if (pick.isDownloadingForPick) const _PickingProgressOverlay(),
|
||||
@@ -401,7 +437,7 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
(selectedTab == AppTab.files || selectedTab == AppTab.photos);
|
||||
|
||||
Widget scaffold;
|
||||
if (NooLayout.isDesktop(context)) {
|
||||
if (isDesktop) {
|
||||
scaffold = Scaffold(
|
||||
backgroundColor: colors.bg,
|
||||
body: Row(
|
||||
@@ -471,23 +507,11 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final navStyle = NooLayout.navStyle(context);
|
||||
final bottomBarStyle = settings.bottomBarStyle;
|
||||
// Picking/offline already override the tab set itself (see
|
||||
// pinnedTabs above) - Search doesn't belong in either: there's
|
||||
// nothing to search for a file-picking flow, and Search needs the
|
||||
// network Offline mode doesn't have.
|
||||
final showBottomBarSearch = !overrideActive && settings.searchInBottomBar;
|
||||
scaffold = Scaffold(
|
||||
backgroundColor: colors.bg,
|
||||
drawerScrimColor: colors.scrim,
|
||||
appBar: pickRequest == null
|
||||
? AppTopBar(
|
||||
style: navStyle,
|
||||
tab: selectedTab,
|
||||
searchInBottomBar: showBottomBarSearch,
|
||||
)
|
||||
: null,
|
||||
// The top bar itself no longer lives here - see `tabStack` above.
|
||||
drawer: pickRequest == null ? const AppDrawer() : null,
|
||||
// Floating needs the body to draw behind the bar's own transparent
|
||||
// margin (see NooBottomBarStyle's doc comment) instead of stopping
|
||||
|
||||
@@ -29,7 +29,11 @@ import '../widgets/tabs/tab_state_slivers.dart';
|
||||
class ActivityView extends StatelessWidget {
|
||||
final ScrollController scrollController;
|
||||
|
||||
const ActivityView({super.key, required this.scrollController});
|
||||
/// This tab's own shell top bar, planted as its first sliver - see
|
||||
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||
final PreferredSizeWidget? topBar;
|
||||
|
||||
const ActivityView({super.key, required this.scrollController, this.topBar});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -90,6 +94,7 @@ class ActivityView extends StatelessWidget {
|
||||
}
|
||||
|
||||
final List<Widget> contentSlivers = [
|
||||
if (topBar != null) topBarSliver(topBar!),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
|
||||
feed,
|
||||
...tabBottomInsetSlivers(context),
|
||||
|
||||
@@ -31,6 +31,7 @@ import '../widgets/share_sheet.dart';
|
||||
import '../widgets/sticky_header_delegate.dart';
|
||||
import '../widgets/tabs/tab_state_slivers.dart';
|
||||
import '../widgets/synced_header_scaffold.dart' show formatBytes;
|
||||
import '../widgets/tabs/tab_state_slivers.dart';
|
||||
import 'file_viewer_screen.dart';
|
||||
import 'move_copy_destination_picker.dart';
|
||||
|
||||
@@ -51,7 +52,11 @@ import 'move_copy_destination_picker.dart';
|
||||
class FavoritesView extends StatefulWidget {
|
||||
final ScrollController scrollController;
|
||||
|
||||
const FavoritesView({super.key, required this.scrollController});
|
||||
/// This tab's own shell top bar, planted as its first sliver - see
|
||||
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||
final PreferredSizeWidget? topBar;
|
||||
|
||||
const FavoritesView({super.key, required this.scrollController, this.topBar});
|
||||
|
||||
@override
|
||||
State<FavoritesView> createState() => _FavoritesViewState();
|
||||
@@ -508,6 +513,7 @@ class _FavoritesViewState extends State<FavoritesView> {
|
||||
);
|
||||
|
||||
final contentSlivers = <Widget>[
|
||||
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||
SliverPersistentHeader(
|
||||
pinned: !_isSelecting,
|
||||
delegate: StickyHeaderDelegate(
|
||||
|
||||
@@ -44,6 +44,7 @@ import '../widgets/noo/noo_layout.dart';
|
||||
import '../widgets/noo/overlays/noo_sheet.dart';
|
||||
import '../widgets/share_sheet.dart';
|
||||
import '../widgets/sticky_header_delegate.dart';
|
||||
import '../widgets/tabs/tab_state_slivers.dart';
|
||||
import '../widgets/synced_header_scaffold.dart' show formatBytes;
|
||||
import '../widgets/tabs/tab_state_slivers.dart';
|
||||
import 'file_viewer_screen.dart';
|
||||
@@ -61,10 +62,15 @@ class FilesView extends StatefulWidget {
|
||||
final ScrollController scrollController;
|
||||
final bool offline;
|
||||
|
||||
/// This tab's own shell top bar, planted as its first sliver - see
|
||||
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||
final PreferredSizeWidget? topBar;
|
||||
|
||||
const FilesView({
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
this.offline = false,
|
||||
this.topBar,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -655,6 +661,7 @@ class _FilesViewState extends State<FilesView> {
|
||||
);
|
||||
|
||||
final List<Widget> contentSlivers = [
|
||||
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||
// Pinned in both states - while browsing this is the controls row
|
||||
// (+ breadcrumbs), while selecting it's the selection bar (see
|
||||
// `topRow` above): either way it's the one thing that always stays
|
||||
|
||||
@@ -54,7 +54,11 @@ class _MonthGroup {
|
||||
class PhotosView extends StatefulWidget {
|
||||
final ScrollController scrollController;
|
||||
|
||||
const PhotosView({super.key, required this.scrollController});
|
||||
/// This tab's own shell top bar, planted as its first sliver - see
|
||||
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||
final PreferredSizeWidget? topBar;
|
||||
|
||||
const PhotosView({super.key, required this.scrollController, this.topBar});
|
||||
|
||||
@override
|
||||
State<PhotosView> createState() => _PhotosViewState();
|
||||
@@ -240,6 +244,7 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
);
|
||||
|
||||
final List<Widget> contentSlivers = [
|
||||
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||
// Sticky while browsing; once selecting, the selection bar takes over
|
||||
// the same slot instead.
|
||||
SliverPersistentHeader(
|
||||
|
||||
@@ -27,7 +27,11 @@ import 'file_viewer_screen.dart';
|
||||
class RecentView extends StatefulWidget {
|
||||
final ScrollController scrollController;
|
||||
|
||||
const RecentView({super.key, required this.scrollController});
|
||||
/// This tab's own shell top bar, planted as its first sliver - see
|
||||
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||
final PreferredSizeWidget? topBar;
|
||||
|
||||
const RecentView({super.key, required this.scrollController, this.topBar});
|
||||
|
||||
@override
|
||||
State<RecentView> createState() => _RecentViewState();
|
||||
@@ -54,6 +58,7 @@ class _RecentViewState extends State<RecentView> {
|
||||
);
|
||||
|
||||
final List<Widget> contentSlivers = [
|
||||
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
|
||||
if (recent.isLoading && items.isEmpty)
|
||||
tabLoadingSliver
|
||||
|
||||
@@ -53,7 +53,11 @@ String _permissionLabel(int permissions) {
|
||||
class SharesView extends StatefulWidget {
|
||||
final ScrollController scrollController;
|
||||
|
||||
const SharesView({super.key, required this.scrollController});
|
||||
/// This tab's own shell top bar, planted as its first sliver - see
|
||||
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||
final PreferredSizeWidget? topBar;
|
||||
|
||||
const SharesView({super.key, required this.scrollController, this.topBar});
|
||||
|
||||
@override
|
||||
State<SharesView> createState() => _SharesViewState();
|
||||
@@ -81,6 +85,7 @@ class _SharesViewState extends State<SharesView> {
|
||||
final isDesktop = NooLayout.isDesktop(context);
|
||||
|
||||
final List<Widget> contentSlivers = [
|
||||
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
NooLayout.gutter(context),
|
||||
|
||||
@@ -25,7 +25,11 @@ import '../widgets/tabs/tab_state_slivers.dart';
|
||||
class TrashView extends StatefulWidget {
|
||||
final ScrollController scrollController;
|
||||
|
||||
const TrashView({super.key, required this.scrollController});
|
||||
/// This tab's own shell top bar, planted as its first sliver - see
|
||||
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||
final PreferredSizeWidget? topBar;
|
||||
|
||||
const TrashView({super.key, required this.scrollController, this.topBar});
|
||||
|
||||
@override
|
||||
State<TrashView> createState() => _TrashViewState();
|
||||
@@ -50,6 +54,7 @@ class _TrashViewState extends State<TrashView> {
|
||||
final isDesktop = NooLayout.isDesktop(context);
|
||||
|
||||
final List<Widget> contentSlivers = [
|
||||
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
NooLayout.gutter(context),
|
||||
|
||||
@@ -11,7 +11,20 @@ import '../views/trash_view.dart';
|
||||
/// Builds the view widget for a given [AppTab]. Shared by the main shell
|
||||
/// (for visible tabs) and the "more tabs" dropdown (for launching a tab
|
||||
/// that's currently hidden from the bottom nav bar).
|
||||
Widget buildAppTabView(AppTab tab, ScrollController controller) {
|
||||
///
|
||||
/// [topBar], when given, is that tab's own [AppTopBar] instance (built by
|
||||
/// the caller, which owns `NooLayout.navStyle`/pick-mode/search-in-bottom-
|
||||
/// bar state) - each view plants it as its own first sliver (see
|
||||
/// `topBarSliver` in `tabs/tab_state_slivers.dart`) so it scrolls away and
|
||||
/// reappears independently, tied to that tab's own `ScrollController`
|
||||
/// rather than living in the shared `Scaffold.appBar`. Null on desktop
|
||||
/// (which shows `NooToolbar` instead) and while picking (no top bar at
|
||||
/// all), matching `Scaffold.appBar`'s old `pickRequest == null` guard.
|
||||
Widget buildAppTabView(
|
||||
AppTab tab,
|
||||
ScrollController controller, {
|
||||
PreferredSizeWidget? topBar,
|
||||
}) {
|
||||
switch (tab) {
|
||||
case AppTab.files:
|
||||
// Keyed so the Files and Offline tabs (same widget type) never share
|
||||
@@ -19,24 +32,26 @@ Widget buildAppTabView(AppTab tab, ScrollController controller) {
|
||||
return FilesView(
|
||||
key: const ValueKey('files'),
|
||||
scrollController: controller,
|
||||
topBar: topBar,
|
||||
);
|
||||
case AppTab.photos:
|
||||
return PhotosView(scrollController: controller);
|
||||
return PhotosView(scrollController: controller, topBar: topBar);
|
||||
case AppTab.favorites:
|
||||
return FavoritesView(scrollController: controller);
|
||||
return FavoritesView(scrollController: controller, topBar: topBar);
|
||||
case AppTab.activity:
|
||||
return ActivityView(scrollController: controller);
|
||||
return ActivityView(scrollController: controller, topBar: topBar);
|
||||
case AppTab.trash:
|
||||
return TrashView(scrollController: controller);
|
||||
return TrashView(scrollController: controller, topBar: topBar);
|
||||
case AppTab.shares:
|
||||
return SharesView(scrollController: controller);
|
||||
return SharesView(scrollController: controller, topBar: topBar);
|
||||
case AppTab.recent:
|
||||
return RecentView(scrollController: controller);
|
||||
return RecentView(scrollController: controller, topBar: topBar);
|
||||
case AppTab.offline:
|
||||
return FilesView(
|
||||
key: const ValueKey('offline'),
|
||||
scrollController: controller,
|
||||
offline: true,
|
||||
topBar: topBar,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,3 +118,29 @@ List<Widget> tabBottomInsetSlivers(BuildContext context) => [
|
||||
SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
|
||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||
];
|
||||
|
||||
/// Wraps a tab's shell top bar ([AppTopBar], passed in as the generic
|
||||
/// [PreferredSizeWidget] it implements - this file can't import
|
||||
/// `app_top_bar.dart` without a cycle) as that tab's own first sliver,
|
||||
/// living inside its `CustomScrollView` instead of `Scaffold.appBar`. Gives
|
||||
/// it Material's native "floating app bar" behavior, via the framework's own
|
||||
/// [SliverFloatingHeader]: it scrolls away as the list scrolls down, and -
|
||||
/// unlike a plain `SliverToBoxAdapter`, which only reappears once scrolled
|
||||
/// all the way back to the top - reappears immediately on any upward
|
||||
/// scroll, following the finger while dragging and settling fully open or
|
||||
/// fully closed once the gesture ends.
|
||||
///
|
||||
/// [SliverFloatingHeader] sizes itself from [topBar]'s own natural layout
|
||||
/// (like `SliverToBoxAdapter`) rather than a fixed extent declared up
|
||||
/// front - so [topBar]'s own internal `SafeArea` (see `NooTopBar`'s doc
|
||||
/// comment) already accounts for the status-bar inset correctly, with no
|
||||
/// extra height math needed here (unlike building this on the general-
|
||||
/// purpose `SliverPersistentHeader` would have required).
|
||||
///
|
||||
/// Sits above a tab's own pinned in-content header (built with
|
||||
/// [StickyHeaderDelegate] - the sort/filter controls row, or the selection
|
||||
/// bar that replaces it) - put this sliver first in `contentSlivers` so
|
||||
/// that header stays exactly where it already is, independent of whether
|
||||
/// [topBar] is currently shown or scrolled away.
|
||||
Widget topBarSliver(PreferredSizeWidget topBar) =>
|
||||
SliverFloatingHeader(child: topBar);
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:noo/widgets/tabs/tab_state_slivers.dart';
|
||||
|
||||
import '../noo/noo_test_utils.dart';
|
||||
|
||||
/// A minimal stand-in for `AppTopBar` - just needs to be a fixed-height
|
||||
/// [PreferredSizeWidget], same contract `topBarSliver` wraps in real tabs.
|
||||
class _FakeTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
const _FakeTopBar();
|
||||
|
||||
static const double height = 56;
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(height);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const SizedBox(
|
||||
height: height,
|
||||
child: ColoredBox(color: Colors.blue),
|
||||
);
|
||||
}
|
||||
|
||||
/// Matches the one sliver `topBarSliver` inserts, even once it's fully
|
||||
/// scrolled away - `find.byType`'s default `skipOffstage: true` treats a
|
||||
/// sliver with `geometry.visible == false` (paintExtent 0, exactly the
|
||||
/// "fully hidden" state part of this test exercises) as offstage and
|
||||
/// excludes it, which would otherwise make `tester.renderObject` throw
|
||||
/// instead of reporting the 0 this test expects. A function, not a
|
||||
/// top-level constant, so each call re-evaluates against whichever widget
|
||||
/// tree is currently pumped.
|
||||
Finder _header() => find.byType(SliverFloatingHeader, skipOffstage: false);
|
||||
|
||||
Future<void> _pumpHost(WidgetTester tester, ScrollController controller) {
|
||||
return tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: nooTheme(Brightness.light),
|
||||
home: Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: controller,
|
||||
slivers: [
|
||||
topBarSliver(const _FakeTopBar()),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) =>
|
||||
SizedBox(height: 60, child: Text('Item $index')),
|
||||
childCount: 40,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpNooTests();
|
||||
|
||||
testWidgets(
|
||||
'topBarSliver floats away scrolling down and reappears the moment the '
|
||||
'drag reverses, not only once back at the top (Material floating app '
|
||||
'bar behavior)',
|
||||
(tester) async {
|
||||
final controller = ScrollController();
|
||||
addTearDown(controller.dispose);
|
||||
await _pumpHost(tester, controller);
|
||||
|
||||
// The render object's own `geometry.paintExtent` is the ground truth
|
||||
// for how much of the bar is actually visible - more reliable than
|
||||
// inferring visibility from hit-testing/finding the child, which a
|
||||
// floating header sliver keeps built regardless of paint extent.
|
||||
double paintExtent() =>
|
||||
tester.renderObject<RenderSliver>(_header()).geometry!.paintExtent;
|
||||
|
||||
// Fully visible before any scroll.
|
||||
expect(paintExtent(), _FakeTopBar.height);
|
||||
expect(controller.offset, 0);
|
||||
|
||||
// Drive one continuous drag by hand (rather than `tester.drag`, which
|
||||
// only pumps once the whole gesture - including the final pointer-up
|
||||
// - has already finished): the floating header's reveal-while-
|
||||
// dragging behavior keys off the *live* scroll direction as reported
|
||||
// mid-gesture, so this needs an intermediate pump while the pointer
|
||||
// is still down to actually observe it.
|
||||
final gesture = await tester.startGesture(const Offset(200, 300));
|
||||
|
||||
// Drag up (content scrolls down) well past the bar's own height, so
|
||||
// it scrolls fully away, same as a plain `SliverToBoxAdapter` would.
|
||||
await gesture.moveBy(const Offset(0, -200));
|
||||
await tester.pump();
|
||||
|
||||
expect(paintExtent(), 0);
|
||||
final scrolledOffset = controller.offset;
|
||||
expect(
|
||||
scrolledOffset,
|
||||
greaterThan(_FakeTopBar.height),
|
||||
reason: "the drag should have moved past the bar's own height",
|
||||
);
|
||||
|
||||
// Reverse direction by a small amount, still mid-gesture and nowhere
|
||||
// near the top of the list. A plain `SliverToBoxAdapter`, or a header
|
||||
// that only reappears once back at the top, would stay fully hidden
|
||||
// here. The floating bar should start reappearing immediately
|
||||
// instead, following the drag.
|
||||
await gesture.moveBy(const Offset(0, 40));
|
||||
await tester.pump();
|
||||
|
||||
expect(controller.offset, lessThan(scrolledOffset));
|
||||
expect(
|
||||
controller.offset,
|
||||
greaterThan(_FakeTopBar.height / 2),
|
||||
reason: 'still far from the top of the list',
|
||||
);
|
||||
expect(
|
||||
paintExtent(),
|
||||
greaterThan(0),
|
||||
reason:
|
||||
'the bar should already be reappearing on the first upward '
|
||||
'movement, without needing to reach the top',
|
||||
);
|
||||
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
"topBarSliver sizes itself from the wrapped bar's own layout (e.g. its "
|
||||
'SafeArea-padded height), not a value declared up front',
|
||||
(tester) async {
|
||||
final controller = ScrollController();
|
||||
addTearDown(controller.dispose);
|
||||
await _pumpHost(tester, controller);
|
||||
|
||||
expect(
|
||||
tester.getSize(find.byType(_FakeTopBar)).height,
|
||||
_FakeTopBar.height,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user