Skip to content

Lists & scrolling

Use ListView for a collection and ScrollView for a block of content. Both support the keyboard, mouse wheel, and an optional scrollbar.

Use ↑ / ↓ to focus a file, then Enter to select it. Clicking a row selects it too. Moving to another row leaves Selected unchanged:

file_list.dart
Source · editable
import 'package:fleury/fleury_core.dart';
class FileList extends StatefulWidget {
const FileList({super.key});
@override
State<FileList> createState() => _FileListState();
}
class _FileListState extends State<FileList> {
final files = ['README.md', 'notes.md', 'sketches.txt'];
String selected = 'None';
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 3,
child: ListView(
autofocus: true,
onSelect: (i) =>
setState(() => selected = files[i]),
children: [for (final file in files) Text(file)],
),
),
const SizedBox(height: 1),
Text('Selected: $selected'),
],
);
}
Focus, then select
Live · interactive
click & type to interact ⓘ how this demo runs
Test references
import 'package:fleury/fleury.dart';
import 'package:fleury_test/fleury_test.dart';
import 'package:test/test.dart';
import '../../lib/lists/file_list.dart';
void main() {
testWidgets('arrows focus a row; Enter selects it', (
tester,
) {
tester.pumpWidget(const FileList());
tester.press(KeySequence.down);
expect(
tester.render().atColRow(0, 1).style.inverse,
isTrue,
);
expect(tester.exists(text('Selected: None')), isTrue);
tester.press(KeySequence.enter);
expect(
tester.exists(text('Selected: notes.md')),
isTrue,
);
tester.press(KeySequence.down);
expect(
tester.render().atColRow(0, 2).style.inverse,
isTrue,
);
expect(
tester.exists(text('Selected: notes.md')),
isTrue,
);
});
testWidgets('a completed click selects a row', (tester) {
tester.pumpWidget(const FileList());
for (final kind in [
MouseEventKind.down,
MouseEventKind.up,
]) {
tester.sendMouse(
MouseEvent(
kind: kind,
button: MouseButton.left,
col: 3,
row: 2,
),
);
tester.pump();
}
expect(
tester.exists(text('Selected: sketches.txt')),
isTrue,
);
});
}

ListView highlights the current row automatically. onSelect handles both click and Enter; the only state this example keeps is the chosen filename.

ListView.builder builds rows as they become visible, so a list can hold thousands of items. This one holds a thousand tasks, and a ListController created with initialIndex: 24 starts with task 25 highlighted and visible:

task_browser.dart
Source · editable
import 'package:fleury/fleury_core.dart';
class TaskBrowser extends StatefulWidget {
const TaskBrowser({super.key});
@override
State<TaskBrowser> createState() => _TaskBrowserState();
}
class _TaskBrowserState extends State<TaskBrowser> {
final list = ListController(initialIndex: 24);
int focused = 24;
int? selected;
bool listFocused = false;
@override
void dispose() {
list.dispose();
super.dispose();
}
Widget buildTask(
BuildContext context,
int index,
bool _,
) {
final chosen = selected == index;
final label = 'Task ${index + 1}';
return Text(
'${chosen ? '✓' : ' '} $label',
style: chosen
? CellStyle(
foreground: context.colors.success,
bold: true,
)
: CellStyle.none,
);
}
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
NotifierBuilder(
notifier: list,
builder: (context, _) {
final range = list.visibleRange;
final showing = range == null
? '…'
: '${range.first + 1}–${range.last + 1}';
return Text(
'Current: ${(list.currentIndex ?? -1) + 1} / 1000\n'
'Showing: $showing',
);
},
),
const SizedBox(height: 1),
FocusDetector(
onFocusChange: (value) =>
setState(() => listFocused = value),
child:
SizedBox(
height: 10,
child: ListView.builder(
controller: list,
itemCount: 1000,
autofocus: true,
scrollbar: true,
onFocusedItemChanged: (index) =>
setState(() => focused = index),
onSelect: (index) =>
setState(() => selected = index),
itemBuilder: buildTask,
),
),
),
const SizedBox(height: 1),
Row(
children: [
Button(
text: 'Go to 25',
onPressed: () {
list
..currentIndex = 24
..jumpToIndex(24);
setState(() => focused = 24);
},
),
Button(
text: 'Scroll to 500',
onPressed: () => list.jumpToIndex(499),
),
],
),
Text(
listFocused
? 'Focused: Task ${focused + 1}'
: 'Focused: outside list',
),
Text(
selected == null
? 'Selected: None'
: 'Selected: Task ${selected! + 1}',
),
],
);
}
Focus and selection
Live · interactive
click & type to interact ⓘ how this demo runs
Test references
import 'package:fleury/fleury.dart';
import 'package:fleury_test/fleury_test.dart';
import 'package:test/test.dart';
import '../../lib/lists/task_browser.dart';
void main() {
testWidgets(
'arrows browse; Enter selects; scrolling keeps the current item',
(tester) async {
tester.pumpWidget(const SizedBox(width: 40, child: TaskBrowser()));
expect(tester.renderToString(), contains('Task 25'));
expect(tester.exists(text('Selected: None')), isTrue);
tester.press(KeySequence.down);
expect(tester.exists(text('Selected: None')), isTrue);
expect(tester.exists(text('Focused: Task 26')), isTrue);
tester.press(KeySequence.enter);
tester.pump();
expect(tester.exists(text('Selected: Task 26')), isTrue);
expect(tester.exists(text('✓ Task 26')), isTrue);
tester.press(KeySequence.down);
expect(tester.exists(text('✓ Task 26')), isTrue);
await tester.button('Go to 25').press();
tester.pump();
expect(tester.renderToString(), contains('Current: 25 / 1000'));
expect(tester.renderToString(), contains('Task 25'));
expect(tester.exists(text('Focused: outside list')), isTrue);
expect(tester.exists(text('Selected: Task 26')), isTrue);
await tester.button('Scroll to 500').press();
tester.pump();
final screen = tester.renderToString();
expect(screen, contains('Current: 25 / 1000'));
expect(screen, contains('Showing: 500–509'));
expect(screen, contains('Task 500'));
expect(screen, isNot(contains(' Task 25\n')));
expect(screen, contains('Focused: outside list'));
await tester.button('Go to 25').press();
tester.pump();
expect(tester.renderToString(), contains('Task 25'));
expect(tester.exists(text('Selected: Task 26')), isTrue);
},
);
testWidgets('a completed click selects the visible row', (tester) {
tester.pumpWidget(const SizedBox(width: 40, child: TaskBrowser()));
tester.press(KeySequence.home);
tester.pump();
for (final kind in [MouseEventKind.down, MouseEventKind.up]) {
tester.sendMouse(
MouseEvent(kind: kind, button: MouseButton.left, col: 3, row: 5),
);
tester.pump();
}
expect(tester.exists(text('Selected: Task 3')), isTrue);
expect(tester.exists(text('✓ Task 3')), isTrue);
tester.press(KeySequence.down);
tester.press(KeySequence.enter);
tester.pump();
expect(tester.exists(text('✓ Task 4')), isTrue);
expect(tester.exists(text('✓ Task 3')), isFalse);
});
}

Choose a task: its green ✓ stays until you choose another, and Selected reads None until you do. The browsing highlight moves independently. Try Scroll to 500, then Go to 25. Scrolling leaves the current item alone; Go to 25 brings it back into view. Neither selects a task.

onFocusedItemChanged reports arrow-key or pointer navigation; onSelect reports the choice. ListController.currentIndex remembers the current item when focus moves to a button. Its highlight stays visible, and the demo says Focused: outside list.

Changing currentIndex from code updates the list without firing either interaction callback. A controller listener observes changes from any source.

Create the controller once in State and dispose it in the state’s dispose, as _TaskBrowserState does. A ListView disposes only a controller it created itself; it never disposes one you pass in.

Page Up / Page Down move by a page; Home / End move to the first or last item. The wheel and scrollbar scroll without moving focus or selecting an item. The scrollbar moves in terminal-cell steps; on a short track over many items, the wheel and arrow keys give finer control.

