선생님, 개발을 잘하고 싶어요.

[Flutter] 유저의 잘못된 행동에 도리도리 애니메이션 구현하기 (feat. SpringCurve) 본문

개발/flutter

[Flutter] 유저의 잘못된 행동에 도리도리 애니메이션 구현하기 (feat. SpringCurve)

알고싶은 승민 2023. 9. 15. 00:06

서문 - 왜 이런 걸 하나

독서타임에 온보딩 기능을 추가하는 중이다. 온보딩은 앱을 사용하면서 달성하고자 하는 게 무엇인지 입력받는 것부터 시작한다.

전달받은 디자인

유저는 목표를 2개까지 선택할 수 있다. 그렇다면 유저가 3개째를 선택할 때는 어떻게 해야 할까? 이미 2개까지 선택할 수 있다는 경고 문구를 두었으니 3개째 클릭에 아예 피드백을 안주는 선택지도 있지만 용납할 수 없었다. 최소한의 피드백으로 토스트 메시지를 띄우자니 영 멋이 없었다. 그래서 목표 위젯이 부들부들 떨리는 애니메이션과 진동으로 유저에게 피드백을 주는 방법을 떠올렸다. 왜냐하면 그게 간지 나니까.

분명 위아래로 진동하는 애니메이션을 구현하고 싶었건만, 결과물은 길거리 양아치마냥 고개를 까딱거리는 애니메이션이 탄생했다. 디자이너(트라)에게 넘기니 동일하게 구리다고 느끼고 있었다. 우리는 머리를 맞대고 구상했다. "파르르르 떨리는 동작 있잖아", "위아래로 숑숑 파르르" 뭐 대충 이런 대화가 오갔는데 구현에는 크게 도움이 안 되는 말이었다. 그래서 트라에게 애니메이션 느낌을 구성해서 전달해 달라고 했다. 그리고 순식간에 뚝딱 전달해 주었다.

 

전달받은 애니메이션

전달 받은 애니메이션

흠 맞아 이런 느낌이야. 도리도리 애니메이션. 느낌이 맞으니 이걸 구성하는 상세 스펙을 보고 구현하기만 하면 되는 문제였다. 프로토파이 핸드오프로 전달받은 내용을 보니 두 개의 애니메이션이 합쳐 저서 흔들거리는 느낌을 구성하고 있었다.

핸드오프

요약하면 이렇다.

  • 첫 애니메이션은 CW(시계방향)으로 10도 회전하는 애니메이션
  • 다음 애니메이션은 CWW(반시계방향)으로 10도 회전하는 애니메이션
  • 각 애니메이션은 SPRING Easing을 가지고 있다.
  • 첫 애니메이션과 두 번째 애니메이션 시작은 0.2초의 딜레이를 두고 시작한다.

이제 코드로 구현해 보자.

애니메이션 구현을 위해서 필요한 거

AnimationController

애니메이션이 전체 얼마동안 이루어지는지 설정한다. 그리고 애니메이션 자체를 제어하는 지점이 된다. 애니메이션 실행 혹은 역재생을 트리거할 수 있는 함수가 제공된다. Widget으로부터 Ticker를 전달받아야 한다. 따라서 정의하는 코드만 때어보면 이렇다. (티스토리 기본 지원 code block에 dart가 없네 젠장할)

class GoalState extends State<Goal> with SingleTickerProviderStateMixin {
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(milliseconds: 1900),
      vsync: this,
    );
  }

  @override
  void dispose() {
    super.dispose();
    controller.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    ...
  }
}

우리는 유저가 세 번째 목표를 선택할 때 우리의 도리도리 애니메이션을 실행할 것이다. 실행할 때 이 AnimationController를 제어해 주면 된다.

Curve

애니메이션은 결국 0부터 1까지 값이 변화하는 것으로 볼 수 있다. 값이 변화하는 경로는 수만 가지가 있다. 0부터 1까지 동일한 속도로 증감한다면 linear 하다고 할 수 있다. 0부터 1까지 어떻게 변화하는지 그 변화의 경로를 표현한 게 Curve다. 그래프로 보면 이해가 쉽다.

curves flutter 문서

그러면 Spring 애니메이션은 간단하다. Spring 애니메이션을 나타내는 곡률의 그래프를 Curve로 구현하면 된다. 스프링 애니메이셔 그래프를 시각적으로 보여주는 웹사이트도 있으니 함 보면 좋다.

대충 요래줌

Tween

솔직히 앱에서는 0부터 1까지 변화하는 애니메이션 값을 그대로 쓰진 않을 거다. 우리는 각도를 변화시켜야 하니까 시계방향은 0도부터 시작해서 10도까지 오르는 애니메이션, 반시계방향은 0도부터 시작해서 -10도까지 내려가는 애니메이션을 표현해야 한다. 이를 정확히 표현하는 개념이 Tween이다. 코드로 보면 너무 간단하다.

final anim1 = Tween<double>(begin: 0.0, end: 10);
final anim2 = Tween<double>(begin: 0.0, end: -10);

구현 뽀인트

