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:
+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);
|
||||
|
||||
Reference in New Issue
Block a user