View

300x250

Untitled.png

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

위 에프터이펙트 강의 영상에서 봤던 것 처럼, 이모지가 불꽃놀이처럼 주변으로 팡팡 터지면서 공중으로 날아가는 효과를 만들고 싶었다. 그런데, 도저히 이거랑 관련된 소스코드를 찾을 수 없었어서, 직접 애니메이션을 한 번 구현해보기로 했다. 후!

작업에 본격적으로 들어가기 전에, 에프터이펙트에서 해당 효과를 나타내기 위해 어떤 작업들을 처리하는지 살펴봤다.

  1. emitter → 주변으로 이모지를 퍼트리는 효과
  2. wind → 바람에 날아가듯이 공중으로 날려보내는 효과
  3. scale → 이모지가 작아졌다 커졌다 하기
  4. rotate → 이모지가 날아가는 동안 빙빙 회전하기
  5. replace emoji → 날아가는 동안 이모지의 종류가 바뀜

각각 떼어놓고 보면 그리 어려울 것 같지도 않았다! ㅎㅎ 여기에서 우선은 1, 2, 3 번을 구현하고, 성능상의 이슈가 없다면 4, 5번을 구현해보려고 계획하였다.

요걸 구현한 결과는 아래처럼 나온다! 나름 만족스러움 ㅋㅋㅋ

heart_bomb.gif

우선, 이전 글에서 공부했던 Animation에 적용할 수 있는 CurvedAnimation의 종류를 살펴봤다.

bounce_ease.gif

Animation에서 ease와 bounce 시리즈의 움직임은 위 처럼 약간의 차이가 있다! 이 중에서 내가 필요한 걸 골라서 적절히 사용해주었다. 처음에는 이모지가 두근대는 효과 등을 구현하기 위해서 bounce를 사용할까 했었는데, 생각보다 애니메이션 효과가 구려서 Ease, Linear를 주로 적용해주었다.

이모지 불꽃놀이 효과를 보여줄 FireworkWidget을 StatefulWidget으로 선언해주었다. FireworkWidget을 생성하면 이모지가 한 번 펑 터지게 되기 때문에, 이모지 폭죽 하나라고 생각해도 될 것 같다. 사용할 때에는 터트리고 싶은 이모지를 AssetImage 타입으로 가져와서, 인자로 넣어주면서 위젯을 생성해주면 된다.

class FireworkWidget extends StatefulWidget {
  FireworkWidget(
      {super.key,
      required this.emojiAsset});

  AssetImage emojiAsset;

  @override
  State<FireworkWidget> createState() => _FireworkWidgetState();
}

FireworkWidget의 State 부분은 아래 코드처럼 짰다.

AnimationController를 사용해주기 위해서 TickerProviderStateMixin을 채택해주었다. 3가지 움직임을 표현하기 위해 3개의 AnimationController를 사용해주었고, initState() 에서 위젯 생성 당시 각각의 Animation 값 초기화에 대해서는 아래 절차를 적용해준다.

  1. Animation Controller 생성
  2. AnimationController에서 Tween을 통해 Animation 값 만들기
  3. AddListener에 setState 호출해, 화면 그릴 때 적용되도록 하기

생성자의 마지막에 애니메이션을 바로 실행시키도록 코드를 작성하였다.

class _FireworkWidgetState extends State<FireworkWidget>
    with TickerProviderStateMixin {

  // 3가지 움직임을 위해, 3가지 AnimationController를 선언
  late final AnimationController emojiAnimationShootController,
      emojiAnimationFloatController,
      emojiAnimationLifeTimeController;

  // AnimationController의 값에 대해 움직이는 double 값(Animation)들을 선언
  late final Animation<double> emojiShootAnimation,
      emojiFloatYAnimation,
      emojiFloatXAnimation,
      emojiLifeTimeAnimation;

  // 애니메이션 지속 시간을 결정하는 변수
  late Duration _emojiLifetimeDuration = Duration(seconds: 5);
  late Duration _emojiShootDuration = Duration(seconds: 2);

  final int emojiAmount = 30;

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

    // 이모지가 폭죽처럼 발사되는 Animation 관련 값 초기화
		// 각각의 Animation 값 초기화에 대해서는 아래 절차를 적용해준다.
		// 1. Animation Controller 생성
		// 2. AnimationController에서 Tween을 통해 Animation 값 만들기 
		// 3. AddListener에 setState 호출해, 화면 그릴 때 적용되도록 하기
    emojiAnimationShootController = AnimationController(
      vsync: this,
      duration: _emojiShootDuration,
    );
    emojiShootAnimation = Tween(begin: 0.0, end: 100.0).animate(CurvedAnimation(
      parent: emojiAnimationShootController,
      curve: Curves.easeOut,
    ));
    emojiShootAnimation.addListener(() {
      setState(() {});
    });

    // 이모지가 하늘로 날아가는 Animation 관련 값 초기화
    // 가로 움직임과 세로 움직임이 별개이기 때문에, Animation을 X와 Y로 분리하였음
    emojiAnimationFloatController = AnimationController(
        vsync: this,
        duration: Duration(seconds: _emojiLifetimeDuration.inSeconds));
    emojiFloatXAnimation = Tween(begin: 0.0, end: 10.0).animate(
      CurvedAnimation(
        parent: emojiAnimationFloatController,
        curve: Curves.linear,
      ),
    );
    emojiFloatXAnimation.addListener(() {
      setState(() {});
    });
    emojiFloatYAnimation = Tween(begin: -50.0, end: 1000.0).animate(
      CurvedAnimation(
        parent: emojiAnimationFloatController,
        curve: Curves.easeIn,
      ),
    );
    emojiFloatYAnimation.addListener(() {
      setState(() {});
    });

    // 불꽃놀이 위젯 자체의 지속시간과 관련된 Animation 관련 값 초기화
		// 여기에서 이모지 크기를 조정할 수 있는 값을 제공한다.
    emojiAnimationLifeTimeController =
        AnimationController(vsync: this, duration: _emojiLifetimeDuration);
    emojiLifeTimeAnimation = Tween(begin: 0.0, end: 1.0).animate(
        CurvedAnimation(
            parent: emojiAnimationLifeTimeController, curve: Curves.linear));
    emojiLifeTimeAnimation.addListener(() {
      setState(() {});
    });
    emojiLifeTimeAnimation.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        // 이후 추가 예정
      }
    });

		// 애니메이션 시작
    startAnimation();
  }

  void startAnimation() {
    emojiAnimationShootController.forward(from: 0.0);
    emojiAnimationFloatController.forward(from: 0.0);
    emojiAnimationLifeTimeController.forward(from: 0.0);
  }

	...
}

FireworkWidgetState에 대한 빌드 함수는 아래처럼 생겼다. Stack에 정해진 개수만큼의 EmojiWidget에 Animation 들을 연결해준 뒤, children으로 전달한다. 여기에서 clipBehavior: Clip.none 으로 속성을 부여해서, 위젯들이 애니메이션을 할 때, 자유롭게 화면 위에 보여질 수 있도록 설계하였다.

@override
Widget build(BuildContext context) {
  return SizedBox(
    width: 50,
    height: 50,
    child: Stack(
      clipBehavior: Clip.none,
      alignment: Alignment.center,
      children: List.generate(
        emojiAmount,
        (index) => EmojiWidget(
          emojiAsset: widget.emojiAsset,
          emojiFloatXAnimation: emojiFloatXAnimation,
          emojiFloatYAnimation: emojiFloatYAnimation,
          emojiShootAnimation: emojiShootAnimation,
          emojiLifeTimeAnimation: emojiLifeTimeAnimation,
        ),
      ),
    ),
  );
}

EmojiWidget도 StatefulWidget으로 만들어주었다. 이펙트를 보여주는데에 필요한 animation 들을 인자로 받아와준다.

class EmojiWidget extends StatefulWidget {
  const EmojiWidget({
    required this.emojiShootAnimation,
    required this.emojiFloatYAnimation,
    required this.emojiFloatXAnimation,
    required this.emojiLifeTimeAnimation,
    required this.emojiAsset,
  });

  final AssetImage emojiAsset;
  final Animation<double> emojiShootAnimation,
      emojiFloatYAnimation,
      emojiFloatXAnimation,
      emojiLifeTimeAnimation;

  @override
  State<EmojiWidget> createState() => EmojiWidgetState();
}

모든 이모지가 동일하게 움직이면 안되기 때문에, 처음 터질 때 x나 y 방향을 무작위로 결정해주는 xScale, yScale 값과, 커지고 작아지는 타이밍과 이모지의 지속시간을 무작위로 결정하는 distinctiveRandomSeed에 무작위 double 값을 만들어 넣어준다.

전달받은 animation 값과 무작위 seed 값들을 기반으로 위젯의 position, scale, opacity를 결정해준다.

class EmojiWidgetState extends State<EmojiWidget> {
  late double xScale, yScale, distinctiveRandomSeed;

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

    xScale = Random().nextDouble() * 2 - 1;
    yScale = Random().nextDouble() * 2 - 1;
    distinctiveRandomSeed = Random().nextDouble();
  }

  @override
  Widget build(BuildContext context) {
    var emojiAnimationShootX = widget.emojiShootAnimation.value * 3 * xScale;
    var emojiAnimationShootY = widget.emojiShootAnimation.value * 5 * yScale;

    var emojiAnimationFloatX =
        sin(widget.emojiFloatXAnimation.value + distinctiveRandomSeed) * 20;
    var emojiAnimationFloatY = widget.emojiFloatYAnimation.value < 0
        ? 0
        : widget.emojiFloatYAnimation.value * -1;

    var emojiAnimationPositionX = emojiAnimationShootX + emojiAnimationFloatX;
    var emojiAnimationPositionY = emojiAnimationShootY + emojiAnimationFloatY;

    var emojiScale = sin(
                (widget.emojiLifeTimeAnimation.value + distinctiveRandomSeed) *
                    10) * 0.3 + 1;
    bool isEmojiTransparent =
        (widget.emojiLifeTimeAnimation.value + distinctiveRandomSeed) > 1.8;

    return Positioned(
      left: emojiAnimationPositionX,
      top: emojiAnimationPositionY,
      child: Opacity(
        opacity: isEmojiTransparent ? 0.0 : 1.0,
        child: Image(
          image: widget.emojiAsset,
          width: 40 * emojiScale,
          height: 40 * emojiScale,
        ),
      ),
    );
  }
}

위 코드를 기반으로 FireworkWidget을 배치하면, 아래처럼 이모지 불꽃놀이가 한 번 작동한다.

heart_once.gif

요렇게 만들어준 FireworkWidget을 간편하게, 중복해서 여러 번 겹쳐서 사용할 수 있게 하기 위해서, EmojiFirework 라는 클래스를 하나 만들어주었다.

EmojiFirework에서는 map 타입의 fireworkWidgets 과 addFireworkWidget() 함수를 가지고 있다. 애니메이션이 끝난 위젯이 화면에서 보여지지 않는 채로 살아있으면 그 자체로 메모리를 계속 차지하고 있기 때문에, 이모지 불꽃놀이 폭죽이 하나 생성된 후 모든 애니메이션이 끝난 다음에는 해당 위젯이 없어져야 한다. List를 통해 데이터를 관리하는 경우에는 실행이 끝난 FirworkWidget이 리스트에서 제거될 때 index가 변경되면서 로직이 꼬여버릴 가능성이 있기 때문에, UniqueKey를 key로 하는 map에 담아 관리해주었다. Firework 위젯의 콜백 메서드로 fireworkWidgets Map 에서 자신을 제거하면서 dispose하는 함수를 넣어주는 방식으로, 애니메이션이 끝나면 자신을 제거하도록 만들었다.

