Flutter 튜토리얼 28편: 애니메이션 API 심화

요약#

핵심 요지#

  • 문제 정의: 단순한 애니메이션은 쉽지만, 복잡한 시퀀스나 커스텀 효과를 만들려면 애니메이션 API를 깊이 이해해야 한다.
  • 핵심 주장: AnimationController, Tween, Curve를 조합하면 어떤 애니메이션이든 구현할 수 있다.
  • 주요 근거: Flutter의 애니메이션 시스템은 컴포지션1 패턴으로 설계되어 각 부품을 자유롭게 조합할 수 있다.
  • 실무 기준: 스태거드 애니메이션, 다중 속성 애니메이션, 커스텀 커브 등 실제 앱에서 자주 사용하는 패턴을 다룬다.
  • 한계: 매우 복잡한 애니메이션은 코드가 길어지므로 패키지 사용을 고려해야 한다.

문서가 설명하는 범위#

  • AnimationController의 생명주기와 제어
  • Tween으로 값 보간하기
  • Curve로 애니메이션 느낌 조절하기
  • AnimatedBuilder로 성능 최적화하기
  • 스태거드 애니메이션으로 시퀀스 만들기
  • 다중 속성 동시 애니메이션

읽는 시간: 18분 | 난이도: 중급~고급


참고 자료#


문제 상황#

AnimatedContainerTweenAnimationBuilder로 간단한 애니메이션은 쉽게 만들 수 있습니다.
하지만 실제 앱에서는 더 복잡한 요구사항이 있습니다.

복잡한 애니메이션 요구사항#

요구사항 1: 버튼을 탭하면 카드가 위로 올라오면서 페이드인
요구사항 2: 첫 번째 카드가 끝나면 두 번째 카드 시작
요구사항 3: 모든 애니메이션이 끝나면 콜백 실행
요구사항 4: 중간에 취소하거나 역재생 가능

문제는 다음과 같습니다.

  • 여러 애니메이션을 순서대로 실행해야 한다.
  • 애니메이션 상태(진행 중, 완료, 취소)를 추적해야 한다.
  • 성능을 위해 불필요한 위젯 리빌드를 피해야 한다.

이런 요구사항을 충족하려면 애니메이션 API를 직접 다뤄야 합니다.


해결 방법#

Flutter 애니메이션은 세 가지 핵심 컴포넌트로 구성됩니다.
AnimationController(시간), Tween(값), Curve(느낌)를 조합하면 어떤 애니메이션이든 만들 수 있습니다.

graph LR A[AnimationController<br>0.0 ~ 1.0] --> B[Curve<br>easing 적용] B --> C[Tween<br>값 변환] C --> D[Animation<br>최종 값] D --> E[Widget<br>화면 반영]

챕터 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;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(milliseconds: 500), // 애니메이션 길이
vsync: this, // 화면 새로고침과 동기화
);
}
@override
void dispose() {
_controller.dispose(); // 메모리 누수 방지
super.dispose();
}
void _playAnimation() {
_controller.forward(); // 재생
}
void _reverseAnimation() {
_controller.reverse(); // 역재생
}
}

vsync란?

vsync는 화면 새로고침 주기(보통 60fps)와 애니메이션을 동기화합니다.
화면이 보이지 않을 때는 애니메이션을 멈춰서 배터리를 절약합니다.

SingleTickerProviderStateMixin은 이 기능을 제공합니다.
애니메이션이 여러 개면 TickerProviderStateMixin을 사용합니다.

Watch out#

WARNING

dispose()를 꼭 호출하세요

AnimationControllerdispose()하지 않으면 메모리 누수가 발생합니다.
위젯이 제거될 때 반드시 정리해야 합니다.

@override
void dispose() {
_controller.dispose(); // 필수!
super.dispose();
}

결론: AnimationController는 애니메이션의 시간축을 관리하며, 반드시 dispose()해야 합니다.


챕터 2: Tween으로 값 변환하기#

Why#

NOTE

AnimationController는 항상 0.0에서 1.0 사이의 값만 생성합니다.
하지만 실제로는 “0픽셀에서 300픽셀”, “빨간색에서 파란색” 같은 변환이 필요합니다.

Tween이 이 변환을 담당합니다.

What#

NOTE

Tween3beginend 사이의 값을 **보간(interpolation)**합니다.

AnimationController: 0.0 → 0.5 → 1.0
Tween(0, 300): 0 → 150 → 300

자주 쓰는 Tween 타입:

Tween용도예시
Tween<double>숫자크기, 위치, 투명도
ColorTween색상배경색 전환
AlignmentTween정렬위젯 이동
SizeTween크기너비/높이
RectTween사각형영역 전환

How#

TIP

Tween 사용법

// 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#

WARNING

Tween.animate()는 새 Animation을 반환합니다

// ❌ 잘못된 예: 매번 새 Animation 생성
Widget build(BuildContext context) {
final anim = Tween<double>(begin: 0, end: 300).animate(_controller);
// build()가 호출될 때마다 새 객체 생성!
}
// ✅ 올바른 예: initState에서 한 번만 생성
late Animation<double> _animation;
@override
void 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#

TIP

CurvedAnimation 사용법

// 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#

WARNING

builder 안에서 무거운 위젯을 만들지 마세요

// ❌ 비효율: 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

상태 리스너 등록

@override
void 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에서 한 번만 등록
@override
void 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#

TIP

Interval로 시간대 나누기

late Animation<double> _slideAnimation;
late Animation<double> _fadeAnimation;
late Animation<double> _scaleAnimation;
@override
void 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),
));
}
@override
Widget 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.75
final endTime = (index + 1) / totalItems; // 0.25, 0.5, 0.75, 1.0
final 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#

WARNING

Interval 범위는 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#

  1. 컴포지션(Composition): 작은 부품을 조합해서 큰 기능을 만드는 설계 패턴이다. Flutter 애니메이션은 Controller, Tween, Curve를 자유롭게 조합한다.

  2. AnimationController(애니메이션컨트롤러): 애니메이션의 시간과 상태를 관리하는 클래스다. 0.0~1.0 사이의 값을 생성하며, forward(), reverse() 등으로 제어한다.

  3. Tween(트윈): “in-between”의 줄임말로, 시작값과 끝값 사이를 보간하는 클래스다. AnimationController의 0~1 값을 원하는 범위로 변환한다.

  4. Curve(커브): 애니메이션의 진행 속도 곡선을 정의한다. linear(일정), easeIn(천천히 시작), easeOut(천천히 끝남) 등이 있다.

  5. AnimatedBuilder(애니메이티드빌더): 애니메이션 값이 변할 때만 builder 함수를 호출하는 위젯이다. child를 분리해서 성능을 최적화한다.

  6. AnimationStatus(애니메이션스테이터스): 애니메이션의 현재 상태를 나타내는 열거형이다. dismissed, forward, reverse, completed의 네 가지 값이 있다.

  7. 스태거드 애니메이션(Staggered Animation): 여러 애니메이션이 시차를 두고 실행되는 패턴이다. Interval로 각 애니메이션의 시작/끝 시점을 지정한다.

  8. Interval(인터벌): 0.0~1.0 범위에서 애니메이션이 활성화되는 구간을 정의하는 Curve 서브클래스다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 28편: 애니메이션 API 심화
https://moodturnpost.net/posts/flutter/flutter-animation-api-deep/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차