View

300x250

Untitled.png

https://www.youtube.com/watch?v=tGeYQlrbdGk&t=4s

위 영상에 나오는 것 처럼, 이모지가 팡팡 터지면서 불꽃놀이 효과가 나는 이펙트가 필요해서, 요런걸 찾고 싶었다. 그런데 왠걸,, 생각보다 저런 이펙트를 플러터로 구현해놓은 코드를 찾는 것이 쉽지 않았다. 구글링과 깃허브 레포지토리까지 열심히 뒤져봤지만, Confetti 라는 효과 말고는 크게 의미 있는 코드를 발견할 수 없었다. (진짜 눈물 줄줄 😭😭😭 괜히 플러터의 단점이 “커뮤니티가 작다”인게 아니였다… )

열심히 고민해봤는데, 이왕 프로젝트를 하는거 여기에서 이펙트, 애니메이션에 대한 이해와 구현하는 능력을 어느정도 공부하면서 정리를 해두면 나중에 앱 개발을 할 때 이펙트에 대한 역량을 충분히 기를 수 있겠다는 생각이 들었다. 그만큼 시간과 건강, 그리고 정신을 갈아넣어야겠지만… 후. 그래서 이때까지 계속 회피해왔던 애니메이션에 대한 공부를 이번에 좀 시도해보려고 한다!!

아래 링크에 있는 버튼을 누르면 예쁜 이펙트가 나오는 예제부터 따라하면서 차츰차츰 불꽃놀이 이펙트를 만들어보려고 생각중이다.

bookmark

https://www.notion.so/sm-amoled/Flutter-Animation-Controller-ea4d1bb1386941d68ffefc50646f70e6?pvs=4#cadc94e39fbc41839189999dbc658dba

AnimationController

네비게이션으로 진입할 수 있는 페이지에 아래 initState 함수를 붙여주면, 네비게이션으로 해당 페이지에 들어갈 때 매 150밀리초마다 0과 1사이의 값을 찍어서 반환한다. 아무래도 진행도 값을 받아오는 것이라고 생각하면 될 것 같다.

void initState() {
    super.initState();
    final scoreInAnimationController = AnimationController(
        duration: const Duration(milliseconds: 150), vsync: this);
    scoreInAnimationController.addListener(() {
      print(scoreInAnimationController.value);
    });
    scoreInAnimationController.forward(from: 0.0);
}

///
flutter: 0.0
flutter: 0.11110666666666667
flutter: 0.22222666666666668
flutter: 0.33333333333333337
flutter: 0.4444466666666667
flutter: 0.5555533333333333
flutter: 0.6666666666666667
flutter: 0.77778
flutter: 0.8888933333333334
flutter: 1.0

위처럼, 애니메이션이 진행되는 동안 선형적으로 애니메이션의 value가 0→1로 증가하는 것을 확인할 수 있다.

AnimationController의 초기화 시점에 받아오는 인자는 2가지 - duration과 vsync 이다.

AnimationController(duration: Duration, vsync: TickerProvider)
  • duration : 현재 애니메이션이 지속되어야 하는 시간. 애니메이션 시간에 대한 단위라고 생각하면 될 것 같다.
  • vsync : 현재 context에서 사용중인 TickerProvider. 애니메이션에 대한 위젯의 refresh를 제어하는 녀석이다. 만약에 애니메이션을 그리기 위해 위젯을 일정 시간마다 refresh 하도록 만들었다면 위젯이 화면에서 사라져서 보이지 않을 때에도 계속 애니메이션을 업데이트하게 되어 비효율적인 실행을 하게된다. TickerProvider를 사용하면 위젯과 controller를 바인딩하여, 위젯이 가려지면 controller가 update를 멈추도록 하여 refresh가 발생하지 않도록 막기 위해 사용된다.

선형적으로 진행되는 애니메이션말고, BouceIn 처럼 value가 곡선형으로 증가하는 애니메이션도 만들 수 있다. 위에서 사용한 AnimationController를 기반으로 bounceInAnimation 같은 CurvedAnimation을 만들어주면 value가 비선형적인 형태로 찍히는 것을 확인할 수 있다

