useScrollController
Creates a ScrollController that is disposed automatically when the hook context unmounts. It returns the controller, ready to attach to a scrollable.
class ScrollToTopList extends HookWidget {
const ScrollToTopList({super.key});
Widget build(BuildContext context) {
final scrollController = useScrollController(); // <- Created once, disposed for you
return Scaffold(
body: ListView.builder(
controller: scrollController,
itemCount: 100,
itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
),
floatingActionButton: FloatingActionButton(
onPressed: () => scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
),
child: const Icon(Icons.arrow_upward),
),
);
}
}
Signature
ScrollController useScrollController({
double initialScrollOffset = 0.0,
bool keepScrollOffset = true,
String? debugLabel,
HookKeys keys = hookKeysEmpty,
});
The parameters mirror the ScrollController constructor. keys behaves as elsewhere in the library: when the keys change, the controller is disposed and a fresh one is created (resetting the offset) - useful when one widget reuses a list for distinct data sets that should each start at the top.
Use cases
-
Programmatic scrolling: jump or animate to an offset (scroll-to-top, scroll-to-index) from a callback.
-
Reading scroll position to drive UI - showing a "back to top" button past a threshold, or triggering a paginated load near the end:
class LoadMoreList extends HookWidget {const LoadMoreList({super.key});Widget build(BuildContext context) {final scrollController = useScrollController();// React to scroll without rebuilding on every pixel - useListenableListener,// not useListenable.useListenableListener(scrollController, () {final position = scrollController.position;if (position.pixels > position.maxScrollExtent - 200) {// load the next page...}});return ListView(controller: scrollController, children: const []);}}For a full paginated list, prefer
usePaginatedComputedStatewithPaginatedComputedStateWrapper, which manages the scroll-drivenloadMorefor you.
Caveats
-
To react to scrolling, attach a listener with
useListenableListener, notuseListenable. AScrollControllernotifies on every scroll frame; rebuilding the whole widget on each is wasteful, so run a side effect and readcontroller.offsetinside the callback.// DON'T - rebuilds the widget on every scroll frameuseListenable(scrollController);// DO - side effect only, no rebuilduseListenableListener(scrollController, () {if (scrollController.offset > 200) showBackToTop.value = true;}); -
This wraps
useMemoized(ScrollController.new, keys, (it) => it.dispose()). The hand-written form works, but the dedicated hook forwards every constructor argument and wires disposal and thekeys-based reset - prefer it. -
Reading
controller.position(oroffset) before the controller is attached to a scrollable throws. Read it from a listener or a post-attach callback, not during the first build.
See also
- useFocusNode - the same auto-dispose pattern for a
FocusNode - useListenableListener - react to scroll changes without rebuilding
- usePaginatedComputedState - manages scroll-driven pagination end to end
- useMemoized - the lower-level building block this hook wraps