Skip to content

NavigationDrawer highlight is wrong when changing index from elsewhere #180359

Closed
flutter/packages
#13010

Description

@gert7

Steps to reproduce

  1. Use a NavigationDrawer with multiple indexes alongside e.g. an IndexedStack
  2. Change the index from inside a page instead of the navigation items

Expected results

A single highlight moving correctly as the selected page changes.

Actual results

When jumping over, the first NavigationDrawerDestination remains highlighted. The third option uses the correct selectedIcon, but has no highlighting.

If the third option is unavailable at first, but added programmatically at the same time, both options become highlighted.

The highlighting corrects itself when the window is resized.

Code sample

Code sample
import 'package:flutter/material.dart';

void main() async {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: .fromSeed(seedColor: Colors.lightBlue),
        splashColor: Colors.transparent,
        navigationDrawerTheme: NavigationDrawerThemeData(
          indicatorShape: RoundedRectangleBorder(
            borderRadius: BorderRadius.all(Radius.elliptical(10, 10)),
          ),
        ),
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class FirstWidget extends StatelessWidget {
  const FirstWidget({super.key, required this.jumpToThird});

  final void Function() jumpToThird;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: ElevatedButton(
        onPressed: jumpToThird,
        child: Text("Jump to third"),
      ),
    );
  }
}

class HelloWidget extends StatelessWidget {
  const HelloWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text(
        "Hello World!",
        style: Theme.of(context).textTheme.titleLarge,
      ),
    );
  }
}

class ThirdWidget extends StatelessWidget {
  const ThirdWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text(
        "The third widget.",
        style: Theme.of(context).textTheme.titleLarge,
      ),
    );
  }
}

class Destination {
  const Destination(this.label, this.icon, this.selectedIcon);

  final String label;
  final Widget icon;
  final Widget selectedIcon;
}

const List<Destination> destinations = [
  Destination("First", Icon(Icons.history), Icon(Icons.history_outlined)),
  Destination("Second", Icon(Icons.search), Icon(Icons.search_outlined)),
];

const Destination resultsDestination = Destination(
  "Third",
  Icon(Icons.reset_tv),
  Icon(Icons.reset_tv_outlined),
);

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<StatefulWidget> createState() => MyHomePageState();
}

class MyHomePageState extends State<MyHomePage> {
  int screenIndex = 0;
  final thirdVisible = ValueNotifier(true); // set this to false to see the other variant

  @override
  void initState() {
    super.initState();
    thirdVisible.addListener(() => setState(() {}));
  }

  void jumpToThird() {
    setState(() {
      thirdVisible.value = true;

      screenIndex = 2;
    });
  }

  @override
  Widget build(BuildContext context) {
    final destWidgets = <Widget>[Padding(padding: EdgeInsets.all(8))];

    for (final destination in destinations) {
      destWidgets.add(
        NavigationDrawerDestination(
          icon: destination.icon,
          label: Text(destination.label),
          selectedIcon: destination.selectedIcon,
        ),
      );
      destWidgets.add(Padding(padding: EdgeInsets.all(4)));
    }

    if (thirdVisible.value) {
      destWidgets.add(
        NavigationDrawerDestination(
          icon: resultsDestination.icon,
          label: Text(resultsDestination.label),
          selectedIcon: resultsDestination.selectedIcon,
        ),
      );
    }

    final frames = <Widget>[
      FirstWidget(jumpToThird: jumpToThird),
      HelloWidget(),
      if (thirdVisible.value) ThirdWidget(),
    ];

    return Scaffold(
      body: SafeArea(
        child: Row(
          children: [
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 5),
              child: NavigationDrawer(
                selectedIndex: screenIndex,
                onDestinationSelected: (value) =>
                    setState(() => screenIndex = value),
                children: destWidgets,
              ),
            ),
            VerticalDivider(),
            Expanded(
              child: IndexedStack(index: screenIndex, children: frames),
            ),
          ],
        ),
      ),
    );
  }
}

Screenshots or Video

Image Image