void initState() {
		...

    final bounceInAnimation = new CurvedAnimation(
        parent: scoreInAnimationController, curve: Curves.bounceIn);
    bounceInAnimation.addListener(() {
      print(bounceInAnimation.value);
    });
  }

///
flutter: 0.024693734530555678
flutter: 0.050151604788888804
flutter: 0.1389011107749999
flutter: 0.2492287345305555
flutter: 0.17284290119722223
flutter: 0.15975583299722285
flutter: 0.6265506789750002
flutter: 0.9066432714555557
flutter: 1.0

값이 0과 1사이에서 움직이는 것이 아니라, 0과 100처럼 더 넒은 범위에서 변하도록 하고싶으면 단순히 값에 100을 곱해주면 된다. 또는 Tween 이라는 녀석을 사용해주면 된다. (Tween이 선형보간—linear interpolation—을 적용하는 것이라고 한다!) 최저값과 최고값을 넣어주면 알아서 해당 범위에 맞게 값을 stretch해서 반환해준다.

void initState() {
	...

	final tweenAnimation =
        Tween(begin: 0.0, end: 100.0).animate(scoreInAnimationController);
  tweenAnimation.addListener(() {
      print(tweenAnimation.value);
  });
}

Tween을 사용하면 단순히 선형적으로 증가하는 AnimationController 뿐만 아니라 CurvedAnimation 등의 value에도 적용할 수 있고, Color나 Position같은 다양한 부분에도 적용할 수 있다고 한다.

흠,,, 그치만 Tween의 타입이 Tween<double>이고, Tween().animate()의 타입은 Animation<double>이라 어떻게 가져와서 사용해야 할 지는 아직 감이 잘 안온다. 만들어보면서 배워야 할 것 같음!!


본격적으로 예제에 들어가기 앞서 제시된 레이아웃은 이렇게 생겼다.

[코드]

여기에 나오는 에셋들은 위에 올려준 미디엄 원문 글 하단에 있는 깃허브 레포지토리에서 받을 수 있다. https://github.com/Kartik1607/FlutterUI/tree/master/MediumClapAnimation/medium_clap/images

class ClapButtonPage extends StatefulWidget {
  const ClapButtonPage({super.key});

  @override
  State<ClapButtonPage> createState() => _ClapButtonPageState();
}

class _ClapButtonPageState extends State<ClapButtonPage>
    with TickerProviderStateMixin {
  late final AnimationController scoreInAnimationController;
  int _count = 0;

  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Container(
        constraints: const BoxConstraints.expand(),
        child: Padding(
          padding: const EdgeInsets.symmetric(vertical: 100.0),
          child: Stack(
            alignment: Alignment.bottomCenter,
            children: [
              putScoreButtonWidget(),
              putClapButtonWidget(),
            ],
          ),
        ),
      ),
    );
  }

	Widget putScoreButtonWidget() {
	  return Positioned(
	    bottom: 150,
	    child: Opacity(
	      opacity: 1.0,
	      child: Container(
	        width: 100,
	        height: 100,
	        decoration: const ShapeDecoration(
	          shape: CircleBorder(),
	          color: Colors.pink,
	        ),
	        child: Center(
	          child: Text(
	            '+$_count',
	            style: const TextStyle(
	              color: Colors.white,
	              fontWeight: FontWeight.bold,
	              fontSize: 30.0,
	            ),
	          ),
	        ),
	      ),
	    ),
	  );
	}
	
	Widget putClapButtonWidget() {
	  return GestureDetector(
	    child: Container(
	      width: 100,
	      height: 100,
	      decoration: BoxDecoration(
	          border: Border.all(color: Colors.pink, width: 1.0),
	          borderRadius: BorderRadius.circular(50),
	          color: Colors.white,
	          boxShadow: const [
	            BoxShadow(
	              color: Colors.pink,
	              blurRadius: 8.0,
	            )
	          ]),
	      child: const Center(
	        child: Image(
	          image: AssetImage("images/clap.png"),
	          color: Colors.pink,
	          width: 70,
	          height: 70,
	        ),
	      ),
	    ),
	  );
	}
}