ListController is a Notifier, so a NotifierBuilder or listener can follow it, as the demo’s Current and Showing lines do. These are the members most apps use:

MemberWhat it does
ListController(initialIndex: …)Starts with that item current and scrolled into view. Without it, the list starts on its first item; null starts with no current item
currentIndexThe highlighted item. Setting it moves the highlight and scrolls it into view without selecting the item or calling onFocusedItemChanged
jumpToIndex(index)Scrolls that item to the start of the viewport, as far as the content allows. The highlight stays put
scrollBy(cells)Scrolls by a number of cells
jumpToEnd()Scrolls to the end of the last item
visibleRangeThe first and last visible item indices, or null before layout
atStart, atEndWhether the viewport shows the start or the end of the content
followTail, isFollowing, unseenCountFollow a list that grows at the end; see Follow a growing list
dispose()Releases the controller. Call it from the owning state’s dispose

A log, a chat transcript, or command output grows at its end. Create the controller with ListController(followTail: true): the list starts at the end and keeps the newest item in view while the viewport stays at the end.

build_log.dart
Source · editable
import 'package:fleury/fleury_core.dart';
class BuildLog extends StatefulWidget {
const BuildLog({super.key});
@override
State<BuildLog> createState() => _BuildLogState();
}
class _BuildLogState extends State<BuildLog> {
final log = ListController(followTail: true);
final lines = [for (var i = 1; i <= 12; i++) 'Step $i complete'];
int detail = 0;
@override
void dispose() {
log.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
NotifierBuilder(
notifier: log,
builder: (context, _) => Text(
log.isFollowing
? 'Following latest output'
: 'Paused · ${log.unseenCount} new',
),
),
const SizedBox(height: 1),
SizedBox(
height: 6,
child: ListView.builder(
controller: log,
selectable: false,
autofocus: true,
scrollbar: true,
itemCount: lines.length,
itemBuilder: (context, index, highlighted) => Text(lines[index]),
),
),
const SizedBox(height: 1),
Row(
children: [
Button(
text: 'Append',
onPressed: () =>
setState(() => lines.add('Step ${lines.length + 1} complete')),
),
Button(text: 'Latest', onPressed: log.jumpToEnd),
Button(
text: 'Grow last entry',
onPressed: () => setState(
() => lines[lines.length - 1] += '\n Detail ${++detail}',
),
),
],
),
],
);
}
Follow, pause, catch up
Live · interactive
click & type to interact ⓘ how this demo runs
Test references
import 'package:fleury/fleury.dart';
import 'package:fleury_test/fleury_test.dart';
import 'package:test/test.dart';
import '../../lib/lists/build_log.dart';
void main() {
testWidgets('reading older output pauses following until Latest', (
tester,
) async {
tester.pumpWidget(const SizedBox(width: 46, child: BuildLog()));
tester.pump();
expect(tester.renderToString(), contains('Step 12 complete'));
tester.press(KeySequence.home);
await tester.button('Append').press();
tester.pump();
final paused = tester.renderToString();
expect(paused, contains('Paused · 1 new'));
expect(paused, contains('Step 1 complete'));
expect(paused, isNot(contains('Step 13 complete')));
await tester.button('Grow last entry').press();
tester.pump();
expect(tester.renderToString(), contains('Step 1 complete'));
expect(tester.renderToString(), isNot(contains('Detail 1')));
await tester.button('Latest').press();
tester.pump();
expect(tester.renderToString(), contains('Step 13 complete'));
expect(tester.exists(text('Following latest output')), isTrue);
await tester.button('Append').press();
tester.pump();
expect(tester.renderToString(), contains('Step 14 complete'));
});
testWidgets('a growing last entry stays visible, even taller than the pane', (
tester,
) async {
tester.pumpWidget(const SizedBox(width: 46, child: BuildLog()));
for (var i = 0; i < 8; i++) {
await tester.button('Grow last entry').press();
}
tester.pump();
expect(tester.renderToString(), contains('Detail 8'));
expect(tester.exists(text('Following latest output')), isTrue);
});
}