이론은 여기까지 하고 이제 도리도리 애니메이션을 구현하자. 실제로 구현에는 몇 가지 주요 포인트가 있으니 그거 기준으로 설명한다.

Spring 애니메이션 구현

아까도 말했듯 Spring 애니메이션은 그냥 Curve다. Curve는 그래프라고도 했다. 그래프는 뭐다? 그냥 수식이다. 아까 공유한 웹사이트에서도 보이지만 그 수식을 그대로 구현하면 되겠다. 이런 거 구현 직접 하기 귀찮으니 ChatGPT에 맡겼다. 나... 개발 잘할지도? 아래는 전달받은 코드. 물론 주석은 양심상 내가 달았다.

// a (진폭): 진동의 진폭을 정의합니다. 높은 값은 진동을 더 두드러지게 만듭니다.
// w (주파수): 진동의 주파수를 나타냅니다. 높은 값은 빠른 진동을 의미합니다.
class SpringCurve extends Curve {
  const SpringCurve({
    this.a = 0.15,
    this.w = 19.4,
  });
  final double a;
  final double w;

  @override
  double transformInternal(double t) {
    return -(pow(e, -t / a) * cos(t * w)) + 1;
  }
}

0.2초 딜레이 실행

이제 문제는 딜레이 실행이다. 두 개의 애니메이션을 별도의 AnimationController를 달아서 딜레이 두고 실행하는 방법이 있지만, 도리도리 애니메이션 실행부가 번잡해질 것이 뻔하다. 이는 원하지 않는 일. 하지만 다행히도 딜레이도 Curve 선에서 어느 정도 정리할 수 있다. 아래 그래프를 보면 단박에 이해가 될 것이다.

interval curve flutter

심지어 이 Curve는 플러터가 이미 구현하고 있다. 그냥 가져다 쓰자. 참고로 CurveAnimation을 사용해서 조합해 준다.

// 아까 추가한 스프링 커브
const springCurve = SpringCurve();

anim1 = Tween<double>(begin: 0.0, end: 10)
  .animate(CurvedAnimation(
    parent: controller,
    curve: const Interval(0, 1.7 / 1.9, curve: springCurve),
  ));
anim2 = Tween<double>(begin: 0.0, end: -10)
  .animate(CurvedAnimation(
    parent: controller,
    curve: const Interval(0.2 / 1.9, 1, curve: springCurve),
  ));

Widget을 도리도리 시키기

이제 모든 게 준비되었다. 위젯을 도리도리 시키자. 그걸 위해서는 위젯을 회전시키면 된다. Transform.rotate을 사용하면 된다. 이 위젯은 라디언 값을 angle 인자로 받고 child 위젯을 회전시킨다. anim1, anim2는 서로 상쇄되며 각도를 조정하면 된다. 상쇄시키는 방법은? 그냥 더하면 된다. 참고로 우리의 애니메이션 Tween 각도로 표현했으니 각도를 라디언 값으로 변환하는 간단한 수학이 들어간다. 1도 = 파이 나누기 180. 글로 설명하면 이해가 잘 안 될 테니 이제 코드를 보자.

return Transform.rotate(
  // 각 애니메이션의 값을 더한다.
  angle: (anim1.value + anim2.value) * math.pi / 180,
  child: const GoalWidget(),
);

최종결과

이제 좀 살겠다.

최종코드

// a (진폭): 진동의 진폭을 정의합니다. 높은 값은 진동을 더 두드러지게 만듭니다.
// w (주파수): 진동의 주파수를 나타냅니다. 높은 값은 빠른 진동을 의미합니다.
class SpringCurve extends Curve {
  const SpringCurve({
    this.a = 0.15,
    this.w = 19.4,
  });
  final double a;
  final double w;

  @override
  double transformInternal(double t) {
    return -(pow(e, -t / a) * cos(t * w)) + 1;
  }
}

class _Goal extends StatefulWidget {
  const _Goal();

  @override
  State<_Goal> createState() => _GoalState();
}

class GoalState extends State<Goal> with SingleTickerProviderStateMixin {
  late AnimationController controller;
  late Animation<double> anim1;
  late Animation<double> anim2;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(milliseconds: 1900),
      vsync: this,
    );

    // 아까 추가한 스프링 커브
    const springCurve = SpringCurve();

    anim1 = Tween<double>(begin: 0.0, end: 10).animate(CurvedAnimation(
      parent: controller,
      curve: const Interval(0, 1.7 / 1.9, curve: springCurve),
    ));
    anim2 = Tween<double>(begin: 0.0, end: -10).animate(CurvedAnimation(
      parent: controller,
      curve: const Interval(0.2 / 1.9, 1, curve: springCurve),
    ));
  }

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

  @override
  Widget build(BuildContext context) {
    return Transform.rotate(
      // 각 애니메이션의 값을 더한다.
      angle: (anim1.value + anim2.value) * math.pi / 180,
      child: const GoalWidget(),
    );
  }
}

 

'개발 > flutter' 카테고리의 다른 글

[Flutter] 네트워크 권한 설정  (0) 2023.03.25
[Flutter 기본] ListView 생성하기  (3) 2020.01.25
Comments