Scroll to top of list when changing pages

The notifications and devices lists kept their scroll offset when paging,
so a new page would open partway down. Attach a ScrollController to each
CustomScrollView and animate back to the top whenever the page changes
via the pagination bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-04 10:10:13 -04:00
co-authored by Claude Opus 4.8
parent aa5a29271b
commit aa5f65de2b
5 changed files with 131 additions and 4 deletions
+1
View File
@@ -22,6 +22,7 @@
## Frontend
- [ ] The notifications list should not refresh coldly every time. It should add/remove notifications with an animation as if a stack
- [x] Make gruvbox the default theme
- [x] Can we add front-end tests?
- [x] Break down oott_api.dart in modules
+17 -1
View File
@@ -41,6 +41,7 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
int _currentPage = 0;
bool _hasNextPage = false;
CancelToken? _fetchToken;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
@@ -69,9 +70,23 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
_ownerDebounce?.cancel();
_fetchToken?.cancel();
_ownerController.dispose();
_scrollController.dispose();
super.dispose();
}
/// Fetches [page] and scrolls back to the top of the list, so changing pages
/// always starts the new page from its first row.
void _goToPage(int page) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
_fetchPage(page);
}
void _onOwnerChanged() {
_ownerDebounce?.cancel();
_ownerDebounce = Timer(
@@ -286,6 +301,7 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
return RefreshIndicator(
onRefresh: () => _fetchPage(_currentPage),
child: CustomScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
if (isWide)
@@ -323,7 +339,7 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
currentPage: _currentPage,
hasNextPage: _hasNextPage,
isLoading: _isLoading,
onPageChanged: _fetchPage,
onPageChanged: _goToPage,
),
),
],
+17 -1
View File
@@ -52,6 +52,7 @@ class _NotificationsListState extends State<NotificationsList>
bool _hasNextPage = false;
String? _error;
CancelToken? _fetchToken;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
@@ -107,9 +108,23 @@ class _NotificationsListState extends State<NotificationsList>
routeObserver.unsubscribe(this);
_notificationTimer?.cancel();
_fetchToken?.cancel();
_scrollController.dispose();
super.dispose();
}
/// Fetches [page] and scrolls back to the top of the list, so changing pages
/// always starts the new page from its first item.
void _goToPage(int page) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
_fetchPage(page);
}
Future<void> _fetchPage(int page) async {
_fetchToken?.cancel();
final token = CancelToken();
@@ -207,6 +222,7 @@ class _NotificationsListState extends State<NotificationsList>
_buildNotificationsHeader(context),
Expanded(
child: CustomScrollView(
controller: _scrollController,
slivers: [
..._buildNotificationSlivers(context),
...widget.trailingSlivers,
@@ -296,7 +312,7 @@ class _NotificationsListState extends State<NotificationsList>
currentPage: _currentPage,
hasNextPage: _hasNextPage,
isLoading: _isLoading,
onPageChanged: _fetchPage,
onPageChanged: _goToPage,
),
),
];
+45 -2
View File
@@ -1,3 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/devices/device_list.dart';
import 'package:frontend/devices/device_list_rows.dart';
@@ -31,8 +32,9 @@ void main() {
await tearDownTree(tester);
});
testWidgets('shows the empty message when there are no devices',
(tester) async {
testWidgets('shows the empty message when there are no devices', (
tester,
) async {
adapter.onGet('/devices', (server) => server.reply(200, <dynamic>[]));
await pumpScreen(tester, const DeviceList());
@@ -57,4 +59,45 @@ void main() {
await tearDownTree(tester);
});
testWidgets('changing pages scrolls back to the top of the list', (
tester,
) async {
// 11 devices: a full page of 10 plus one, so the pagination bar appears.
adapter.onGet(
'/devices',
(server) => server.reply(
200,
List.generate(
11,
(i) => deviceJson(
macAddress: '00:00:00:00:00:${i.toString().padLeft(2, '0')}',
),
),
),
);
// A short viewport so the rows overflow the screen and the list can scroll.
await pumpScreen(tester, const DeviceList(), size: const Size(900, 500));
await pumpUntilFound(tester, find.byType(DeviceRowWide));
final scrollable = find.descendant(
of: find.byType(CustomScrollView),
matching: find.byType(Scrollable),
);
ScrollPosition position() =>
tester.state<ScrollableState>(scrollable).position;
// Scroll down to reveal the pagination bar.
await tester.drag(scrollable, const Offset(0, -2000));
await tester.pumpAndSettle();
expect(position().pixels, greaterThan(0));
// Advance a page: the list should jump back to the top.
await tester.tap(find.byTooltip('Next page'));
await tester.pumpAndSettle();
expect(position().pixels, 0);
await tearDownTree(tester);
});
}
@@ -54,4 +54,55 @@ void main() {
await tester.pumpWidget(const SizedBox());
},
);
testWidgets('changing pages scrolls back to the top of the list', (
tester,
) async {
tester.view.physicalSize = const Size(400, 300);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
List<Map<String, dynamic>> page(int firstId) => List.generate(
6,
(i) => notificationJson(id: firstId + i, title: 'Item ${firstId + i}'),
);
adapter.onGet(
'/notifications',
(server) => server.reply(200, page(1)),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 6},
);
adapter.onGet(
'/notifications',
(server) => server.reply(200, page(7)),
queryParameters: {'is_new': true, 'page_offset': 5, 'page_limit': 6},
);
await tester.pumpWidget(
MaterialApp(
theme: gruvboxDarkTheme,
home: const Scaffold(body: NotificationsList()),
),
);
await tester.pumpAndSettle();
final scrollable = find.descendant(
of: find.byType(CustomScrollView),
matching: find.byType(Scrollable),
);
ScrollPosition position() =>
tester.state<ScrollableState>(scrollable).position;
// Scroll down to reveal the pagination bar.
await tester.drag(scrollable, const Offset(0, -2000));
await tester.pumpAndSettle();
expect(position().pixels, greaterThan(0));
// Advance a page: the list should jump back to the top.
await tester.tap(find.byTooltip('Next page'));
await tester.pumpAndSettle();
expect(find.textContaining('Item 7'), findsWidgets);
expect(position().pixels, 0);
await tester.pumpWidget(const SizedBox());
});
}