Logs

Logs
Launching lib/main.dart on Linux in debug mode...
✓ Built build/linux/x64/debug/bundle/simagefbug

(com.example.simagefbug:16985): Atk-CRITICAL **: 10:15:59.255: atk_socket_embed: assertion 'plug_id != NULL' failed
Gdk-Message: 10:15:59.316: Unable to load  from the cursor theme
Connecting to VM Service at ws://127.0.0.1:35039/C4nmcnsfdos=/ws
Connected to the VM Service.

Flutter Doctor output

Doctor output
[✓] Flutter (Channel stable, 3.38.5, on Arch Linux 6.18.2-arch2-1, locale en_US.UTF-8) [41ms]
    • Flutter version 3.38.5 on channel stable at /home/gert/development/flutter
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision f6ff1529fd (3 weeks ago), 2025-12-11 11:50:07 -0500
    • Engine revision 1527ae0ec5
    • Dart version 3.10.4
    • DevTools version 2.51.1
    • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets,
      omit-legacy-version-file, enable-lldb-debugging

[!] Android toolchain - develop for Android devices (Android SDK version 36.0.0) [1,793ms]
    • Android SDK at /home/gert/Android/Sdk
    • Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A)
    • Platform android-36, build-tools 36.0.0
    • ANDROID_HOME = /home/gert/Android/Sdk
    • Java binary at: /home/gert/development/android-studio/jbr/bin/java
      This is the JDK bundled with the latest Android Studio installation on this machine.
      To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
    • Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)
    ! Some Android licenses not accepted. To resolve this, run: flutter doctor --android-licenses

[✓] Chrome - develop for the web [9ms]
    • CHROME_EXECUTABLE = chromium

[✓] Linux toolchain - develop for Linux desktop [444ms]
    • clang version 21.1.6
    • cmake version 4.2.1
    • ninja version 1.13.2
    • pkg-config version 2.5.1
    • OpenGL core renderer: Mesa Intel(R) UHD Graphics (TGL GT1)
    • OpenGL core version: 4.6 (Core Profile) Mesa 25.3.2-arch1.1
    • OpenGL core shading language version: 4.60
    • OpenGL ES renderer: Mesa Intel(R) UHD Graphics (TGL GT1)
    • OpenGL ES version: OpenGL ES 3.2 Mesa 25.3.2-arch1.1
    • OpenGL ES shading language version: OpenGL ES GLSL ES 3.20
    • GL_EXT_framebuffer_blit: yes
    • GL_EXT_texture_format_BGRA8888: yes

[✓] Connected device (2 available) [185ms]
    • Linux (desktop) • linux  • linux-x64      • Arch Linux 6.18.2-arch2-1
    • Chrome (web)    • chrome • web-javascript • Chromium 143.0.7499.169 Arch Linux

[✓] Network resources [1,148ms]
    • All expected network resources are available.

! Doctor found issues in 1 category.

