Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions lib/features/home/widgets/animated_branch_page_view.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
part of 'widgets.dart';

/// Duration of the branch switch slide.
const _branchSlideDuration = Duration(milliseconds: 300);

/// Hosts the shell branches in a [PageView], so switching a bottom navigation tab slides horizontally instead of
/// jumping. Every branch keeps its own navigator and state because the pages stay mounted.
class AnimatedBranchPageView extends StatefulWidget {
/// Constructor.
const AnimatedBranchPageView({required this.navigationShell, required this.children, super.key});

/// The shell that owns the branches and reports the current one.
final StatefulNavigationShell navigationShell;

/// The branch navigators, in branch order.
final List<Widget> children;

@override
State<AnimatedBranchPageView> createState() => _AnimatedBranchPageViewState();
}

class _AnimatedBranchPageViewState extends State<AnimatedBranchPageView> {
late final PageController _controller = PageController(initialPage: widget.navigationShell.currentIndex);

@override
void didUpdateWidget(AnimatedBranchPageView oldWidget) {
super.didUpdateWidget(oldWidget);
final index = widget.navigationShell.currentIndex;
if (index == oldWidget.navigationShell.currentIndex) {
return;
}
// Respect the system "reduce motion" setting: the switch is instant instead of a slide.
if (MediaQuery.disableAnimationsOf(context)) {
_controller.jumpToPage(index);
return;
}
unawaited(_controller.animateToPage(index, duration: _branchSlideDuration, curve: Curves.easeInOut));
}

@override
void dispose() {
_controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
// The shell reports the new branch before the slide starts, so the branch being slid to is the current one as
// well; deciding by `_controller.page` would freeze the target until the slide is over.
final currentIndex = widget.navigationShell.currentIndex;
return PageView(
controller: _controller,
// The navigation bar drives the tab. Not swipeable, so the scrollables inside the branches keep their own gestures.
physics: const NeverScrollableScrollPhysics(),
// The pages stay mounted, so a branch that is off screen has to stop its own animations: the `indexedStack`
// container this replaced did that for us by wrapping every hidden branch in an `Offstage` with a disabled
// `TickerMode`.
children: [
for (final (index, child) in widget.children.indexed)
TickerMode(enabled: index == currentIndex, child: child),
],
);
}
}
1 change: 1 addition & 0 deletions lib/features/home/widgets/widgets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import 'package:tsdm_client/instance.dart';
import 'package:tsdm_client/routes/screen_paths.dart';
import 'package:tsdm_client/widgets/app_surface.dart';

