애니메이션 기초

개요

Flitter는 Flutter와 동일한 강력한 애니메이션 시스템을 제공합니다. AnimationController, Tween, Curve를 조합하여 부드럽고 자연스러운 애니메이션을 쉽게 구현할 수 있습니다.

왜 애니메이션이 중요한가?

잘 설계된 애니메이션은 사용자 경험을 크게 향상시킵니다:

  • 시각적 피드백: 사용자 액션에 대한 즉각적인 반응 제공
  • 상태 전환: 화면이나 요소의 변화를 부드럽게 표현
  • 주의 유도: 중요한 정보나 변화에 사용자의 시선을 끌기
  • 브랜드 아이덴티티: 독특한 애니메이션으로 앱의 개성 표현
  • 인지적 연속성: 요소의 이동이나 변형을 자연스럽게 보여줌

핵심 개념

1. AnimationController

애니메이션의 재생, 정지, 반복 등을 제어하는 핵심 클래스입니다:

class MyWidgetState extends State<MyWidget> {
  controller!: AnimationController;
  
  initState(context: BuildContext) {
    super.initState(context);
    // duration은 밀리초 단위로 지정
    this.controller = new AnimationController({
      duration: 1000  // 1초
    });
  }
  
  dispose() {
    this.controller.dispose();
    super.dispose();
  }
}

2. Tween

시작값과 끝값 사이의 보간을 정의합니다:

// 숫자 Tween
const sizeTween = new Tween({
  begin: 50,
  end: 200
});

// 색상 Tween
const colorTween = new ColorTween({
  begin: '#3b82f6',
  end: '#ef4444'
});

// Offset Tween (위치 이동)
const positionTween = new Tween({
  begin: new Offset(0, 0),
  end: new Offset(100, 100)
});

3. Animation

Tween과 AnimationController를 연결하여 실제 애니메이션 값을 생성합니다:

const animation = sizeTween.animated(this.controller);

// CurvedAnimation으로 이징 효과 적용
const curvedAnimation = new CurvedAnimation({
  parent: this.controller,
  curve: Curves.easeInOut
});

const animation = sizeTween.animated(curvedAnimation);

4. 애니메이션 리스너

애니메이션 값이 변경될 때마다 위젯을 재빌드하려면 리스너를 추가합니다:

class MyWidgetState extends State<MyWidget> {
  controller!: AnimationController;
  animation!: Animation<number>;
  
  initState(context: BuildContext) {
    super.initState(context);
    this.controller = new AnimationController({ duration: 1000 });
    this.animation = new Tween({ begin: 0, end: 100 }).animated(this.controller);
    
    // 리스너 추가 - 애니메이션 값이 변경될 때마다 setState 호출
    this.controller.addListener(() => {
      this.setState();
    });
  }
  
  build(context: BuildContext): Widget {
    return Container({
      width: this.animation.value,
      height: this.animation.value,
      color: '#3b82f6'
    });
  }
}

코드 예제

기본 애니메이션 구현

AnimationController 기본 예제

크기, 색상, 회전을 동시에 애니메이션하는 예제

코드 보기
// 커스텀 BoxDecoration Tween
class DecorationTween extends Tween<BoxDecoration> {
  constructor({ begin, end }: { begin: BoxDecoration; end: BoxDecoration }) {
    super({ begin, end });
  }

  protected lerp(t: number): BoxDecoration {
    return BoxDecoration.lerp(this.begin, this.end, t);
  }
}

class AnimationExample extends StatefulWidget {
  createState() {
    return new AnimationExampleState();
  }
}

class AnimationExampleState extends State<AnimationExample> {
  controller!: AnimationController;
  sizeAnimation!: Animation<number>;
  decorationAnimation!: Animation<BoxDecoration>;
  rotationAnimation!: Animation<number>;
  