Activity

  1. tirth-patel-nc commented on Jan 5, 2026

    @tirth-patel-nc
    Member

    Thanks for the report. Seeing the same behaviour with latest SDK versions.

    stable : 3.38.5
    master : 3.40.0-1.0.pre-384
    
  2. added
    frameworkflutter/packages/flutter repository. See also f: labels.
    p: material_uimaterial_ui package in flutter/packages
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    team-designOwned by Design Languages team
    and removed
    in triagePresently being triaged by the triage team
    on Jan 5, 2026
  3. added
    P2Important issues not at the top of the work list
    triaged-designTriaged by Design Languages team
    on Jan 7, 2026
  4. dkwingsmt commented on Mar 25, 2026

    @dkwingsmt
    Contributor

    I've investigated this issue and it was quite a rabbit hole.

    I appreciate the mention of how resizing the window makes the paint appear correctly. I've tested it further: If the mouse hovers over the item as soon as you click the button, the paint jumps to a "middle value" and stops there. Apparently the animation object is being updated, but the repaint signal is not transmitted to the correct place.

    The following section is a summary of the cause. It was drafted with the assistance of AI, because there are a few details that I'm not completely clear yet.


    The root cause is a repaint signal decoupling between the local animation and the remote painting canvas. Here is the technical breakdown:

    • Remote Rendering via Ink:
      Following PR Fix NavigationBar indicator overlay color #164484, NavigationIndicator now utilizes the Ink widget. Unlike standard widgets, Ink does not paint on its own local RenderObject. Instead, it identifies the nearest Material ancestor and delegates the painting to that ancestor’s canvas. In NavigationDrawer, this Material widget sits outside the ListView.

    • Local vs. Remote Layer Mismatch:
      When the selectedIndex changes, the AnimatedBuilder updates the Transform widget. This calls markNeedsPaint() on the Transform's local layer. However, because Ink is a "remote" painter, the Transform is effectively scaling an empty area on its local layer. The actual pixels belong to the Material layer, which is unaware that a transformation is occurring deep inside the ListView's sub-hierarchy.

    • The ListView Signal Barrier:
      Even with addRepaintBoundaries: false, a ListView (and its internal Viewport) creates a complex rendering structure. The "update signal" from a local animation doesn't automatically propagate upward to the Material ancestor. Since the Ink widget itself is often treated as a static element within the ListView's cache, the rendering engine sees no reason to tell the distant Material canvas: "Hey, you need to redraw your Ink decorations because this specific child has changed."

    • Manual Override (Hover/Click):
      The reason clicking or hovering "fixes" the frame is that InkWell (used for the ripple/hover effect) is hardcoded to call markNeedsPaint() directly on the Material controller. This "wake-up call" forces the Material canvas to finally check the current state of all delegated Ink features, which is why the animation "jumps" to the current value once you interact with it.


    I've verified that the following change fixes the problem:

    class NavigationIndicator {
    // ... 
    // (Line 841)
        return AnimatedBuilder(
          animation: animation,
          builder: (BuildContext context, Widget? child) {
            // The scale should be 0 when the animation is unselected, as soon as
            // the animation starts, the scale jumps to 40%, and then animates to
            // 100% along a curve.
            final double scale = animation.isDismissed
                ? 0.0
                : Tween<double>(begin: .4, end: 1.0).transform(
                    CurveTween(curve: Curves.easeInOutCubicEmphasized).transform(animation.value),
                  );
    
            Material.of(context).markNeedsPaint();  /* NEW: Add this line */
            return Transform(
              alignment: Alignment.center,
              // Scale in the X direction only.
              transform: Matrix4.diagonal3Values(scale, 1.0, 1.0),
              child: child,
            );
          },

    This forces that the closest Material is updated every time animation changes, which I think also makes sense. Although I'm not sure if this is an idiomatic enough way to write Material related painting.

    cc @QuncCccccc @bleroux Maybe you have more insight and have more say in deciding whether this is an acceptable fix.

  5. QuncCccccc commented on Apr 8, 2026

    @QuncCccccc
    Contributor

    Sorry for the late response. I think the fix makes sense to me!

  6. theprantadutta commented on Jun 10, 2026

    @theprantadutta
    Contributor

    Since this has been sitting for a couple of months with the fix already worked out, I'd like to pick it up. Plan: apply the Material.of(context).markNeedsPaint() approach @dkwingsmt outlined (which @QuncCccccc said makes sense), add a regression test that changes selectedIndex programmatically and verifies the indicator repaints on the ancestor Material, and open a PR tagging you both for review. If you'd rather see a different mechanism than calling markNeedsPaint from the builder, I'm glad to iterate on that in review.

  7. dkwingsmt commented on Jun 10, 2026

    @dkwingsmt
    Contributor

    @theprantadutta Sounds good! LMK when the PR is ready.

  8. theprantadutta commented on Jun 10, 2026

    @theprantadutta
    Contributor

    PR is ready: #187802 — it applies the fix you verified, with a note on why markNeedsPaint() seems idiomatic here (it's part of the public MaterialInkController contract and the same mechanism ink features use). The regression test pumps to EnginePhase.layout so it observes the missing repaint signal directly rather than forcing a paint; it fails without the fix.

  9. JonoGrubb commented on Jul 27, 2026

    @JonoGrubb

    We hit this in production (web) and can offer a smaller regression case plus a sharper characterization that may help whoever picks up the fix.

    Two-widget minimal repro — freezes 100% of the time, no navigation drawer or selection machinery needed:

    import 'dart:async';
    import 'package:flutter/material.dart';
    
    void main() => runApp(const MaterialApp(home: Blink()));
    
    class Blink extends StatefulWidget {
      const Blink({super.key});
      @override
      State<Blink> createState() => _BlinkState();
    }
    
    class _BlinkState extends State<Blink> with SingleTickerProviderStateMixin {
      late final c = AnimationController(vsync: this, duration: const Duration(milliseconds: 500));
      late final t = Timer.periodic(const Duration(milliseconds: 1500),
          (_) => c.isForwardOrCompleted ? c.reverse() : c.forward());
      @override
      void dispose() { t.cancel(); c.dispose(); super.dispose(); }
      @override
      Widget build(BuildContext context) => Scaffold(
            body: Center(
              // The RepaintBoundary is the essential ingredient: it blocks the
              // repaint signal from reaching the ancestor Material that Ink
              // paints on. Remove it and the indicator animates correctly.
              child: RepaintBoundary(
                child: NavigationIndicator(animation: c, color: Colors.purple, width: 200, height: 60),
              ),
            ),
          );
    }

    The indicator never visually changes (0 distinct frames over 12s of automated screenshots), while the same tree without the RepaintBoundary — or with the pre-#164484 non-Ink indicator — animates normally. In NavigationDrawer the boundary comes for free from the ListView's addRepaintBoundaries, which is why the drawer is the reported surface; NavigationBar (Row, no boundary) mostly self-heals but we still caught it stale in 1 of 12 automated browser-back runs.

    Two observations that pinned this down for us, consistent with @dkwingsmt's analysis:

    1. What the user sees is the transition's first frame, forever. Icon/label swap immediately (status-based, painted locally inside the row's boundary, and the selection rebuild repaints the rows), while the incoming pill in frame 1 is scale 0.4 × alpha ≈ 0 (invisible) and the outgoing pill is still ≈ full. Any later repaint of the Material (ink hover, resize, an unrelated animation) presents the settled state — which is why the bug reads as intermittent/timing-dependent in real apps and is 100% deterministic when selection changes arrive with zero pointer traffic (browser back, deep links, programmatic navigation).
    2. Reproduces identically on CanvasKit and skwasm release builds (Flutter 3.41.4 stable), driven via CDP with no pointer events; also reproduced with plain Navigator + zero-duration routes and with a bare timer-driven setState — no Router/go_router involvement (a default MaterialPageRoute transition masks it by repainting everything for ~300ms).

    Externally we're carrying the same workaround shape as #187802 (a scoped per-frame markNeedsPaint over the host Material for the animation window) and it takes our automated browser-back stale rate from 12/12 to 0/12, which is decent evidence the PR's mechanism is the right one. Happy to test a build of #187802 against our harness if useful.

  10. added
    packageflutter/packages repository. See also p: labels.
    and removed
    frameworkflutter/packages/flutter repository. See also f: labels.
    on Aug 17, 2026
  11. added a commit that references this issue on Oct 2, 2026
    499f859
  12. github-actions commented on Oct 16, 2026

    @github-actions

    This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of flutter doctor -v and a minimal reproduction of the issue.

  13. locked as resolved and limited conversation to collaborators on Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work listfound in release: 3.38Found to occur in 3.38found in release: 3.40Found to occur in 3.40has reproducible stepsThe issue has been confirmed reproducible and is ready to work onp: material_uimaterial_ui package in flutter/packagespackageflutter/packages repository. See also p: labels.team-designOwned by Design Languages teamtriaged-designTriaged by Design Languages team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions