[Flutter] 버튼에 애니메이션 넣기

2023. 3. 28. 01:22Developers 공간 [Shorts]/Frontend

728x90
반응형
<분류>
A. 수단
- OS/Platform/Tool : Linux, Kubernetes(k8s), Docker, AWS
- Package Manager : node.js, yarn, brew, 
- Compiler/Transpillar : React, Nvcc, gcc/g++, Babel, Flutter

- Module Bundler  : React, Webpack, Parcel

B. 언어
- C/C++, python, Javacsript, Typescript, Go-Lang, CUDA, Dart, HTML/CSS

C. 라이브러리 및 프레임워크 및 SDK
- OpenCV, OpenCL, FastAPI, PyTorch, Tensorflow, Nsight

 


1. What? (현상)

Flutter는 2D로 보이지만, 사실 사용하지 않을 뿐 3D로 Rendering되는 중이고, 3D Rendering에 더 최적화되어있다고 할 수 있습니다.

이번엔 버튼에 Transform과 Animation을 활용해 애니메이션 효과를 주는 방법을 정리하고자 합니다.

 

구현은 Stateful 위젯으로 진행했으며, 아래와 같은 에러메시지를 피하려면 State에 with TickerProviderStateMixin을 해주어야 합니다.

The argument type '_ButtonViewState' can't be assigned to the parameter type 'TickerProvider'.

** Flutter engine은 60frames/s 마다 Rendering하는 scheduler를 가지고 있는데, state가 바뀌어 setState()가 불리거나 UI를 update해야 하는 순간이 오면, 그다음 scheduled frame에 Rendering을 진행합니다.

** Ticker : Flutter engine이 새로운 frame을 Rendering하기 위해 사용하는 timer로, Timer보다 정교하고, Rendering 타이밍(Refresh Rate)에 맞춰 사용할 수 있기 때문에 사용됩니다. 또한 Ticker는 해당 위젯이 Visible할 때만 불립니다.

** SingleTickerProviderStateMixin : 하나의 AnimationController를 사용시
** TickerProviderStateMixin : 하나 이상의 AnimationController를 사용시

 


2. Why? (원인)

  • X

3. How? (해결책)

  • 버튼에 Scale 애니메이션 적용하기
    1. timimg은 AnimationController()을 사용했습니다.
    2. 위의 timing에 맞춰 Transform.scale() 함수를 활용해 동작을 반영했습니다.
    3. Trigger 방법은 위 선언한 AnimationController에 직접 forward(), reverse() 함수를 사용했습니다.
// Caller
ButtonView(
    onTap: viewModel.onLightPressed,
    heroTag:'light',
    child: ImageIcon(
              viewModel.light_on
              ? AssetImage("images/on.png")
                  : AssetImage("images/off.png"),
              size: 50,
              color: viewModel.light_on ? kSelectedColor : kSubColor,
            ),
)
// Callee

import 'package:flutter/material.dart';

class ButtonView extends StatefulWidget {
  final VoidCallback onTap; // 혹은 final Function(bool)? onTap;
  final Widget child;
  final String heroTag;
  ButtonView({
    required this.onTap,
    required this.child,
    required this.heroTag,
    Key? key
  }) : super(key: key);

  @override
  _ButtonViewState createState() => _ButtonViewState();
}
// Callee State

class _ButtonViewState extends State<ButtonView> with SingleTickerProviderStateMixin{
  final Duration time = const Duration(milliseconds: 100);

  late AnimationController _buttonController = AnimationController(
    vsync: this,
    duration: time,
    lowerBound: 0.0,
    upperBound: 0.4,
  )..addListener(()=>setState((){}));

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


  @override
  Widget build(BuildContext context) {
    return FloatingActionButton(
      backgroundColor: kMainColor.withOpacity(0.0),
      heroTag: widget.heroTag,
      onPressed: (){
        _buttonController.forward();
        print('forward!!');
        Future.delayed(
          time,
          (){_buttonController.reverse();}
        );
        print('backward!!');
        widget.onTap();
      },
      child:Transform.scale(
        scale:1+_buttonController.value,
        child:widget.child
      )
    );
  }
}

  • 버튼에 Rotate 애니메이션 적용하기
    1. timimg은 AnimationSwitcher()와 transitionBuilder에 넣을 Tween을 사용했습니다.
      ** Tween : begin 부터 end 까지 값을 변화시키거나 Curve등을 활용해 Animation 효과를 변형할 때 사용합니다.
      (Curve와 관련해서는 아래 더보기를 참조하세요)
    2. 위 타이밍에 맞춰 transitionBuilder에 넣을 AnimationBuilder()함수를 활용했으며, Rotate는 Transform() 자체함수를 사용해 Matrix4 구조체를 활용해 효과를 주었습니다. 자세한 내용은 아래 그림과 더보기를 참조하시면 좋습니다.
    3. trigger는 showFront라는 변수를 false ↔ true 변환하는 방법으로 진행했습니다.