  initState(context: BuildContext) {
    super.initState(context);
    
    // AnimationController 생성 (duration은 밀리초 단위)
    this.controller = new AnimationController({
      duration: 2000  // 2초
    });
    
    // 크기 애니메이션
    this.sizeAnimation = new Tween({
      begin: 100,
      end: 200
    }).animated(new CurvedAnimation({
      parent: this.controller,
      curve: Curves.easeInOut
    }));
    
    // BoxDecoration 애니메이션 (색상 포함)
    this.decorationAnimation = new DecorationTween({
      begin: new BoxDecoration({
        color: '#3b82f6',
        shape: 'rectangle',
        borderRadius: BorderRadius.circular(16)
      }),
      end: new BoxDecoration({
        color: '#ef4444',
        shape: 'rectangle',
        borderRadius: BorderRadius.circular(16)
      })
    }).animated(new CurvedAnimation({
      parent: this.controller,
      curve: Curves.easeInOut
    }));
    
    // 회전 애니메이션
    this.rotationAnimation = new Tween({
      begin: 0,
      end: Math.PI * 2
    }).animated(this.controller);
    
    // 애니메이션 리스너 추가
    this.controller.addListener(() => {
      this.setState();
    });
  }
  
  dispose() {
    this.controller.dispose();
    super.dispose();
  }
  
  build(context: BuildContext) {
    return GestureDetector({
      onClick: () => {
        if (this.controller.isCompleted) {
          this.controller.reverse();
        } else {
          this.controller.forward();
        }
      },
      child: Transform.rotate({
        angle: this.rotationAnimation.value,
        child: Container({
          width: this.sizeAnimation.value,
          height: this.sizeAnimation.value,
          decoration: this.decorationAnimation.value,
          child: Center({
            child: Text(
              this.controller.isCompleted ? "뒤로" : "앞으로",
              {
                style: new TextStyle({
                  color: '#ffffff',
                  fontSize: 18,
                  fontWeight: 'bold'
                })
              }
            )
          })
        })
      })
    });
  }
}

애니메이션 제어

// 애니메이션 시작
this.controller.forward();

// 애니메이션 역재생
this.controller.reverse();

// 애니메이션 반복
this.controller.repeat();

// 특정 값으로 애니메이션
this.controller.animateTo(0.5);

// 애니메이션 정지
this.controller.stop();

// 애니메이션 리셋
this.controller.reset();

실습 예제

1. 펄스 애니메이션

class PulseAnimation extends StatefulWidget {
  createState() {
    return new PulseAnimationState();
  }
}

class PulseAnimationState extends State<PulseAnimation> {
  controller!: AnimationController;
  animation!: Animation<number>;
  
  initState(context: BuildContext) {
    super.initState(context);
    
    this.controller = new AnimationController({
      duration: 1000  // 1초
    });
    
    this.animation = new Tween({
      begin: 1.0,
      end: 1.2
    }).animated(new CurvedAnimation({
      parent: this.controller,
      curve: Curves.easeInOut
    }));
    
    this.controller.repeat({ reverse: true });
  }
  
  dispose() {
    this.controller.dispose();
    super.dispose();
  }
  
  build(context: BuildContext): Widget {
    return Transform.scale({
      scale: this.animation.value,
      child: Container({
        width: 100,
        height: 100,
        decoration: new BoxDecoration({
          color: '#3b82f6',
          shape: 'circle'
        })
      })
    });
  }
}

2. 순차 애니메이션

class SequentialAnimation extends StatefulWidget {
  createState() {
    return new SequentialAnimationState();
  }
}

class SequentialAnimationState extends State<SequentialAnimation> {
  controller!: AnimationController;
  slideAnimation!: Animation<Offset>;
  fadeAnimation!: Animation<number>;
  
  initState(context: BuildContext) {
    super.initState(context);
    
    this.controller = new AnimationController({
      duration: 2000  // 2초
    });
    
    // 슬라이드 애니메이션
    this.slideAnimation = new Tween({
      begin: new Offset(-1, 0),
      end: new Offset(0, 0)
    }).animated(new CurvedAnimation({
      parent: this.controller,
      curve: Curves.easeOut
    }));
    
    // 페이드 애니메이션
    this.fadeAnimation = new Tween({
      begin: 0.0,
      end: 1.0
    }).animated(new CurvedAnimation({
      parent: this.controller,
      curve: Curves.easeIn
    }));
    
    // 애니메이션 리스너 추가
    this.controller.addListener(() => {
      this.setState();
    });
    
    this.controller.forward();
  }
  
  dispose() {
    this.controller.dispose();
    super.dispose();
  }
  