Press Append a few times; the log stays on the newest step. Then click the log, press Home to read from the top, and append again. Moving away from the end pauses following: isFollowing becomes false, and unseenCount counts the entries that arrived since. Latest calls jumpToEnd(), which returns to the end and resumes following. Scrolling back to the end yourself does the same.

Grow last entry makes the newest entry taller. While the list is following, it keeps the end of that entry in view, even once it is taller than the viewport.

By default, a list tracks positions. When items move, the highlight stays at the same index, which now shows a different item. If items can be inserted, removed, or reordered, give ListView.builder an itemKeyBuilder that returns each item’s stable identity, such as a database ID:

reorder_tasks.dart
Source · editable
import 'package:fleury/fleury_core.dart';
class ReorderTasks extends StatefulWidget {
const ReorderTasks({super.key});
@override
State<ReorderTasks> createState() => _ReorderTasksState();
}
class _ReorderTasksState extends State<ReorderTasks> {
final list = ListController(initialIndex: 1);
var tasks = [
(id: 'sketch', title: 'Sketch the layout'),
(id: 'build', title: 'Build the prototype'),
(id: 'test', title: 'Test the keyboard path'),
(id: 'ship', title: 'Ship the guide'),
];
@override
void dispose() {
list.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 4,
child: ListView.builder(
controller: list,
itemCount: tasks.length,
itemKeyBuilder: (index) => tasks[index].id,
itemBuilder: (context, i, highlighted) => Text(
'${highlighted ? '›' : ' '} ${tasks[i].title}',
style: highlighted
? Theme.of(context).selectionStyle
: CellStyle.none,
),
),
),
const SizedBox(height: 1),
Button(
text: 'Reverse order',
onPressed: () => setState(
() => tasks = tasks.reversed.toList(),
),
),
NotifierBuilder(
notifier: list,
builder: (context, _) {
final index = list.currentIndex;
return Text(
index == null
? 'No current item'
: 'Current: ${tasks[index].title}',
);
},
),
],
);
}
}
Reorder the data
Live · interactive
click & type to interact ⓘ how this demo runs
Test references
import 'package:fleury/fleury.dart';
import 'package:fleury_test/fleury_test.dart';
import 'package:test/test.dart';
import '../../lib/lists/reorder_tasks.dart';
void main() {
testWidgets('reversing keeps the current task, not its old index', (
tester,
) async {
tester.pumpWidget(const SizedBox(width: 40, child: ReorderTasks()));
final before = tester.renderToString().split('\n');
expect(before[1], contains('› Build the prototype'));
await tester.button('Reverse order').press();
tester.pump();
final after = tester.renderToString().split('\n');
expect(after[2], contains('› Build the prototype'));
expect(tester.exists(text('Current: Build the prototype')), isTrue);
await tester.button('Reverse order').press();
tester.pump();
expect(
tester.renderToString().split('\n')[1],
contains('› Build the prototype'),
);
});
}

Press Reverse order. The highlight stays on Build the prototype as it moves from the second row to the third, and currentIndex follows it. Keys also keep the viewport anchored on the same items, and each mounted row keeps its widget state as its item moves. Each key must be unique within the list. ListView.separated accepts itemKeyBuilder too.

Set scrollDirection: Axis.horizontal to arrange list items from left to right. Use ← / → to browse, then Enter or click to select. The scrollbar runs along the bottom edge:

horizontal_list.dart
Source · editable
import 'package:fleury/fleury_core.dart';
class HorizontalList extends StatefulWidget {
const HorizontalList({super.key});
@override
State<HorizontalList> createState() =>
_HorizontalListState();
}
class _HorizontalListState extends State<HorizontalList> {
final projects = const [
'Editor',
'Preview',
'Search',
'Terminal',
'Settings',
'Outline',
];
int? selected;
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 2,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: projects.length,
autofocus: true,
scrollbar: true,
onSelect: (index) =>
setState(() => selected = index),
separatorBuilder: (_, _) =>
const SizedBox(width: 2),
itemBuilder: (context, index, _) => SizedBox(
width: 12,
child: Text(
projects[index],
style: index == selected
? CellStyle(
foreground: context.colors.success,
bold: true,
)
: CellStyle.none,
),
),
),
),
const SizedBox(height: 1),
Text(
'Selected: ${selected == null ? 'None' : projects[selected!]}',
),
],
);
}
Browse sideways
Live · interactive
click & type to interact ⓘ how this demo runs
Test references
import 'package:fleury/fleury.dart';
import 'package:fleury_test/fleury_test.dart';
import 'package:test/test.dart';
import '../../lib/lists/horizontal_list.dart';
void main() {
testWidgets(
'browse horizontally, then select with Enter',
(tester) {
tester.pumpWidget(
const SizedBox(
width: 36,
height: 6,
child: HorizontalList(),
),
);
tester.press(KeySequence.end);
tester.pump();
expect(tester.renderToString(), contains('Outline'));
expect(
tester.renderToString(),
contains('Selected: None'),
);
tester.press(KeySequence.enter);
tester.pump();
expect(
tester.renderToString(),
contains('Selected: Outline'),
);
tester.press(KeySequence.left);
tester.pump();
expect(
tester.renderToString(),
contains('Selected: Outline'),
);
},
);
}

ScrollView accepts the same property for wide content. This report keeps each line intact. Try Home / End to reach its first and last columns:

horizontal_content.dart
Source · editable
import 'package:fleury/fleury_core.dart';
class HorizontalContent extends StatefulWidget {
const HorizontalContent({super.key});
@override
State<HorizontalContent> createState() =>
_HorizontalContentState();
}
class _HorizontalContentState
extends State<HorizontalContent> {
final scroll = ScrollController();
static const report =
'NAME STATUS DURATION OUTPUT RESULT\n'
'compile done 2.4s build/application.js SUCCESS\n'
'test done 1.1s build/test-results.txt SUCCESS';
@override
void dispose() {
scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 4,
child: ScrollView(
scrollDirection: Axis.horizontal,
controller: scroll,
autofocus: true,
scrollbar: true,
child: const Text(report),
),
),
const SizedBox(height: 1),
NotifierBuilder(
notifier: scroll,
builder: (_, _) => Text(
'Columns ${scroll.offset + 1}–'
'${scroll.offset + scroll.viewportExtent}'
' / ${scroll.contentExtent}'
'${scroll.atStart ? ' · START' : ''}'
'${scroll.atEnd ? ' · END' : ''}',
),
),
],
);
}
Read the whole line
Live · interactive
click & type to interact ⓘ how this demo runs
Test references
import 'package:fleury/fleury.dart';
import 'package:fleury_test/fleury_test.dart';
import 'package:test/test.dart';
import '../../lib/lists/horizontal_content.dart';
void main() {
testWidgets(
'wide text keeps its lines and reveals the final columns',
(tester) {
tester.pumpWidget(
const SizedBox(
width: 36,
height: 8,
child: HorizontalContent(),
),
);
expect(tester.renderToString(), contains('NAME'));
expect(tester.renderToString(), contains('START'));
tester.press(KeySequence.end);
tester.pump();
expect(tester.renderToString(), contains('SUCCESS'));
expect(tester.renderToString(), contains('END'));
tester.press(KeySequence.home);
tester.pump();
expect(tester.renderToString(), contains('NAME'));
expect(tester.renderToString(), contains('START'));
},
);
}

Both support sideways trackpad gestures, Shift + wheel, and dragging the scrollbar. A regular vertical wheel can still scroll a surrounding vertical pane. Page Up / Page Down move along the chosen axis.

Controllers work in either direction: ScrollController.offset counts cells from the start, and ListController.currentIndex identifies the current item. Give a horizontal list a bounded width; with a scrollbar, also bound its height, as these examples do.

Use DataTable to compare items across named columns, such as a task’s name and status. It builds visible text cells on demand and keeps the header in place as you scroll.

Try ↑ / ↓ and Page Down to browse, then click or press Enter to select a row:

datatable_rows.dart
Source · editable
import 'package:fleury/fleury_core.dart';
class TableRows extends StatefulWidget {
const TableRows({super.key});
@override
State<TableRows> createState() => _TableRowsState();
}
class _TableRowsState extends State<TableRows> {
final table = DataTableController();
String browsing = 'Row 1';
String chosen = 'None';
@override
void dispose() {
table.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 7,
child: DataTable(
controller: table,
rowCount: 100,
autofocus: true,
onFocusedItemChanged: (row) =>
setState(() => browsing = 'Row ${row + 1}'),
onSelect: (row) => setState(() => chosen = 'Row ${row + 1}'),
columns: const [
DataTableColumn(
id: 'name',
title: 'Name',
width: FixedColumnWidth(12),
),
DataTableColumn(id: 'status', title: 'Status'),
],
cellBuilder: (row, column) =>
column == 'name' ? 'Row ${row + 1}' : 'Ready',
),
),
Text('Browsing: $browsing'),
Text('Chosen: $chosen'),
],
);
}
Browse a table
Live · interactive
click & type to interact ⓘ how this demo runs

For spreadsheet-style interaction, the DataTable examples show selecting and copying cell ranges.

Scroll these eight numbered lines. The counter and TOP / BOTTOM markers show exactly where the four-row viewport is.

With Bubble (leave pane), press ↓ at BOTTOM: focus moves to Next. Choose Contain (stay in pane) from Edge behavior, Tab back into the pane, and try again. Now ↓ stays in the pane; Tab still leaves it.

scroll_edges.dart
Source · editable
import 'package:fleury/fleury_core.dart';
class ScrollEdges extends StatefulWidget {
const ScrollEdges({super.key});
@override
State<ScrollEdges> createState() => _ScrollEdgesState();
}
class _ScrollEdgesState extends State<ScrollEdges> {
final scroll = ScrollController();
EdgeBehavior edgeBehavior = EdgeBehavior.bubble;
bool paneFocused = false;
bool continued = false;
@override
void dispose() {
scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => FocusTraversalGroup(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Edge behavior'),
Select<EdgeBehavior>(
semanticLabel: 'Edge behavior',
value: edgeBehavior,
options: const [
SelectOption(
value: EdgeBehavior.bubble,
label: 'Bubble (leave pane)',
),
SelectOption(
value: EdgeBehavior.contain,
label: 'Contain (stay in pane)',
),
],
onChanged: (value) =>
setState(() => edgeBehavior = value),
),
const SizedBox(height: 1),
NotifierBuilder(
notifier: scroll,
builder: (context, _) {
final first = scroll.offset + 1;
final last =
scroll.offset + scroll.viewportExtent;
final edge = scroll.atStart
? 'TOP'
: scroll.atEnd
? 'BOTTOM'
: 'MIDDLE';
return Text('Rows $first–$last / 8 · $edge');
},
),
Panel(
title: 'Scroll pane',
expandChild: false,
focused: paneFocused,
child: FocusDetector(
onFocusChange: (value) =>
setState(() => paneFocused = value),
child:
SizedBox(
height: 4,
child: ScrollView(
controller: scroll,
autofocus: true,
scrollbar: true,
edgeBehavior: edgeBehavior,
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
const Text('1 ─── TOP'),
for (var n = 2; n < 8; n++)
Text('$n'),
const Text('8 ─── BOTTOM'),
],
),
),
),
),
),
Button(
text: 'Next',
onPressed: () => setState(() => continued = true),
),
Text(
paneFocused
? 'Focus: scroll pane'
: 'Focus: controls',
),
if (continued) const Text('Next selected'),
],
),
);
}
Reach the bottom
Live · interactive
click & type to interact ⓘ how this demo runs
Test references
import 'package:fleury/fleury.dart';
import 'package:fleury_test/fleury_test.dart';
import 'package:test/test.dart';
import '../../lib/lists/scroll_edges.dart';
void main() {
testWidgets(
'the numbered viewport reports top, middle, and bottom',
(tester) {
tester.pumpWidget(
const SizedBox(
width: 38,
height: 16,
child: ScrollEdges(),
),
);
tester.pump();
expect(
tester.exists(text('Rows 1–4 / 8 · TOP')),
isTrue,
);
tester.press(KeySequence.down);
expect(
tester.exists(text('Rows 2–5 / 8 · MIDDLE')),
isTrue,
);
tester.press(KeySequence.end);
expect(
tester.exists(text('Rows 5–8 / 8 · BOTTOM')),
isTrue,
);
expect(
tester.renderToString(),
contains('8 ─── BOTTOM'),
);
tester.press(KeySequence.down);
expect(tester.button('Next'), isFocused);
expect(
tester.exists(text('Focus: controls')),
isTrue,
);
tester.press(KeySequence.enter);
expect(
tester.renderToString(),
contains('Next selected'),
);
},
);
testWidgets(
'contain keeps the edge arrow; Tab still leaves',
(tester) async {
tester.pumpWidget(
const SizedBox(
width: 38,
height: 16,
child: ScrollEdges(),
),
);
await tester.button('Edge behavior').focus();
await tester.button('Edge behavior').press();
tester.press(KeySequence.down);
tester.press(KeySequence.enter);
tester.pump();
expect(
tester.button('Edge behavior'),
hasValue('Contain (stay in pane)'),
);
tester.press(KeySequence.tab);
tester.press(KeySequence.end);
tester.press(KeySequence.down);
expect(
tester.exists(text('Rows 5–8 / 8 · BOTTOM')),
isTrue,
);
expect(
tester.exists(text('Focus: scroll pane')),
isTrue,
);
expect(tester.button('Next'), isNot(isFocused));
tester.press(KeySequence.tab);
expect(tester.button('Next'), isFocused);
},
);
}

