206 lines
7.9 KiB
Dart
206 lines
7.9 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
/// The floating nav bar used to slide off the bottom of the screen as the rider
|
|
/// moved down a list and come back when he reversed. That buys 66pt of list and
|
|
/// costs him the ability to change tab at all until he scrolls the other way —
|
|
/// on a long Activity list the only route out is a gesture he has to know
|
|
/// about. A control that vanishes is not a smaller control; it is a missing one.
|
|
///
|
|
/// It compacts instead: labels collapse, icons draw in, the pill narrows and
|
|
/// shortens, and every target stays on screen. This is Instagram's iOS tab bar.
|
|
///
|
|
/// `BottomPage` builds four real tab pages (Get controllers, SharedPreferences,
|
|
/// Geolocator, live fetches) and cannot be pumped in a unit test, so — as in
|
|
/// `card_stripe_layout_test.dart` — these pin the *idiom* rather than the
|
|
/// widget. Two halves, only one of them obvious:
|
|
///
|
|
/// • A `NotificationListener<ScrollNotification>` accumulates `scrollDelta`
|
|
/// from `ScrollUpdateNotification`. Deliberately not `UserScrollNotification`:
|
|
/// that fires when a drag *begins*, when `metrics.pixels` has barely moved,
|
|
/// so a distance threshold measured there compares an offset against itself
|
|
/// and never fires at all.
|
|
///
|
|
/// • The label collapses via `Align(heightFactor:)` inside a `ClipRect`, not a
|
|
/// fade. Fading alone leaves the label's height behind, so the bar cannot
|
|
/// actually get shorter — it shrinks around a blank line.
|
|
void main() {
|
|
const barKey = ValueKey('nav');
|
|
const labelKey = ValueKey('label');
|
|
|
|
const double resting = 66;
|
|
const double compactHeight = 46;
|
|
|
|
/// The shell shape, with the same wiring `BottomPage` uses.
|
|
Widget shell({
|
|
required ValueNotifier<double> compact,
|
|
required ValueNotifier<double> accum,
|
|
bool scrollable = true,
|
|
}) {
|
|
return MaterialApp(
|
|
home: ValueListenableBuilder<double>(
|
|
valueListenable: compact,
|
|
builder: (context, k, _) => Scaffold(
|
|
body: NotificationListener<ScrollNotification>(
|
|
onNotification: (n) {
|
|
if (n.metrics.axis != Axis.vertical) return false;
|
|
if (n.metrics.maxScrollExtent <= 0) {
|
|
accum.value = 0;
|
|
compact.value = 0;
|
|
return false;
|
|
}
|
|
if (n is! ScrollUpdateNotification) return false;
|
|
final delta = n.scrollDelta ?? 0;
|
|
if (delta == 0) return false;
|
|
accum.value =
|
|
(accum.value * delta > 0) ? accum.value + delta : delta;
|
|
if (delta > 0 && compact.value == 0 && accum.value > 24) {
|
|
accum.value = 0;
|
|
compact.value = 1;
|
|
} else if (delta < 0 && compact.value == 1 && accum.value < -6) {
|
|
accum.value = 0;
|
|
compact.value = 0;
|
|
}
|
|
return false;
|
|
},
|
|
child: ListView(
|
|
children: [SizedBox(height: scrollable ? 3000 : 10)],
|
|
),
|
|
),
|
|
bottomNavigationBar: SizedBox(
|
|
key: barKey,
|
|
height: resting + (compactHeight - resting) * k,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.home_rounded, size: 21),
|
|
ClipRect(
|
|
child: Align(
|
|
alignment: Alignment.topCenter,
|
|
heightFactor: (1 - k).clamp(0.0, 1.0),
|
|
child: Opacity(
|
|
opacity: (1 - k * 1.8).clamp(0.0, 1.0),
|
|
child: const Text('Home', key: labelKey),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
double barHeight(WidgetTester tester) =>
|
|
tester.getSize(find.byKey(barKey)).height;
|
|
|
|
testWidgets('scrolling down shrinks the bar; scrolling back restores it',
|
|
(tester) async {
|
|
final compact = ValueNotifier(0.0);
|
|
final accum = ValueNotifier(0.0);
|
|
addTearDown(compact.dispose);
|
|
addTearDown(accum.dispose);
|
|
await tester.pumpWidget(shell(compact: compact, accum: accum));
|
|
|
|
expect(barHeight(tester), resting);
|
|
|
|
// Finger bottom → up: the list scrolls forward, the bar draws in.
|
|
await tester.drag(find.byType(ListView), const Offset(0, -300));
|
|
await tester.pumpAndSettle();
|
|
expect(barHeight(tester), compactHeight);
|
|
|
|
// Finger top → down: he is heading back, so it returns to full size.
|
|
await tester.drag(find.byType(ListView), const Offset(0, 300));
|
|
await tester.pumpAndSettle();
|
|
expect(barHeight(tester), resting);
|
|
});
|
|
|
|
testWidgets('the bar never leaves — the rider can always change tab',
|
|
(tester) async {
|
|
// This is the whole reason the behaviour changed. Whatever the rider does
|
|
// with his thumb, the tab targets stay on screen and stay tappable.
|
|
final compact = ValueNotifier(0.0);
|
|
final accum = ValueNotifier(0.0);
|
|
addTearDown(compact.dispose);
|
|
addTearDown(accum.dispose);
|
|
await tester.pumpWidget(shell(compact: compact, accum: accum));
|
|
|
|
for (final drag in const [-300.0, -600.0, -1200.0]) {
|
|
await tester.drag(find.byType(ListView), Offset(0, drag));
|
|
await tester.pumpAndSettle();
|
|
expect(barHeight(tester), greaterThanOrEqualTo(compactHeight),
|
|
reason: 'the bar must never collapse past its compact height');
|
|
expect(find.byType(Icon), findsOneWidget,
|
|
reason: 'and its icons must stay on screen');
|
|
}
|
|
});
|
|
|
|
testWidgets('the label collapses rather than merely fading', (tester) async {
|
|
// A fade alone leaves the label's height behind, so the bar would shrink
|
|
// around a blank line and never reach its compact height.
|
|
final compact = ValueNotifier(0.0);
|
|
final accum = ValueNotifier(0.0);
|
|
addTearDown(compact.dispose);
|
|
addTearDown(accum.dispose);
|
|
await tester.pumpWidget(shell(compact: compact, accum: accum));
|
|
|
|
final labelled = tester.getSize(find.byKey(labelKey)).height;
|
|
expect(labelled, greaterThan(0));
|
|
|
|
await tester.drag(find.byType(ListView), const Offset(0, -300));
|
|
await tester.pumpAndSettle();
|
|
|
|
// The Align is what gives the room back; the Text keeps its own size and is
|
|
// clipped, so measuring the Text would report the same height either way.
|
|
final align = tester.getSize(
|
|
find.ancestor(of: find.byKey(labelKey), matching: find.byType(Align)).first,
|
|
);
|
|
expect(align.height, lessThan(1),
|
|
reason: 'the label must hand its height back, not just go invisible');
|
|
});
|
|
|
|
testWidgets('a page too short to scroll never compacts the bar',
|
|
(tester) async {
|
|
final compact = ValueNotifier(0.0);
|
|
final accum = ValueNotifier(0.0);
|
|
addTearDown(compact.dispose);
|
|
addTearDown(accum.dispose);
|
|
await tester
|
|
.pumpWidget(shell(compact: compact, accum: accum, scrollable: false));
|
|
|
|
await tester.drag(find.byType(ListView), const Offset(0, -300));
|
|
await tester.pumpAndSettle();
|
|
expect(barHeight(tester), resting);
|
|
});
|
|
|
|
testWidgets('a horizontal scroll leaves the bar alone', (tester) async {
|
|
// Tab strips and carousels scroll sideways; the nav bar is not theirs.
|
|
final compact = ValueNotifier(0.0);
|
|
addTearDown(compact.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: NotificationListener<ScrollNotification>(
|
|
onNotification: (n) {
|
|
if (n.metrics.axis != Axis.vertical) return false;
|
|
compact.value = 1;
|
|
return false;
|
|
},
|
|
child: ListView(
|
|
scrollDirection: Axis.horizontal,
|
|
children: [Container(width: 3000)],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.drag(find.byType(ListView), const Offset(-300, 0));
|
|
await tester.pumpAndSettle();
|
|
expect(compact.value, 0);
|
|
});
|
|
}
|