feat(ui): Profile's tab row slides back in on any scroll up, the tab's name fading under the title

FoldingTabs, as Strava's Groups (the developer's review, 9 Nov 2026):
the tab row is a floating header now, following the finger out as the
content scrolls down and back in as it scrolls up however little, wherever
the content is; before, it came back only at the top. Folded is read from
how much of the row shows. The chosen tab's name fades in and out under
the title as its room opens and closes, where it appeared at once: the
title keeps one tree folded or not, as a tree changing under it made its
switcher start over without a fade.

Whats-New: Profile's tabs slide back in as soon as you scroll up, and the tab's name fades in under the title as they go.
Whats-New-De: Die Tabs im Profil gleiten zurück, sobald du nach oben scrollst, und der Name des Tabs blendet unter dem Titel ein, wenn sie verschwinden.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-09 23:18:14 +01:00
commit 83c61b8d81
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 123 additions and 39 deletions

View file

@ -14,8 +14,10 @@ class const FoldingTab({
/// A screen of tabs whose row folds into the title, as Strava's do: at the
/// top the tabs show under the app bar's [title]; scrolled down, they
/// scroll away with the content and the chosen tab's name shows small under
/// the title, which, tapped, scrolls back up to them. The app bar is
/// scroll away with the content and the chosen tab's name fades in small
/// under the title, which, tapped, scrolls back up to them. Scrolled back
/// up however little, the row slides back in wherever the content is, the
/// name fading out (the developer's review, 9 Nov 2026). The app bar is
/// transparent, for a [TopGradient] around the screen.
class const FoldingTabs({
/// The screen's name, in the app bar.
@ -40,22 +42,19 @@ class _FoldingTabsState() extends State<FoldingTabs> {
/// Whether the tab row has scrolled away.
bool _folded = false;
/// The tab row's height, as laid out last.
double _rowHeight = kTextTabBarHeight;
@override
void initState() {
super.initState();
_outer.addListener(_scrolled);
}
void _scrolled() {
// Folded once the row is all but gone: as long as some of it shows,
// it names the tab itself.
final folded = _outer.offset >= _rowHeight - 1;
if (folded != _folded) {
setState(() => _folded = folded);
/// Told by the row, as it lays out, how much of it is hidden: folded once
/// it is all but gone, as long as some of it shows, it names the tab
/// itself. After the frame, as the row is told while it lays out.
void _rowHidden(double hidden, double height) {
final folded = hidden >= height - 1;
if (folded == _folded) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && folded != _folded) {
setState(() => _folded = folded);
}
});
}
@override
@ -78,7 +77,6 @@ class _FoldingTabsState() extends State<FoldingTabs> {
dividerColor: Colors.transparent,
tabs: [for (final tab in widget.tabs) Tab(text: tab.label)],
);
_rowHeight = row.preferredSize.height;
return DefaultTabController(
length: widget.tabs.length,
child: Scaffold(
@ -94,13 +92,17 @@ class _FoldingTabsState() extends State<FoldingTabs> {
),
actions: [...widget.actions, const SizedBox(width: 4)],
),
// The row alone in the header, nothing pinned there, so the tabs'
// content needs no room kept for it: it starts right under the row,
// and under the app bar once the row is gone.
// The row alone in the header, floating, nothing pinned there, so
// the tabs' content needs no room kept for it: it starts right
// under the row, and under the app bar once the row is gone.
body: NestedScrollView(
controller: _outer,
floatHeaderSlivers: true,
headerSliverBuilder: (context, _) => [
SliverToBoxAdapter(child: row),
SliverPersistentHeader(
floating: true,
delegate: _FloatingRow(row, onHidden: _rowHidden),
),
],
body: TabBarView(children: [for (final tab in widget.tabs) tab.body]),
),
@ -109,8 +111,39 @@ class _FoldingTabsState() extends State<FoldingTabs> {
}
}
/// The tab row as a floating header, sliding out under the app bar as it
/// scrolls away and back in as the content scrolls up, telling [onHidden]
/// how much of it is hidden.
class const _FloatingRow(
final TabBar row, {
required final void Function(double hidden, double height) onHidden,
}) extends SliverPersistentHeaderDelegate {
@override
double get maxExtent => row.preferredSize.height;
@override
double get minExtent => 0;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlaps) {
onHidden(shrinkOffset, maxExtent);
return ClipRect(
child: OverflowBox(
alignment: Alignment.bottomCenter,
minHeight: maxExtent,
maxHeight: maxExtent,
child: row,
),
);
}
@override
bool shouldRebuild(_FloatingRow old) => old.row != row;
}
/// The app bar's title, with the chosen tab's name small beneath it while
/// the row is folded away; then tapping it unfolds the row.
/// the row is folded away, fading in and out; then tapping it unfolds the
/// row.
class const _Title({
required final String title,
required final List<String> labels,
@ -127,29 +160,31 @@ class const _Title({
children: [
Text(title),
AnimatedSize(
duration: const Duration(milliseconds: 150),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
alignment: Alignment.topLeft,
child: folded
? ListenableBuilder(
listenable: controller,
builder: (context, _) => Text(
labels[controller.index],
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: folded
? ListenableBuilder(
listenable: controller,
builder: (context, _) => Text(
labels[controller.index],
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
)
: const SizedBox.shrink(),
)
: const SizedBox(width: double.infinity),
),
),
],
);
if (!folded) {
return column;
}
// The same tree folded or not, so the name fades rather than appears.
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Semantics(button: true, child: column),
onTap: folded ? onTap : null,
child: Semantics(button: folded, child: column),
);
}
}

View file

@ -87,6 +87,55 @@ void main() {
expect(inBar('Progress'), findsNothing);
});
testWidgets('scrolled down far, a little back up brings the tabs back '
"over the content, as Strava's do (the developer's review, 9 Nov "
'2026)', (tester) async {
await show(tester);
await tester.drag(find.text('Progress 3'), const Offset(0, -1200));
await tester.pumpAndSettle();
expect(tabRowShows(tester), isFalse);
await tester.drag(find.text('Progress 22'), const Offset(0, 120));
await tester.pumpAndSettle();
expect(tabRowShows(tester), isTrue);
expect(inBar('Progress'), findsNothing);
expect(
find.text('Progress 0'),
findsNothing,
reason: 'the content stays where it was scrolled to',
);
await tester.drag(find.text('Progress 22'), const Offset(0, -200));
await tester.pumpAndSettle();
expect(tabRowShows(tester), isFalse, reason: 'and goes again down');
expect(inBar('Progress'), findsOneWidget);
});
testWidgets("the chosen tab's name fades in under the title as the tabs "
'go', (tester) async {
await show(tester);
await tester.drag(find.text('Progress 3'), const Offset(0, -400));
// The row's frame, the title's, then midway through the fade.
await tester.pump();
await tester.pump();
await tester.pump(const Duration(milliseconds: 80));
final opacity = tester
.widgetList<FadeTransition>(
find.ancestor(
of: inBar('Progress'),
matching: find.byType(FadeTransition),
),
)
.map((fade) => fade.opacity.value)
.reduce((a, b) => a * b);
expect(opacity, inExclusiveRange(0, 1));
await tester.pumpAndSettle();
expect(inBar('Progress'), findsOneWidget);
});
testWidgets('the small name follows the tab swiped to', (tester) async {
await show(tester);
await tester.drag(find.text('Progress 3'), const Offset(0, -400));