Making Infinite Variable Animation in Flutter

Making Infinite Variable Animation in Flutter

Making Reflecting Ball Animation

·

7 min read

I've been looking into Flutter's animation lately. As Flutter begins to support the web, I think customizing animation is a very essential element for competitiveness in html, css, etc.

Basic Animations in Flutter

Flutter 2.0 basically supports many animation widgets. We can use built-in animation widgets like Fade Transition, Scale Transition widgets. A more complex animation can be implemented through AnimatedBuilder, TweenAnimationBuilder, etc. And we create the AnimationController class to run and control animations. But from what I've experienced, these animations always show the same animations. (You may not understand the meaning of this sentence. If you keep reading my writing, you'll realize what I'm trying to say!) I want to create a more interesting animation on flutter!

Reflecting Ball Animation

ezgif.com-gif-maker (3).gif

   ReflectBall(
          ballRad: 20,
          mapXsize: 300,
          mapYsize: 500,
          xPosition: 100,
          yPosition: 200,
          xSpeed: 4,
          ySpeed: 4,
        );

This is the animation widget I want to create. Let's take a look at my code that embodies this animation.

Factors

As in the code above, we need several factors to make this animation. Ball's radius, size of space in which the ball moves, initial position of the ball and initial ball speed. Therefore, since these factors are essential, set required in the ReflectBall widget class.

class ReflectBall extends StatefulWidget {
  final double mapXsize;
  final double mapYsize;
  final double xPosition;
  final double yPosition;
  final int ballRad;
  final double xVector;
  final double yVector;
  final double xSpeed;
  final double ySpeed;

  const ReflectBall({Key? key,
    required this.mapXsize,
    required this.mapYsize,
    required this.xPosition,
    required this.yPosition,
    required this.ballRad,
    this.xVector = 1,
    this.yVector = 1,
    required this.xSpeed,
    required this.ySpeed
  }) : super(key: key);

  @override
  _ReflectBallState createState() => _ReflectBallState();
}

Infinite Animation

This animation should basically work endlessly. In general, there are several simple ways to implement infinite animation.

  1. Set Duration very long

    AnimationController(
         vsync: this,
         duration: Duration(seconds: 1000)
     );
    

    Strictly speaking, it's not an infinite animation, but you'll be able to create an animation that lasts a long time.

  2. Repeat() Method

    _animationController.repeat();
    

    If you run this method in initState() part, the animation will repeat over and over.

  3. addStatusListener

    _animationController.forward();
    _animationController.addStatusListener((status) {
       if (status == AnimationStatus.completed) {
         _animationController.reverse();
       } else if (status == AnimationStatus.dismissed) {
         _animationController.forward();
       }
     });
    

    This is a method of controlling animation through AnimationStatus information. Consider some animation that changes A to B. If this method is used, A to B animation and B to A animation will continue to be repeated in order.

As you all know, if you just use this method, the same animation will continue to run constantly. What I need is not the same animation. In our animation, the ball constantly changes its position and direction depending on the conditions. I defined this ever-changing animation as "Variable Animation" as in the title of this article. And I finally came up with a way to implement it!

Infinite Variable Animation

What is the relationship between Animation and AnimationStatus? If you look at the animations.dart file, you can see the following comments.

  // The result of this function includes an icon describing the status of this
  // [Animation] object:
  //
  // * "▶": [AnimationStatus.forward] ([value] increasing)
  // * "◀": [AnimationStatus.reverse] ([value] decreasing)
  // * "⏭": [AnimationStatus.completed] ([value] == 1.0)
  // * "⏮": [AnimationStatus.dismissed] ([value] == 0.0)

That is, [value] having a value from 0 to 1 determines status. So I wrote the code as follows.

    _animationController.forward();
    _animationController.addStatusListener((status) {
      if(status == AnimationStatus.completed ){
        });
        _animationController.value=0;
        _animationController.forward();
      }
    });

The _animationController.value changes continuously from 0 to 1 by .forward() in the first line. When the value is 1, the animation status becomes completed and enters the if statement. Next, if we set the value to 0, then again .forward() can be executed. In other words, this code functions the same as .repeat().

    _animationController.forward();
    _animationController.addStatusListener((status) {
      if(status == AnimationStatus.completed ){
        setState(() {
          if(xPos >= (widget.mapXsize - widget.ballRad) || xPos <= widget.ballRad){
            xVec*=-1;
          }
          if(yPos >= (widget.mapYsize - widget.ballRad) || yPos <= widget.ballRad){
            yVec*=-1;
          }

          xPos+=widget.xSpeed*xVec;
          yPos+=widget.ySpeed*yVec;

        });
        _animationController.value=0;
        _animationController.forward();
      }
    });

Here, we can add a setState statement to create an animation that keeps changing. In the above code, the code in the setState statement is a code that keeps changing the direction of the ball and the position of the ball. If the ball gets out from the size of the map set, the vector value must be inverted to move the ball in the opposite direction. The position of the ball is multiplied by the speed value, which is a scalar value, and the vector value to set a new position value. Since the screen is two-dimensional, the x-axis and y-axis must be set according to vector decomposition.

