Skip to content

fix: reset AppBar _scrolledUnder flag when scroll context changes#183062

Draft
ishaquehassan wants to merge 43 commits into
flutter:masterfrom
ishaquehassan:fix/181701-appbar-scrolled-under-reset
Draft

fix: reset AppBar _scrolledUnder flag when scroll context changes#183062
ishaquehassan wants to merge 43 commits into
flutter:masterfrom
ishaquehassan:fix/181701-appbar-scrolled-under-reset

Conversation

@ishaquehassan

@ishaquehassan ishaquehassan commented Feb 28, 2026

Copy link
Copy Markdown
Contributor

When the Scaffold body is swapped out (for example, during a tab switch or navigation), _AppBarState.didChangeDependencies re-registers the scroll notification listener on the new scroll context but never clears the _scrolledUnder flag. If the previous body had been scrolled down, the AppBar retains its elevated (scrolledUnderElevation) appearance even though the new body starts at the top with nothing scrolled under it.

The fix adds a setState(() { _scrolledUnder = false; }) call inside didChangeDependencies, right after re-subscribing to the new scroll notification observer. The new content will update _scrolledUnder correctly via ScrollUpdateNotification on its first scroll event.

Two regression tests are added to app_bar_test.dart covering both cases: elevation resets after body swap, and elevation is preserved when the body is unchanged.

Fixes #181701

Pre-launch Checklist

If you need help, consider asking for advice on the #hackers-new channel on Discord.

Note: The Flutter team is currently trialing the use of Gemini Code Assist for GitHub. Comments from the gemini-code-assist bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.

@flutter-dashboard

Copy link
Copy Markdown

It looks like this pull request may not have tests. Please make sure to add tests or get an explicit test exemption before merging.

If you are not sure if you need tests, consider this rule of thumb: the purpose of a test is to make sure someone doesn't accidentally revert the fix. Ask yourself, is there anything in your PR that you feel it is important we not accidentally revert back to how it was before your fix?

Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing. If you believe this PR qualifies for a test exemption, contact "@test-exemption-reviewer" in the #hackers channel in Discord (don't just cc them here, they won't see it!). The test exemption team is a small volunteer group, so all reviewers should feel empowered to ask for tests, without delegating that responsibility entirely to the test exemption group.

@github-actions github-actions Bot added framework flutter/packages/flutter repository. See also f: labels. f: material design flutter/packages/flutter/material repository. labels Feb 28, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses an issue where the _scrolledUnder flag in _AppBarState was not being reset when the scroll context changed. The change adds logic to didChangeDependencies to check if _scrolledUnder is true and, if so, calls setState to set it to false. This ensures that when the Scaffold body is swapped, the AppBar's appearance correctly reflects the scroll state of the new content.

@ishaquehassan ishaquehassan force-pushed the fix/181701-appbar-scrolled-under-reset branch 2 times, most recently from 9780582 to d8f0730 Compare March 1, 2026 22:19
@fluttergithubbot

Copy link
Copy Markdown
Contributor

An existing Git SHA, d8f073007b7c5df50a94e71e9726b32927018a32, was detected, and no actions were taken.

To re-trigger presubmits after closing or re-opeing a PR, or pushing a HEAD commit (i.e. with --force) that already was pushed before, push a blank commit (git commit --allow-empty -m "Trigger Build") or rebase to continue.

@dkwingsmt dkwingsmt self-requested a review March 4, 2026 19:31
@dkwingsmt

Copy link
Copy Markdown
Contributor

@ishaquehassan Thank you for the great analysis. Before we start a detailed review, it seems that there are a lot of reformating in this PR, which are unnecessary and make the PR really hard to review. Can you undo these lines? (Maybe you used a different auto formatter?)

@ishaquehassan ishaquehassan force-pushed the fix/181701-appbar-scrolled-under-reset branch from da5c188 to e71f558 Compare March 5, 2026 17:54
@ishaquehassan

ishaquehassan commented Mar 5, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for the review! I've force-pushed the branch to remove all the unnecessary dart format changes.

The branch now contains only the 3 relevant commits:

  1. fix: reset AppBar _scrolledUnder flag when scroll context changes — the actual fix (9 lines in app_bar.dart)
  2. test: add regression tests for AppBar _scrolledUnder reset on scroll context change — regression tests
  3. fix: resolve analyzer lint warnings in regression tests, lint fixes for the test code

Sorry for the noise from the auto-formatter, I had inadvertently run dart format on the full files instead of limiting it to just the changed sections.