Untitled.png

여기에서 initState 부분에 AnimationController를 등록하고, GestureDetector 또는 버튼을 통해 특정 이벤트가 발생했을 때, 만들어준 AnimationController의 forward 함수를 호출해주면 애니메이션이 실행된다.

애니메이션을 적용할 위젯에는 AnimationController의 value를 받아와 위치나 투명도 등을 조절해주면 된다.

@override
void initState() {
  super.initState();
  scoreInAnimationController = AnimationController(
      duration: const Duration(milliseconds: 150), vsync: this);
  scoreInAnimationController.addListener(() {
    setState(() {});
  });
}

Widget putClapButtonWidget() {
	return GestureDetector(
	  onTap: () {
	    scoreInAnimationController.forward(from: 0.0);
	    _increment();
	  },
		...
	)
}

Widget putScoreButtonWidget() {
  var scorePosition = scoreInAnimationController.value * 150;
  var scoreOpacity = scoreInAnimationController.value;
  return Positioned(
    bottom: scorePosition,
    child: Opacity(
      opacity: scoreOpacity,
      child: Container(
	...
}


코드의 실행 결과는 아래와 같다!

emoji_firework_increment.gif


버튼을 누를 때 마다 점수 표시가 나타나도록 애니메이션을 만들어줬는데, 이번에는 버튼을 꾹 누르고 있으면 점수가 올라가고, 버튼을 떼면 점수가 서서히 사라지도록 애니메이션을 추가해보자.

위젯의 에니메이션 상태를 좀 더 쉽게 관리하기 위해서, enum 타입으로 위젯 상태를 정의해준다.

enum ScoreWidgetStatus {
  HIDDEN,
  BECOMING_VISIBLE,
  BECOMING_INVISIBLE,
}

사라지는 애니메이션에 대한 Controller를 추가해주는데, 이번에는 TweenCurves.easeOut을 적용해주었다.

또, 컨트롤러의 addStatusListener((status) => )을 사용해서, 애니메이션이 종료되는 시점에 특정 로직을 호출할 수 있도록 작성해주었다.

@override
void initState() {
	...

  scoreOutAnimationController =
      AnimationController(duration: _duration, vsync: this);
  scoreOutPositionAnimation = Tween(begin: 100.0, end: 150.0).animate(
      CurvedAnimation(
          parent: scoreOutAnimationController, curve: Curves.easeOut));
  scoreOutAnimationController.addListener(() {
    setState(() {});
  });
  scoreOutAnimationController.addStatusListener((status) {
    if (status == AnimationStatus.completed) {
      _scoreWidgetStatue = ScoreWidgetStatus.HIDDEN;
    }
  });
}

그리고, 아래처럼 현재 위젯의 상태에 따라 scorePositionscoreOpacity 값을 조정할 수 있도록 switch 문으로 값들을 정해주었다.

setState로 값이 업데이트 될 때 상위 위젯이 build 메서드를 다시 호출해주기 때문에, 위젯을 그리는 시점에 해당 값들이 update 될 수 있다!

Widget putScoreButtonWidget() {
    var scorePosition = 0.0;
    var scoreOpacity = 0.0;

    switch (_scoreWidgetStatue) {
      case ScoreWidgetStatus.HIDDEN:
        break;
      case ScoreWidgetStatus.BECOMING_INVISIBLE:
        scorePosition = scoreOutPositionAnimation.value;
        scoreOpacity = 1.0 - scoreOutAnimationController.value;
        break;

      case ScoreWidgetStatus.BECOMING_VISIBLE:
        scorePosition = scoreInAnimationController.value * 150;
        scoreOpacity = scoreInAnimationController.value;
        break;
    }

		return Positioned (
		...
}

마지막으로 onTapUp 함수와 onTapDown 함수를 작성해준다. onTapDown시에는 주기적으로 _increment() 함수를 호출해주는 타이머를 등록해주고, onTapUp 이 타이머를 정지시키며 사라지는 애니메이션을 실행해준다. scoreOutETA는 TapDown을 멈췄다가 다시 터치로 버튼을 누르는 경우를 이어주기 위해 있는 타이머이다!

// 이 두 값은 변수로 State 클래스 내에 추가해주면 됨
Timer? holdTimer, scoreOutETA;
var _scoreWidgetStatue = ScoreWidgetStatus.HIDDEN;

extension _ClapButtonPageStateFunctions on _ClapButtonPageState {
  void onTapUp(TapUpDetails tap) {
    scoreOutETA = Timer(_duration, () {
      scoreOutAnimationController.forward(from: 0.0);
      _scoreWidgetStatue = ScoreWidgetStatus.BECOMING_INVISIBLE;
    });
    if (holdTimer != null) {
      holdTimer!.cancel();
    }
  }

  void onTapDown(TapDownDetails tap) {
    if (scoreOutETA != null) scoreOutETA!.cancel();
    if (_scoreWidgetStatue == ScoreWidgetStatus.HIDDEN) {
      scoreInAnimationController.forward(from: 0.0);
      _scoreWidgetStatue = ScoreWidgetStatus.BECOMING_VISIBLE;
    }
    holdTimer = Timer.periodic(_duration, (timer) => _increment());
  }
}

요렇게 구현해주면, 아래처럼 잘 작동하는 것을 확인할 수 있다!

emoji_clear.gif

동일하게 이번에는 버튼을 누르고 있으면 버튼과 점수 동그라미의 사이즈가 변경되는 애니메이션을 추가해보았다. 예제를 보지 않고 직접 구현해보려 했는데, initState 쪽에 addListener() 추가를 안해줬더니 문제가 발생하고 있었다. 요걸 넣어줘야 제대로 등록이 되니, 꼭꼭 넣어주자! 이번에는 애니메이션이 complete 되면 처음으로 돌아가도록 reverse 함수를 넣어서 만들어줬다. 이를 통해 value가 0으로 돌아가서, 버튼의 크기가 처음 상태로 돌아가도록 만들어줄 수 있다.

initState() {
	...
 	scoreSizeAnimationController =
      AnimationController(duration: _duration, vsync: this);
  scoreInAnimationController.addStatusListener((status) {
    if (status == AnimationStatus.completed) {
      scoreSizeAnimationController.reverse(from: 0.0);
    }
  });
  scoreSizeAnimationController.addListener(() {
    setState(() {});
  });
}

increment 가 호출될 때 마다 SizeAnimation을 실행해주도록 만들어주고,

void _increment() {
  scoreSizeAnimationController.forward(from: 0.0);
  setState(() {
    _count += 1;
  });
}

버튼과 점수 위젯의 크기를 적당히 커졌다 작아졌다 할 수 있도록 지정해주었다.

child: Container(
  width: 100 + scoreSizeAnimationController.value * 20,
  height: 100 + scoreSizeAnimationController.value * 20,
...
)

요렇게 만들어서 실행하면, 아래처럼 결과를 얻을 수 있다! 좋아좋아!

button_size.gif

그 다음, 숫자 위젯 뒤쪽에 이전과 비슷하게 코드를 넣어주면 숫자 뒤쪽에 애니메이션을 약간 넣어줄 수 있다.

for (int idx = 0; idx < 5; idx++) {
      var currentAngle = (firstAngle + ((2 * pi) / 5) * (idx));
      var sparkleWidget = Positioned(
        left: (sparkleRadius * cos(currentAngle)) + 40,
        top: (sparkleRadius * sin(currentAngle)) + 40,
        child: Transform.rotate(
            angle: currentAngle - pi / 2,
            child: Opacity(
              opacity: sparklesOpacity,
              child: Image.asset(
                "images/sparkles.png",
                width: 30.0,
                height: 30.0,
              ),
            )),
      );
      stackChildren.add(sparkleWidget);

Simulator_Screen_Recording_-iPhone_13_Pro-_2023-10-28_at_21.40.08.gif


좋아. 대충 AnimationController의 사용 방법을 이해할 수 있었던 것 같다. 처음에 시도하려 했던 이모지 불꽃놀이를 구현하는 방법을 한 번 고민해봐야겠다.

320x100
Share Link
reply
반응형
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31