In Flutter, how do you scroll a ListView back to the top from a button using a ScrollController?
answer
- one controller, owned by a State
- pass it as controller:
- animateTo needs duration and curve
- jumpTo skips the animation
- check hasClients, then dispose
basics
~20 sCreate a ScrollController in a State, pass it to the ListView's controller parameter, and on tap call animateTo(0, duration:, curve:) or jumpTo(0). Guard with hasClients so nothing runs before the list is attached, and dispose the controller.
solid answer
~40 sThe `ScrollController` is created once in a `State` (a field initializer or `initState`), never in `build`, and passed to the list through `controller:`. The button calls `animateTo(0, duration: ..., curve: ...)` for a visible glide, or `jumpTo(0)` to move instantly; both require an attached scroll view, so I check `hasClients` first — a callback can fire before the list is built or after it is gone. `animateTo` returns a `Future` that completes when the animation ends or is interrupted, for example by the user grabbing the list. The controller is a `ChangeNotifier`, so the `State` disposes it; one created in `build` would reset the position on each rebuild and never be disposed.
code
dart · 46 linesimport 'package:flutter/material.dart';
class ForumThreadPage extends StatefulWidget {
const ForumThreadPage({super.key, required this.posts});
final List<String> posts;
@override
State<ForumThreadPage> createState() => _ForumThreadPageState();
}
class _ForumThreadPageState extends State<ForumThreadPage> {
final ScrollController _controller = ScrollController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _backToTop() {
if (!_controller.hasClients) return;
_controller.animateTo(
0,
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutCubic,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Thread')),
floatingActionButton: FloatingActionButton(
onPressed: _backToTop,
tooltip: 'Back to top',
child: const Icon(Icons.arrow_upward),
),
body: ListView.builder(
controller: _controller,
itemCount: widget.posts.length,
itemBuilder: (context, index) => ListTile(title: Text(widget.posts[index])),
),
);
}
}go deeper
Recall the steps: controller in the State, pass it to controller:, call animateTo or jumpTo, dispose it.
Explain attachment and hasClients, the difference between jumpTo and animateTo, and what the animateTo future means.
Handle initial offsets, very long lists and lazy maxScrollExtent estimates, and catch controllers created in build during review.
Set a shared pattern for scroll controllers across screens so ownership and disposal are never left to chance.
## What a ScrollController is A **`ScrollController`** is the handle you hold to a scroll view's position. A scroll view (`ListView`, `GridView`, `CustomScrollView`, `SingleChildScrollView`) creates a **`ScrollPosition`** when it is built and **attaches** it to the controller you pass as `controller:`. Through the controller you can read the current `offset` and drive the position with `jumpTo` and `animateTo`. Because the controller outlives many builds and owns listeners, it is a `ChangeNotifier` that must be disposed. ## The back-to-top button, step by step For a long forum thread with a floating 'back to top' button: 1. **Own the controller in a `State`.** Declare it as a `final` field or create it in `initState`. Creating it inside `build` gives the list a fresh controller on every rebuild, which loses the position and leaks the old one. 2. **Pass it to the list** via `controller: _controller`. 3. **On tap, guard with `hasClients`.** `hasClients` is true once at least one `ScrollPosition` is attached. Calling `jumpTo` or `animateTo` with none attached trips the assertion `ScrollController not attached to any scroll views.` 4. **Scroll.** `animateTo(0, duration: const Duration(milliseconds: 400), curve: Curves.easeOutCubic)` glides to the top; `jumpTo(0)` moves there instantly. 5. **Dispose it** in the `State`'s `dispose`, before `super.dispose()`. ## jumpTo versus animateTo | | `jumpTo(value)` | `animateTo(value, duration:, curve:)` | |---|---|---| | Motion | instant | animated over `duration` with `curve` | | Return type | `void` | `Future<void>` | | Required arguments | the offset | offset, `duration` (non-zero) and `curve` | | Range check | none; a ballistic activity then settles an out-of-range value | stops early if it reaches an edge that does not overscroll | | Notifications | start, update and end scroll notifications | the same, spread over the animation | | Typical use | restoring a position, tests, very long jumps | user-visible navigation such as back to top | The `animateTo` future completes when the animation **ends or is interrupted** — the user dragging the list, another scroll activity starting, or the animation hitting an edge. Code that awaits it cannot assume the list actually reached the target. In widget tests, awaiting it can hang; the framework docs recommend driving it with `pumpAndSettle` instead. ## Why the hasClients guard matters - A button can be tapped during the first frame, before the list has laid out. - A timer or stream callback can fire after the route was popped and the list detached. - With a lazy list the controller knows nothing about unbuilt rows, but offset 0 is always valid, which is why back-to-top is the easy case. Jumping to the *end* relies on `maxScrollExtent`, which for `ListView.builder` is an estimate until rows are built. ## Very long lists `animateTo(0)` from 50,000 pixels down builds and lays out rows along the way for the duration of the animation. For very long threads, many apps `jumpTo` a point near the top and then `animateTo(0)` the last stretch, so the gesture still reads as motion without building thousands of rows mid-flight. ## Common mistakes - Creating `ScrollController()` inside `build`. - Forgetting `dispose`, which leaves listeners attached and shows up in leak tracking. - Reading `_controller.offset` in `initState`, before any position is attached. - Using a zero `Duration` with `animateTo` instead of calling `jumpTo`.
- What does the Future returned by animateTo tell you?Only that the animation is over. It completes when the animation finishes or is interrupted — by a user drag, another scroll activity, or reaching an edge that does not overscroll. To know where the list ended up, read `offset` or `position.pixels` after it completes.
- How do you open a screen already scrolled to a saved offset?Pass `ScrollController(initialScrollOffset: saved)`, which positions the first attached `ScrollPosition` without a visible jump. Calling `jumpTo` in `initState` fails because nothing is attached yet; if you must jump later, do it in a post-frame callback behind a `hasClients` check.
- Why is scrolling to the end of a ListView.builder less reliable than scrolling to the top?Offset 0 is always valid, but `maxScrollExtent` for a lazy list is an estimate based on the rows built so far. `animateTo(position.maxScrollExtent)` computes its target once and may stop short as more rows are laid out; the docs suggest `Scrollable.ensureVisible` on a built child when you need a specific row.
saying these in an interview costs you the question
- Create the ScrollController inside build so it is always fresh.
- animateTo can be called before the list is built.
- A ScrollController needs no dispose because it holds no resources.
- Awaiting animateTo guarantees the list reached the target offset.
- jumpTo refuses offsets outside the scroll range.