Published on
| 12 mins read

Chap 3 - Add a Rive Animated Button to a Flutter Flame Game

Authors

Overview

Table of Contents

Problem

In the previous article, we added a pause button. But it's just a static image. How boring it is! Whenever a user taps on a button, she must have a "feeling" that it is like a "real" button, not a static image. In other words, it should be animated.

Ok, so how can we make an animated button?

Some solutions

As far as I know, there are at least 2 ways to add an animated button into a Flame-based Flutter game:

  • Use the HudButtonComponent. It lets us set callbacks to handle tap events, but it requires us to provide 2 separate images for 2 states of a button: idle and pressed.
  • Use the RiveComponent from the flame_rive package along with the Tappable mixin. We must get or create a Rive file using rive.app and manage to animate it on tap event. Sounds pretty harder but it opens the door to adding more awesome animations to our game.

So, I chose to use Rive with the hope that we can easily make this game such an animated beast once we get used to it.

Add flame_rive dependency

Run this command to add flame_rive package:

flutter pub add flame_rive

Our pubspec.yaml should become this:

    json_annotation: ^4.7.0
    flame: ^1.3.0
    flame_svg: ^1.5.0
+   flame_rive: ^1.5.1

Add the Rive file

To show an animation using Rive, we need a Rive file that has *.riv extension. To create that file, we need to use the rive.app editor. Open and register a FREE account to use it at editor.rive.app.

You can learn how to create animations with Rive on the Rive official documentation site. I prepared a buttons.riv file for you. It is supposed to contain all the Rive animated buttons for our game. However, for now, it is only having the animated pause button.

This is how the buttons.riv file looks like in the Rive editor:

the artboard at the center, the timeline with an animation

There are 2 main concepts you need to know about a Rive file. We will need them in the code.

  • artboard: Each Rive file can have multiple artboards. Each artboard contains a group of shapes that will show up as a single UI component on the screen.

    In this case, the artboard pause-one (its name is the SVG file name I downloaded from IconPark) contains the pause button component. We can name the artboard whatever we want.

  • animation: Each artboard can have multiple animations. Each animation is a series of states of the original object on the artboard. Of course, we have to design those states on our own. I just cut that design part from this article because it's out of scope.

    In this case, the animation Press (we can name it whatever we want) will be like this if I press the play button:

the button is smaller when tapping on the play button in the timeline

Now, we export this into the buttons.riv file and save it in the assets/images directory.

the new buttons.riv file

Play the Rive animation when tapping on the button

We need to load the Rive file into the app to use a Rive animation. Here are the code changes:

 import 'package:flame/game.dart';
-import 'package:flame_svg/flame_svg.dart';
+import 'package:flame_rive/flame_rive.dart';
 import 'package:flutter/widgets.dart';
+import 'package:rive/rive.dart';

class MainGame extends FlameGame {
   @override
   Color backgroundColor() => const Color(0xFFFFFFFF);

   @override
   Future<void>? onLoad() async {
-    final pauseIcon = await Svg.load('images/ic_pause.svg');
-    final pauseIconComponent = SvgComponent(
-      svg: pauseIcon,
+    final riveFile = RiveFile.asset('assets/images/buttons.riv');
+    final artboard = await loadArtboard(
+      riveFile,
+      artboardName: 'pause-one',
     );
+    final pauseIconComponent = RiveComponent(
+      artboard: artboard,
       position: Vector2.all(0),
       size: Vector2.all(100),
     );
   }
 }

In the main_game.dart file, we replace the old code that loads the SVG image. First, it loads the RiveFile by calling RiveFile.asset('assets/images/buttons.riv'). Then, we must specify which artboard we want to show on the screen. Do you remember the pause-one artboard I mentioned above? We load it by calling loadArtboard() with the pause-one value of the artboardName argument.

After that, we need to create a RiveComponent instance from that artboard instead of the SvgComponent. This component still needs to hold the size and position, so that the game knows how to draw it on the screen.

Do you notice that we imported the rive/rive.dart package? We need it to be able to use the RiveFile class. So, remember to add the package to our app by running this command:

flutter pub add rive

And looking for the change in pubspec.yaml:

    json_annotation: ^4.7.0
    flame: ^1.3.0
    flame_svg: ^1.5.0
    flame_rive: ^1.5.1
+   rive: ^0.9.1

Here is the full current code in main_game.dart:

import 'package:flame/game.dart';
import 'package:flame_rive/flame_rive.dart';
import 'package:flutter/widgets.dart';
import 'package:rive/rive.dart';

class MainGame extends FlameGame {
  