part 'animated_branch_page_view.dart';
part 'home_navigation_bar.dart';
part 'home_navigation_drawer.dart';
part 'home_navigation_rail.dart';
Expand Down
6 changes: 5 additions & 1 deletion lib/routes/app_routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import 'package:tsdm_client/features/forum/view/forum_group_page.dart';
import 'package:tsdm_client/features/forum/view/forum_page.dart';
import 'package:tsdm_client/features/friend/view/friend_page.dart';
import 'package:tsdm_client/features/home/view/home_page.dart';
import 'package:tsdm_client/features/home/widgets/widgets.dart';
import 'package:tsdm_client/features/homepage/view/homepage_page.dart';
import 'package:tsdm_client/features/image/view/image_detail_page.dart';
import 'package:tsdm_client/features/latest_thread/view/latest_thread_page.dart';
Expand Down Expand Up @@ -79,12 +80,15 @@ final router = GoRouter(

/// All named routes in app.
final List<RouteBase> _appRoutes = [
StatefulShellRoute.indexedStack(
StatefulShellRoute(
builder: (context, router, navigator) {
final hideNavigationBarPages = [ScreenPaths.settingsThreadAppearance.fullPath];
// Partial global singleton page here.
return HomePage(showNavigationBar: !hideNavigationBarPages.contains(router.fullPath), child: navigator);
},
// Slide horizontally between the shell branches instead of switching instantly.
navigatorContainerBuilder: (context, navigationShell, children) =>
AnimatedBranchPageView(navigationShell: navigationShell, children: children),
branches: [
StatefulShellBranch(
routes: [AppRoute(path: ScreenPaths.homepage, builder: (_) => const HomepagePage())],
Expand Down
222 changes: 222 additions & 0 deletions test/regression/test_175_home_branch_slide_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:tsdm_client/features/home/widgets/widgets.dart';

/// Regression test of the shell branch slide (#145).
///
/// The bottom navigation tabs switch through [AnimatedBranchPageView], which hosts the branches in a [PageView]: the
/// switch animates instead of jumping, and every branch has to stay mounted so its page state survives — the
/// `indexedStack` route it replaced kept all of them alive. Staying mounted also means the hidden branches have to be
/// muted by hand, otherwise their progress indicators keep animating behind the visible one.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();

/// Pump a three-branch shell that uses the same container as the app.
///
/// With [disableAnimations] the container receives the system "reduce motion" setting through a [MediaQuery], like
/// the app does when the platform reports it. [ticks] makes every branch run an endless animation and counts its
/// frames there; the shell is not settled then, because an endless animation never settles.
Future<void> pumpShell(WidgetTester tester, {bool disableAnimations = false, Map<String, int>? ticks}) async {
final router = GoRouter(
initialLocation: '/a',
routes: [
StatefulShellRoute(
builder: (context, state, navigationShell) => Scaffold(
body: navigationShell,
bottomNavigationBar: BottomNavigationBar(
currentIndex: navigationShell.currentIndex,
onTap: (index) => navigationShell.goBranch(index),
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home_outlined), label: 'tab-a'),
BottomNavigationBarItem(icon: Icon(Icons.star_outline), label: 'tab-b'),
BottomNavigationBarItem(icon: Icon(Icons.favorite_outline), label: 'tab-c'),
],
),
),
navigatorContainerBuilder: (context, navigationShell, children) {
final container = AnimatedBranchPageView(navigationShell: navigationShell, children: children);
if (!disableAnimations) {
return container;
}
return MediaQuery(data: MediaQuery.of(context).copyWith(disableAnimations: true), child: container);
},
branches: [
StatefulShellBranch(
routes: [
GoRoute(
path: '/a',
builder: (_, _) => _CounterPage(label: 'a', ticks: ticks, key: const ValueKey('page-a')),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/b',
builder: (_, _) => _CounterPage(label: 'b', ticks: ticks, key: const ValueKey('page-b')),
),
],
),
// Three branches, like the app: switching to the far one is what a PageView would drop when it does not
// keep its pages alive.
StatefulShellBranch(
routes: [
GoRoute(
path: '/c',
builder: (_, _) => _CounterPage(label: 'c', ticks: ticks, key: const ValueKey('page-c')),
),
],
),
],
),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
if (ticks == null) {
await tester.pumpAndSettle();
return;
}
// The endless animations never settle, so let the router build and the first branch mount instead.
await tester.pump();
await tester.pump();
}

/// Tap the "+1" button of the branch [label].
Future<void> tapPlus(WidgetTester tester, String label) async {
await tester.tap(
find.descendant(of: find.byKey(ValueKey('page-$label')), matching: find.text('plus')),
);
await tester.pumpAndSettle();
}

/// Current page of the branch container.
double? currentPage(WidgetTester tester) => tester.widget<PageView>(find.byType(PageView)).controller!.page;

testWidgets('switching a branch slides instead of jumping', (tester) async {
await pumpShell(tester);
final pageView = tester.widget<PageView>(find.byType(PageView));
expect(pageView.physics, isA<NeverScrollableScrollPhysics>());
expect(pageView.controller!.page, 0);

await tester.tap(find.text('tab-c'));
await tester.pumpAndSettle();

expect(currentPage(tester), 2);
expect(find.text('c: 0'), findsOneWidget);
});

testWidgets('a branch keeps its state while a far branch is shown', (tester) async {
await pumpShell(tester);
await tapPlus(tester, 'a');
expect(find.text('a: 1'), findsOneWidget);

await tester.tap(find.text('tab-c'));
await tester.pumpAndSettle();
expect(find.text('c: 0'), findsOneWidget);

await tester.tap(find.text('tab-a'));
await tester.pumpAndSettle();

// The first branch must not have been rebuilt from scratch by the slide.
expect(find.text('a: 1'), findsOneWidget);
});

testWidgets('a branch stops animating while it is off screen', (tester) async {
final ticks = <String, int>{};
await pumpShell(tester, ticks: ticks);

// The branch on screen animates.
final firstVisible = ticks['a'] ?? 0;
await tester.pump(const Duration(milliseconds: 100));
expect(ticks['a'], greaterThan(firstVisible));

await tester.tap(find.text('tab-b'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 150));
// The branch moving into view mounts halfway through the slide; its animation must run from there on.
final incoming = ticks['b'] ?? 0;
await tester.pump(const Duration(milliseconds: 50));
expect(ticks['b'], greaterThan(incoming), reason: 'the slide target must animate while it moves in');

// Let the slide finish, then watch both branches for another stretch.
await tester.pump(const Duration(milliseconds: 200));
final hidden = ticks['a'] ?? 0;
final shown = ticks['b'] ?? 0;
await tester.pump(const Duration(milliseconds: 300));
expect(ticks['a'], hidden, reason: 'the branch that is off screen must not keep animating');
expect(ticks['b'], greaterThan(shown));
});

testWidgets('the reduced motion setting jumps instead of sliding', (tester) async {
await pumpShell(tester, disableAnimations: true);

await tester.tap(find.text('tab-c'));
// A single frame is enough: the switch does not wait for a slide.
await tester.pump();
expect(currentPage(tester), 2);
expect(find.text('c: 0'), findsOneWidget);

// Animations on: the same single frame leaves the slide in flight, so the case above really covers the setting.
await pumpShell(tester);
await tester.tap(find.text('tab-c'));
await tester.pump();
expect(currentPage(tester), lessThan(2));

await tester.pumpAndSettle();
expect(currentPage(tester), 2);
});
}

/// A branch page with local state, so a lost branch shows up as a reset counter.
///
/// With [ticks] it also runs an endless animation that counts its own frames there, which is how a test tells whether
/// the branch is allowed to animate.
class _CounterPage extends StatefulWidget {
const _CounterPage({required this.label, this.ticks, super.key});

final String label;

/// Frame counter of the endless animation, shared by every branch; null to keep the page still.
final Map<String, int>? ticks;

@override
State<_CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<_CounterPage> with SingleTickerProviderStateMixin {
int _count = 0;

AnimationController? _spinner;

@override
void initState() {
super.initState();
final ticks = widget.ticks;
if (ticks == null) {
return;
}
final spinner = AnimationController(vsync: this, duration: const Duration(seconds: 1))
..addListener(() => ticks[widget.label] = (ticks[widget.label] ?? 0) + 1);
_spinner = spinner;
unawaited(spinner.repeat());
}

@override
void dispose() {
_spinner?.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('${widget.label}: $_count'),
FilledButton(onPressed: () => setState(() => _count++), child: const Text('plus')),
],
);
}
Loading