[Flutter] 무한히 지속되며 상태가 계속 변하는 가변 Animation

RGLie,·2022년 4월 3일
2

나는 요즘 Flutter의 애니메이션에 대해 알아보고 개발하는 중이다. Flutter 2.0부터 WEB을 지원하면서, React 등과 같은 기존의 웹 프레임워크와의 경쟁력을 갖추기 위해서는 애니메이션이 매우 중요한 요소라고 생각한다.

Basic Animations in Flutter

우리는 fluttter가 제공하는 기본 애니메이션 위젯으로 간단한 애니메이션을 구현할 수 있다. 예를 들어 Scale Transition, Fade Transition 과 같은 위젯들이 있다. 그리고 조금더 다양한 애니메이션을 만들기 위해 Animated Builder, Tween Animation Builder와 같은 위젯들도 사용할 수 있다. 하지만 내가 최근 개발하며 경험해본 바로는 이러한 위젯들을 통한 애니메이션은 항상 같은 애니메이션만 보여준다는 것이다. (이것이 정확히 맞는 표현인지는 모르겠다. 이 글을 읽다보면 내가 말하고자 하는바를 이해할 수 있을것이다!) 그래서 나는 Flutter 에서 조금 더 특별한 애니메이션을 구현하고 싶었다.

Reflecting Ball Animation

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

이것이 구현하고 싶은 애니메이션 위젯 ReflectBall이다. 초록색 상자안에서 하나의 공이 움직이고 있고 벽을 만나면 반사된다. 이제 내가 이것을 어떻게 구현했는지 설명하겠다.

Factors

위의 ReflectBall의 위젯을 보면 몇 개의 인자를 입력값으로 받고 있다. 공의 반지름, 공이 움직이는 상자의 크기, 공의 초기 위치 그리고 공의 속력이 그것들이다. 나는 이 인자들이 곡 필효하다고 생각해 required 옵션을 넣어주었다.

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);

  
  _ReflectBallState createState() => _ReflectBallState();
}

Infinite Animation

앞서 말했듯이 이 공반사 애니메이션은 무한하게 지속된다. Flutter에서 애니메이션을 계속 지속시키고 싶을때는 다음과 같은 간단한 방법들이 있다.

  1. Set Duration very long

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

    정확히 말하면 무한한 것은 아니지만, 일반적으로 유저가 한 화면에 몇 시간을 보고 있는 경우는 없을 것이다.

  2. Repeat() Method

    _animationController.repeat();

    코드 그대로 반복된다.

  3. addStatusListener

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

    이것은 Flutter의 애니메이션의 AnimationStatus 값을 컨트롤하는 방법이다. A에서 B로 변하는 애니메이션이 있다고 할때, 위의 코드는 A에서 B로, 다시 B에서 A로 변하는 애니메이션이 반복되는 것이다.

Flutter의 애니메이션을 사용해본 사람은 알겠지만 위의 3가지 방법을 사용할 경우 애니메이션이 무한히 실행은 되지만, 그 애니매이션은 항상 같은 애니메이션이다. 하지만 내가 원하는 것은 그게 아니다. 공을 반사시키는 애니메이션을 만들기 위해서는 계속 인자들이 변해야한다. 예를 들어 공은 움직이면서 계속 위치가 변하고, 위치가 변함에 따라 벽에 충돌이라는 조건이 만족시 방향도 바뀌어야한다. 물론! 에측할 수 있는 경로들이기에 미리 공의 이동경로를 설정하고 그것을 그냥 repeat() 할 수 도 있다. 하지만/// 가치가 없다..!

나는 이렇게 애니메이션을 구성하는 변수들이 계속해서 변하는 애니메이션을 가변 애니메이션이라고 부르기로 했다!

Infinite Variable Animation

위의 3번째 방법에서 AnimationStatus와 Animation의 관계는 무엇일까? 안드로이드 스튜디오에서 F4를 누르고 animations.dart 파일을 보면 다음과 같은 주석을 찾을 수 있다.

  // 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)

이 주석에 따르면, Animation의 value 값에 따라서 AnimationStatus가 결정된다는 것이다!! 그래서 나는 아래와 같이 코드를 작성했다.

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

_animationController.value 값은 애니메이션이 진행되면서 0부터 1까지 연속적으로 증가한다. 코드의 첫번째 줄 .forward() 메소드는 애니메이션을 한번 실행 시키며 0이었던 value를 1로 만든다. 그럼 AnimationStatus는 Completed 상태가 되며 addStatusListenr에서 if문의 조건이 성립한다.
그 후, if문 안에서 value 값을 0으로 만들면 다시 .forward() 메소드를 실행 할 수 있다! 일단 여기까지는 .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();
      }
    });

하지만 이제 setState문을 추가할 수 있다!! 위의 코드에서 setState문 안의 코드는 공의 위치가 정해준 맵 사이즈를 벗어날시 공의 벡터 방향을 뒤집어 주고, 그 후 x축과 y축에서 공의 위치를 속력 X 방향 만큼 더해준다. (2차원 벡터분해)

여기서 중요한 것은, 애니매이션의 정보를 setState문을 이용해 계속 바꿀 수 있다는 것이다.

Build Widgets

  
  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,

  });

  
  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);
  }

  
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

나는 Animated Builder와 Custom Paint를 사용했다. Custom Paint Class에서 path의 addOval과 for문으로 원을 그렸다. x축 위치, y축 위치와 같은 값들을 인자로 다시 넘겨주면서 계속해서 다른 위치로 공을 그릴 수 있다.

Conclusion

나는 내가 원하고자 하는 애니메이션을 구현하기 위한 100퍼센트 완벽한 코드인지는 장담하지 못한다. 애니메이션을 그리는 과정에서의 최적화 등과 같은 부분의 개선이 필요할 것이다. 그러나 나는 이것을 구현하면서 구글링을 해도 나오지 않는 해답을 나 스스로의 힘으로 Flutter Docs를 찾아보며 어느정도 해결했다는 점에서 매우매우 큰 의의를 가진다. 정말 뿌듯했다.

혹시 나의 글을 보고 나의 코드에서 문제점이나 개선이 필요한 부분이 있다면 꼭! 커맨트를 달아줬으면 좋겠다.

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);

  
  _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;

  
  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();
      }
    });
  }

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

  
  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,

  });

  
  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);
  }

  
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}
profile
Flutter Programmer (github: RGLie)

0개의 댓글