  Color backgroundColor() => const Color(0xFFFFFFFF);

  
  Future<void>? onLoad() async {
    final riveFile = RiveFile.asset('assets/images/btn_pause.riv');
    final artboard = await loadArtboard(
      riveFile,
      artboardName: 'pause-one',
    );
    final pauseIconComponent = RiveComponent(
      artboard: artboard,
      position: Vector2.all(0),
      size: Vector2.all(100),
    );
    await add(pauseIconComponent);
  }
}

Let's run the app!

run the game

Trigger the animation

But the button hasn't animated when we tap it. To achieve that, we need to do 2 things:

  • Detect tap events to run our code.
  • The code must run the animation.

Our main_game.dart will become this:

+import 'package:flame/components.dart';
 import 'package:flame/game.dart';
 import 'package:flame_rive/flame_rive.dart';
 import 'package:flutter/widgets.dart';
 import 'package:rive/rive.dart';

-class MainGame extends FlameGame {
+class PauseBtnComponent extends RiveComponent with Tappable {
+  PauseBtnComponent({required super.artboard})
+      : super(
+          position: Vector2.all(0),
+          size: Vector2.all(100),
+        );
+}
+
+class MainGame extends FlameGame with HasTappables {
   @override
   Color backgroundColor() => const Color(0xFFFFFFFF);
   ...
       riveFile,
       artboardName: 'pause-one',
     );
-    final pauseIconComponent = RiveComponent(
-      artboard: artboard,
-      position: Vector2.all(0),
-      size: Vector2.all(100),
-    );
+    final pauseIconComponent = PauseBtnComponent(artboard: artboard);
     await add(pauseIconComponent);
   }
 }

The RiveComponent doesn't have the ability to detect tap events. To extend its ability, we must create a separate class to extend the RiveComponent class and combine with the Tappable mixin. This mixin has the ability to handle tap events. So, the PauseBtnComponent class also inherits that ability. We are gonna handle the tap event in a callback function later.

In Flame engine, if a game contains at least 1 Tappable component, the game class must inherit the HasTappables mixin. Otherwise, it will throw this exception message: Tappable components can only be added to a FlameGame with HasTappables. So, we must add with HasTappables to our FlameGame class to make it work.

Now, to run the animation on a tap down event, we need to change our code again:

 import 'package:flame/game.dart';
+import 'package:flame/input.dart';
 import 'package:flame_rive/flame_rive.dart';
 import 'package:flutter/widgets.dart';
 import 'package:rive/rive.dart';

 class PauseBtnComponent extends RiveComponent with Tappable {
           position: Vector2.all(0),
           size: Vector2.all(100),
         );
+
+  late final RiveAnimationController _controller;
+
+  @override
+  Future<void>? onLoad() {
+    _controller = OneShotAnimation('Press', autoplay: false);
+    artboard.addController(_controller);
+    return super.onLoad();
+  }
+
+  @override
+  bool onTapDown(TapDownInfo info) {
+    _controller.isActive = true;
+    return true;
+  }
 }

 class MainGame extends FlameGame with HasTappables {

To be able to run the animation, we grab a RiveAnimationController. In this case, we want the animation to run from start to end and then stop until the next tap event. So, it should be an OneShotAnimation. We pass the Press argument, which is the name of our animation in the artboard pause-one in our Rive file if you still remember. The autoplay named argument is false because we don't want it to play right after the button shows up. We will trigger the animation on our will later.

Then, remember to attach this new controller to the artboard by calling addController() function. Otherwise, the artboard will not run the animation.

Now, in a Tappable object, the onTapDown() callback will be called on a tap down event. So, we override it and change the isActive of the _controller to true here. That means the animation specified in the controller will run.

That's all, this is the full code of the main_game.dart file:

import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flame/input.dart';
import 'package:flame_rive/flame_rive.dart';
import 'package:flutter/widgets.dart';
import 'package:rive/rive.dart';

class PauseBtnComponent extends RiveComponent with Tappable {
  PauseBtnComponent({required super.artboard})
      : super(
          position: Vector2.all(0),
          size: Vector2.all(50),
        );

  late final RiveAnimationController _controller;

  
  Future<void>? onLoad() {
    _controller = OneShotAnimation('Press', autoplay: false);
    artboard.addController(_controller);
    return super.onLoad();
  }

  
  bool onTapDown(TapDownInfo info) {
    _controller.isActive = true;
    return true;
  }
}

class MainGame extends FlameGame {
  
  Color backgroundColor() => const Color(0xFFFFFFFF);

  
  Future<void>? onLoad() async {
    final riveFile = RiveFile.asset('assets/images/buttons.riv');
    final artboard = await loadArtboard(
      riveFile,
      artboardName: 'pause-one',
    );
    final pauseIconComponent = PauseBtnComponent(artboard: artboard);
    await add(pauseIconComponent);
  }
}

Let's run the app!

run the game

It's good.

Check out the commit for this article in this pull request.