Flutter 튜토리얼 25편: 암시적 애니메이션
요약
핵심 요지
- 문제 정의: 애니메이션을 구현하려면 AnimationController, Tween, Ticker 등 복잡한 설정이 필요해서 진입 장벽이 높다.
- 핵심 주장:
ImplicitlyAnimatedWidget1 계열 위젯을 사용하면setState()만으로 부드러운 애니메이션을 구현할 수 있다. - 주요 근거:
AnimatedContainer2,AnimatedOpacity3,AnimatedAlign등은 속성 변경을 자동으로 애니메이션 처리한다. - 실무 기준: 간단한 속성 변화에는 암시적 애니메이션을, 복잡한 제어가 필요하면 명시적 애니메이션을 사용한다.
- 한계: 애니메이션 중간에 멈추거나, 역방향 재생 같은 세밀한 제어는 어렵다.
문서가 설명하는 범위
- 암시적 애니메이션의 개념과 동작 원리
- AnimatedContainer, AnimatedOpacity 등 주요 위젯 사용법
- Curves를 사용한 이징(easing) 적용
- TweenAnimationBuilder로 커스텀 애니메이션 구현
읽는 시간: 12분 | 난이도: 초급
참고 자료
- Implicit animations - Flutter 공식 암시적 애니메이션 가이드
- Animate the properties of a container - AnimatedContainer 예제
- Curves class - 애니메이션 커브 API
문제 상황
UI에 애니메이션을 추가하면 사용자 경험이 향상됩니다. 하지만 Flutter에서 애니메이션을 구현하려면 여러 개념을 이해해야 합니다.
명시적 애니메이션의 복잡성
AnimationController 생성 → Tween 정의 → Animation 객체 생성→ addListener로 setState 연결 → dispose에서 정리간단한 크기 변경 애니메이션도 많은 보일러플레이트 코드가 필요합니다.
해결 방법
Flutter는 암시적 애니메이션(Implicit Animation) 위젯을 제공합니다. 속성 값이 변경되면 자동으로 이전 값에서 새 값으로 애니메이션됩니다.
챕터 1: 암시적 애니메이션 이해하기
Why
NOTE암시적 애니메이션은 “값이 변경되면 알아서 애니메이션하라”는 선언적 방식입니다.
복잡한 애니메이션 로직을 숨기고 간단한 API를 제공합니다.graph LR A[이전 값] --> B[setState로 새 값 설정] B --> C[자동 애니메이션] C --> D[새 값]
What
NOTE
ImplicitlyAnimatedWidget을 상속하는 위젯들은 속성 변경 시 자동으로 애니메이션됩니다.
duration과curve로 애니메이션 동작을 제어합니다.
How
TIP주요 암시적 애니메이션 위젯
위젯 애니메이션 속성 AnimatedContainer크기, 색상, 패딩, 마진, 테두리 AnimatedOpacity투명도 AnimatedAlign정렬 AnimatedPadding패딩 AnimatedPositionedStack 내 위치 AnimatedDefaultTextStyle텍스트 스타일 AnimatedSwitcher위젯 전환 AnimatedCrossFade두 위젯 간 크로스페이드 기본 사용 패턴
class AnimationExample extends StatefulWidget {const AnimationExample({super.key});@overrideState<AnimationExample> createState() => _AnimationExampleState();}class _AnimationExampleState extends State<AnimationExample> {bool _isExpanded = false;@overrideWidget build(BuildContext context) {return GestureDetector(onTap: () => setState(() => _isExpanded = !_isExpanded),child: AnimatedContainer(width: _isExpanded ? 200 : 100,height: _isExpanded ? 200 : 100,color: _isExpanded ? Colors.blue : Colors.red,duration: const Duration(milliseconds: 300),curve: Curves.easeInOut,),);}}
Watch out
WARNING
duration은 필수 속성입니다.
생략하면 컴파일 에러가 발생합니다.// 에러 - duration 누락AnimatedContainer(width: 100,height: 100,)// 올바른 사용AnimatedContainer(width: 100,height: 100,duration: const Duration(milliseconds: 300),)
결론: 암시적 애니메이션은 속성 변경 시 자동으로 애니메이션되며, duration만 지정하면 동작합니다.
챕터 2: AnimatedContainer 활용
Why
NOTE
AnimatedContainer는 가장 다재다능한 암시적 애니메이션 위젯입니다.
Container의 거의 모든 속성을 애니메이션할 수 있습니다.크기, 색상, 패딩, 마진, 테두리, 그림자 → 모두 애니메이션 가능
What
NOTE
AnimatedContainer는Container와 동일한 속성을 가지면서 값 변경 시 자동으로 애니메이션됩니다.
BoxDecoration을 사용해서 복잡한 시각 효과도 애니메이션할 수 있습니다.
How
TIP크기와 색상 애니메이션
import 'dart:math';import 'package:flutter/material.dart';class AnimatedContainerDemo extends StatefulWidget {const AnimatedContainerDemo({super.key});@overrideState<AnimatedContainerDemo> createState() => _AnimatedContainerDemoState();}class _AnimatedContainerDemoState extends State<AnimatedContainerDemo> {double _width = 100;double _height = 100;Color _color = Colors.blue;BorderRadiusGeometry _borderRadius = BorderRadius.circular(8);void _randomize() {final random = Random();setState(() {_width = random.nextInt(200).toDouble() + 100;_height = random.nextInt(200).toDouble() + 100;_color = Color.fromRGBO(random.nextInt(256),random.nextInt(256),random.nextInt(256),1,);_borderRadius = BorderRadius.circular(random.nextInt(50).toDouble(),);});}@overrideWidget build(BuildContext context) {return Scaffold(body: Center(child: AnimatedContainer(width: _width,height: _height,decoration: BoxDecoration(color: _color,borderRadius: _borderRadius,boxShadow: [BoxShadow(color: _color.withOpacity(0.5),blurRadius: 20,spreadRadius: 5,),],),duration: const Duration(milliseconds: 500),curve: Curves.easeInOut,),),floatingActionButton: FloatingActionButton(onPressed: _randomize,child: const Icon(Icons.refresh),),);}}토글 상태 애니메이션
class ExpandableCard extends StatefulWidget {const ExpandableCard({super.key});@overrideState<ExpandableCard> createState() => _ExpandableCardState();}class _ExpandableCardState extends State<ExpandableCard> {bool _isExpanded = false;@overrideWidget build(BuildContext context) {return GestureDetector(onTap: () => setState(() => _isExpanded = !_isExpanded),child: AnimatedContainer(width: double.infinity,height: _isExpanded ? 200 : 80,margin: EdgeInsets.all(_isExpanded ? 8 : 16),padding: const EdgeInsets.all(16),decoration: BoxDecoration(color: _isExpanded ? Colors.blue[100] : Colors.white,borderRadius: BorderRadius.circular(_isExpanded ? 16 : 8),boxShadow: [BoxShadow(color: Colors.black.withOpacity(_isExpanded ? 0.2 : 0.1),blurRadius: _isExpanded ? 10 : 4,),],),duration: const Duration(milliseconds: 300),curve: Curves.easeInOut,child: const Text('탭하여 확장/축소'),),);}}
Watch out
WARNING
AnimatedContainer안의child는 애니메이션되지 않습니다.
자식 위젯의 변화는AnimatedSwitcher를 사용하세요.// child 변경은 애니메이션되지 않음AnimatedContainer(duration: const Duration(milliseconds: 300),child: _isExpanded? const Text('확장됨') // 즉시 전환: const Text('축소됨'),)// AnimatedSwitcher 사용AnimatedContainer(duration: const Duration(milliseconds: 300),child: AnimatedSwitcher(duration: const Duration(milliseconds: 300),child: _isExpanded? const Text('확장됨', key: ValueKey('expanded')): const Text('축소됨', key: ValueKey('collapsed')),),)
결론: AnimatedContainer로 크기, 색상, 데코레이션 등 다양한 속성을 쉽게 애니메이션합니다.
챕터 3: AnimatedOpacity와 AnimatedAlign
Why
NOTE특정 속성만 애니메이션하고 싶을 때는 전문화된 위젯이 더 효율적입니다.
AnimatedOpacity는 투명도를,AnimatedAlign은 정렬을 애니메이션합니다.AnimatedOpacity → 페이드 인/아웃AnimatedAlign → 위치 이동
What
NOTE전문화된 암시적 애니메이션 위젯은 특정 속성에 최적화되어 있습니다.
불필요한 계산을 줄여 성능이 향상될 수 있습니다.
How
TIPAnimatedOpacity로 페이드 효과
class FadeDemo extends StatefulWidget {const FadeDemo({super.key});@overrideState<FadeDemo> createState() => _FadeDemoState();}class _FadeDemoState extends State<FadeDemo> {bool _visible = true;@overrideWidget build(BuildContext context) {return Column(mainAxisAlignment: MainAxisAlignment.center,children: [AnimatedOpacity(opacity: _visible ? 1.0 : 0.0,duration: const Duration(milliseconds: 500),child: Container(width: 200,height: 200,color: Colors.blue,child: const Center(child: Text('Hello', style: TextStyle(color: Colors.white)),),),),const SizedBox(height: 20),ElevatedButton(onPressed: () => setState(() => _visible = !_visible),child: Text(_visible ? '숨기기' : '보이기'),),],);}}AnimatedAlign으로 위치 이동
class AlignDemo extends StatefulWidget {const AlignDemo({super.key});@overrideState<AlignDemo> createState() => _AlignDemoState();}class _AlignDemoState extends State<AlignDemo> {AlignmentGeometry _alignment = Alignment.topLeft;void _moveToCorner() {setState(() {_alignment = _alignment == Alignment.topLeft? Alignment.bottomRight: Alignment.topLeft;});}@overrideWidget build(BuildContext context) {return Scaffold(body: AnimatedAlign(alignment: _alignment,duration: const Duration(seconds: 1),curve: Curves.elasticOut,child: Container(width: 50,height: 50,color: Colors.red,),),floatingActionButton: FloatingActionButton(onPressed: _moveToCorner,child: const Icon(Icons.swap_horiz),),);}}AnimatedPadding으로 패딩 애니메이션
class PaddingDemo extends StatefulWidget {const PaddingDemo({super.key});@overrideState<PaddingDemo> createState() => _PaddingDemoState();}class _PaddingDemoState extends State<PaddingDemo> {double _padding = 8;@overrideWidget build(BuildContext context) {return GestureDetector(onTap: () => setState(() {_padding = _padding == 8 ? 32 : 8;}),child: AnimatedPadding(padding: EdgeInsets.all(_padding),duration: const Duration(milliseconds: 300),curve: Curves.easeInOut,child: Container(color: Colors.blue,child: const Center(child: Text('탭하여 패딩 변경'),),),),);}}
Watch out
WARNING
AnimatedOpacity에서opacity: 0.0이어도 위젯은 여전히 공간을 차지하고 탭 이벤트를 받습니다.
완전히 숨기려면Visibility또는 조건부 렌더링을 사용하세요.// opacity 0이어도 공간 차지 + 탭 가능AnimatedOpacity(opacity: _visible ? 1.0 : 0.0,child: ElevatedButton(...), // 안 보이지만 탭됨)// 완전히 숨기기AnimatedOpacity(opacity: _visible ? 1.0 : 0.0,child: IgnorePointer(ignoring: !_visible, // 안 보일 때 탭 무시child: ElevatedButton(...),),)
결론: 특정 속성만 애니메이션할 때는 전문화된 위젯을 사용하면 더 간결하고 효율적입니다.
챕터 4: Curves로 애니메이션 스타일링
Why
NOTE기본 선형 애니메이션은 기계적으로 느껴집니다.
Curves를 사용하면 자연스러운 가속/감속 효과를 줄 수 있습니다.linear → 일정한 속도 (기계적)easeIn → 천천히 시작, 빠르게 끝easeOut → 빠르게 시작, 천천히 끝easeInOut → 천천히 시작, 천천히 끝
What
NOTE
Curve는 0.0에서 1.0 사이의 시간값을 변환하는 함수입니다.
Flutter는 다양한 사전 정의 커브를Curves클래스에서 제공합니다.
How
TIP주요 Curves 종류
Curve 설명 사용 예 linear일정한 속도 로딩 인디케이터 easeIn천천히 시작 등장 애니메이션 easeOut천천히 끝 퇴장 애니메이션 easeInOut부드러운 시작/끝 대부분의 UI bounceOut끝에서 튀는 효과 강조 효과 elasticOut탄성 효과 재미있는 UI fastOutSlowInMaterial 기본 Material Design 커브 비교 데모
class CurvesDemo extends StatefulWidget {const CurvesDemo({super.key});@overrideState<CurvesDemo> createState() => _CurvesDemoState();}class _CurvesDemoState extends State<CurvesDemo> {bool _moved = false;final List<Curve> _curves = [Curves.linear,Curves.easeIn,Curves.easeOut,Curves.easeInOut,Curves.bounceOut,Curves.elasticOut,];final List<String> _names = ['linear','easeIn','easeOut','easeInOut','bounceOut','elasticOut',];@overrideWidget build(BuildContext context) {return Scaffold(body: Column(children: List.generate(_curves.length, (index) {return Expanded(child: Row(children: [SizedBox(width: 100,child: Text(_names[index], textAlign: TextAlign.center),),Expanded(child: AnimatedAlign(alignment: _moved? Alignment.centerRight: Alignment.centerLeft,duration: const Duration(seconds: 1),curve: _curves[index],child: Container(width: 40,height: 40,decoration: BoxDecoration(color: Colors.blue,borderRadius: BorderRadius.circular(8),),),),),],),);}),),floatingActionButton: FloatingActionButton(onPressed: () => setState(() => _moved = !_moved),child: const Icon(Icons.play_arrow),),);}}커스텀 커브 (Cubic Bezier)
// 커스텀 베지어 커브const customCurve = Cubic(0.68, -0.55, 0.27, 1.55);AnimatedContainer(curve: customCurve,duration: const Duration(milliseconds: 500),// ...)
Watch out
WARNING
bounceOut이나elasticOut같은 커브는 값이 목표를 넘어갔다가 돌아옵니다.
크기 애니메이션에 사용하면 음수가 될 수 있어 레이아웃 에러가 발생할 수 있습니다.// 위험 - bounceOut으로 크기 애니메이션AnimatedContainer(width: _expanded ? 200 : 50, // 바운스 중 음수 가능curve: Curves.bounceOut,// ...)// 안전 - 위치 애니메이션에 사용AnimatedAlign(alignment: _moved ? Alignment.centerRight : Alignment.centerLeft,curve: Curves.bounceOut, // 정렬은 음수 가능// ...)
결론: Curves를 적절히 선택하면 애니메이션이 더 자연스럽고 생동감 있어집니다.
챕터 5: AnimatedSwitcher와 AnimatedCrossFade
Why
NOTE위젯 자체가 교체될 때도 애니메이션이 필요합니다.
AnimatedSwitcher와AnimatedCrossFade는 위젯 전환을 애니메이션합니다.AnimatedSwitcher → 다양한 위젯 전환AnimatedCrossFade → 두 위젯 간 페이드 전환
What
NOTE
AnimatedSwitcher는 자식 위젯이 변경될 때 이전 위젯을 페이드 아웃하고 새 위젯을 페이드 인합니다.
위젯 변경을 감지하려면key가 필요합니다.
How
TIPAnimatedSwitcher 기본 사용
class SwitcherDemo extends StatefulWidget {const SwitcherDemo({super.key});@overrideState<SwitcherDemo> createState() => _SwitcherDemoState();}class _SwitcherDemoState extends State<SwitcherDemo> {int _count = 0;@overrideWidget build(BuildContext context) {return Column(mainAxisAlignment: MainAxisAlignment.center,children: [AnimatedSwitcher(duration: const Duration(milliseconds: 300),transitionBuilder: (child, animation) {return ScaleTransition(scale: animation, child: child);},child: Text('$_count',key: ValueKey<int>(_count), // key 필수!style: const TextStyle(fontSize: 48),),),const SizedBox(height: 20),ElevatedButton(onPressed: () => setState(() => _count++),child: const Text('증가'),),],);}}커스텀 전환 효과
AnimatedSwitcher(duration: const Duration(milliseconds: 500),transitionBuilder: (child, animation) {return SlideTransition(position: Tween<Offset>(begin: const Offset(1, 0), // 오른쪽에서 등장end: Offset.zero,).animate(animation),child: child,);},layoutBuilder: (currentChild, previousChildren) {return Stack(alignment: Alignment.center,children: [...previousChildren,if (currentChild != null) currentChild,],);},child: Text(_text,key: ValueKey<String>(_text),),)AnimatedCrossFade 사용
class CrossFadeDemo extends StatefulWidget {const CrossFadeDemo({super.key});@overrideState<CrossFadeDemo> createState() => _CrossFadeDemoState();}class _CrossFadeDemoState extends State<CrossFadeDemo> {bool _showFirst = true;@overrideWidget build(BuildContext context) {return Column(mainAxisAlignment: MainAxisAlignment.center,children: [AnimatedCrossFade(duration: const Duration(milliseconds: 300),crossFadeState: _showFirst? CrossFadeState.showFirst: CrossFadeState.showSecond,firstChild: Container(width: 200,height: 200,color: Colors.blue,child: const Center(child: Text('첫 번째')),),secondChild: Container(width: 200,height: 100, // 다른 크기도 부드럽게 전환color: Colors.red,child: const Center(child: Text('두 번째')),),),const SizedBox(height: 20),ElevatedButton(onPressed: () => setState(() => _showFirst = !_showFirst),child: const Text('전환'),),],);}}
Watch out
WARNING
AnimatedSwitcher에서 자식 위젯이 같은 타입이면key가 없으면 변경을 감지하지 못합니다.// 문제 - 같은 Text 타입, key 없음AnimatedSwitcher(child: Text(_count.toString()), // 애니메이션 안 됨)// 해결 - key 추가AnimatedSwitcher(child: Text(_count.toString(),key: ValueKey<int>(_count), // 변경 감지),)
결론: AnimatedSwitcher는 위젯 전환을 애니메이션하며, key를 반드시 설정해야 변경을 감지합니다.
챕터 6: TweenAnimationBuilder로 커스텀 애니메이션
Why
NOTE기본 제공 위젯으로 원하는 애니메이션을 만들 수 없을 때
TweenAnimationBuilder를 사용합니다.
어떤 값이든 애니메이션할 수 있는 유연한 빌더입니다.TweenAnimationBuilder → 값 보간 → builder에서 위젯 생성
What
NOTE
TweenAnimationBuilder는Tween으로 정의한 시작값과 끝값 사이를 보간하면서 매 프레임 builder를 호출합니다.
AnimationController 없이 커스텀 애니메이션을 구현할 수 있습니다.
How
TIP기본 사용법
class TweenBuilderDemo extends StatefulWidget {const TweenBuilderDemo({super.key});@overrideState<TweenBuilderDemo> createState() => _TweenBuilderDemoState();}class _TweenBuilderDemoState extends State<TweenBuilderDemo> {double _targetValue = 0;@overrideWidget build(BuildContext context) {return Column(mainAxisAlignment: MainAxisAlignment.center,children: [TweenAnimationBuilder<double>(tween: Tween<double>(begin: 0, end: _targetValue),duration: const Duration(milliseconds: 500),builder: (context, value, child) {return Container(width: 100 + value,height: 100 + value,decoration: BoxDecoration(color: Colors.blue,borderRadius: BorderRadius.circular(value / 2),),child: child,);},child: const Center(child: Text('Hello', style: TextStyle(color: Colors.white)),),),const SizedBox(height: 20),Slider(value: _targetValue,min: 0,max: 100,onChanged: (value) => setState(() => _targetValue = value),),],);}}색상 애니메이션
TweenAnimationBuilder<Color?>(tween: ColorTween(begin: Colors.red,end: _isActive ? Colors.green : Colors.red,),duration: const Duration(milliseconds: 300),builder: (context, color, child) {return Container(width: 100,height: 100,color: color,);},)회전 애니메이션
class SpinningWidget extends StatefulWidget {const SpinningWidget({super.key});@overrideState<SpinningWidget> createState() => _SpinningWidgetState();}class _SpinningWidgetState extends State<SpinningWidget> {double _turns = 0;@overrideWidget build(BuildContext context) {return Column(mainAxisAlignment: MainAxisAlignment.center,children: [TweenAnimationBuilder<double>(tween: Tween<double>(begin: 0, end: _turns),duration: const Duration(milliseconds: 500),builder: (context, value, child) {return Transform.rotate(angle: value * 2 * 3.14159, // 라디안child: child,);},child: Container(width: 100,height: 100,color: Colors.blue,child: const Icon(Icons.star, color: Colors.white, size: 50),),),const SizedBox(height: 20),ElevatedButton(onPressed: () => setState(() => _turns += 0.25),child: const Text('90도 회전'),),],);}}
Watch out
WARNING
TweenAnimationBuilder의begin값은 처음 한 번만 사용됩니다.
이후에는 현재 값에서 새end값으로 애니메이션됩니다.// begin은 최초에만 적용TweenAnimationBuilder<double>(tween: Tween<double>(begin: 0, // 처음에만 사용end: _target, // 이후에는 현재값 → end로 애니메이션),duration: const Duration(milliseconds: 300),builder: (context, value, child) {return Text('$value');},)
결론: TweenAnimationBuilder로 어떤 값이든 AnimationController 없이 암시적으로 애니메이션할 수 있습니다.
한계
암시적 애니메이션은 간단하지만 몇 가지 제약이 있습니다.
- 제어 불가: 애니메이션을 중간에 멈추거나 역방향으로 재생할 수 없습니다.
- 상태 접근 불가: 현재 애니메이션 진행률을 직접 읽을 수 없습니다.
- 복잡한 시퀀스 불가: 여러 애니메이션을 순차적으로 연결하기 어렵습니다.
- 성능 제한: 많은 위젯을 동시에 애니메이션하면 성능이 저하될 수 있습니다.
이런 경우에는 명시적 애니메이션(AnimationController)을 사용해야 합니다.
Footnotes
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!