  build(context: BuildContext): Widget {
    return Transform.translate({
      offset: new Offset(
        this.slideAnimation.value.dx * 200,  // x축 이동
        this.slideAnimation.value.dy * 0     // y축 이동
      ),
      child: Opacity({
        opacity: this.fadeAnimation.value,
        child: Container({
          width: 200,
          height: 100,
          color: '#10b981',
          child: Center({
            child: Text("순차 애니메이션", {
              style: new TextStyle({
                color: '#ffffff',
                fontSize: 18
              })
            })
          })
        })
      })
    });
  }
}

3. 커스텀 커브 애니메이션

class CustomCurveAnimation extends StatefulWidget {
  createState() {
    return new CustomCurveAnimationState();
  }
}

class CustomCurveAnimationState extends State<CustomCurveAnimation> {
  controller!: AnimationController;
  bounceAnimation!: Animation<number>;
  
  initState(context: BuildContext) {
    super.initState(context);
    
    this.controller = new AnimationController({
      duration: 1500  // 1.5초
    });
    
    // 바운스 효과를 위한 커스텀 커브
    this.bounceAnimation = new Tween({
      begin: 0,
      end: 300
    }).animated(new CurvedAnimation({
      parent: this.controller,
      curve: Curves.bounceOut
    }));
    
    // 애니메이션 리스너 추가
    this.controller.addListener(() => {
      this.setState();
    });
    
    this.controller.forward();
  }
  
  dispose() {
    this.controller.dispose();
    super.dispose();
  }
  
  build(context: BuildContext): Widget {
    return Transform.translate({
      offset: new Offset(this.bounceAnimation.value, 0),
      child: Container({
        width: 50,
        height: 50,
        decoration: new BoxDecoration({
          color: '#10b981',
          shape: 'circle'
        })
      })
    });
  }
}

주의사항

1. 성능 최적화

애니메이션은 성능에 영향을 줄 수 있으므로 최적화가 중요합니다:

// ❌ 나쁜 예: 전체 위젯 트리 재빌드
class MyWidgetState extends State<MyWidget> {
  build(context: BuildContext): Widget {
    return Column({
      children: [
        ComplexWidget({}),  // 매번 재생성
        Container({
          width: this.animation.value,
          height: 100
        })
      ]
    });
  }
}

// ✅ 좋은 예: 애니메이션 영향을 받는 부분만 분리
class MyWidgetState extends State<MyWidget> {
  complexWidget = ComplexWidget({});  // 한 번만 생성
  
  build(context: BuildContext): Widget {
    return Column({
      children: [
        this.complexWidget,  // 재사용
        Container({
          width: this.animation.value,
          height: 100
        })
      ]
    });
  }
}

2. 메모리 관리

AnimationController는 반드시 dispose해야 합니다:

dispose() {
  this.controller.dispose();  // 필수!
  super.dispose();
}

3. vsync 설정

SingleTickerProviderStateMixin 또는 TickerProviderStateMixin을 사용해야 합니다:

// Flitter에서는 mixin을 사용하지 않습니다
// AnimationController를 직접 생성하고 관리합니다
class MyState extends State<MyWidget> {
  controller!: AnimationController;
  
  initState(context: BuildContext) {
    super.initState(context);
    this.controller = new AnimationController({
      duration: 1000
    });
  }
  
  dispose() {
    this.controller.dispose();
    super.dispose();
  }
}

내장 애니메이션 위젯

Flitter는 자주 사용되는 애니메이션을 위한 내장 위젯들을 제공합니다:

  • AnimatedContainer: 속성 변경 시 자동 애니메이션
  • AnimatedOpacity: 투명도 애니메이션
  • AnimatedPositioned: Stack 내 위치 애니메이션
  • AnimatedScale: 크기 애니메이션
  • AnimatedRotation: 회전 애니메이션
  • AnimatedSlide: 슬라이드 애니메이션

이러한 위젯들은 AnimationController 없이도 간단한 애니메이션을 구현할 수 있게 해줍니다.

다음 단계

애니메이션 기초를 마스터했다면, 다음 주제들을 학습해보세요:

  • 고급 애니메이션 - 복잡한 애니메이션 패턴과 최적화
  • 커스텀 페인팅 - CustomPaint로 고급 그래픽 구현
  • 위젯 레퍼런스 - 내장 애니메이션 위젯 상세 가이드