Animation 완벽 마스터
Animation의 핵심 개념과 실전 활용법
학습 항목
이미지 로딩 중...
Flutter Animation 패턴 완벽 가이드
Flutter 앱에 생동감을 불어넣는 애니메이션 패턴들을 실무 중심으로 알아봅니다. 기본 애니메이션부터 고급 패턴까지, 초급 개발자도 쉽게 따라할 수 있도록 단계별로 설명합니다. 실제 프로젝트에서 바로 활용할 수 있는 패턴과 팁을 제공합니다.
목차
- AnimationController - 애니메이션의 시작점
- Tween - 값의 범위를 자유롭게
- Curves - 자연스러운 움직임의 비밀
- AnimatedBuilder - 효율적인 리빌드의 핵심
- TweenAnimationBuilder - 선언적 애니메이션의 편리함
- Staggered Animations - 순차적인 아름다움
- Hero Animations - 화면 간 매끄러운 전환
- Implicit Animations - 자동으로 부드럽게
- Custom Painters와 애니메이션 - 완전한 자유
- 애니메이션 성능 최적화 - 부드러운 60fps 유지
1. AnimationController - 애니메이션의 시작점
시작하며
여러분이 Flutter 앱을 만들면서 "이 버튼을 눌렀을 때 부드럽게 화면이 전환됐으면 좋겠는데..."라고 생각해본 적 있나요? 또는 로딩 스피너가 딱딱하게 멈춰있는 것처럼 보여서 사용자 경험이 좋지 않다고 느낀 적이 있으실 겁니다.
이런 문제는 실제 개발 현장에서 정말 자주 발생합니다. 애니메이션 없이 화면이 갑자기 바뀌면 사용자는 무슨 일이 일어났는지 인지하기 어렵고, 앱이 뚝뚝 끊기는 느낌을 받게 됩니다.
특히 모바일 앱에서는 부드러운 전환이 사용자 만족도에 직접적인 영향을 미칩니다. 바로 이럴 때 필요한 것이 AnimationController입니다.
이것은 Flutter 애니메이션의 핵심이자 시작점으로, 여러분이 원하는 모든 애니메이션을 제어할 수 있게 해줍니다.
개요
간단히 말해서, AnimationController는 애니메이션의 타임라인을 관리하는 지휘자와 같습니다. 0.0에서 1.0 사이의 값을 시간에 따라 변화시키면서, 여러분이 원하는 애니메이션을 만들어냅니다.
왜 이게 필요할까요? 앱에서 무언가를 움직이거나, 크기를 바꾸거나, 색상을 변경하려면 시간의 흐름에 따라 값을 조절해야 합니다.
예를 들어, 카드가 아래에서 위로 슬라이드되는 애니메이션을 만들 때, 카드의 위치를 0%에서 100%까지 점진적으로 이동시켜야 하는데, 이때 AnimationController가 그 시간 흐름을 관리해줍니다. 기존에는 타이머를 직접 관리하고 프레임마다 값을 계산해야 했다면, 이제는 AnimationController가 모든 것을 자동으로 처리합니다.
시작, 정지, 되감기, 반복까지 간단한 메서드 호출로 가능합니다. 이 컨트롤러의 핵심 특징은 세 가지입니다.
첫째, vsync를 통해 화면 주사율과 동기화하여 부드러운 애니메이션을 보장합니다. 둘째, duration을 설정하여 애니메이션 속도를 정확히 제어할 수 있습니다.
셋째, addListener를 통해 값이 변할 때마다 UI를 자동으로 업데이트할 수 있습니다. 이러한 특징들이 있기 때문에 개발자는 복잡한 타이밍 로직 없이도 전문적인 애니메이션을 만들 수 있습니다.
코드 예제
// StatefulWidget에서 SingleTickerProviderStateMixin 사용
class FadeInDemo extends StatefulWidget {
@override
_FadeInDemoState createState() => _FadeInDemoState();
}
class _FadeInDemoState extends State<FadeInDemo> with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
// 2초 동안 실행되는 컨트롤러 생성
_controller = AnimationController(
duration: const Duration(seconds: 2),
vsync: this, // 화면 주사율과 동기화
);
// 애니메이션 시작
_controller.forward();
}
@override
void dispose() {
_controller.dispose(); // 메모리 누수 방지
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _controller, // 0.0 → 1.0으로 변화
child: const FlutterLogo(size: 100),
);
}
}
설명
이것이 하는 일: AnimationController는 정해진 시간(duration) 동안 0.0에서 1.0까지의 값을 생성하면서, 이 값의 변화를 듣고 있는 위젯들에게 알려줍니다. 그러면 위젯들은 이 값에 따라 투명도, 위치, 크기 등을 변경하여 애니메이션을 표현합니다.
첫 번째로, SingleTickerProviderStateMixin을 State 클래스에 추가하는 것부터 시작합니다. 이것은 vsync를 제공하는데, vsync는 화면이 새로고침될 때만 애니메이션을 업데이트하도록 해서 불필요한 계산을 막고 배터리를 절약합니다.
만약 위젯이 화면에 보이지 않으면 애니메이션도 자동으로 멈추게 됩니다. 그 다음으로, initState에서 AnimationController를 생성합니다.
duration을 2초로 설정하면, forward()를 호출했을 때 2초 동안 0.0에서 1.0까지 값이 증가합니다. 이 과정에서 매 프레임마다(보통 1초에 60번) 컨트롤러는 새로운 값을 계산하고, 리스너들에게 "값이 바뀌었어요!"라고 알립니다.
마지막으로, dispose 메서드에서 반드시 _controller.dispose()를 호출해야 합니다. 이렇게 하지 않으면 애니메이션이 백그라운드에서 계속 실행되면서 메모리 누수가 발생합니다.
이것은 초급 개발자들이 가장 자주 놓치는 부분입니다. 여러분이 이 코드를 사용하면 FlutterLogo가 2초 동안 서서히 나타나는 페이드인 효과를 볼 수 있습니다.
실무에서는 이를 활용하여 스플래시 스크린, 이미지 갤러리, 알림 메시지 등 다양한 곳에 적용할 수 있습니다. 또한 forward() 대신 repeat()를 사용하면 로딩 인디케이터처럼 무한 반복되는 애니메이션도 쉽게 만들 수 있습니다.
실전 팁
💡 항상 dispose에서 controller.dispose()를 호출하세요. 이를 잊으면 앱이 느려지고 메모리 누수가 발생합니다.
💡 여러 개의 AnimationController가 필요하면 SingleTickerProviderStateMixin 대신 TickerProviderStateMixin을 사용하세요.
💡 애니메이션을 반복하고 싶다면 repeat() 메서드를 사용하되, reverse: true 옵션으로 왔다갔다 하는 효과를 줄 수 있습니다.
💡 개발 중에는 duration을 짧게 설정해서 빠르게 테스트하고, 완성 단계에서 적절한 시간으로 조정하세요.
💡 애니메이션이 너무 많으면 성능 문제가 생길 수 있으니, Flutter DevTools의 Performance 탭에서 프레임 드롭을 체크하세요.
2. Tween - 값의 범위를 자유롭게
시작하며
여러분이 AnimationController를 배우고 나서 이런 생각을 해보셨을 겁니다. "0.0에서 1.0까지는 알겠는데, 나는 100픽셀에서 300픽셀로 이동시키고 싶은데?" 또는 "빨간색에서 파란색으로 변하게 하려면 어떻게 하지?"라고 고민하신 적이 있을 겁니다.
이런 문제는 실무에서 정말 흔합니다. AnimationController는 항상 0.0~1.0 사이의 값만 제공하기 때문에, 실제로 필요한 값의 범위와는 맞지 않는 경우가 대부분입니다.
픽셀, 각도, 색상, 심지어 문자열까지 애니메이션하려면 이 값을 변환해야 합니다. 바로 이럴 때 필요한 것이 Tween입니다.
Tween은 "between"의 줄임말로, 시작 값과 끝 값 사이를 보간(interpolation)해주는 역할을 합니다. 여러분이 원하는 어떤 범위의 값도 자유롭게 애니메이션할 수 있게 해줍니다.
개요
간단히 말해서, Tween은 AnimationController의 0.0~1.0 값을 여러분이 원하는 범위의 값으로 변환해주는 번역기입니다. 100에서 300으로, 빨강에서 파랑으로, 작은 크기에서 큰 크기로 무엇이든 가능합니다.
왜 이게 필요할까요? 실무에서는 단순히 투명도만 조절하는 경우는 드뭅니다.
버튼의 크기를 키우거나, 리스트 아이템을 오른쪽으로 밀거나, 배경색을 변경하는 등 구체적인 값의 변화가 필요합니다. 예를 들어, 사용자가 스와이프할 때 카드가 -300픽셀에서 0픽셀로 이동하는 애니메이션을 만들 때, Tween을 사용하면 이 픽셀 값을 쉽게 계산할 수 있습니다.
기존에는 0.0~1.0 값을 받아서 직접 수식으로 계산해야 했다면(예: value * 200 + 100), 이제는 Tween이 자동으로 정확하게 계산해줍니다. 심지어 복잡한 색상 변환이나 커스텀 객체의 보간도 처리할 수 있습니다.
Tween의 핵심 특징은 다음과 같습니다. 첫째, begin과 end 값을 지정하면 그 사이의 모든 값을 자동으로 계산합니다.
둘째, IntTween, ColorTween, SizeTween 등 다양한 타입을 지원합니다. 셋째, animate() 메서드로 AnimationController와 쉽게 연결할 수 있습니다.
이러한 특징들 덕분에 복잡한 수학 계산 없이도 자연스러운 애니메이션을 만들 수 있습니다.
코드 예제
class SlidingBox extends StatefulWidget {
@override
_SlidingBoxState createState() => _SlidingBoxState();
}
class _SlidingBoxState extends State<SlidingBox> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
// 0.0~1.0을 -200.0~0.0으로 변환
_animation = Tween<double>(
begin: -200.0, // 시작 위치 (화면 왼쪽 밖)
end: 0.0, // 끝 위치 (원래 위치)
).animate(_controller);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.translate(
offset: Offset(_animation.value, 0), // X축으로 이동
child: child,
);
},
child: Container(width: 100, height: 100, color: Colors.blue),
);
}
}
설명
이것이 하는 일: Tween은 시작 값(begin)과 끝 값(end)을 받아서, AnimationController의 0.0~1.0 진행도에 따라 그 사이의 적절한 값을 계산합니다. 0.0일 때는 begin 값을, 0.5일 때는 중간 값을, 1.0일 때는 end 값을 반환합니다.
첫 번째로, Tween<double>을 생성할 때 제네릭 타입을 지정합니다. 여기서는 double이지만, int, Color, Size, Offset 등 다양한 타입을 사용할 수 있습니다.
begin을 -200.0, end를 0.0으로 설정하면, 애니메이션이 진행되면서 값이 -200, -150, -100, -50, 0으로 점진적으로 변합니다. 그 다음으로, animate() 메서드로 AnimationController와 연결합니다.
이렇게 하면 _animation이라는 Animation<double> 객체가 생성되는데, 이것은 컨트롤러의 값이 변할 때마다 자동으로 Tween을 통해 변환된 값을 제공합니다. addListener나 AnimatedBuilder를 사용하면 이 값의 변화를 감지하고 UI를 업데이트할 수 있습니다.
마지막으로, Transform.translate 위젯에서 _animation.value를 사용하여 실제 이동 거리를 적용합니다. 컨트롤러가 0.0에서 1.0으로 변하는 동안, Tween이 -200에서 0으로 변환해주므로, 박스는 왼쪽 밖에서 시작해서 원래 위치로 슬라이드됩니다.
여러분이 이 코드를 사용하면 박스가 왼쪽에서 부드럽게 들어오는 슬라이드 애니메이션을 볼 수 있습니다. 실무에서는 이를 활용하여 사이드 메뉴, 알림 배너, 카드 슬라이더 등을 구현할 수 있습니다.
ColorTween을 사용하면 배경색 변경, SizeTween으로는 크기 변경 애니메이션도 쉽게 만들 수 있습니다. 또한 여러 개의 Tween을 조합하면 위치와 색상을 동시에 변경하는 복합 애니메이션도 가능합니다.
실전 팁
💡 ColorTween을 사용할 때는 Colors.red가 아닌 Color(0xFFFF0000) 형식을 사용하면 더 정확한 색상 보간이 가능합니다.
💡 정수 값이 필요하면 Tween<double> 대신 Tween<int> 또는 IntTween을 사용하세요. 픽셀 위치에서 소수점이 생기는 것을 방지합니다.
💡 여러 Tween을 동시에 사용할 때는 각각 별도의 Animation 객체로 만들지 말고, TweenSequence를 고려해보세요.
💡 커스텀 객체를 애니메이션하고 싶다면 Tween을 상속받아 lerp 메서드를 오버라이드하세요.
💡 begin과 end 값이 런타임에 바뀌어야 한다면, Tween을 다시 생성하는 대신 AnimationController를 리셋하고 새 값으로 forward하세요.
3. Curves - 자연스러운 움직임의 비밀
시작하며
여러분이 지금까지 만든 애니메이션을 보면서 "왜 로봇처럼 움직이지?"라고 느낀 적 있나요? 애니메이션이 일정한 속도로만 움직여서 지루하거나 부자연스럽게 보이는 경우가 많습니다.
이런 문제는 애니메이션의 속도 곡선 때문입니다. 실제 세계의 물체는 일정한 속도로 움직이지 않습니다.
공을 던지면 처음에는 빠르다가 점점 느려지고, 문을 닫으면 마지막에 살짝 튕기듯이 멈춥니다. 이런 자연스러운 움직임이 없으면 사용자는 무의식적으로 이질감을 느끼게 됩니다.
바로 이럴 때 필요한 것이 Curves입니다. Curves는 애니메이션의 진행 속도를 조절하여, 처음에는 천천히 시작했다가 빨라지거나, 끝에서 부드럽게 멈추거나, 튕기는 효과를 줄 수 있습니다.
이것이 프로페셔널한 앱과 아마추어 앱을 구분하는 핵심 요소입니다.
개요
간단히 말해서, Curves는 애니메이션의 속도 변화 패턴을 정의하는 함수입니다. 시간에 따라 일정하게 변하는 것이 아니라, 가속하거나 감속하거나 튕기는 등의 효과를 줄 수 있습니다.
왜 이게 필요할까요? 사용자 경험(UX) 관점에서 애니메이션의 타이밍은 매우 중요합니다.
같은 이동 거리라도 어떻게 움직이느냐에 따라 느낌이 완전히 달라집니다. 예를 들어, 모달 창이 나타날 때 급하게 튀어나오면 놀라게 되지만, 처음에 천천히 시작해서 점점 빨라지면 자연스럽고 우아하게 느껴집니다.
기존에는 linear(일정한 속도)로만 애니메이션이 진행되었다면, 이제는 easeIn(천천히 시작), easeOut(천천히 끝), elasticOut(탄성 효과) 등 다양한 곡선을 선택할 수 있습니다. Material Design과 iOS의 인터페이스도 모두 특정한 커브를 사용하여 일관된 느낌을 제공합니다.
Curves의 핵심 특징은 다음과 같습니다. 첫째, Flutter는 40개 이상의 미리 정의된 커브를 제공합니다.
둘째, CurvedAnimation을 사용하여 기존 애니메이션에 쉽게 적용할 수 있습니다. 셋째, Cubic 클래스로 커스텀 베지어 곡선도 만들 수 있습니다.
이러한 특징들이 있어서 개발자는 수학적 지식 없이도 전문가 수준의 모션 디자인을 구현할 수 있습니다.
코드 예제
class BouncingButton extends StatefulWidget {
@override
_BouncingButtonState createState() => _BouncingButtonState();
}
class _BouncingButtonState extends State<BouncingButton> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
// Curves.elasticOut으로 튕기는 효과 추가
_scaleAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut, // 끝에서 튕기는 효과
),
);
}
void _playAnimation() {
_controller.reset(); // 처음으로 되돌리기
_controller.forward(); // 다시 시작
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _playAnimation,
child: AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: child,
);
},
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.purple,
borderRadius: BorderRadius.circular(50),
),
child: const Icon(Icons.favorite, color: Colors.white, size: 50),
),
),
);
}
}
설명
이것이 하는 일: Curves는 0.0에서 1.0으로 가는 과정에서 속도를 조절합니다. 예를 들어, Curves.easeIn은 처음에는 천천히 증가하다가 끝에 가서 빠르게 증가하고, Curves.elasticOut은 목표값을 넘어갔다가 튕겨서 돌아오는 효과를 만듭니다.
첫 번째로, CurvedAnimation 객체를 생성합니다. parent에는 원래의 AnimationController를, curve에는 원하는 Curve를 지정합니다.
여기서는 Curves.elasticOut을 사용했는데, 이것은 스프링처럼 튕기는 효과를 만들어냅니다. 이렇게 하면 기존 컨트롤러의 일정한 속도가 탄성 있는 움직임으로 변환됩니다.
그 다음으로, 이 CurvedAnimation을 Tween의 animate() 메서드에 전달합니다. 그러면 Tween이 값을 계산할 때, 단순히 선형적으로 증가하는 것이 아니라 커브에 따라 변화하는 값을 받게 됩니다.
예를 들어, 시간이 50% 진행되었을 때 값이 0.5가 아니라 0.7이 될 수도 있고, 90% 진행되었을 때 1.2가 되었다가 다시 1.0으로 돌아올 수도 있습니다. 마지막으로, Transform.scale에서 이 값을 사용하면, 버튼이 0에서 1로 커지면서 마지막에 살짝 크게 튕겼다가 원래 크기로 돌아오는 효과를 볼 수 있습니다.
이것은 사용자가 탭했을 때 "반응이 있다"는 느낌을 강하게 주어 인터랙티브한 경험을 제공합니다. 여러분이 이 코드를 사용하면 아이콘을 탭할 때마다 통통 튀는 듯한 재미있는 애니메이션을 볼 수 있습니다.
실무에서는 '좋아요' 버튼, 장바구니 추가, 알림 아이콘 등에 이런 효과를 주면 사용자 참여도가 높아집니다. Curves.easeInOut은 일반적인 화면 전환에, Curves.fastOutSlowIn은 Material Design 스타일에 적합합니다.
상황에 맞는 커브를 선택하는 것이 중요합니다.
실전 팁
💡 가장 많이 사용되는 커브는 Curves.easeInOut입니다. 자연스럽고 무난해서 대부분의 상황에 잘 맞습니다.
💡 Material Design 가이드를 따르려면 Curves.fastOutSlowIn을 사용하세요. Google 앱들이 사용하는 표준 커브입니다.
💡 탄성 효과(elastic, bounce)는 너무 자주 사용하면 산만해 보일 수 있으니, 중요한 인터랙션에만 제한적으로 사용하세요.
💡 커브를 비교하고 싶다면 Flutter API 문서의 Curves 페이지에서 각 커브의 그래프를 확인할 수 있습니다.
💡 reverseCurve 파라미터를 사용하면 애니메이션이 되돌아갈 때 다른 커브를 적용할 수 있습니다. 예: 열 때는 easeOut, 닫을 때는 easeIn.
4. AnimatedBuilder - 효율적인 리빌드의 핵심
시작하며
여러분이 애니메이션을 만들면서 "왜 화면 전체가 계속 다시 그려지지?"라고 궁금해하신 적 있나요? AnimationController에 addListener를 붙이고 setState를 호출하면, 애니메이션하는 부분뿐만 아니라 화면 전체가 매 프레임마다 다시 빌드됩니다.
이런 문제는 성능에 심각한 영향을 미칩니다. 애니메이션은 보통 초당 60프레임으로 실행되므로, 1초에 60번 화면 전체를 다시 그리게 됩니다.
복잡한 위젯 트리에서 이렇게 하면 앱이 버벅거리고 배터리도 빨리 소모됩니다. 특히 리스트나 복잡한 레이아웃에서는 눈에 띄게 느려집니다.
바로 이럴 때 필요한 것이 AnimatedBuilder입니다. 이것은 애니메이션이 변할 때 꼭 필요한 부분만 다시 빌드하도록 최적화해주는 위젯입니다.
성능 좋은 애니메이션을 만들기 위한 필수 패턴입니다.
개요
간단히 말해서, AnimatedBuilder는 애니메이션 값이 변할 때 특정 부분만 효율적으로 리빌드하는 위젯입니다. setState 없이도 자동으로 UI를 업데이트하며, 변하지 않는 부분은 다시 빌드하지 않습니다.
왜 이게 필요할까요? 성능 최적화가 핵심입니다.
예를 들어, 화면 상단의 로고만 회전하는 애니메이션이라면, 하단의 복잡한 리스트까지 다시 그릴 필요가 없습니다. AnimatedBuilder를 사용하면 로고 부분만 정확히 리빌드하여 CPU와 GPU 사용량을 크게 줄일 수 있습니다.
기존에는 addListener와 setState로 전체 화면을 리빌드했다면, 이제는 AnimatedBuilder가 자동으로 리스너를 등록하고 필요한 부분만 업데이트합니다. 또한 child 파라미터를 활용하면 애니메이션과 무관한 부분은 아예 한 번만 빌드하고 재사용할 수 있습니다.
AnimatedBuilder의 핵심 특징은 다음과 같습니다. 첫째, animation 파라미터에 전달된 애니메이션이 변할 때만 builder 함수가 호출됩니다.
둘째, child 파라미터로 전달된 위젯은 한 번만 빌드되고 캐시됩니다. 셋째, setState를 직접 호출할 필요가 없어 코드가 깔끔해집니다.
이러한 특징들로 인해 복잡한 앱에서도 부드러운 60fps 애니메이션을 유지할 수 있습니다.
코드 예제
class RotatingLogo extends StatefulWidget {
@override
_RotatingLogoState createState() => _RotatingLogoState();
}
class _RotatingLogoState extends State<RotatingLogo> with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
)..repeat(); // 무한 반복
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// 애니메이션되는 부분만 AnimatedBuilder로 감싸기
AnimatedBuilder(
animation: _controller,
// child: 변하지 않는 위젯 (한 번만 빌드됨)
child: const FlutterLogo(size: 100),
// builder: 애니메이션마다 호출됨
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 2 * 3.14159, // 360도 회전
child: child, // 캐시된 child 재사용
);
},
),
const SizedBox(height: 20),
// 이 부분은 리빌드되지 않음!
const Text(
'Flutter 로고가 회전하고 있습니다',
style: TextStyle(fontSize: 16),
),
],
);
}
}
설명
이것이 하는 일: AnimatedBuilder는 내부적으로 animation에 리스너를 등록하고, 값이 변할 때마다 builder 함수만 다시 호출합니다. 이때 child로 전달된 위젯은 재사용하여 불필요한 빌드를 방지합니다.
첫 번째로, animation 파라미터에 AnimationController나 Animation 객체를 전달합니다. AnimatedBuilder는 이 애니메이션의 값이 변할 때마다 자동으로 감지하고 builder를 다시 실행합니다.
개발자가 addListener나 setState를 직접 호출할 필요가 전혀 없습니다. 그 다음으로, child 파라미터에 애니메이션과 무관한 위젯을 전달합니다.
여기서는 FlutterLogo가 그 예인데, 로고 자체는 변하지 않고 단지 회전만 하므로 child로 빼냅니다. 이 child는 단 한 번만 빌드되고, 이후에는 builder 함수에서 계속 재사용됩니다.
복잡한 위젯일수록 이 최적화 효과가 커집니다. 마지막으로, builder 함수 안에서 실제 애니메이션 로직을 구현합니다.
_controller.value를 사용하여 회전 각도를 계산하고, Transform.rotate로 child를 회전시킵니다. 중요한 점은 builder 외부의 Text 위젯은 전혀 영향을 받지 않는다는 것입니다.
로고가 60fps로 회전하는 동안에도 Text는 한 번만 빌드됩니다. 여러분이 이 코드를 사용하면 로고가 부드럽게 회전하면서도 앱 전체가 빠르게 동작하는 것을 볼 수 있습니다.
실무에서는 복잡한 카드 애니메이션, 차트 업데이트, 프로그레스 바 등에 AnimatedBuilder를 활용하여 성능을 크게 개선할 수 있습니다. Flutter DevTools의 Performance 오버레이를 켜면, AnimatedBuilder를 사용했을 때와 사용하지 않았을 때의 프레임 차이를 명확히 확인할 수 있습니다.
특히 리스트의 각 아이템에 애니메이션을 넣을 때는 AnimatedBuilder가 필수입니다.
실전 팁
💡 child 파라미터를 최대한 활용하세요. 복잡한 위젯일수록 성능 향상이 큽니다. 예: 이미지, 긴 텍스트, 복잡한 레이아웃 등.
💡 여러 애니메이션을 동시에 사용할 때는 Listenable.merge()로 묶어서 하나의 AnimatedBuilder로 처리할 수 있습니다.
💡 디버그 모드에서는 성능 차이가 크게 안 느껴질 수 있습니다. 반드시 릴리즈 모드(flutter run --release)에서 테스트하세요.
💡 builder 함수 안에서 무거운 계산을 하지 마세요. 매 프레임마다 호출되므로 성능에 악영향을 줍니다.
💡 AnimatedBuilder 대신 커스텀 AnimatedWidget을 만들 수도 있지만, 대부분의 경우 AnimatedBuilder가 더 간단하고 직관적입니다.
5. TweenAnimationBuilder - 선언적 애니메이션의 편리함
시작하며
여러분이 간단한 애니메이션을 만들려는데, AnimationController를 만들고, initState에서 초기화하고, dispose도 해야 하고... "이렇게까지 복잡해야 하나?"라고 생각해본 적 있나요?
단순히 값 하나를 부드럽게 변화시키는 것뿐인데 보일러플레이트 코드가 너무 많다고 느끼셨을 겁니다. 이런 문제는 특히 간단한 UI 효과를 만들 때 개발 속도를 늦춥니다.
예를 들어, 사용자가 버튼을 눌렀을 때 텍스트 색상을 부드럽게 바꾸거나, 아이콘 크기를 살짝 키우는 등의 단순한 효과에도 많은 코드가 필요합니다. 또한 StatefulWidget으로 변경해야 하고, Mixin도 추가해야 합니다.
바로 이럴 때 필요한 것이 TweenAnimationBuilder입니다. 이것은 복잡한 설정 없이 단순히 시작 값과 끝 값만 주면 자동으로 애니메이션을 처리해주는 위젯입니다.
AnimationController를 직접 관리할 필요 없이 선언적으로 애니메이션을 만들 수 있습니다.
개요
간단히 말해서, TweenAnimationBuilder는 tween과 duration만 지정하면 자동으로 애니메이션을 실행해주는 위젯입니다. Controller, initState, dispose 등의 복잡한 코드 없이 간단하게 애니메이션을 만들 수 있습니다.
왜 이게 필요할까요? 개발 생산성과 코드 간결성이 핵심입니다.
모든 애니메이션에 AnimationController를 만드는 것은 과한 경우가 많습니다. 예를 들어, 테마 변경 시 배경색을 부드럽게 전환하거나, 스코어가 증가할 때 숫자를 카운팅하는 애니메이션은 TweenAnimationBuilder로 훨씬 간단하게 구현할 수 있습니다.
기존에는 AnimationController, Tween, AnimatedBuilder를 모두 조합해야 했다면, 이제는 TweenAnimationBuilder 하나로 모든 것을 해결합니다. 심지어 StatelessWidget에서도 사용할 수 있습니다.
end 값만 바꿔주면 자동으로 새로운 애니메이션이 시작됩니다. TweenAnimationBuilder의 핵심 특징은 다음과 같습니다.
첫째, tween과 duration만 필수이고 나머지는 선택사항입니다. 둘째, end 값이 바뀔 때마다 자동으로 현재 값에서 새 값으로 애니메이션합니다.
셋째, curve를 지정하여 애니메이션 스타일도 쉽게 조절할 수 있습니다. 이러한 특징들로 인해 빠른 프로토타이핑과 간단한 애니메이션에 최적입니다.
코드 예제
class AnimatedCounter extends StatefulWidget {
@override
_AnimatedCounterState createState() => _AnimatedCounterState();
}
class _AnimatedCounterState extends State<AnimatedCounter> {
int _targetValue = 0; // 목표 값
void _incrementCounter() {
setState(() {
_targetValue += 10; // 10씩 증가
});
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// AnimationController 없이 애니메이션!
TweenAnimationBuilder<int>(
tween: IntTween(begin: 0, end: _targetValue),
duration: const Duration(milliseconds: 500),
curve: Curves.easeOut,
builder: (context, value, child) {
return Text(
'$value',
style: const TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
),
);
},
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: _incrementCounter,
child: const Text('+10'),
),
],
);
}
}
설명
이것이 하는 일: TweenAnimationBuilder는 내부적으로 AnimationController를 자동으로 생성하고 관리합니다. 개발자는 단지 어떤 값(tween)을 얼마 동안(duration) 애니메이션할지만 지정하면 됩니다.
첫 번째로, tween 파라미터에 Tween을 전달합니다. 여기서는 IntTween을 사용해서 정수를 애니메이션합니다.
begin은 초기값(보통 0 또는 이전 값), end는 목표값입니다. _targetValue가 0에서 10으로, 그리고 10에서 20으로 바뀔 때마다, TweenAnimationBuilder는 자동으로 현재 값에서 새 값으로 부드럽게 전환합니다.
그 다음으로, builder 함수에서 현재 애니메이션 값을 받아서 UI를 빌드합니다. value는 애니메이션이 진행되면서 0, 1, 2, 3, ..., 10처럼 점진적으로 변합니다.
이 값을 Text 위젯에 표시하면, 숫자가 빠르게 카운팅되는 효과를 볼 수 있습니다. curve를 Curves.easeOut으로 설정했기 때문에 처음에는 빠르게 증가하다가 끝에 가서 천천히 멈춥니다.
마지막으로, 버튼을 누를 때마다 setState로 _targetValue만 변경합니다. TweenAnimationBuilder는 end 값의 변화를 감지하고 자동으로 새로운 애니메이션을 시작합니다.
개발자가 controller.forward()나 reset() 같은 것을 호출할 필요가 전혀 없습니다. 모든 것이 자동으로 처리됩니다.
여러분이 이 코드를 사용하면 버튼을 누를 때마다 숫자가 부드럽게 증가하는 카운터를 볼 수 있습니다. 실무에서는 점수 표시, 다운로드 진행률, 통계 대시보드, 좋아요 수 등에 활용할 수 있습니다.
ColorTween으로 배경색 전환, SizeTween으로 크기 변화, Tween<Offset>으로 위치 이동 등 다양하게 응용 가능합니다. 특히 사용자 인터랙션에 즉각 반응하는 UI를 만들 때 매우 유용합니다.
실전 팁
💡 복잡한 애니메이션 제어가 필요 없고 단순히 값만 변화시키면 되는 경우에 최적입니다.
💡 onEnd 콜백을 사용하면 애니메이션이 끝났을 때 특정 동작을 실행할 수 있습니다. 예: 다음 화면으로 이동.
💡 child 파라미터를 활용하면 AnimatedBuilder처럼 일부만 리빌드할 수 있어 성능을 개선할 수 있습니다.
💡 begin을 생략하면 이전 end 값이 자동으로 begin이 됩니다. 연속적인 애니메이션에 유용합니다.
💡 StatelessWidget에서도 사용할 수 있지만, end 값을 바꾸려면 부모에서 새 값을 전달해야 합니다.
6. Staggered Animations - 순차적인 아름다움
시작하며
여러분이 리스트의 아이템들이 한꺼번에 나타나는 것을 보고 "좀 더 우아하게 하나씩 나타나면 좋을 텐데..."라고 생각해본 적 있나요? 또는 여러 요소가 동시에 애니메이션되면 너무 급하고 혼란스러워 보이는 경우가 있습니다.
이런 문제는 특히 복잡한 UI에서 사용자의 주의를 분산시킵니다. 모든 것이 한 번에 움직이면 어디를 봐야 할지 모르게 됩니다.
반면 요소들이 순차적으로 나타나면 시선의 흐름이 자연스럽게 유도되고, 더 세련된 느낌을 줍니다. 바로 이럴 때 필요한 것이 Staggered Animations입니다.
이것은 여러 애니메이션을 시간차를 두고 순차적으로 실행하는 패턴으로, 하나의 컨트롤러로 여러 애니메이션을 조율합니다. Material Design과 iOS 앱에서 자주 볼 수 있는 프로페셔널한 효과입니다.
개요
간단히 말해서, Staggered Animations는 하나의 AnimationController를 사용하여 여러 애니메이션을 다른 시간대에 실행하는 패턴입니다. Interval을 사용하여 각 애니메이션의 시작과 끝 시점을 정밀하게 제어할 수 있습니다.
왜 이게 필요할까요? 사용자 경험과 시각적 우선순위 때문입니다.
예를 들어, 프로필 화면이 열릴 때 먼저 프로필 사진이 페이드인되고, 이어서 이름이 슬라이드되고, 마지막으로 버튼들이 나타나면, 사용자는 자연스럽게 위에서 아래로 시선을 이동하며 정보를 파악합니다. 이것이 모두 동시에 나타나는 것보다 훨씬 인지하기 쉽습니다.
기존에는 각 애니메이션마다 별도의 컨트롤러를 만들고 타이머로 순차 실행했다면, 이제는 하나의 컨트롤러와 Interval을 조합하여 훨씬 효율적으로 구현합니다. 여러 컨트롤러를 동기화하는 복잡함 없이도 완벽한 타이밍 제어가 가능합니다.
Staggered Animations의 핵심 특징은 다음과 같습니다. 첫째, Interval을 사용하여 전체 duration의 특정 구간만 애니메이션합니다.
둘째, 하나의 컨트롤러만 관리하면 되어 메모리와 성능에 유리합니다. 셋째, 각 애니메이션에 서로 다른 Tween과 Curve를 적용할 수 있어 다채로운 효과를 만들 수 있습니다.
이러한 특징들로 인해 복잡한 인트로 화면, 온보딩, 상세 페이지 등에서 프로페셔널한 애니메이션을 구현할 수 있습니다.
코드 예제
class StaggeredCard extends StatefulWidget {
@override
_StaggeredCardState createState() => _StaggeredCardState();
}
class _StaggeredCardState extends State<StaggeredCard> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _imageAnimation;
late Animation<Offset> _titleAnimation;
late Animation<double> _buttonAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1500), // 전체 1.5초
vsync: this,
);
// 0.0~0.4 구간: 이미지 페이드인
_imageAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.4, curve: Curves.easeIn),
),
);
// 0.3~0.7 구간: 타이틀 슬라이드
_titleAnimation = Tween<Offset>(
begin: const Offset(0, 0.5),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.3, 0.7, curve: Curves.easeOut),
),
);
// 0.6~1.0 구간: 버튼 스케일
_buttonAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.6, 1.0, curve: Curves.elasticOut),
),
);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// 첫 번째: 이미지
Opacity(
opacity: _imageAnimation.value,
child: const FlutterLogo(size: 80),
),
const SizedBox(height: 16),
// 두 번째: 타이틀
SlideTransition(
position: _titleAnimation,
child: const Text(
'Flutter 개발',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 16),
// 세 번째: 버튼
Transform.scale(
scale: _buttonAnimation.value,
child: ElevatedButton(
onPressed: () {},
child: const Text('시작하기'),
),
),
],
),
),
);
},
);
}
}
설명
이것이 하는 일: 하나의 AnimationController가 0.0에서 1.0으로 진행하는 동안, 각 애니메이션은 Interval로 지정된 구간에서만 활성화됩니다. 예를 들어, Interval(0.0, 0.4)는 컨트롤러가 0.0~0.4까지 진행할 때만 0.0에서 1.0으로 변화합니다.
첫 번째로, 전체 duration을 1500ms로 설정합니다. 이것이 전체 애니메이션의 타임라인입니다.
그 다음 각 애니메이션에 Interval을 지정하는데, 이미지는 040% 구간, 타이틀은 3070% 구간, 버튼은 60~100% 구간에서 실행됩니다. 이렇게 구간이 겹치면서도 시작 시점이 다르기 때문에 순차적으로 나타나는 효과가 만들어집니다.
그 다음으로, 각 애니메이션에 서로 다른 Tween과 Curve를 적용합니다. 이미지는 투명도(Opacity)로 페이드인, 타이틀은 Offset으로 아래에서 위로 슬라이드, 버튼은 scale로 작게 시작해서 튕기듯이 커집니다.
이렇게 다양한 효과를 조합하면 더욱 생동감 있는 애니메이션이 됩니다. 마지막으로, 하나의 AnimatedBuilder로 모든 애니메이션을 처리합니다.
_controller가 변할 때마다 세 개의 애니메이션 값이 모두 업데이트되고, 각자의 Interval에 따라 적절한 타이밍에 움직입니다. 이렇게 하면 여러 컨트롤러를 관리하는 것보다 훨씬 간단하고 동기화도 완벽합니다.
여러분이 이 코드를 사용하면 카드가 나타날 때 이미지, 타이틀, 버튼이 차례로 우아하게 등장하는 것을 볼 수 있습니다. 실무에서는 온보딩 화면, 제품 상세 페이지, 프로필 화면, 성공 메시지 등에 활용할 수 있습니다.
리스트에서 각 아이템마다 약간씩 지연시간을 주면 전체 리스트가 물결치듯 나타나는 효과도 만들 수 있습니다. Google Play Store나 App Store의 앱 상세 페이지가 바로 이런 패턴을 사용합니다.
실전 팁
💡 Interval의 구간을 약간 겹치게 하면(예: 0.0-0.5, 0.3-0.8) 더 자연스러운 흐름을 만들 수 있습니다.
💡 전체 duration은 너무 길지 않게 1~2초 정도가 적당합니다. 너무 길면 사용자가 기다리는 느낌을 받습니다.
💡 중요한 요소일수록 먼저 나타나게 하세요. 예: 제품 이미지 → 가격 → 부가 정보 순서.
💡 리스트에서 staggered 효과를 줄 때는 AnimationController 대신 각 아이템의 index를 활용하여 delay를 조절하는 방법도 있습니다.
💡 복잡한 staggered 애니메이션은 별도의 클래스로 분리하여 재사용성을 높이세요. 예: StaggerAnimation 클래스 생성.
7. Hero Animations - 화면 간 매끄러운 전환
시작하며
여러분이 리스트에서 아이템을 탭했을 때 상세 화면으로 넘어가면서 "이 전환이 너무 급작스러워..."라고 느낀 적 있나요? 사용자는 화면이 바뀌면서 맥락을 잃고, 어떤 아이템을 선택했는지 헷갈리게 됩니다.
이런 문제는 특히 이미지가 포함된 UI에서 두드러집니다. 작은 썸네일을 탭했는데 갑자기 새 화면에서 큰 이미지가 나타나면, 이것이 같은 이미지인지 다른 이미지인지 순간적으로 인지하기 어렵습니다.
화면 전환의 연속성이 끊기는 것입니다. 바로 이럴 때 필요한 것이 Hero 애니메이션입니다.
이것은 하나의 위젯이 화면을 넘나들며 자연스럽게 이동하고 크기가 변하는 효과를 만들어, 사용자에게 연속적인 경험을 제공합니다. Instagram, Pinterest, Google Photos 등 대부분의 유명 앱에서 사용하는 필수 패턴입니다.
개요
간단히 말해서, Hero 애니메이션은 두 화면 사이에서 공통 요소가 부드럽게 날아가는(fly) 효과를 만드는 위젯입니다. 같은 tag를 가진 Hero 위젯들이 자동으로 연결되어 애니메이션됩니다.
왜 이게 필요할까요? 사용자 경험의 연속성과 공간 인지 때문입니다.
예를 들어, 상품 리스트에서 썸네일을 탭하면 그 이미지가 확대되면서 상세 화면으로 이동하는 것을 보여주면, 사용자는 "내가 선택한 그 상품이 지금 크게 보이고 있구나"라고 직관적으로 이해합니다. 화면 전환이 갑작스럽지 않고 자연스럽습니다.
기존에는 화면 전환 시 페이드나 슬라이드만 가능했다면, 이제는 특정 요소가 화면을 넘어 날아가는 영화 같은 효과를 간단히 만들 수 있습니다. Flutter가 자동으로 두 Hero의 위치, 크기, 모양을 계산하여 부드러운 전환을 만들어줍니다.
Hero 애니메이션의 핵심 특징은 다음과 같습니다. 첫째, 같은 tag만 지정하면 자동으로 연결됩니다.
둘째, Navigator의 push/pop과 자동으로 통합되어 별도 코드가 필요 없습니다. 셋째, 크기, 위치, 모양이 달라도 자동으로 보간됩니다.
이러한 특징들로 인해 개발자는 복잡한 애니메이션 로직 없이도 프로페셔널한 화면 전환을 구현할 수 있습니다.
코드 예제
// 첫 번째 화면: 리스트
class ProductList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: 10,
itemBuilder: (context, index) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ProductDetail(productId: index),
),
);
},
child: Hero(
tag: 'product-$index', // 고유한 tag 지정
child: Container(
margin: const EdgeInsets.all(8),
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'제품 $index',
style: const TextStyle(color: Colors.white),
),
),
),
),
);
},
);
}
}
// 두 번째 화면: 상세
class ProductDetail extends StatelessWidget {
final int productId;
const ProductDetail({required this.productId});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('상품 상세')),
body: Center(
child: Hero(
tag: 'product-$productId', // 같은 tag 사용!
child: Container(
width: 300,
height: 300,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
'제품 $productId',
style: const TextStyle(color: Colors.white, fontSize: 32),
),
),
),
),
),
);
}
}
설명
이것이 하는 일: Flutter는 Navigator.push가 호출될 때 현재 화면과 다음 화면에서 같은 tag를 가진 Hero 위젯을 찾아냅니다. 그리고 자동으로 시작 위치/크기에서 끝 위치/크기로 부드럽게 변환하는 애니메이션을 실행합니다.
첫 번째로, 리스트의 각 아이템을 Hero로 감싸고 고유한 tag를 부여합니다. 여기서는 'product-$index'를 사용하여 각 제품마다 다른 tag를 갖게 했습니다.
tag는 문자열이면 무엇이든 가능하지만, 두 화면에서 정확히 일치해야 합니다. 그 다음으로, 상세 화면에서도 같은 tag를 가진 Hero를 배치합니다.
이 Hero의 child는 리스트와 다른 크기(100→300)와 모양(borderRadius 8→16)을 가질 수 있습니다. Flutter는 이 차이를 자동으로 계산하여 자연스러운 변형 애니메이션을 만듭니다.
마지막으로, 사용자가 아이템을 탭하면 Navigator.push가 실행되고, 이 순간 Flutter는 자동으로 Hero 애니메이션을 트리거합니다. 작은 박스가 확대되면서 화면 중앙으로 이동하는 것처럼 보입니다.
뒤로 가기(pop)를 하면 반대로 애니메이션되어 원래 위치로 돌아갑니다. 여러분이 이 코드를 사용하면 리스트 아이템을 탭할 때 그 아이템이 커지면서 상세 화면으로 전환되는 매끄러운 효과를 볼 수 있습니다.
실무에서는 이미지 갤러리, 상품 목록, 연락처 리스트, 음악 앨범 등에 활용됩니다. 특히 네트워크 이미지를 사용할 때 Hero로 감싸면 이미지가 로딩되는 동안에도 부드러운 전환이 유지됩니다.
주의할 점은 tag가 반드시 고유해야 하며, 같은 화면에 중복된 tag가 있으면 오류가 발생합니다.
실전 팁
💡 tag는 반드시 고유해야 합니다. 리스트에서는 ID나 index를 활용하여 각 아이템마다 다른 tag를 만드세요.
💡 Hero는 이미지뿐만 아니라 텍스트, 아이콘, 커스텀 위젯 등 모든 위젯에 사용할 수 있습니다.
💡 애니메이션 속도를 조절하고 싶다면 PageRouteBuilder와 transitionDuration을 사용하세요.
💡 복잡한 Hero 애니메이션(예: 여러 요소 동시 이동)은 flightShuttleBuilder를 사용하여 커스터마이징할 수 있습니다.
💡 Hero 애니메이션 중 에러가 발생하면 빨간 박스가 나타납니다. 보통 tag 중복이나 위젯 트리 문제이므로 tag를 확인하세요.
8. Implicit Animations - 자동으로 부드럽게
시작하며
여러분이 버튼의 색상을 바꾸거나 크기를 조절할 때마다 AnimationController를 만들어야 한다면 정말 번거롭지 않나요? "그냥 값만 바꾸면 자동으로 애니메이션되면 안 될까?"라고 생각해보신 적 있으실 겁니다.
이런 문제는 간단한 UI 변경에도 많은 보일러플레이트를 요구합니다. 예를 들어, 다크모드 토글 시 배경색을 바꾸거나, 선택된 아이템의 크기를 키우는 등의 단순한 효과에도 복잡한 애니메이션 코드를 작성해야 합니다.
바로 이럴 때 필요한 것이 Implicit Animations입니다. AnimatedContainer, AnimatedOpacity 같은 위젯들은 속성 값이 바뀌면 자동으로 이전 값에서 새 값으로 부드럽게 전환해줍니다.
setState 하나로 프로페셔널한 애니메이션을 만들 수 있는 가장 쉬운 방법입니다.
개요
간단히 말해서, Implicit Animations는 속성 값의 변화를 감지하여 자동으로 애니메이션을 적용하는 위젯들입니다. Container 대신 AnimatedContainer를 사용하면, width나 color 같은 속성이 바뀔 때 자동으로 애니메이션됩니다.
왜 이게 필요할까요? 개발 편의성과 코드 가독성이 핵심입니다.
대부분의 UI 애니메이션은 "A 상태에서 B 상태로 부드럽게 전환"하는 것인데, 이를 위해 매번 Controller를 만드는 것은 비효율적입니다. 예를 들어, 선택된 탭의 밑줄이 이동하는 애니메이션은 단순히 position 값만 바꾸면 되는데, Implicit Animation을 사용하면 코드 한 줄로 해결됩니다.
기존에는 AnimationController + Tween + AnimatedBuilder의 조합이 필요했다면, 이제는 AnimatedXXX 위젯과 duration만 지정하면 끝입니다. Flutter는 자동으로 이전 값을 기억하고 새 값으로 전환하는 애니메이션을 실행합니다.
Implicit Animations의 핵심 특징은 다음과 같습니다. 첫째, AnimatedContainer, AnimatedOpacity, AnimatedPositioned 등 20개 이상의 위젯이 제공됩니다.
둘째, duration과 curve만 지정하면 자동으로 애니메이션이 적용됩니다. 셋째, 값이 바뀔 때마다 자동으로 새 애니메이션이 시작됩니다.
이러한 특징들로 인해 빠른 개발과 깔끔한 코드가 가능하며, 초급 개발자도 쉽게 사용할 수 있습니다.
코드 예제
class ExpandableCard extends StatefulWidget {
@override
_ExpandableCardState createState() => _ExpandableCardState();
}
class _ExpandableCardState extends State<ExpandableCard> {
bool _isExpanded = false;
void _toggleExpand() {
setState(() {
_isExpanded = !_isExpanded;
});
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _toggleExpand,
child: AnimatedContainer(
// duration: 애니메이션 시간
duration: const Duration(milliseconds: 400),
// curve: 애니메이션 스타일
curve: Curves.easeInOut,
// 크기, 색상, 패딩 등 모든 속성이 자동 애니메이션됨
width: _isExpanded ? 300 : 200,
height: _isExpanded ? 200 : 100,
decoration: BoxDecoration(
color: _isExpanded ? Colors.blue : Colors.grey,
borderRadius: BorderRadius.circular(_isExpanded ? 20 : 10),
),
padding: EdgeInsets.all(_isExpanded ? 20 : 10),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'탭하여 확장/축소',
style: TextStyle(color: Colors.white, fontSize: 16),
),
if (_isExpanded) ...[
const SizedBox(height: 10),
const Text(
'추가 정보가 여기에 표시됩니다.',
style: TextStyle(color: Colors.white70),
),
],
],
),
),
);
}
}
설명
이것이 하는 일: AnimatedContainer는 내부적으로 이전 속성 값들을 저장하고 있다가, setState로 새 값이 들어오면 자동으로 Tween과 AnimationController를 생성하여 전환 애니메이션을 실행합니다. 첫 번째로, AnimatedContainer에 duration을 지정합니다.
이것은 속성이 바뀔 때 얼마나 오래 애니메이션할지를 결정합니다. 400ms는 대부분의 UI 전환에 적합한 시간입니다.
curve를 Curves.easeInOut으로 설정하면 시작과 끝이 부드러운 자연스러운 전환이 됩니다. 그 다음으로, _isExpanded 상태에 따라 다른 속성 값을 지정합니다.
width는 200 또는 300, height는 100 또는 200, color는 grey 또는 blue로 바뀝니다. 이 모든 속성들이 동시에 애니메이션됩니다.
개발자는 단지 목표 값만 지정하면 되고, Flutter가 알아서 부드러운 전환을 만들어줍니다. 마지막으로, _toggleExpand에서 setState를 호출하여 _isExpanded를 반전시킵니다.
이 순간 Flutter는 이전 렌더링의 속성 값과 새 속성 값을 비교하고, AnimatedContainer가 자동으로 애니메이션을 시작합니다. 카드가 커지면서 색상이 변하고 모서리가 둥글어지는 모든 변화가 동시에 부드럽게 일어납니다.
여러분이 이 코드를 사용하면 카드를 탭할 때마다 크기, 색상, 모서리가 부드럽게 변하는 확장/축소 효과를 볼 수 있습니다. 실무에서는 FAB(Floating Action Button) 확장, 필터 패널 열기, 카드 선택 효과, 다크모드 전환 등에 활용됩니다.
AnimatedOpacity는 페이드 효과, AnimatedPadding은 레이아웃 조정, AnimatedAlign은 위치 이동에 사용됩니다. 이 위젯들을 조합하면 복잡한 Controller 없이도 대부분의 UI 애니메이션을 구현할 수 있습니다.
실전 팁
💡 AnimatedContainer는 거의 모든 Container 속성을 애니메이션할 수 있습니다. width, height, color, padding, margin, decoration 등.
💡 여러 위젯을 동시에 애니메이션하려면 AnimatedContainer 안에 AnimatedOpacity를 넣는 등 중첩해서 사용하세요.
💡 onEnd 콜백을 사용하면 애니메이션이 끝났을 때 특정 동작을 실행할 수 있습니다.
💡 AnimatedCrossFade는 두 위젯 간의 크로스페이드 전환에 특화되어 있어 조건부 UI에 유용합니다.
💡 모든 Animated 위젯은 성능 최적화가 되어 있지만, 너무 많은 동시 애니메이션은 피하세요. 화면당 5개 이하가 적당합니다.
9. Custom Painters와 애니메이션 - 완전한 자유
시작하며
여러분이 기본 위젯으로는 만들 수 없는 특별한 애니메이션을 구상하고 계신가요? 물결 효과, 입자 시스템, 커스텀 차트 등 "이건 기존 위젯으로는 불가능한데..."라고 느낀 적이 있으실 겁니다.
이런 문제는 창의적인 디자인을 구현할 때 발생합니다. Flutter의 기본 위젯들은 강력하지만, 완전히 자유로운 그래픽은 제한적입니다.
예를 들어, 사인파처럼 움직이는 선, 회전하는 별 패턴, 불규칙하게 튕기는 공 등은 표준 위젯으로 만들기 어렵습니다. 바로 이럴 때 필요한 것이 CustomPainter와 애니메이션의 조합입니다.
Canvas에 직접 그리면서 AnimationController로 제어하면, 여러분의 상상력을 제한 없이 구현할 수 있습니다. 게임, 데이터 시각화, 독특한 UI 효과를 만들 때 필수적인 패턴입니다.
개요
간단히 말해서, CustomPainter는 Canvas에 직접 선, 원, 경로 등을 그릴 수 있게 해주는 클래스이고, 이것을 AnimationController와 연결하면 완전히 커스텀한 애니메이션을 만들 수 있습니다. 왜 이게 필요할까요?
표준 위젯의 한계를 넘어서기 위해서입니다. 예를 들어, 주식 차트의 실시간 라인 애니메이션, 오디오 이퀄라이저의 바 움직임, 로딩 인디케이터의 독특한 패턴 등은 CustomPainter로만 구현 가능합니다.
Canvas API는 픽셀 단위의 완벽한 제어를 제공합니다. 기존에는 복잡한 그래픽은 외부 라이브러리에 의존해야 했다면, 이제는 CustomPainter로 직접 구현할 수 있습니다.
성능도 네이티브 수준으로 빠르며, 애니메이션과의 통합도 완벽합니다. CustomPainter 애니메이션의 핵심 특징은 다음과 같습니다.
첫째, paint 메서드에서 Canvas에 자유롭게 그릴 수 있습니다. 둘째, shouldRepaint를 통해 리페인트 조건을 제어하여 성능을 최적화할 수 있습니다.
셋째, AnimationController와 연결하면 매 프레임마다 다른 그림을 그려 애니메이션을 만듭니다. 이러한 특징들로 인해 완전히 독창적인 비주얼 효과를 구현할 수 있으며, 브랜드 아이덴티티를 강하게 표현할 수 있습니다.
코드 예제
// CustomPainter 클래스: 실제 그리기 로직
class WavePainter extends CustomPainter {
final double animationValue; // 0.0~1.0
WavePainter(this.animationValue);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.blue
..style = PaintingStyle.stroke
..strokeWidth = 3.0;
final path = Path();
// 물결 모양 계산
for (double x = 0; x < size.width; x++) {
final y = size.height / 2 +
30 * sin((x / size.width * 4 * 3.14159) + (animationValue * 2 * 3.14159));
if (x == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(WavePainter oldDelegate) {
// animationValue가 바뀌면 다시 그리기
return oldDelegate.animationValue != animationValue;
}
}
// 위젯에서 사용
class AnimatedWave extends StatefulWidget {
@override
_AnimatedWaveState createState() => _AnimatedWaveState();
}
class _AnimatedWaveState extends State<AnimatedWave> with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
)..repeat(); // 무한 반복
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return CustomPaint(
painter: WavePainter(_controller.value),
size: const Size(double.infinity, 200),
);
},
);
}
}
설명
이것이 하는 일: CustomPainter의 paint 메서드는 Canvas와 Size를 받아서 원하는 그래픽을 그립니다. AnimationController의 값을 paint 메서드에 전달하면, 값이 변할 때마다 다른 그림을 그려 애니메이션 효과를 만들 수 있습니다.
첫 번째로, CustomPainter를 상속받는 클래스를 만들고, animationValue를 생성자로 받습니다. 이 값은 0.0에서 1.0까지 변하면서 애니메이션의 진행도를 나타냅니다.
paint 메서드에서는 이 값을 사용하여 물결의 위상(phase)을 계산합니다. 그 다음으로, paint 메서드 안에서 Canvas API를 사용합니다.
Path를 생성하고, for 루프로 화면 가로 전체를 순회하면서 사인파(sin) 함수로 y 좌표를 계산합니다. animationValue가 증가하면 사인파 전체가 오른쪽으로 이동하는 효과가 나타나 물결이 흐르는 것처럼 보입니다.
마지막으로, shouldRepaint 메서드에서 언제 다시 그려야 하는지 결정합니다. animationValue가 바뀔 때만 true를 반환하여 불필요한 리페인트를 방지합니다.
AnimatedBuilder와 연결하면, 컨트롤러가 변할 때마다 CustomPaint가 리빌드되고 WavePainter가 새 값으로 다시 그림을 그립니다. 여러분이 이 코드를 사용하면 물결 모양의 선이 끊임없이 흐르는 아름다운 애니메이션을 볼 수 있습니다.
실무에서는 오디오 플레이어의 파형, 데이터 흐름 시각화, 로딩 인디케이터, 배경 패턴 등에 활용됩니다. Canvas는 drawCircle, drawRect, drawImage 등 다양한 메서드를 제공하므로, 입자 효과, 폭죽, 별똥별 등 복잡한 애니메이션도 만들 수 있습니다.
성능을 위해 shouldRepaint를 신중하게 구현하는 것이 중요합니다.
실전 팁
💡 복잡한 계산은 paint 메서드 밖에서 미리 하세요. paint는 매 프레임마다 호출되므로 성능에 민감합니다.
💡 shouldRepaint에서 항상 true를 반환하면 매번 다시 그리므로 성능이 떨어집니다. 정확한 조건을 지정하세요.
💡 여러 레이어를 그릴 때는 canvas.saveLayer()와 restore()를 사용하여 블렌딩 효과를 줄 수 있습니다.
💡 Path.quadraticBezierTo()나 Path.cubicTo()를 사용하면 부드러운 곡선을 그릴 수 있습니다.
💡 CustomPainter는 위젯 트리 밖에서 동작하므로 Theme이나 MediaQuery에 직접 접근할 수 없습니다. 필요한 값은 생성자로 전달하세요.
10. 애니메이션 성능 최적화 - 부드러운 60fps 유지
시작하며
여러분이 멋진 애니메이션을 만들었는데 "왜 이렇게 버벅거리지?"라고 느낀 적 있나요? 특히 복잡한 리스트나 여러 애니메이션이 동시에 실행될 때 프레임이 떨어지는 문제가 발생합니다.
이런 문제는 사용자 경험을 크게 해칩니다. 60fps는 매 16.67ms마다 프레임을 그려야 하는데, 애니메이션 로직이 이보다 오래 걸리면 프레임 드롭이 발생하고 화면이 끊기는 것처럼 보입니다.
특히 저사양 기기에서는 더욱 심각합니다. 바로 이럴 때 필요한 것이 성능 최적화 패턴들입니다.
AnimatedBuilder의 child 활용, RepaintBoundary 사용, 불필요한 리빌드 방지 등의 기법으로 부드러운 애니메이션을 보장할 수 있습니다. 프로페셔널한 앱과 아마추어 앱을 구분하는 결정적인 차이입니다.
개요
간단히 말해서, 애니메이션 성능 최적화는 불필요한 위젯 리빌드와 리페인트를 최소화하여 60fps를 안정적으로 유지하는 기법들입니다. 작은 코드 변경으로 큰 성능 향상을 얻을 수 있습니다.
왜 이게 필요할까요? 사용자는 부드러운 애니메이션을 당연하게 여기지만, 끊기는 순간 즉시 불편함을 느낍니다.
예를 들어, 스크롤 중 애니메이션이 버벅거리면 앱이 저품질이라고 인식하게 됩니다. 특히 모바일 기기에서는 배터리 소모와도 직결됩니다.
기존에는 setState를 남발하거나 전체 화면을 매번 리빌드했다면, 이제는 정확히 필요한 부분만 업데이트하는 전략을 사용합니다. Flutter DevTools를 활용하여 병목 지점을 찾고 개선합니다.
성능 최적화의 핵심 특징은 다음과 같습니다. 첫째, const 생성자와 child 파라미터로 불필요한 리빌드를 제거합니다.
둘째, RepaintBoundary로 리페인트 영역을 격리합니다. 셋째, shouldRebuild와 shouldRepaint로 업데이트 조건을 세밀하게 제어합니다.
이러한 기법들로 인해 복잡한 애니메이션도 모든 기기에서 부드럽게 실행됩니다.
코드 예제
class OptimizedAnimationExample extends StatefulWidget {
@override
_OptimizedAnimationExampleState createState() => _OptimizedAnimationExampleState();
}
class _OptimizedAnimationExampleState extends State<OptimizedAnimationExample>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
)..repeat(reverse: true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// 1. RepaintBoundary로 리페인트 영역 격리
RepaintBoundary(
child: AnimatedBuilder(
animation: _controller,
// 2. child 파라미터로 불변 부분 캐싱
child: const ExpensiveWidget(),
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 2 * 3.14159,
child: child, // 캐시된 child 재사용
);
},
),
),
const SizedBox(height: 20),
// 3. const 생성자로 리빌드 방지
const Text(
'이 텍스트는 절대 리빌드되지 않음',
style: TextStyle(fontSize: 16),
),
],
);
}
}
// 무거운 위젯 (예시)
class ExpensiveWidget extends StatelessWidget {
const ExpensiveWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// 실제로는 복잡한 레이아웃이나 이미지 등
return Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.purple,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.star, color: Colors.white, size: 50),
);
}
}
설명
이것이 하는 일: 여러 최적화 기법을 조합하여 애니메이션 중에도 최소한의 작업만 수행하도록 만듭니다. 변하는 부분만 업데이트하고, 나머지는 캐시하거나 스킵합니다.
첫 번째로, RepaintBoundary를 사용합니다. 이것은 리페인트 영역을 격리하는 특별한 위젯으로, 내부의 변화가 외부에 영향을 주지 않도록 합니다.
예를 들어, 회전하는 아이콘이 있을 때 RepaintBoundary로 감싸면, 그 아이콘만 리페인트되고 나머지 화면은 영향을 받지 않습니다. 이것만으로도 성능이 크게 향상됩니다.
그 다음으로, AnimatedBuilder의 child 파라미터를 활용합니다. ExpensiveWidget은 애니메이션과 무관하게 항상 같은 모양이므로, child로 빼내서 한 번만 빌드되고 계속 재사용되도록 합니다.
만약 child를 사용하지 않으면, 매 프레임마다(초당 60번) ExpensiveWidget이 다시 빌드됩니다. 마지막으로, const 생성자를 사용합니다.
const로 선언된 위젯은 컴파일 타임에 생성되어 절대 다시 빌드되지 않습니다. 위 예제에서 Text는 _controller가 변해도 전혀 영향받지 않습니다.
애니메이션과 무관한 모든 위젯에 const를 붙이는 습관을 들이면 전체적인 성능이 향상됩니다. 여러분이 이 코드를 사용하면 복잡한 위젯이 회전하면서도 앱이 부드럽게 동작하는 것을 볼 수 있습니다.
실무에서는 Flutter DevTools의 Performance 탭에서 프레임 차트를 확인하며 병목을 찾습니다. 16ms 바를 넘는 프레임이 있으면 최적화가 필요합니다.
ListView.builder의 각 아이템에 RepaintBoundary를 넣거나, 이미지에 cacheWidth/cacheHeight를 지정하는 등의 추가 최적화도 고려하세요. 또한 릴리즈 모드에서 테스트하는 것이 중요합니다.
디버그 모드는 성능 측정에 부적합합니다.
실전 팁
💡 Flutter DevTools의 Performance Overlay를 활성화하여 실시간으로 프레임 드롭을 확인하세요. (flutter run --profile)
💡 모든 StatelessWidget에 const 생성자를 습관적으로 사용하세요. IDE가 자동으로 제안해줍니다.
💡 복잡한 리스트 아이템은 각각 RepaintBoundary로 감싸서 스크롤 성능을 향상시키세요.
💡 큰 이미지는 Image.network의 cacheWidth와 cacheHeight로 메모리 사용량을 줄이세요.
💡 애니메이션이 화면 밖에 있을 때는 자동으로 멈추도록 TickerMode나 VisibilityDetector를 사용하세요.