AnimationController로 정밀한 애니메이션 제어하기
이번 튜토리얼에서는 Flitter의 AnimationController를 사용해 애니메이션을 정밀하게 제어하고, 복잡한 애니메이션 시퀀스를 구현하는 방법을 배워봅시다.
🎯 학습 목표
이 튜토리얼을 완료하면 다음을 할 수 있게 됩니다:
- AnimationController의 기본 개념과 생명주기 이해하기
- Tween을 사용해 시작값과 끝값 정의하기
- CurvedAnimation으로 이징 효과 적용하기
- 애니메이션 재생, 정지, 역재생, 반복 제어하기
- 여러 애니메이션을 순차적으로 실행하기
- 복잡한 애니메이션 시퀀스 구현하기
🚀 명시적 애니메이션 vs 암시적 애니메이션
지금까지 배운 AnimatedContainer, AnimatedOpacity 등은 암시적 애니메이션입니다:
- 속성 값만 바꾸면 자동으로 애니메이션
- 간단하고 사용하기 쉬움
- 제어 기능이 제한적
명시적 애니메이션은 AnimationController를 사용합니다:
- 애니메이션을 직접 제어 (시작, 정지, 속도 등)
- 복잡한 애니메이션 시퀀스 구현 가능
- 더 정교한 제어가 필요할 때 사용
🎨 AnimationController 기본 개념
핵심 구성 요소
- AnimationController: 애니메이션의 진행 상태를 제어
- Tween: 시작값과 끝값을 정의
- Animation: Controller와 Tween을 연결
- CurvedAnimation: 이징 효과 적용 (선택사항)
기본 구조
class MyAnimationState extends State<MyAnimation> {
controller = null;
animation = null;
initState(context) {
super.initState(context);
// 1. Controller 생성
this.controller = AnimationController({
duration: 1000 // 지속 시간 (밀리초)
});
// 2. Tween과 Animation 생성
this.animation = Tween({
begin: 0,
end: 100
}).animated(this.controller);
// 3. 리스너 등록 (setState 호출)
this.controller.addListener(() => {
this.setState();
});
}
dispose() {
// 4. 메모리 해제 (필수!)
this.controller.dispose();
super.dispose();
}
}
📋 단계별 실습
1단계: 기본 AnimationController 사용
간단한 크기 애니메이션을 직접 제어해봅시다:
import { AnimationController, Tween, Animation, CurvedAnimation } from "@meursyphus/flitter";
class BasicControllerAnimation extends StatefulWidget {
createState() {
return new BasicControllerAnimationState();
}
}
class BasicControllerAnimationState extends State<BasicControllerAnimation> {
controller = null;
animation = null;
initState(context) {
super.initState(context);
this.controller = AnimationController({ duration: 1000 });
this.animation = Tween({ begin: 50, end: 200 }).animated(this.controller);
this.controller.addListener(() => {
this.setState();
});
}
dispose() {
this.controller.dispose();
super.dispose();
}
build(context) {
return Column({
children: [
// 제어 버튼들
Row({
children: [
GestureDetector({
onClick: () => this.controller.forward(),
child: Container({
width: 80,
height: 40,
color: '#4CAF50',
child: Text("시작")
})
}),
GestureDetector({
onClick: () => this.controller.reverse(),
child: Container({
width: 80,
height: 40,
color: '#FF5722',
child: Text("역재생")
})
}),
GestureDetector({
onClick: () => this.controller.reset(),
child: Container({
width: 80,
height: 40,
color: '#9E9E9E',
child: Text("리셋")
})
})
]
}),
// 애니메이션되는 박스
Container({
width: this.animation.value,
height: this.animation.value,
color: '#2196F3',
child: Text(`${Math.round(this.animation.value)}px`)
})
]
});
}
}
2단계: CurvedAnimation으로 이징 효과 추가
애니메이션에 이징 효과를 적용해서 더 자연스럽게 만들어봅시다:
class CurvedAnimationExample extends StatefulWidget {
createState() {
return CurvedAnimationExampleState();
}
}
class CurvedAnimationExampleState extends State<CurvedAnimationExample> {
controller = null;
linearAnimation = null;
curvedAnimation = null;
initState(context) {
super.initState(context);
this.controller = AnimationController({ duration: 2000 });
// 선형 애니메이션
this.linearAnimation = Tween({
begin: 0,
end: 250
}).animated(this.controller);
// 곡선 애니메이션
this.curvedAnimation = Tween({
begin: 0,
end: 250
}).animated(CurvedAnimation({
parent: this.controller,
curve: "bounceOut"
}));
this.controller.addListener(() => {
this.setState();
});
}
dispose() {
this.controller.dispose();
super.dispose();
}
build(context) {
return Column({
children: [
GestureDetector({
onClick: () => {
if (this.controller.isCompleted) {
this.controller.reverse();
} else {
this.controller.forward();
}
},
child: Container({
width: 120,
height: 50,
color: '#673AB7',
child: Text("애니메이션 토글")
})
}),
// 선형 애니메이션
Container({
width: this.linearAnimation.value,
height: 40,
color: '#FF9800',
child: Text("Linear")
}),
// 곡선 애니메이션
Container({
width: this.curvedAnimation.value,
height: 40,
color: '#E91E63',
child: Text("BounceOut")
})
]
});
}
}
3단계: 여러 속성 동시 애니메이션
하나의 Controller로 여러 속성을 동시에 애니메이션해봅시다:
class MultiPropertyAnimation extends StatefulWidget {
createState() {
return new MultiPropertyAnimationState();
}
}
class MultiPropertyAnimationState extends State<MultiPropertyAnimation> {
controller = null;
sizeAnimation = null;
colorAnimation = null;
rotationAnimation = null;
initState(context) {
super.initState(context);
this.controller = AnimationController({ duration: 2000 });
// 크기 애니메이션
this.sizeAnimation = Tween({
begin: 80,
end: 160
}).animated(CurvedAnimation({
parent: this.controller,
curve: "easeInOut"
}));
// 색상 애니메이션 (0-1 값으로 색상 보간)
this.colorAnimation = Tween({
begin: 0,
end: 1
}).animated(this.controller);
// 회전 애니메이션
this.rotationAnimation = Tween({
begin: 0,
end: 2
}).animated(CurvedAnimation({
parent: this.controller,
curve: "elasticOut"
}));
this.controller.addListener(() => {
this.setState();
});
}
dispose() {
this.controller.dispose();
super.dispose();
}
getInterpolatedColor() {
const t = this.colorAnimation.value;
const r = Math.round(255 * (1 - t) + 100 * t);
const g = Math.round(150 * (1 - t) + 200 * t);
const b = Math.round(200 * (1 - t) + 50 * t);
return `rgb(${r}, ${g}, ${b})`;
}
build(context) {
return Column({
children: [
GestureDetector({
onClick: () => {
if (this.controller.status === "completed") {
this.controller.reverse();
} else {
this.controller.forward();
}
},
child: Container({
width: 150,
height: 50,
color: '#607D8B',
child: Text("다중 속성 애니메이션")
})
}),
Transform.rotate({
angle: this.rotationAnimation.value * Math.PI, // 라디안으로 변환
child: Container({
width: this.sizeAnimation.value,
height: this.sizeAnimation.value,
color: this.getInterpolatedColor(),
child: Text("🎨")
})
})
]
});
}
}
4단계: 순차적 애니메이션 (Interval 사용)
하나의 Controller로 순차적으로 실행되는 애니메이션을 만들어봅시다:
import { Interval } from "@meursyphus/flitter";
class SequentialAnimation extends StatefulWidget {
createState() {
return new SequentialAnimationState();
}
}
class SequentialAnimationState extends State<SequentialAnimation> {
controller = null;
slideAnimation = null;
fadeAnimation = null;
scaleAnimation = null;
initState(context) {
super.initState(context);
this.controller = AnimationController({ duration: 3000 });
// 0~1초: 슬라이드 애니메이션
this.slideAnimation = Tween({
begin: -200,
end: 0
}).animated(CurvedAnimation({
parent: this.controller,
curve: new Interval(0.0, 0.33, { curve: "easeOut" })
}));
// 1~2초: 페이드 애니메이션
this.fadeAnimation = Tween({
begin: 0.0,
end: 1.0
}).animated(CurvedAnimation({
parent: this.controller,
curve: new Interval(0.33, 0.66, { curve: "easeIn" })
}));
// 2~3초: 스케일 애니메이션
this.scaleAnimation = Tween({
begin: 0.5,
end: 1.0
}).animated(CurvedAnimation({
parent: this.controller,
curve: new Interval(0.66, 1.0, { curve: "bounceOut" })
}));
this.controller.addListener(() => {
this.setState();
});
}
dispose() {
this.controller.dispose();
super.dispose();
}
build(context) {
return Column({
children: [
GestureDetector({
onClick: () => {
this.controller.reset();
this.controller.forward();
},
child: Container({
width: 150,
height: 50,
color: '#795548',
child: Text("순차 애니메이션 시작")
})
}),
Transform.translate({
offset: new Offset(this.slideAnimation.value, 0),
child: Transform.scale({
scale: this.scaleAnimation.value,
child: AnimatedOpacity({
duration: 0, // 즉시 변경 (Controller가 제어)
opacity: this.fadeAnimation.value,
child: Container({
width: 120,
height: 80,
color: '#009688',
child: Text("순차 등장!")
})
})
})
})
]
});
}
}
5단계: 반복 애니메이션
자동으로 반복되는 애니메이션을 만들어봅시다:
class RepeatAnimation extends StatefulWidget {
createState() {
return new RepeatAnimationState();
}
}
class RepeatAnimationState extends State<RepeatAnimation> {
controller = null;
animation = null;
isRunning = false;
initState(context) {
super.initState(context);
this.controller = AnimationController({ duration: 1000 });
this.animation = Tween({ begin: -50, end: 50 }).animated(
CurvedAnimation({
parent: this.controller,
curve: "easeInOut"
})
);
this.controller.addListener(() => {
this.setState();
});
}
dispose() {
this.controller.dispose();
super.dispose();
}
startRepeating() {
this.isRunning = true;
this.controller.repeat({ reverse: true });
}
stopRepeating() {
this.isRunning = false;
this.controller.stop();
}
build(context) {
return Column({
children: [
Row({
children: [
GestureDetector({
onClick: () => this.startRepeating(),
child: Container({
width: 80,
height: 40,
color: '#4CAF50',
child: Text("시작")
})
}),
GestureDetector({
onClick: () => this.stopRepeating(),
child: Container({
width: 80,
height: 40,
color: '#F44336',
child: Text("정지")
})
})
]
}),
Container({
width: 200,
height: 100,
color: '#F5F5F5',
child: Stack({
children: [
Positioned({
left: 100 + this.animation.value,
top: 25,
child: Container({
width: 50,
height: 50,
color: '#FF5722',
child: Text("🏃♂️")
})
})
]
})
})
]
});
}
}
🎯 실습 도전 과제
TODO 1: 진행률 표시가 있는 애니메이션
애니메이션 진행률을 표시하는 프로그레스 바와 함께 제어하는 애니메이션을 만들어보세요:
class ProgressAnimation extends StatefulWidget {
createState() {
return new ProgressAnimationState();
}
}
class ProgressAnimationState extends State<ProgressAnimation> {
controller = null;
animation = null;
initState(context) {
super.initState(context);
this.controller = AnimationController({ duration: 3000 });
this.animation = Tween({ begin: 0, end: 300 }).animated(
CurvedAnimation({
parent: this.controller,
curve: "easeInOut"
})
);
this.controller.addListener(() => {
this.setState();
});
}
dispose() {
this.controller.dispose();
super.dispose();
}
build(context) {
const progress = this.controller.value; // 0.0 ~ 1.0
return Column({
children: [
// 진행률 표시
Container({
width: 300,
height: 20,
color: '#E0E0E0',
child: Container({
width: 300 * progress,
height: 20,
color: '#4CAF50'
})
}),
Text(`진행률: ${Math.round(progress * 100)}%`),
// 제어 버튼들
Row({
children: [
GestureDetector({
onClick: () => this.controller.forward(),
child: Container({
width: 60,
height: 40,
color: '#2196F3',
child: Text("▶")
})
}),
GestureDetector({
onClick: () => this.controller.reverse(),
child: Container({
width: 60,
height: 40,
color: '#FF9800',
child: Text("◀")
})
}),
GestureDetector({
onClick: () => this.controller.stop(),
child: Container({
width: 60,
height: 40,
color: '#F44336',
child: Text("⏸")
})
}),
GestureDetector({
onClick: () => this.controller.reset(),
child: Container({
width: 60,
height: 40,
color: '#9E9E9E',
child: Text("⏹")
})
})
]
}),
// 애니메이션되는 요소
Container({
width: this.animation.value,
height: 60,
color: '#E91E63',
child: Text(`${Math.round(this.animation.value)}px`)
})
]
});
}
}
TODO 2: 다단계 애니메이션 시퀀스
버튼을 클릭할 때마다 다음 단계로 진행하는 다단계 애니메이션을 만들어보세요:
class MultiStepAnimation extends StatefulWidget {
createState() {
return new MultiStepAnimationState();
}
}
class MultiStepAnimationState extends State<MultiStepAnimation> {
controllers = [];
animations = [];
currentStep = 0;
steps = [
{ name: "등장", duration: 500, curve: "easeOut" },
{ name: "회전", duration: 800, curve: "elasticOut" },
{ name: "확대", duration: 600, curve: "bounceOut" },
{ name: "페이드", duration: 400, curve: "easeIn" }
];
initState(context) {
super.initState(context);
// 각 단계별 컨트롤러 생성
this.steps.forEach((step, index) => {
const controller = AnimationController({ duration: step.duration });
controller.addListener(() => this.setState());
this.controllers.push(controller);
});
// 각 단계별 애니메이션 정의
this.animations = [
Tween({ begin: -200, end: 0 }).animated(
CurvedAnimation({
parent: this.controllers[0],
curve: "easeOut"
})
),
Tween({ begin: 0, end: 1 }).animated(
CurvedAnimation({
parent: this.controllers[1],
curve: "elasticOut"
})
),
Tween({ begin: 1, end: 2 }).animated(
CurvedAnimation({
parent: this.controllers[2],
curve: "bounceOut"
})
),
Tween({ begin: 1, end: 0 }).animated(
CurvedAnimation({
parent: this.controllers[3],
curve: "easeIn"
})
)
];
}
dispose() {
this.controllers.forEach(controller => controller.dispose());
super.dispose();
}
nextStep() {
if (this.currentStep < this.steps.length) {
this.controllers[this.currentStep].forward();
this.currentStep++;
}
}
reset() {
this.controllers.forEach(controller => controller.reset());
this.currentStep = 0;
this.setState();
}
build(context) {
return Column({
children: [
Text(`현재 단계: ${this.currentStep} / ${this.steps.length}`),
Row({
children: [
GestureDetector({
onClick: () => this.nextStep(),
child: Container({
width: 100,
height: 40,
color: this.currentStep < this.steps.length ? '#4CAF50' : '#BDBDBD',
child: Text("다음 단계")
})
}),
GestureDetector({
onClick: () => this.reset(),
child: Container({
width: 80,
height: 40,
color: '#FF5722',
child: Text("리셋")
})
})
]
}),
// 애니메이션 요소
Transform.translate({
offset: new Offset(this.animations[0].value, 0),
child: Transform.rotate({
angle: this.animations[1].value * Math.PI,
child: Transform.scale({
scale: this.animations[2].value,
child: AnimatedOpacity({
duration: 0,
opacity: this.animations[3].value,
child: Container({
width: 80,
height: 80,
color: '#9C27B0',
child: Text("🎭")
})
})
})
})
})
]
});
}
}
🎨 예상 결과
완성하면 다음과 같은 기능들이 작동해야 합니다:
- 기본 제어: forward, reverse, reset으로 애니메이션 제어
- 곡선 효과: 선형과 곡선 애니메이션의 차이 확인
- 다중 속성: 크기, 색상, 회전이 동시에 애니메이션
- 순차 실행: 슬라이드 → 페이드 → 스케일 순서로 실행
- 반복 애니메이션: 자동으로 앞뒤로 반복하는 효과
- 진행률 표시: 실시간으로 애니메이션 진행률 확인
💡 추가 도전
더 도전하고 싶다면:
- 복잡한 시퀀스: 5단계 이상의 복잡한 애니메이션 시퀀스
- 인터랙티브 제어: 드래그로 애니메이션 진행률 직접 제어
- 조건부 애니메이션: 특정 조건에 따라 다른 애니메이션 실행
- 물리 기반: 스프링 효과나 중력 효과 시뮬레이션
⚠️ 흔한 실수와 해결법
1. dispose() 잊어버리기
// ❌ dispose 안 함 - 메모리 누수!
dispose() {
super.dispose(); // controller.dispose() 빠짐
}
// ✅ 반드시 dispose 호출
dispose() {
this.controller.dispose();
super.dispose();
}
2. addListener에서 setState 호출 안 함
// ❌ setState 없음 - UI 업데이트 안됨
this.controller.addListener(() => {
// setState() 호출 안 함
});
// ✅ setState로 UI 업데이트
this.controller.addListener(() => {
this.setState();
});
3. Tween 범위 잘못 설정
// ❌ 의도하지 않은 범위
Tween({ begin: 100, end: 0 }) // 감소하는 애니메이션
// ✅ 의도한 범위 확인
Tween({ begin: 0, end: 100 }) // 증가하는 애니메이션
4. Interval 범위 오류
// ❌ 잘못된 범위 (0.0~1.0 벗어남)
new Interval(0.5, 1.5) // 1.0 초과
// ✅ 올바른 범위
new Interval(0.0, 0.5) // 첫 절반
new Interval(0.5, 1.0) // 두 번째 절반
5. 회전각 단위 혼동
// ❌ 도(degree) 단위 사용
Transform.rotate({ angle: 90 }) // 90도가 아님!
// ✅ 라디안(radian) 단위 사용
Transform.rotate({ angle: Math.PI / 2 }) // 90도
🎓 핵심 정리
AnimationController 생명주기
- initState(): Controller 생성, Animation 설정, Listener 등록
- build(): animation.value 사용해서 UI 구성
- dispose(): Controller 메모리 해제 (필수!)
제어 메서드들
- forward(): 시작 → 끝으로 애니메이션
- reverse(): 끝 → 시작으로 역방향 애니메이션
- reset(): 시작 위치로 즉시 이동
- stop(): 현재 위치에서 정지
- repeat(): 반복 애니메이션
- animateTo(value): 특정 값으로 애니메이션
상태 확인
- controller.value: 현재 진행률 (0.0 ~ 1.0)
- controller.status: 애니메이션 상태
- controller.isCompleted: 완료 여부
- controller.isAnimating: 진행 중 여부
AnimationController는 Flitter에서 가장 강력한 애니메이션 도구입니다. 정밀한 제어와 복잡한 시퀀스가 필요한 고급 애니메이션을 구현할 때 필수적입니다.
🚀 다음 단계
다음 튜토리얼에서는 커스텀 애니메이션을 배워봅시다:
- AnimatedWidget 상속으로 재사용 가능한 애니메이션 위젯 만들기
- CustomPaint와 애니메이션 결합하기
- 물리 기반 애니메이션 구현하기
- 성능 최적화 기법 적용하기