class EmojiFireWork {
  EmojiFireWork({required this.emojiAsset});
  final AssetImage emojiAsset;

  late Map<Key, FireworkWidget> fireworkWidgets = {};

  void addFireworkWidget() {
    final fireworkWidgetKey = UniqueKey();

    fireworkWidgets.addEntries(<Key, FireworkWidget>{
      fireworkWidgetKey: FireworkWidget(
        key: fireworkWidgetKey,
				// 아래 함수가 호출되면 fireworkWidgets에서 자신을 제거한다.
        notifyWidgetIsDisposed: (Key widgetKey) {
          fireworkWidgets.remove(widgetKey);
        },
        emojiAsset: emojiAsset,
      )
    }.entries);
  }
}

class FireworkWidget extends StatefulWidget {
  FireworkWidget({
			...
      required this.notifyWidgetIsDisposed,
  });
  Function notifyWidgetIsDisposed;
  ...
}

// 애니메이션 생명주기가 끝나면 notifyWidgetIsDisposed를 호출한다.
class _FireworkWidgetState extends State<FireworkWidget>
    with TickerProviderStateMixin {
	...
  @override
  void initState() {
		...
		emojiLifeTimeAnimation.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        widget.notifyWidgetIsDisposed();
      }
    });
	}
}

이렇게 만든 EmojiFirework를 사용하기 위해서는 아래처럼 해주면 된다. 나름 열심히 줄였는데, 조금 더 인터페이스를 정리할 필요도 있어보인다. 흠.,,

class _EmojiFireworkPageState extends State<EmojiFireworkPage> {
	// 1. EmojiFirework를 생성하기
  EmojiFireWork emojiFireWork =
      EmojiFireWork(emojiAsset: const AssetImage('images/heart_icon.png'));

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("Emoji Firework")),
      body: Container(
			       ...
									// 2. 애니메이션을 추가하고싶은 위치에
									// Stack에 emojiFirework에 있는 맵의 데이터를 그려주기
                  Stack(
                    children: emojiFireWork.fireworkWidgets.values.toList(),
                  ),
							...
            ),
						...
						ElevatedButton(
                onPressed: () {
                  setState(() {
									// 3. 이벤트가 발생하면 애니메이션을 호출하기
                    emojiFireWork.addFireworkWidget();
                  });
                },
                child: const Text("Tap Button"),
              ),
            ),
	...
}

이렇게 만든 코드를 실행하면, 아래처럼 잘 작동하는 것을 확인할 수 있다!! 한 번에 만들어지는 애니메이션이 5개 이상 넘어가면 아이폰 13 프로 기준으로는 버벅임이 약간 발생하는 수준이라, 실제 코드로 적용할 때에는 좀 더 트릭을 써서 버벅임을 제거해볼 예정이다. 굿!!

heart_bomb.gif

아래 깃허브에 해당 방식의 코드를 올려두었다!

GitHub 코드는 여기에 ↓

https://github.com/sm-amoled/flutter_sandbox/tree/EmojiFireworkAnimation

아무래도 게임이나 이펙트 쪽의 파티클은 훨씬 더 효율적인 방식으로 이펙트를 그려주면서 좋은 성능을 뽑아낼 수 있을텐데, 플러터의 이펙트 시스템을 활용했다면 요 부분도 많은 성능면에서의 개선이 이루어질 수 있었을 것 같다. 그래도, 내가 넣은 이미지를 활용해 이런 이펙트를 보여준다는 점에서 유의미하게 공부하고 노력한 코드라고 생각이 된다 :)

확실히, 플러터에 대한 이해가 기본적으로 있어야지 이런 UI적인 작업을 할 수 있겠다는 생각이 들었다. 흠,,, 좀 더 많은 고민과 공부가 필요할 것 같다.

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