Flutter 튜토리얼 28편: 애니메이션 API 심화
요약
핵심 요지
- 문제 정의: 단순한 애니메이션은 쉽지만, 복잡한 시퀀스나 커스텀 효과를 만들려면 애니메이션 API를 깊이 이해해야 한다.
- 핵심 주장:
AnimationController,Tween,Curve를 조합하면 어떤 애니메이션이든 구현할 수 있다. - 주요 근거: Flutter의 애니메이션 시스템은 컴포지션1 패턴으로 설계되어 각 부품을 자유롭게 조합할 수 있다.
- 실무 기준: 스태거드 애니메이션, 다중 속성 애니메이션, 커스텀 커브 등 실제 앱에서 자주 사용하는 패턴을 다룬다.
- 한계: 매우 복잡한 애니메이션은 코드가 길어지므로 패키지 사용을 고려해야 한다.
문서가 설명하는 범위
- AnimationController의 생명주기와 제어
- Tween으로 값 보간하기
- Curve로 애니메이션 느낌 조절하기
- AnimatedBuilder로 성능 최적화하기
- 스태거드 애니메이션으로 시퀀스 만들기
- 다중 속성 동시 애니메이션
읽는 시간: 18분 | 난이도: 중급~고급
참고 자료
- Animations tutorial - 애니메이션 공식 튜토리얼
- Animations API overview - 애니메이션 API 개요
- AnimationController class - AnimationController API
- Tween class - Tween API
- Curves class - 내장 커브 목록
문제 상황
AnimatedContainer나 TweenAnimationBuilder로 간단한 애니메이션은 쉽게 만들 수 있습니다.
하지만 실제 앱에서는 더 복잡한 요구사항이 있습니다.
복잡한 애니메이션 요구사항
요구사항 1: 버튼을 탭하면 카드가 위로 올라오면서 페이드인요구사항 2: 첫 번째 카드가 끝나면 두 번째 카드 시작요구사항 3: 모든 애니메이션이 끝나면 콜백 실행요구사항 4: 중간에 취소하거나 역재생 가능문제는 다음과 같습니다.
- 여러 애니메이션을 순서대로 실행해야 한다.
- 애니메이션 상태(진행 중, 완료, 취소)를 추적해야 한다.
- 성능을 위해 불필요한 위젯 리빌드를 피해야 한다.
이런 요구사항을 충족하려면 애니메이션 API를 직접 다뤄야 합니다.
해결 방법
Flutter 애니메이션은 세 가지 핵심 컴포넌트로 구성됩니다.
AnimationController(시간), Tween(값), Curve(느낌)를 조합하면 어떤 애니메이션이든 만들 수 있습니다.
챕터 1: AnimationController 이해하기
Why
NOTE애니메이션은 시간에 따라 값이 변하는 것입니다.
AnimationController는 이 “시간”을 관리합니다.
- 언제 시작할지
- 얼마나 오래 실행할지
- 정방향으로 갈지 역방향으로 갈지
이것이 모든 애니메이션의 기반입니다.
What
NOTE
AnimationController2는 0.0에서 1.0 사이의 값을 시간에 따라 생성합니다.핵심 속성과 메서드:
속성/메서드 설명 value현재 애니메이션 값 (0.0 ~ 1.0) status현재 상태 (forward, reverse, completed, dismissed) forward()정방향 재생 (0→1) reverse()역방향 재생 (1→0) stop()애니메이션 중지 repeat()반복 재생 reset()값을 0으로 리셋
How
TIP기본 사용법
class _MyWidgetState extends State<MyWidget>with SingleTickerProviderStateMixin { // 필수!late AnimationController _controller;@overridevoid initState() {super.initState();_controller = AnimationController(duration: Duration(milliseconds: 500), // 애니메이션 길이vsync: this, // 화면 새로고침과 동기화);}@overridevoid dispose() {_controller.dispose(); // 메모리 누수 방지super.dispose();}void _playAnimation() {_controller.forward(); // 재생}void _reverseAnimation() {_controller.reverse(); // 역재생}}vsync란?
vsync는 화면 새로고침 주기(보통 60fps)와 애니메이션을 동기화합니다.
화면이 보이지 않을 때는 애니메이션을 멈춰서 배터리를 절약합니다.
SingleTickerProviderStateMixin은 이 기능을 제공합니다.
애니메이션이 여러 개면TickerProviderStateMixin을 사용합니다.
Watch out
WARNINGdispose()를 꼭 호출하세요
AnimationController는dispose()하지 않으면 메모리 누수가 발생합니다.
위젯이 제거될 때 반드시 정리해야 합니다.@overridevoid dispose() {_controller.dispose(); // 필수!super.dispose();}
결론: AnimationController는 애니메이션의 시간축을 관리하며, 반드시 dispose()해야 합니다.
챕터 2: Tween으로 값 변환하기
Why
NOTE
AnimationController는 항상 0.0에서 1.0 사이의 값만 생성합니다.
하지만 실제로는 “0픽셀에서 300픽셀”, “빨간색에서 파란색” 같은 변환이 필요합니다.
Tween이 이 변환을 담당합니다.
What
NOTE
Tween3은begin과end사이의 값을 **보간(interpolation)**합니다.AnimationController: 0.0 → 0.5 → 1.0Tween(0, 300): 0 → 150 → 300자주 쓰는 Tween 타입:
Tween 용도 예시 Tween<double>숫자 크기, 위치, 투명도 ColorTween색상 배경색 전환 AlignmentTween정렬 위젯 이동 SizeTween크기 너비/높이 RectTween사각형 영역 전환
How
TIPTween 사용법
// 1. Tween 생성final sizeTween = Tween<double>(begin: 0, end: 300);// 2. Animation과 연결final Animation<double> sizeAnimation = sizeTween.animate(_controller);// 3. 값 사용Container(width: sizeAnimation.value, // 0 → 300 으로 변함height: sizeAnimation.value,)ColorTween 예시:
final colorTween = ColorTween(begin: Colors.red,end: Colors.blue,);final colorAnimation = colorTween.animate(_controller);// 사용Container(color: colorAnimation.value, // 빨강 → 파랑)여러 Tween을 하나의 컨트롤러에 연결:
// 같은 컨트롤러로 크기와 투명도 동시에 애니메이션final sizeAnimation = Tween<double>(begin: 0, end: 300).animate(_controller);final opacityAnimation = Tween<double>(begin: 0, end: 1).animate(_controller);
Watch out
WARNINGTween.animate()는 새 Animation을 반환합니다
// ❌ 잘못된 예: 매번 새 Animation 생성Widget build(BuildContext context) {final anim = Tween<double>(begin: 0, end: 300).animate(_controller);// build()가 호출될 때마다 새 객체 생성!}// ✅ 올바른 예: initState에서 한 번만 생성late Animation<double> _animation;@overridevoid initState() {super.initState();_animation = Tween<double>(begin: 0, end: 300).animate(_controller);}
결론: Tween은 0~1 범위를 원하는 값 범위로 변환하며, initState에서 한 번만 생성합니다.
챕터 3: Curve로 느낌 조절하기
Why
NOTE일정한 속도의 애니메이션은 기계적으로 느껴집니다.
현실 세계에서는 물체가 천천히 시작해서 빨라지거나, 빠르게 시작해서 느려집니다.
Curve는 이런 자연스러운 가속/감속을 적용합니다.
What
NOTE
Curve4는 0.01.0 입력을 받아 변형된 0.01.0을 출력합니다.Linear (기본): 0.0 → 0.5 → 1.0 (일정한 속도)easeIn: 0.0 → 0.2 → 1.0 (천천히 시작)easeOut: 0.0 → 0.8 → 1.0 (천천히 끝남)easeInOut: 0.0 → 0.5 → 1.0 (양쪽 천천히)자주 쓰는 Curve:
Curve 효과 사용 예시 Curves.linear일정한 속도 로딩 바 Curves.easeIn천천히 시작 화면 나타남 Curves.easeOut천천히 끝남 화면 사라짐 Curves.easeInOut부드러운 전환 일반적인 전환 Curves.bounceOut튕김 효과 드롭 애니메이션 Curves.elasticOut탄성 효과 강조 효과
How
TIPCurvedAnimation 사용법
// 1. CurvedAnimation 생성final curvedAnimation = CurvedAnimation(parent: _controller,curve: Curves.easeInOut,);// 2. Tween과 연결final animation = Tween<double>(begin: 0, end: 300).animate(curvedAnimation);chain() 메서드로 간결하게:
final animation = Tween<double>(begin: 0, end: 300).chain(CurveTween(curve: Curves.easeInOut)).animate(_controller);정방향/역방향 다른 커브:
final curvedAnimation = CurvedAnimation(parent: _controller,curve: Curves.easeIn, // 정방향: 천천히 시작reverseCurve: Curves.easeOut, // 역방향: 천천히 끝);
Watch out
WARNING커브가 범위를 벗어날 수 있습니다
Curves.elasticOut같은 커브는 1.0을 초과했다가 돌아옵니다.
이런 커브를 색상이나 투명도에 적용하면 오류가 발생할 수 있습니다.// ❌ 위험: elasticOut은 1.0을 초과할 수 있음final opacityAnimation = Tween<double>(begin: 0, end: 1).chain(CurveTween(curve: Curves.elasticOut)).animate(_controller);// ✅ 안전: clamp로 범위 제한Opacity(opacity: opacityAnimation.value.clamp(0.0, 1.0),child: child,)
결론: Curve로 애니메이션의 가속/감속을 조절하여 자연스러운 느낌을 만듭니다.
챕터 4: AnimatedBuilder로 성능 최적화
Why
NOTE애니메이션이 실행되면 매 프레임(초당 60회)마다 화면을 갱신해야 합니다.
setState()를 호출하면 전체 위젯 트리가 리빌드됩니다.
AnimatedBuilder는 애니메이션에 영향받는 부분만 리빌드합니다.
What
NOTE
AnimatedBuilder5는 애니메이션 값이 변할 때만builder함수를 호출합니다.graph TD A[AnimatedBuilder] --> B[builder 함수] A --> C[child 위젯<br>리빌드 안함] B --> D[애니메이션 적용 부분]핵심 포인트:
child파라미터에 전달된 위젯은 리빌드되지 않습니다builder함수 안에서만animation.value를 사용합니다
How
TIP기본 사용법
AnimatedBuilder(animation: _animation, // 감시할 애니메이션builder: (context, child) {// 애니메이션 값 변경될 때마다 호출됨return Transform.scale(scale: _animation.value,child: child, // 아래의 child가 전달됨);},child: const HeavyWidget(), // 한 번만 빌드됨!)여러 애니메이션 동시 적용:
AnimatedBuilder(animation: Listenable.merge([_scaleAnim, _opacityAnim]),builder: (context, child) {return Opacity(opacity: _opacityAnim.value,child: Transform.scale(scale: _scaleAnim.value,child: child,),);},child: const MyWidget(),)vs addListener + setState:
// ❌ 비효율: 전체 위젯 트리 리빌드_controller.addListener(() {setState(() {}); // 전체 build() 재호출});// ✅ 효율: AnimatedBuilder 부분만 리빌드AnimatedBuilder(animation: _controller,builder: (context, child) => ...,child: child, // 리빌드 안 됨)
Watch out
WARNINGbuilder 안에서 무거운 위젯을 만들지 마세요
// ❌ 비효율: HeavyWidget이 매 프레임 생성됨AnimatedBuilder(animation: _animation,builder: (context, child) {return Transform.scale(scale: _animation.value,child: const HeavyWidget(), // 매번 새로 생성!);},)// ✅ 효율: child로 분리AnimatedBuilder(animation: _animation,builder: (context, child) {return Transform.scale(scale: _animation.value,child: child, // 재사용);},child: const HeavyWidget(), // 한 번만 생성)
결론: AnimatedBuilder로 애니메이션 성능을 최적화하고, child로 불변 위젯을 분리합니다.
챕터 5: 애니메이션 상태 추적하기
Why
NOTE애니메이션이 끝나면 다음 애니메이션을 시작하거나, 반복하거나, 콜백을 실행해야 할 때가 있습니다.
AnimationStatus로 애니메이션의 현재 상태를 추적합니다.
What
NOTE
AnimationStatus6는 네 가지 상태를 가집니다.
상태 의미 언제? dismissed시작점에 있음 value == 0.0 forward정방향 진행 중 0.0 → 1.0 reverse역방향 진행 중 1.0 → 0.0 completed끝점에 있음 value == 1.0
How
TIP상태 리스너 등록
@overridevoid initState() {super.initState();_controller = AnimationController(duration: Duration(seconds: 1),vsync: this,);_controller.addStatusListener((status) {if (status == AnimationStatus.completed) {print('애니메이션 완료!');_controller.reverse(); // 자동 역재생} else if (status == AnimationStatus.dismissed) {print('원점 복귀!');_controller.forward(); // 자동 재생}});}무한 반복 애니메이션:
// 방법 1: repeat() 사용_controller.repeat(); // 무한 반복// 방법 2: 상태 리스너로 구현_controller.addStatusListener((status) {if (status == AnimationStatus.completed) {_controller.reverse();} else if (status == AnimationStatus.dismissed) {_controller.forward();}});_controller.forward();한 번만 실행 후 콜백:
void _playOnce(VoidCallback onComplete) {void listener(AnimationStatus status) {if (status == AnimationStatus.completed) {_controller.removeStatusListener(listener); // 리스너 제거onComplete(); // 콜백 실행}}_controller.addStatusListener(listener);_controller.forward();}
Watch out
WARNING리스너 제거를 잊지 마세요
상태 리스너를 제거하지 않으면 중복 호출될 수 있습니다.
// ❌ 위험: 매번 새 리스너 추가void _startAnimation() {_controller.addStatusListener((status) { ... }); // 누적됨!_controller.forward();}// ✅ 안전: initState에서 한 번만 등록@overridevoid initState() {super.initState();_controller.addStatusListener(_onStatusChanged);}
결론: addStatusListener로 애니메이션 완료, 시작 등의 이벤트를 감지합니다.
챕터 6: 스태거드 애니메이션 만들기
Why
NOTE여러 요소가 순서대로 또는 겹쳐서 애니메이션되면 더 세련된 효과를 만들 수 있습니다.
예: 목록 항목이 하나씩 차례로 나타남, 카드가 여러 효과로 변환됨
What
NOTE스태거드 애니메이션7은 하나의
AnimationController로 여러 애니메이션을 다른 시간에 실행합니다.컨트롤러 진행: 0.0 ──────────────────────→ 1.0애니메이션 1: [=====] (0.0 ~ 0.3)애니메이션 2: [=====] (0.3 ~ 0.6)애니메이션 3: [=====] (0.6 ~ 1.0)
Interval8 클래스로 각 애니메이션의 시작/끝 시점을 지정합니다.
How
TIPInterval로 시간대 나누기
late Animation<double> _slideAnimation;late Animation<double> _fadeAnimation;late Animation<double> _scaleAnimation;@overridevoid initState() {super.initState();_controller = AnimationController(duration: Duration(milliseconds: 1500),vsync: this,);// 0% ~ 40%: 슬라이드_slideAnimation = Tween<double>(begin: -100, end: 0).animate(CurvedAnimation(parent: _controller,curve: Interval(0.0, 0.4, curve: Curves.easeOut),));// 20% ~ 60%: 페이드 (슬라이드와 겹침)_fadeAnimation = Tween<double>(begin: 0, end: 1).animate(CurvedAnimation(parent: _controller,curve: Interval(0.2, 0.6, curve: Curves.easeIn),));// 50% ~ 100%: 스케일_scaleAnimation = Tween<double>(begin: 0.8, end: 1).animate(CurvedAnimation(parent: _controller,curve: Interval(0.5, 1.0, curve: Curves.elasticOut),));}@overrideWidget build(BuildContext context) {return AnimatedBuilder(animation: _controller,builder: (context, child) {return Transform.translate(offset: Offset(0, _slideAnimation.value),child: Opacity(opacity: _fadeAnimation.value,child: Transform.scale(scale: _scaleAnimation.value,child: child,),),);},child: const Card(...),);}목록 항목 순차 등장:
Widget _buildAnimatedItem(int index, int totalItems) {final startTime = index / totalItems; // 0.0, 0.25, 0.5, 0.75final endTime = (index + 1) / totalItems; // 0.25, 0.5, 0.75, 1.0final animation = Tween<double>(begin: 0, end: 1).animate(CurvedAnimation(parent: _controller,curve: Interval(startTime, endTime, curve: Curves.easeOut),));return FadeTransition(opacity: animation,child: SlideTransition(position: animation.drive(Tween(begin: Offset(0, 0.3), end: Offset.zero),),child: ListTile(title: Text('Item $index')),),);}
Watch out
WARNINGInterval 범위는 0.0 ~ 1.0이어야 합니다
// ❌ 오류: 범위 초과Interval(0.8, 1.2, ...) // 1.2는 유효하지 않음// ✅ 올바른 예Interval(0.0, 1.0, ...) // 전체 시간Interval(0.5, 1.0, ...) // 후반 50%
결론: Interval로 하나의 컨트롤러에서 여러 애니메이션의 시작/끝 시점을 다르게 설정합니다.
한계
저수준 애니메이션 API는 강력하지만 코드가 복잡해질 수 있습니다.
- 보일러플레이트: 컨트롤러, Tween, 리스너 등 설정 코드가 많습니다.
- 복잡한 시퀀스: 매우 복잡한 애니메이션은
flutter_animate같은 패키지가 더 편리합니다. - 디버깅: 여러 애니메이션이 얽히면 디버깅이 어렵습니다.
- 메모리 관리: 컨트롤러 dispose를 잊으면 메모리 누수가 발생합니다.
Footnotes
-
컴포지션(Composition): 작은 부품을 조합해서 큰 기능을 만드는 설계 패턴이다. Flutter 애니메이션은 Controller, Tween, Curve를 자유롭게 조합한다. ↩
-
AnimationController(애니메이션컨트롤러): 애니메이션의 시간과 상태를 관리하는 클래스다. 0.0~1.0 사이의 값을 생성하며, forward(), reverse() 등으로 제어한다. ↩
-
Tween(트윈): “in-between”의 줄임말로, 시작값과 끝값 사이를 보간하는 클래스다. AnimationController의 0~1 값을 원하는 범위로 변환한다. ↩
-
Curve(커브): 애니메이션의 진행 속도 곡선을 정의한다. linear(일정), easeIn(천천히 시작), easeOut(천천히 끝남) 등이 있다. ↩
-
AnimatedBuilder(애니메이티드빌더): 애니메이션 값이 변할 때만 builder 함수를 호출하는 위젯이다. child를 분리해서 성능을 최적화한다. ↩
-
AnimationStatus(애니메이션스테이터스): 애니메이션의 현재 상태를 나타내는 열거형이다. dismissed, forward, reverse, completed의 네 가지 값이 있다. ↩
-
스태거드 애니메이션(Staggered Animation): 여러 애니메이션이 시차를 두고 실행되는 패턴이다. Interval로 각 애니메이션의 시작/끝 시점을 지정한다. ↩
-
Interval(인터벌): 0.0~1.0 범위에서 애니메이션이 활성화되는 구간을 정의하는 Curve 서브클래스다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!