더보기

-----------------------------------------------------------------------------------------------------

<Tween과 Curve를 같이 사용한 예시>

animate 
  1. Animation<Offset> cupSlideUpAnimation = Tween(begin: 0.0, end: 0.07).animate(
                CurvedAnimation(
                    parent: animation, 
                    curve: const ShakeCurve(count: 3)));
  2. Animation<double> cupRotateAnimation = Tween(begin: Offset(0, 1), end: Offset(0, 0)).animate(
                CurvedAnimation(
                    parent: animation
                    curve: Curves.elasticOut));
  3. Animation<double> cupFadeInAnimation = Tween(begin: 0.0, end: 1.0).animate(
                CurvedAnimation(
                    parent: animation
                    curve: Curves.easeOutCubic));

-----------------------------------------------------------------------------------------------------

// Caller
ButtonView2(
  onTap: viewModel.onReversePressed,
  child: ImageIcon(
            AssetImage("images/btn.png"),
            size: 50,
            color: kSubColor,
          ),
  heroTag: 'reverse'
)
// Callee

import 'package:flutter/material.dart';
import 'dart:math' as math;

class ButtonView2 extends StatefulWidget {
  final VoidCallback onTap;
  final Widget child;
  Widget? child_back;
  final String heroTag;
  ButtonView2({
    required this.onTap,
    required this.child,
    required this.heroTag,
    Key? key
  }) : super(key: key){
  	// initialize back image with original image
    child_back = Transform(
      key: ValueKey('back'),
      transform: Matrix4.rotationY(math.pi),
      child:child,
      alignment: Alignment.center,
    );
  }

  @override
  _ButtonView2State createState() => _ButtonView2State();
}
// Callee State

class _ButtonView2State extends State<ButtonView2>{
  bool showFront = true;
  final Duration time = const Duration(milliseconds: 500);

  @override
  void initState(){
    super.initState();
  }
  
  Widget wrapAnimatedBuilder(Widget widget, Animation<double> animation) {
    // 0~1 to pi~0
    final rotate = Tween(begin: math.pi, end: 0.0).animate(animation);

    return AnimatedBuilder(
      animation: rotate,
      child: widget,
      builder: (_, widget) {
        //check if current widget is back
        final isBack = showFront
            ? widget?.key == ValueKey('back')
            : widget?.key != ValueKey('back');

        // when back, pi/2~pi/2~0
        // when front, pi~pi/2~0
        final value = isBack ? math.min(rotate.value, math.pi / 2) : rotate.value;

        var tilt = ((animation.value - 0.5).abs() - 0.5) * 0.0025;

        return Transform(
          transform: Matrix4.rotationY(value)..setEntry(3, 0, tilt),
          child: widget,
          alignment: Alignment.center,
        );
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return FloatingActionButton(
        backgroundColor: kMainColor.withOpacity(0.0),
        heroTag: widget.heroTag,
        onPressed: (){
          showFront=!showFront;
          widget.onTap();
        },
        child:AnimatedSwitcher(
          transitionBuilder: wrapAnimatedBuilder,
          layoutBuilder: (widget, list){
            return Stack(children:[widget!, ...list]);
          },
          duration : time,
          child : showFront? widget.child : widget.child_back,
        )
    );
  }
}

[matrix transform 예시로, 회전 방향은 시계 방향임을 주의합니다]

더보기

-----------------------------------------------------------------------------------------------------

<Matrix4 관련>

[Matrix4 관련 정보 정리]

-----------------------------------------------------------------------------------------------------

 


https://velog.io/@cyb9701/%ED%86%A0%EC%8A%A4-%EC%83%81%ED%92%88%EA%B6%8C-3D-%EC%9D%B8%ED%84%B0%EB%9E%99%EC%85%98

https://velog.io/@larsien/UI-challenge-dribble

https://lucky516.tistory.com/123

https://blog.codefactory.ai/flutter/card-flip/

https://codewithandrea.com/articles/flutter-timer-vs-ticker/

728x90
반응형