@ishaquehassan ishaquehassan force-pushed the fix/181701-appbar-scrolled-under-reset branch from 41c10dd to b5e63fb Compare March 5, 2026 22:46
When the Scaffold body is swapped out, didChangeDependencies re-registers
the scroll listener on the new context but never resets _scrolledUnder.
This causes the AppBar to retain its scrolled-under color even when the
new body starts at the top.

Reset _scrolledUnder to false in didChangeDependencies after subscribing
to the new ScrollNotificationObserver. A ScrollUpdateNotification on the
next frame will restore it to true if the new content is also scrolled.

Fixes flutter#181701
@ishaquehassan ishaquehassan force-pushed the fix/181701-appbar-scrolled-under-reset branch from 00d10dd to 95cca1f Compare March 10, 2026 13:10
@dkwingsmt

Copy link
Copy Markdown
Contributor

The PR now only contains 1 file with 9 lines of addition. It doesn't align with your previous comment, and I also remembered seeing more. Did you lose your changelist during the merge?

@ishaquehassan

ishaquehassan commented Mar 13, 2026

Copy link
Copy Markdown
Contributor Author

oops yeah my bad, i accidentally dropped the test commits when i force pushed to remove the extra formatting. pushed them back, should be good now

@dkwingsmt dkwingsmt added the CICD Run CI/CD label Mar 16, 2026

@dkwingsmt dkwingsmt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally looking good!

Comment on lines +839 to +841
// Reset _scrolledUnder when the scroll context is re-established (e.g.
// when the Scaffold body is swapped out). The new content will update
// _scrolledUnder via ScrollUpdateNotification on its first scroll event.

@dkwingsmt dkwingsmt Mar 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I rephrased this part of comment, which is clearer to me by explaining "if the new content is already scrolled". What do you think?

Suggested change
// Reset _scrolledUnder when the scroll context is re-established (e.g.
// when the Scaffold body is swapped out). The new content will update
// _scrolledUnder via ScrollUpdateNotification on its first scroll event.
// Reset _scrolledUnder when the scroll context is re-established (e.g. tab
// switch). Assume a starting state of false; if the new content is already
// scrolled, a ScrollUpdateNotification will be dispatched immediately to
// update the flag.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done, went with your suggestion, makes it way clearer

});

