애니메이션이 도는 법 — Ticker와 AnimationController
2026-01-24
애니메이션을 쓰다 보면 늘 나오는 의문들이 있죠.
AnimationController에 왜vsync: this를 넘기나.SingleTickerProviderStateMixin은 뭔가.- 왜
dispose를 꼭 해야 하나. Timer로 16ms 마다 갱신하면 될 것 같은데, 왜 그러면 끊기나.
이 셋은 전부 한 사실 에서 나옵니다 — 애니메이션은 별도 스레드가 아니라, 매 프레임(vsync)마다 값을 조금씩 바꾸는 것 입니다. 6편에서 한 프레임이 어떻게 만들어지는지 봤죠. 애니메이션은 바로 그 프레임 위에서 돕니다. 이번 편에서 그 구동 장치를 뜯어봅니다.
💻 인용한 소스는 Flutter 3.44.8 의
animation/animation_controller.dart·scheduler/ticker.dart, 증명 테스트는 github.com/kahnco/flutter-study 의test/animation_test.dart입니다.
Ticker — 매 프레임 깨우는 알람
애니메이션의 심장은 Ticker 입니다. Ticker 는 매 프레임 콜백을 받는 물건이에요 — 그것도 그냥 타이머가 아니라, 디스플레이의 vsync(수직 동기)에 물려 있습니다.
// scheduler/ticker.dart (3.44.8) — Ticker 는 프레임 콜백에 자기를 문다
_animationId = SchedulerBinding.instance.scheduleFrameCallback(_tick);
scheduleFrameCallback 은 5편·6편에서 본 그 프레임 콜백(스케줄러의 transientCallbacks 단계)입니다. 그래서 Ticker 는 디스플레이가 그릴 준비가 될 때마다 정확히 한 번 깨어납니다. Timer 와 다른 지점이 여기예요 — Timer 는 디스플레이와 무관하게 도니 리프레시와 어긋나 드리프트·끊김 이 생기지만, Ticker 는 vsync 에 맞춰 돕니다.
vsync: this 가 하는 일이 이겁니다. SingleTickerProviderStateMixin(또는 TickerProviderStateMixin)이 위젯 생명주기에 묶인 Ticker 를 제공 하죠.
AnimationController — 흐른 시간을 값으로
AnimationController 는 그 Ticker 를 타고 매 프레임 깨어나, 흐른 시간만큼 값(0..1)을 전진 시키고 리스너에게 알립니다. 소스가 그대로 보여줍니다.
// animation/animation_controller.dart (3.44.8)
void _tick(Duration elapsed) {
final elapsedInSeconds = elapsed.inMicroseconds / Duration.microsecondsPerSecond;
_value = clampDouble(_simulation!.x(elapsedInSeconds), lowerBound, upperBound); // 시간 → 값
if (_simulation!.isDone(elapsedInSeconds)) {
_status = AnimationStatus.completed;
stop(canceled: false);
}
notifyListeners(); // "값 바뀌었어" — 3편의 그 ChangeNotifier 알림
}
AnimationController 는 Animation<double>, 즉 Listenable 입니다(3편의 그 notifier). 매 틱마다 흐른 시간으로 값을 계산해 notifyListeners 를 부르죠. (1초짜리를 500ms 진행하면 값이 0.5, 마저 채우면 1.0·completed 가 되는 걸 테스트로 확인했습니다.)
값이 바뀌면 리빌드 — 그래서 부드럽다
값이 바뀌면 그걸 화면에 반영해야죠. addListener 로 setState 를 부르거나, 더 흔히는 AnimatedBuilder 로 그 animation 을 구독합니다. 그러면 값이 바뀔 때마다 그 위젯이 그 프레임의 build 단계 에서 리빌드됩니다(6편).
AnimatedBuilder(
animation: controller, // 값 바뀌면 이 builder 다시 호출
builder: (context, child) => Opacity(opacity: controller.value, child: child),
child: const FlutterLogo(), // 안 바뀌는 부분은 child 로 빼 리빌드 스킵(2편)
)
정리하면 이렇습니다 — Ticker 가 매 프레임 깨우고 → Controller 가 값을 조금 전진시켜 알리고 → 구독한 위젯이 그 프레임에 리빌드. 이게 프레임마다 반복되니 애니메이션이 부드럽게 흐릅니다. 별도 스레드는 없어요. (애니메이션이 도는 동안 AnimatedBuilder 가 프레임마다 리빌드되는 걸 테스트로 확인했습니다.)
Tween·Curve — 0..1을 화면으로
Controller 는 0..1 이라는 밋밋한 숫자만 만듭니다. 그걸 실제 값과 자연스러운 움직임으로 옮기는 게 Tween 과 Curve 예요. 둘 다 순수 함수 입니다.
Tween<double>(begin: 10, end: 20).transform(0.5); // → 15 (begin..end 선형 보간)
Curves.easeIn.transform(0.5); // → 0.5 보다 작다(초반이 느림)
Curve 는 시간 0..1 을 다시 0..1 로 구부려(ease-in/out 등) 움직임을 자연스럽게 만들고, Tween 은 그 값을 begin..end(색·크기·오프셋 등)로 옮깁니다. 보통 tween.animate(CurvedAnimation(parent: controller, curve: Curves.easeInOut)) 로 엮죠. (Tween·Curve 가 순수 매핑이라는 걸 테스트로 확인했습니다.)
생명주기 — dispose 와 TickerMode
이제 dispose 규칙이 왜 있는지 보입니다. Ticker 는 프레임 콜백을 물고 있습니다. 안 끊으면 위젯이 사라져도 Ticker 가 계속 깨어나 값을 돌리죠 — 리소스 누수 이자, 죽은 위젯에 setState 하려다 예외가 납니다(5편의 그 mounted 이야기). 그래서 반드시 dispose 에서 controller.dispose() 를 부릅니다.
@override
void dispose() {
controller.dispose(); // Ticker 를 끊는다 — 안 하면 프레임마다 계속 돈다
super.dispose();
}
TickerMode 도 여기 있습니다 — 위젯이 화면 밖(안 보이는 탭 등)에 있으면 그 아래 Ticker 들을 자동으로 멈춰 낭비를 막습니다.
정직하게 — 뼈대만 봤다
- implicit vs explicit.
AnimatedContainer같은 암시적 애니메이션은 이 Controller 를 내부에 감춘 것뿐입니다. 직접AnimationController를 드는 명시적 방식은 제어(반복·역방향·동기화) 가 필요할 때 씁니다. - 물리 시뮬레이션. 스프링(
SpringSimulation) 같은 것도 같은_tick위에서 돕니다 — 값이 시간의 함수라는 점은 같아요. - Ticker 도 UI 스레드입니다. 프레임이 무거워 밀리면(5편의 jank) 애니메이션도 같이 끊깁니다. 부드러운 애니메이션의 전제는 결국 가벼운 프레임 입니다.
정리 — 매 프레임 조금씩
처음의 의문들을 회수합니다.
- "vsync 가 왜 필요한가" → Ticker 를 디스플레이 리프레시에 맞춰 매 프레임 깨우려고. Timer 로는 어긋나니까.
- "왜 dispose 하나" → Ticker 가 프레임 콜백을 물고 있어, 안 끊으면 계속 돌며 새니까.
- "Timer 로 하면 왜 끊기나" → vsync 동기가 아니라 디스플레이와 어긋나니까.
핵심 한 줄 — 애니메이션은 마법이 아니라 매 프레임 값을 조금씩 바꿔 리빌드하는 것 입니다. Ticker 가 vsync 에 맞춰 깨우고, Controller 가 흐른 시간을 값으로, Tween·Curve 가 값을 화면으로 옮기죠. 전부 6편의 그 한 프레임 파이프라인 위에서 돕니다 — 애니메이션조차, 결국 프레임 이야기였습니다.
세 가지 증명 테스트는 리포의
test/animation_test.dart에 있습니다.fvm flutter test로 돌려볼 수 있습니다.