Flutter: Prevent ChangeNotifier's notifyListeners from preventing the animation of the sending button whose state depends on the ChangeNotifier?

Issue

My actual app is a lot more complex but I have been able to simplify it down to this example which demonstrates the issue.

I have 2 buttons which are supposed to reflect the same data. In this example, I have 2 like (heart) buttons. They can either be in liked or unliked state. However, both’s like/unliked status needs to match. I am doing this via ChangeNotifier, ChangeNotifierProvider and Consumer.

In the actual app, there are basically multiple screens which have buttons whose visual state all needs to match the same data.

All this works fine so far.

Here’s the problem. I need the button to do some animation on tap. This animation only needs to happen in the tapped button and not the other ones. In this simple example, the button does a pop animation. However, the ChangeNotifier‘s notifyListener prevents the animation from happening as I think it’s basically rebuilding the widget and thus preventing the animation. If I comment out the notifyListener, then the animation works but obviously, the data no longer gets updated and thus other button’s don’t reflect the correct liked/unliked status.

I have tried setting keys to the buttons but that didn’t help. I am lost at how I can solve this. Basically, I need to prevent the tapped button from being affected from the Consumer and notifyListener.

Code:

var b1 = GlobalKey();
var b2 = GlobalKey();
final buttonStateProvider = ButtonStateProvider();

@override
Widget build(BuildContext context) {
  return Scaffold(
    body: Center(
      child: ChangeNotifierProvider.value(
        value: buttonStateProvider,
        child: Consumer<ButtonStateProvider>(builder: (context, provider, child) {
          return Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              PoppingButton(
                key: b1,
                liked: provider.isLiked,
                onTap: () {
                  provider.toggleLiked();
                },
              ),
              PoppingButton(
                key: b2,
                liked: provider.isLiked,
                onTap: () {
                  provider.toggleLiked();
                },
              ),
            ],
          );
        }),
      ),
    ),
  );
}

//-------------------

class ButtonStateProvider extends ChangeNotifier {
  bool isLiked = false;
  void toggleLiked() {
    isLiked = !isLiked;
    notifyListeners();
  }
}

class PoppingButton extends StatelessWidget {
  const PoppingButton({super.key, required this.liked, required this.onTap});
  final bool liked;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    var keyForHeart = GlobalKey<PopWidgetState>();
    return GestureDetector(
      onTap: (() {
        keyForHeart.currentState?.pop();
        onTap.call();
      }),
      child: PopWidget(
        key: keyForHeart,
        child: Icon(
          liked ? CupertinoIcons.suit_heart_fill : CupertinoIcons.suit_heart,
          size: 100,
          color: liked ? Colors.pink : Colors.grey,
        ),
      ),
    );
  }
}

class PopWidget extends StatefulWidget {
  const PopWidget({super.key, required this.child});
  final Widget child;
  @override
  State<PopWidget> createState() => PopWidgetState();
}

class PopWidgetState extends State<PopWidget> with SingleTickerProviderStateMixin {
  late AnimationController animationController = AnimationController(duration: const Duration(milliseconds: 50), vsync: this);
  late var scaleAnimation = Tween<double>(begin: 1.0, end: 1.4).animate(CurvedAnimation(parent: animationController, curve: Curves.easeOutSine));

  void pop() {
    animationController.forward();
  }

  @override
  void initState() {
    super.initState();
    animationController.addStatusListener(animationListenerHandler);
  }

  void animationListenerHandler(status) {
    // print("Status: ${animationController.status}");
    switch (animationController.status) {
      case AnimationStatus.completed:
        animationController.reverse();
        break;
      case AnimationStatus.dismissed:
        break;
      default:
    }
  }

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

  @override
  Widget build(BuildContext context) {
    // print("scaleAnimation.value: ${scaleAnimation.value}");
    return AnimatedBuilder(
      animation: animationController,
      builder: ((context, child) {
        return Transform.scale(
          scale: scaleAnimation.value,
          child: widget.child,
        );
      }),
    );
  }
}

UI State:

enter image description here

enter image description here

Solution

I have modified your code a little, you don’t need GlobalKey

I have also add some comments to the changes I made

  final buttonStateProvider = ButtonStateProvider();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: ChangeNotifierProvider.value(
          value: buttonStateProvider,
          child: Consumer<ButtonStateProvider>(
              builder: (context, provider, child) {
            return Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                for (int i = 1; i < 3; i++)
                  PoppingButton(
                    id: i,
                    liked: provider.isLiked,
                    tappedId: provider.tappedId,
                    onTap: () => provider.toggleLiked(i),
                  ),
              ],
            );
          }),
        ),
      ),
    );
  }

//-------------------
class ButtonStateProvider extends ChangeNotifier {
  bool isLiked = false;

  // used to indentfiy the widget to play the scale animation
  int tappedId = 0;

  void toggleLiked(int id) {
    isLiked = !isLiked;
    tappedId = id;
    notifyListeners();
  }
}

class PoppingButton extends StatelessWidget {
  const PoppingButton(
      {super.key,
      required this.liked,
      required this.tappedId,
      required this.onTap,
      required this.id});
  final bool liked;
  final VoidCallback onTap;
  final int id;
  final int tappedId;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: (() {
        onTap.call();
      }),
      child: PopWidget(
        key: ValueKey(id),
        id: id,
        tappedId: tappedId,
        isLiked: liked,
        child: Icon(
          liked ? CupertinoIcons.suit_heart_fill : CupertinoIcons.suit_heart,
          size: 100,
          color: liked ? Colors.pink : Colors.grey,
        ),
      ),
    );
  }
}

class PopWidget extends StatefulWidget {
  const PopWidget(
      {super.key,
      required this.child,
      required this.tappedId,
      required this.isLiked,
      required this.id});
  final Widget child;
  final bool isLiked;
  final int id;
  final int tappedId;
  @override
  State<PopWidget> createState() => PopWidgetState();
}

class PopWidgetState extends State<PopWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController animationController = AnimationController(
      duration: const Duration(milliseconds: 50), vsync: this);
  late var scaleAnimation = Tween<double>(begin: 1.0, end: 1.4).animate(
      CurvedAnimation(parent: animationController, curve: Curves.easeOutSine));

  @override
  void initState() {
    super.initState();
    animationController.addStatusListener(animationListenerHandler);
  }

  void animationListenerHandler(status) {
    // print("Status: ${animationController.status}");
    switch (animationController.status) {
      case AnimationStatus.completed:
        animationController.reverse();
        break;
      case AnimationStatus.dismissed:
        break;
      default:
    }
  }

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

  // update the animation state when [isLiked] is true
  // and check the id of the tapped widget and the widget id
  @override
  void didUpdateWidget(covariant PopWidget oldWidget) {
    if (widget.isLiked && widget.id == widget.tappedId) {
      animationController.forward();
    }

    super.didUpdateWidget(oldWidget);
  }

  @override
  Widget build(BuildContext context) {
  // print("scaleAnimation.value: ${scaleAnimation.value}");
    return AnimatedBuilder(
      animation: animationController,
      builder: ((context, child) {
        return Transform.scale(
          scale: scaleAnimation.value,
          child: widget.child,
        );
      }),
    );
  }
}

Answered By – Mohammed Alfateh

Answer Checked By – Terry (FlutterFixes Volunteer)

Leave a Reply

Your email address will not be published. Required fields are marked *