Build Widgets

It's almost done now.

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animationController,
      builder: (context, child) {
        return Container(
          width: widget.mapXsize,
          height: widget.mapYsize,
          color: Colors.lightGreen,
          child: CustomPaint(
            painter: _ball(
              animationValue: _animationController.value,
              xVector: xVec,
              yVector: yVec,
              xPosition: xPos,
              yPosition: yPos,
              ballRad: widget.ballRad,
              xSpeed: widget.xSpeed,
              ySpeed: widget.ySpeed
            ),
          ),
        );
      },
    );
  }
class _ball extends CustomPainter {
  final animationValue;
  final xPosition;
  final yPosition;
  final xVector;
  final yVector;
  final ballRad;
  final xSpeed;
  final ySpeed;

  _ball({
    required this.animationValue,
    required this.xPosition,
    required this.yPosition,
    required this.xVector,
    required this.yVector,
    required this.ballRad,
    required this.xSpeed,
    required this.ySpeed,

  });

  @override
  void paint(Canvas canvas, Size size) {

    Paint paint = Paint()
      ..color = Colors.indigoAccent
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2;

    Path path = Path();

    for(double i=0; i<ballRad; i++){
      path.addOval(Rect.fromCircle(
        center: Offset(
          xPosition + animationValue*xSpeed*xVector,
          yPosition + animationValue*ySpeed*yVector,
        ),
        radius: i
      ));
    }
    path.close();

    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

I used Animated Builder and Custom Paint for drawing the ball. To draw a variable ball, painter class need several factors. I used the addOval method with for statement to draw a full ball, but there will be many ways to draw a ball.

Conclusion

I'm not sure if this code I wrote is the perfect code to meet what I'm trying to make. Many improvements may be needed in areas such as optimization. However, I have great significance in that I solved the answers myself that I didn't get through googling. Even if it's not the perfect answer.

If there is a problem with my code or if there is a better direction, please leave a comment! Thank you for reading my long article.

Full Code

import 'package:flutter/material.dart';

class ReflectBall extends StatefulWidget {
  final double mapXsize;
  final double mapYsize;
  final double xPosition;
  final double yPosition;
  final int ballRad;
  final double xVector;
  final double yVector;
  final double xSpeed;
  final double ySpeed;

  const ReflectBall({Key? key,
    required this.mapXsize,
    required this.mapYsize,
    required this.xPosition,
    required this.yPosition,
    required this.ballRad,
    this.xVector = 1,
    this.yVector = 1,
    required this.xSpeed,
    required this.ySpeed
  }) : super(key: key);

  @override
  _ReflectBallState createState() => _ReflectBallState();
}

class _ReflectBallState extends State<ReflectBall> with SingleTickerProviderStateMixin{
  late AnimationController _animationController;
  late double xPos;
  late double yPos;
  late double xVec;
  late double yVec;

  @override
  void initState() {
    // TODO: implement initState
    super.initState();

    xPos = widget.xPosition;
    yPos = widget.yPosition;
    xVec = widget.xVector;
    yVec = widget.yVector;

    _animationController = AnimationController(
        vsync: this,
        duration: Duration(milliseconds: 1)
    );
    _animationController.forward();

    _animationController.addStatusListener((status) {
      if(status == AnimationStatus.completed ){
        setState(() {
          if(xPos >= (widget.mapXsize - widget.ballRad) || xPos <= widget.ballRad){
            xVec*=-1;
          }
          if(yPos >= (widget.mapYsize - widget.ballRad) || yPos <= widget.ballRad){
            yVec*=-1;
          }

          xPos+=widget.xSpeed*xVec;
          yPos+=widget.ySpeed*yVec;

        });
        _animationController.value=0;
        _animationController.forward();
      }
    });
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animationController,
      builder: (context, child) {
        return Container(
          width: widget.mapXsize,
          height: widget.mapYsize,
          color: Colors.lightGreen,
          child: CustomPaint(
            painter: _ball(
              animationValue: _animationController.value,
              xVector: xVec,
              yVector: yVec,
              xPosition: xPos,
              yPosition: yPos,
              ballRad: widget.ballRad,
              xSpeed: widget.xSpeed,
              ySpeed: widget.ySpeed
            ),
          ),
        );
      },
    );
  }
}

class _ball extends CustomPainter {
  final animationValue;
  final xPosition;
  final yPosition;
  final xVector;
  final yVector;
  final ballRad;
  final xSpeed;
  final ySpeed;

  _ball({
    required this.animationValue,
    required this.xPosition,
    required this.yPosition,
    required this.xVector,
    required this.yVector,
    required this.ballRad,
    required this.xSpeed,
    required this.ySpeed,

  });

  @override
  void paint(Canvas canvas, Size size) {

    Paint paint = Paint()
      ..color = Colors.indigoAccent
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2;

    Path path = Path();

    for(double i=0; i<ballRad; i++){
      path.addOval(Rect.fromCircle(
        center: Offset(
          xPosition + animationValue*xSpeed*xVector,
          yPosition + animationValue*ySpeed*yVector,
        ),
        radius: i
      ));
    }
    path.close();

    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}