skip to content

In a Flutter comments list, why does scrolling to maxScrollExtent right after setState stop short of the new comment, and how do you fix it?

level: juniorimportance: should knowfreq 45%

answer

  1. setState only marks dirty
  2. layout has not run yet
  3. maxScrollExtent is the old value
  4. WidgetsBinding.instance.addPostFrameCallback
  5. check mounted and hasClients

basics

~20 s

setState only marks the widget dirty; the list is rebuilt and laid out in the next frame, so maxScrollExtent read immediately still describes the old list. Scroll inside WidgetsBinding.instance.addPostFrameCallback, which runs after that frame's layout.

solid answer

~30 s

`setState` runs the closure, marks the element dirty and requests a frame; it does not rebuild or lay out anything before returning. So `scrollController.position.maxScrollExtent` read on the next line is the extent of the list **without** the new comment, and the scroll stops one item short. Register the scroll with `WidgetsBinding.instance.addPostFrameCallback`: it runs once, after the next frame's build, layout and paint, when the new comment has a size and the extent includes it. Inside the callback check `mounted` and `scrollController.hasClients`, because the screen may have been closed in between. `await WidgetsBinding.instance.endOfFrame` achieves the same in an async method.

code

dart · 11 lines
dart
void _addComment(Comment comment) {
  setState(() => _comments.add(comment));
  WidgetsBinding.instance.addPostFrameCallback((_) {
    if (!mounted || !_scrollController.hasClients) return;
    _scrollController.animateTo(
      _scrollController.position.maxScrollExtent,
      duration: const Duration(milliseconds: 250),
      curve: Curves.easeOut,
    );
  });
}

go deeper

for a junior

Remember that setState only schedules a rebuild. Anything that needs the new size or scroll extent goes in addPostFrameCallback, guarded by mounted.

for a middle

Explain the frame order that makes the extent stale, why a zero-duration timer is not tied to the frame, and when endOfFrame is the tidier choice.

for a senior

Point out the estimated extent of lazy lists with variable heights, and prefer a reversed list for chat-style screens over repeated corrective scrolls.

for a principal

Encourage a shared helper or pattern for post-layout work so screens do not scatter timers and magic delays through the codebase.

## The symptom A comments screen shows a `ListView` with a `ScrollController`. When the user posts a comment, the code adds it to the list inside `setState` and immediately calls `animateTo(controller.position.maxScrollExtent, ...)`. The list scrolls, but it stops just above the new comment, which stays hidden below the fold. Posting a second comment reveals the first one. ## Why it happens `setState` is not a synchronous render. It does three things and returns: 1. runs the closure you passed, so the list data now includes the new comment; 2. marks the `State`'s element **dirty**; 3. makes sure a frame is scheduled for the next **vsync**. The new comment row does not exist yet as a render object, and nothing has been laid out. `ScrollPosition.maxScrollExtent` is a value that the viewport computes **during layout**. Read on the line after `setState`, it still describes the previous frame's list. The code scrolls to the bottom of a list that no longer exists. The frame that follows runs in a fixed order: animations, then **build** (the list rebuilds with the new item), then **layout** (the viewport computes the new extent), then **paint**, and only then the **post-frame callbacks**. So the fix is to move the scroll into that last slot. ## The fix: a post-frame callback `WidgetsBinding.instance.addPostFrameCallback(callback)` registers a function that runs exactly once, after the current frame (or the next one, if no frame is in progress) has finished its build, layout and paint. At that point `maxScrollExtent` includes the new comment. Two checks belong inside the callback: - **`mounted`** — the user may have left the screen before the frame ran; using a disposed `State`'s controller throws. - **`scrollController.hasClients`** — the controller must be attached to a scroll view; reading `position` with no attached scroll view throws. An async method can write the same thing as `await WidgetsBinding.instance.endOfFrame;`, which returns a future that completes after the frame and schedules one if the scheduler is idle. ## Approaches that look right but are not | Approach | Problem | |---|---| | Scroll on the line after `setState` | Layout has not run; the extent is stale. | | `Future.delayed(Duration.zero, ...)` | A zero timer is not tied to the frame and usually fires before the next vsync, so layout still has not run. | | A fixed delay such as 100 ms | Works on a fast phone, fails on a slow one, and adds visible lag. | | `addPostFrameCallback` | Runs after the frame that laid the new comment out. | ## A caveat for long lazy lists With `ListView.builder`, rows off screen are not built, so for items of different heights the viewport **estimates** the full extent from the rows it has laid out. After a jump to the estimated bottom, the extent can be corrected and the final comment can still be partly hidden. Two common remedies: - Build the list with `reverse: true` and keep the newest comment at index 0. "Scroll to the newest" becomes "scroll to offset 0", which never depends on an estimate. - After the animation finishes, check whether the position is at the (now exact) `maxScrollExtent` and correct once. ## Where the registration belongs Register the callback in the **event handler** that made the change, as in the example, not inside `build`. A `build` method runs whenever anything above it rebuilds: a theme change, a keyboard opening, a parent's `setState`. An unconditional `addPostFrameCallback` in `build` therefore queues a new scroll after every one of those frames, and the list keeps jumping to the bottom while the user is trying to read older comments. If the scroll must depend on state, for example "only follow new comments when the user is already near the bottom", record that decision in the handler before calling `setState`: 1. read `scrollController.position.pixels` and `maxScrollExtent` **before** the change, while they still describe what the user sees; 2. decide whether to follow; 3. call `setState`, then register the post-frame scroll only if the decision was yes. ## Takeaways - `setState` requests a frame; it does not perform one. - Anything that needs a size, a position or a scroll extent after a change belongs in a post-frame callback. - A post-frame callback runs once; register a new one for each change.

  • Why is Future.delayed(Duration.zero) not a reliable replacement for addPostFrameCallback here?
    A zero-duration timer runs as soon as the event loop reaches it, which is usually before the next vsync fires. At that point the frame that builds and lays out the new comment has not happened, so `maxScrollExtent` is still stale. A post-frame callback is tied to the frame itself, so it cannot run before layout.
  • How would you write the same fix in an async method without a callback?
    Call `setState`, then `await WidgetsBinding.instance.endOfFrame;`, then check `mounted` and `hasClients` and scroll. `endOfFrame` completes after the current frame, or schedules a frame when the scheduler is idle and completes after that one, so the new comment has been laid out when the await resumes.
  • The comments list is a lazy ListView.builder with rows of different heights, and the scroll still lands slightly short. Why?
    Rows that were never built have no measured height, so the viewport estimates the total extent from the rows it has laid out. After the jump, newly built rows correct the estimate and the true bottom moves. A `reverse: true` list with the newest comment at index 0 avoids the estimate, because the newest comment is always at offset 0.

saying these in an interview costs you the question

  • setState rebuilds and lays out the list before it returns.
  • Future.delayed(Duration.zero) reliably waits until the new row is laid out.
  • A post-frame callback keeps running on every frame until it is removed.
  • Calling setState from inside a post-frame callback is not allowed.
  • A longer fixed delay is the proper way to wait for layout.