EdgeBehavior.bubble lets an unused arrow move focus onward. EdgeBehavior.contain keeps it in the pane. Lists have the same option.

ScrollView lays out its whole child. Give it a bounded height with SizedBox or Expanded; keep large collections in ListView.builder.

Scroll Recent to the end, then keep scrolling: the outer All notes pane moves and reveals older months. Enable Keep scrolling in Recent and try again. EdgeBehavior.contain keeps wheel input in the inner pane at its edge.

Scroll behavior
Choose what happens at the edge · editable
Widget get recentScroll => ScrollView(
edgeBehavior: contain ? EdgeBehavior.contain : EdgeBehavior.bubble,
child: const Text(
'1 Sketches\n2 Research\n3 Draft\n4 Feedback\n5 Revision\n6 Final',
),
);
Reach the end of Recent
Live · interactive
click & type to interact ⓘ how this demo runs
Full source
import 'package:fleury/fleury_core.dart';
class ScrollPanes extends StatefulWidget {
const ScrollPanes({super.key});
@override
State<ScrollPanes> createState() => _ScrollPanesState();
}
class _ScrollPanesState extends State<ScrollPanes> {
bool contain = false;
Widget get recentScroll => ScrollView(
edgeBehavior: contain ? EdgeBehavior.contain : EdgeBehavior.bubble,
child: const Text(
'1 Sketches\n2 Research\n3 Draft\n4 Feedback\n5 Revision\n6 Final',
),
);
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Checkbox(
label: 'Keep scrolling in Recent',
value: contain,
onChanged: (value) => setState(() => contain = value),
),
const SizedBox(height: 1),
SizedBox(
height: 10,
child: Panel(
title: 'All notes',
child: ScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 6,
child: Panel(title: 'Recent', child: recentScroll),
),
const Text('OLDER NOTES\nJuly\nJune\nMay\nApril'),
],
),
),
),
),
],
);
}

EdgeBehavior.bubble is the default: an ancestor can use wheel input the inner pane cannot consume. Containment is useful when scrolling a small pane should leave the surrounding view in place.

The sources and tests above are the files behind the live demos. The list demos live in website/examples/lib/lists and their tests in website/examples/test/lists. To run those tests and the nested scrolling one, use this command from the root of a clone of the Fleury repository:

Terminal window
cd website/examples && dart test test/lists_guide_test.dart