group('AppBar _scrolledUnder reset on scroll context change', () {
testWidgets('scrolledUnder flag resets when Scaffold body is swapped', (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to add a test where the new tab has a non-zero scroll, so that the scrollUnder is correctly updated to true immediately?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a third test that swaps to a new body, then calls jumpTo(300) on the controller which fires a ScrollUpdateNotification immediately, and verifies elevation goes back to scrolledUnderElevation. works cleanly since jumpTo dispatches the notification synchronously

@dkwingsmt

Copy link
Copy Markdown
Contributor

Also: Analyze errors and some other real errors.

- Rephrase comment per reviewer suggestion: clarify that starting
  state is assumed false and ScrollUpdateNotification updates it
- Add test verifying scrolledUnder correctly becomes true when new
  body is already at a non-zero scroll position after body swap
- Fix dart format issues (collapsed short expressions to single lines)
@github-actions github-actions Bot removed the CICD Run CI/CD label Mar 17, 2026
@ishaquehassan

ishaquehassan commented Mar 17, 2026

Copy link
Copy Markdown
Contributor Author

fixed in the latest commit. the analyze failure was dart format complaining about some multi-line expressions that fit on one line. ran dart format, cleaned it up. the other failures (Mac/Windows framework tests, web tests) are all in system_navigator_test and text_field_test, not related to this change

@dkwingsmt dkwingsmt added the CICD Run CI/CD label Mar 17, 2026
@dkwingsmt dkwingsmt added the CICD Run CI/CD label Apr 3, 2026
@dkwingsmt

dkwingsmt commented Apr 3, 2026

Copy link
Copy Markdown
Contributor

@ishaquehassan FYI recently we've changed the CICD policy in that the presubmit tests will only be triggered with the CICD label. And since labels can only applied by Members, feel free to comment and let us to apply the label. For more details, see #183554. Sorry for the inconvenience while we work on improving this work flow. Thanks!

@ishaquehassan

Copy link
Copy Markdown
Contributor Author

Thanks for the heads up on the new CICD workflow @dkwingsmt, good to know! Will ask for the label once the approach is finalized.

Still waiting on @Piinks for guidance on the Scaffold-level fix I proposed above. No rush, just want to make sure I build it at the right layer before pushing new code 🙂

@github-actions github-actions Bot removed the CICD Run CI/CD label Apr 6, 2026
@ishaquehassan

Copy link
Copy Markdown
Contributor Author

@dkwingsmt hey, would you mind adding the CICD label whenever you get a chance? want to make sure the tests pass with the latest changes 😊

@Piinks

Piinks commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Merging in main shouldn't have much of an impact on CI. Give me a moment to provide feedback.

@Piinks

Piinks commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

In general, you do not need to merge in master so frequently. :)

@Piinks

Piinks commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

@Piinks did some digging in the meantime, think the fix actually belongs in Scaffold, not AppBar 😅

AppBar has no dependency that changes when the body swaps so didChangeDependencies never fires here. What if Scaffold.didUpdateWidget handled it?

@override
void didUpdateWidget(Scaffold oldWidget) {
  super.didUpdateWidget(oldWidget);
  if (widget.body != oldWidget.body) {
    // dispatch synthetic reset signal through ScrollNotificationObserver
  }
}

When body changes, dispatch a synthetic notification with pixels: 0 through ScrollNotificationObserver. AppBar's existing _handleScrollNotification picks it up and resets _scrolledUnder, no changes to AppBar needed.

Covers both cases:

  • Scrollable swap: synthetic reset fires first, real ScrollMetricsNotification from new scrollable corrects state after
  • Non-scrollable swap: only synthetic reset fires, which is exactly what we want ✅

Only thing I am stuck on is the dispatch mechanism since _notify is private on ScrollNotificationObserver. Right direction or is there something cleaner? 👀

This would not be a good approach here. The _notify method being private is WAI. If you were to call that method it would break the API contract. It is meant to trigger notifications related to scrolling, enabling dispatch of notifications for any other reason could cause issues in many other widgets that listen for scrolling changes.

@ishaquehassan

Copy link
Copy Markdown
Contributor Author

@Piinks totally makes sense, won't touch _notify. And yeah the frequent master merges were just me being curious whether CI would catch anything new with the latest changes, not a workflow I plan to keep doing 😄

So both approaches I tried are dead ends:

  • didChangeDependencies doesn't fire on body swaps
  • Synthetic notifications through _notify would break the scrolling API contract

At this point I've run out of ideas on where the reset should live. Would you be able to point me toward the right layer or mechanism for handling this? More than happy to implement whatever direction you think is correct 🙂

@Piinks

Piinks commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

At this point I've run out of ideas on where the reset should live. Would you be able to point me toward the right layer or mechanism for handling this? More than happy to implement whatever direction you think is correct 🙂

I am not sure how to resolve the issue, unfortunately I am not able to work on it right now.

@ishaquehassan

Copy link
Copy Markdown
Contributor Author

At this point I've run out of ideas on where the reset should live. Would you be able to point me toward the right layer or mechanism for handling this? More than happy to implement whatever direction you think is correct 🙂

I am not sure how to resolve the issue, unfortunately I am not able to work on it right now.

No worries, still investigating myself.

@ishaquehassan

Copy link
Copy Markdown
Contributor Author

@Piinks did a deep dive into the internals and I think I found an approach that actually works. Hear me out on this one 😄

I know I said earlier that didChangeDependencies is fundamentally wrong for body swaps, and that was true at the time because AppBar had no dependency that changed on body swap. But I think the real problem wasn't the lifecycle method itself, it was the missing dependency. If we add that missing link, didChangeDependencies becomes the correct place for the reset.

Why didChangeDependencies never fired

AppBar has no InheritedWidget dependency that changes when the body changes:

  • Scaffold.maybeOf(context) uses findAncestorStateOfType (creates no dependency)
  • ScrollNotificationObserver.maybeOf(context) depends on _ScrollNotificationObserverScope, but updateShouldNotify only checks state instance identity (unchanged on body swap)
  • _ScaffoldScope.updateShouldNotify only checks hasDrawer

No signal path from "body changed" to AppBar exists.

Fix: add the missing signal path

Track body identity in _ScaffoldScope so dependents get notified on body swap.

scaffold.dart:

Add a generation counter to ScaffoldState:

int _bodyGeneration = 0;

Detect body changes in didUpdateWidget via Widget.canUpdate:

@override
void didUpdateWidget(Scaffold oldWidget) {
  super.didUpdateWidget(oldWidget);

  if (widget.body != null && oldWidget.body != null) {
    if (!Widget.canUpdate(widget.body!, oldWidget.body!)) {
      _bodyGeneration++;
    }
  } else if ((widget.body == null) != (oldWidget.body == null)) {
    _bodyGeneration++;
  }

  // ... existing FAB/bottomSheet handling
}

build is always called after didUpdateWidget, so no setState needed.

Add bodyGeneration to _ScaffoldScope:

class _ScaffoldScope extends InheritedWidget {
  const _ScaffoldScope({
    required this.hasDrawer,
    required this.geometryNotifier,
    required this.bodyGeneration,
    required super.child,
  });

  final bool hasDrawer;
  final _ScaffoldGeometryNotifier geometryNotifier;
  final int bodyGeneration;

  @override
  bool updateShouldNotify(_ScaffoldScope oldWidget) {
    return hasDrawer != oldWidget.hasDrawer ||
        bodyGeneration != oldWidget.bodyGeneration;
  }
}

app_bar.dart:

Register the dependency using the existing Scaffold.hasDrawer(context) API, which already calls dependOnInheritedWidgetOfExactType<_ScaffoldScope>() internally. Then reset _scrolledUnder:

@override
void didChangeDependencies() {
  super.didChangeDependencies();
  _scrollNotificationObserver?.removeListener(_handleScrollNotification);

  // Register dependency on _ScaffoldScope so didChangeDependencies
  // fires when the Scaffold body changes (bodyGeneration update).
  Scaffold.hasDrawer(context);

  final ScaffoldState? scaffoldState = Scaffold.maybeOf(context);
  if (scaffoldState != null &&
      (scaffoldState.isDrawerOpen || scaffoldState.isEndDrawerOpen)) {
    return;
  }
  _scrollNotificationObserver = ScrollNotificationObserver.maybeOf(context);
  _scrollNotificationObserver?.addListener(_handleScrollNotification);

  if (_scrolledUnder) {
    setState(() {
      _scrolledUnder = false;
    });
  }
}

How it handles each case

  • Scrollable to non-scrollable: bodyGeneration increments, updateShouldNotify returns true, didChangeDependencies fires, _scrolledUnder resets to false. No ScrollMetricsNotification from the non-scrollable body, so it stays false.
  • Scrollable to different scrollable: same reset, then the new scrollable fires ScrollMetricsNotification on init, and _handleScrollNotification sets the correct state.
  • Same body rebuild (parent setState): Widget.canUpdate returns true (same type, same key), bodyGeneration unchanged, updateShouldNotify false, no didChangeDependencies call.
  • Drawer open/close: bodyGeneration unchanged, hasDrawer unchanged, updateShouldNotify false. Existing behavior preserved.

No synthetic notifications, no new public API, zero changes to the scrolling contract. Just an InheritedWidget dependency that was missing.

One thing worth flagging: using Scaffold.hasDrawer(context) purely for the side effect of dependency registration is indirect. If you'd prefer a dedicated method following the geometryOf pattern, happy to add one instead.

Does this direction make sense?

@Piinks

Piinks commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

I appreciate the deep dive into the Scaffold internals, but adding a generation counter to such a fundamental widget as a workaround for AppBar state feels like a significant change that could have unintended side effects on performance and rebuild logic for all Scaffold dependents.

At this point, since we've cycled through several implementations (from didChangeDependencies to proposed Scaffold modifications), it’s clear the solution isn't straightforward. To ensure we land on an approach that aligns with the framework's design goals and doesn't introduce technical debt, let's move the design discussion back to the original issue #181701.

This will allow other maintainers and contributors subscribed to the issue to provide feedback on the best path forward before any more code is written, unfortunately myself and @dkwingsmt cannot go back and forth here trying to solve the issue with you. Once a robust strategy is agreed upon there, we can revisit the implementation here.

@Piinks Piinks dismissed dkwingsmt’s stale review April 7, 2026 19:45

Discovered issues with the implementation.

@ishaquehassan

Copy link
Copy Markdown
Contributor Author

Totally understand @Piinks, moving the discussion to #181701. Really appreciate you and @dkwingsmt taking the time to walk through all these approaches with me, learned a lot about the framework internals through this process 🙌

@Piinks

Piinks commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

Thanks! In the meantime, please stop pushing merges from main to this branch. It is not necessary right now, and creates a lot of noise. 🙏

@dkwingsmt

dkwingsmt commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

I'm converting this PR to a draft since we're planning to discuss the design further. (from triage)

@dkwingsmt dkwingsmt marked this pull request as draft April 8, 2026 18:38
@Piinks Piinks added the Decoupling: Not ready to port yet Instructions will be provided when this is ready to move to flutter/packages. label Jun 24, 2026
@Piinks

Piinks commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

I've marked this PR as not ready to port to flutter/packages yet.
We'll provide instructions to move this change over to material_ui/cupertino_ui once ready to receive PRs. Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Decoupling: Not ready to port yet Instructions will be provided when this is ready to move to flutter/packages. f: material design flutter/packages/flutter/material repository. framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AppBar "scrolled under" status doesn't update properly

4 participants