Skip to content

Animations in NavigationDestination icons don't work well #122811

Closed

Description

@math1man

Steps to Reproduce

  1. Run the code sample at https://dartpad.dev/?id=70f5ce75c3fdb7b614e9253ebd8cc2c9
  2. Switch between destinations in the NavigationBar

Expected results:

When switching destinations, the destination icon should animate. The expected result can be produced by uncommenting line 49 in the dartpad example that adds a GlobaKey to the AnimatedIcon.

Actual results:

The icon does not animate and jumps from start to end state.

This issue was introduced with #116888. That change adds a UniqueKey to a widget within NavigationDestination's construction of its icon, which breaks the animated icon's ability to retain state without using a GlobalKey of some sort. Though adding the GlobalKey is a reasonably easy fix to the issue, it's very unintuitive and in my opinion shouldn't be required.

Code sample

(copied from https://dartpad.dev/?id=70f5ce75c3fdb7b614e9253ebd8cc2c9)

import 'package:flutter/material.dart';

const Color darkBlue = Color.fromARGB(255, 18, 32, 47);

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark().copyWith(
        scaffoldBackgroundColor: darkBlue,
      ),
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: Center(
          child: Text(
            'Hello, World!',
            style: Theme.of(context).textTheme.headlineMedium,
          ),
        ),
        bottomNavigationBar: AnimatedNavigationBar(),
      ),
    );
  }
}

class AnimatedNavigationBar extends StatefulWidget {
  @override
  State<AnimatedNavigationBar> createState() => _AnimatedNavigationBarState();
}

class _AnimatedNavigationBarState extends State<AnimatedNavigationBar> {
  int selectedIndex = 0;

  @override
  Widget build(BuildContext context) => NavigationBar(
        selectedIndex: selectedIndex,
        onDestinationSelected: (index) => setState(() {
          selectedIndex = index;
        }),
        destinations: [
          for (var i = 0; i < 3; i++)
            NavigationDestination(
              icon: AnimatedIcon(
                // Uncomment the following line to fix the bug.
                // key: GlobalObjectKey(i),
                selected: selectedIndex == i,
              ),
              label: 'Destination$i',
            ),
        ],
      );
}

class AnimatedIcon extends StatefulWidget {
  const AnimatedIcon({super.key, required this.selected});

  final bool selected;

  @override
  State<AnimatedIcon> createState() => _AnimatedIconState();
}

class _AnimatedIconState extends State<AnimatedIcon>
    with SingleTickerProviderStateMixin {
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      lowerBound: 0.5,
      upperBound: 1,
      duration: const Duration(seconds: 1),
      vsync: this,
    );
    if (widget.selected) controller.value = 1;
  }

  @override
  void didUpdateWidget(AnimatedIcon oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.selected != oldWidget.selected) {
      if (widget.selected) {
        controller.forward();
      } else {
        controller.reset();
      }
    }
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => ScaleTransition(
        scale: controller,
        child: const Icon(Icons.favorite),
      );
}

Internal bug b/273309891

Activity

  1. added
    p: material_uimaterial_ui package in flutter/packages
    P2Important issues not at the top of the work list
    frameworkflutter/packages/flutter repository. See also f: labels.
    on Mar 16, 2023
  2. moved this to In progress in Nevercodeon Mar 17, 2023
  3. bleroux commented on Mar 24, 2023

    @bleroux
    Contributor

    Here is an updated code sample that makes the issue a little more visible by changing the icon color on each rebuild (and also disposing properly the animation controller to avoid exceptions):

    Updated code sample
    import 'dart:math';
    
    import 'package:flutter/material.dart';
    
    void main() {
      runApp(const MyApp());
    }
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return const MaterialApp(
          debugShowCheckedModeBanner: false,
          home: Scaffold(
            bottomNavigationBar: AnimatedNavigationBar(),
          ),
        );
      }
    }
    
    class AnimatedNavigationBar extends StatefulWidget {
      const AnimatedNavigationBar({super.key});
    
      @override
      State<AnimatedNavigationBar> createState() => _AnimatedNavigationBarState();
    }
    
    class _AnimatedNavigationBarState extends State<AnimatedNavigationBar> {
      int selectedIndex = 0;
    
      @override
      Widget build(BuildContext context) {
        return NavigationBar(
          labelBehavior: NavigationDestinationLabelBehavior.onlyShowSelected,
          backgroundColor: Colors.green.withOpacity(0.2),
          selectedIndex: selectedIndex,
          onDestinationSelected: (int index) {
            setState(() {
              selectedIndex = index;
            });
          },
          destinations: <Widget>[
            for (int i = 0; i < 3; i++)
              NavigationDestination(
                icon: AnimatedIcon(
                  selected: selectedIndex == i,
                ),
                label: 'Destination $i',
              ),
          ],
        );
      }
    }
    
    class AnimatedIcon extends StatefulWidget {
      const AnimatedIcon({super.key, required this.selected});
    
      final bool selected;
    
      @override
      State<AnimatedIcon> createState() => _AnimatedIconState();
    }
    
    class _AnimatedIconState extends State<AnimatedIcon>
        with SingleTickerProviderStateMixin {
      late AnimationController controller;
    
      @override
      void initState() {
        super.initState();
        controller = AnimationController(
          lowerBound: 0.5,
          // ignore: avoid_redundant_argument_values
          upperBound: 1,
          duration: const Duration(seconds: 1),
          vsync: this,
        );
        if (widget.selected) {
          controller.value = 1;
        }
      }
    
      @override
      void didUpdateWidget(AnimatedIcon oldWidget) {
        super.didUpdateWidget(oldWidget);
        if (widget.selected != oldWidget.selected) {
          if (widget.selected) {
            controller.forward();
          } else {
            controller.reset();
          }
        }
      }
    
      @override
      void dispose() {
        // Make sure to dispose the controller when the widget is disposed.
        controller.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return ScaleTransition(
          scale: controller,
          child: const IconWithRandomColor(icon: Icons.favorite),
        );
      }
    }
    
    class IconWithRandomColor extends StatelessWidget {
      const IconWithRandomColor({super.key, required this.icon});
    
      final IconData icon;
    
      @override
      Widget build(BuildContext context) {
        final Color randomColor = Color((Random().nextDouble() * 0xAAAAAA).toInt()).withOpacity(1.0);
        return Icon(icon, color: randomColor);
      }
    }

    Capture (icon color changes on every tap and icon animation shows only initial and final states):

    122811_repro.mp4
  4. math1man commented on Mar 24, 2023

    @math1man
    ContributorAuthor

    Whoops, updated my sample to add disposal. Thanks for the additional example though.

  5. moved this from In progress to PR submitted in Nevercodeon Mar 24, 2023
  6. moved this from PR submitted to Done (PR merged) in Nevercodeon Mar 27, 2023
  7. added
    r: fixedIssue is closed as already fixed in a newer version
    on Mar 27, 2023
  8. github-actions commented on Apr 10, 2023

    @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.

  9. locked as resolved and limited conversation to collaborators on Apr 10, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P2Important issues not at the top of the work listframeworkflutter/packages/flutter repository. See also f: labels.p: material_uimaterial_ui package in flutter/packagesr: fixedIssue is closed as already fixed in a newer version

Type

No type

Projects

  • Status
    Done (PR merged)

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions