# 애니메이션 애니메이션은 사용자 경험(UX)을 향상시키는 중요한 요소입니다. 적절한 애니메이션은 사용자 인터페이스에 생동감을 부여하고, 상태 변화를 자연스럽게 표현하며, 사용자의 주의를 필요한 곳으로 유도합니다. Flutter는 다양한 애니메이션 기법을 쉽게 구현할 수 있는 풍부한 API를 제공합니다. ## Flutter 애니메이션의 기본 개념 Flutter 애니메이션을 이해하기 위한 핵심 개념들을 살펴보겠습니다. ```mermaid graph LR A[AnimationController] -->|생성 및 제어| B[Animation 객체] B -->|값 변화 감지| C[애니메이션 위젯] C -->|UI 변화| D[화면 업데이트] style A fill:#f9d5e5 style B fill:#f9d5e5 style C fill:#d5e5f9 ``` ### 주요 구성 요소 1. **AnimationController**: 애니메이션의 시작, 정지, 반복 등을 제어하는 중앙 관리자 2. **Animation\**: 시간에 따라 변화하는 값(double, Color, Offset 등)을 제공 3. **Tween**: 시작 값과 끝 값 사이의 보간(interpolation)을 정의 4. **Curve**: 애니메이션의 가속도와 시간 흐름을 결정하는 곡선 ## 기본 애니메이션 구현하기 ### 1. 암시적(Implicit) 애니메이션 Flutter는 많은 내장 암시적 애니메이션 위젯을 제공합니다. 이 위젯들은 `Animated` 접두사로 시작하며, 값이 변경될 때 자동으로 애니메이션이 적용됩니다. ```dart class ImplicitAnimationExample extends StatefulWidget { @override _ImplicitAnimationExampleState createState() => _ImplicitAnimationExampleState(); } class _ImplicitAnimationExampleState extends State { double _width = 100.0; double _height = 100.0; Color _color = Colors.blue; double _borderRadius = 8.0; void _changeProperties() { setState(() { _width = _width == 100.0 ? 200.0 : 100.0; _height = _height == 100.0 ? 300.0 : 100.0; _color = _color == Colors.blue ? Colors.red : Colors.blue; _borderRadius = _borderRadius == 8.0 ? 60.0 : 8.0; }); } @override Widget build(BuildContext context) { return GestureDetector( onTap: _changeProperties, child: Center( child: AnimatedContainer( width: _width, height: _height, decoration: BoxDecoration( color: _color, borderRadius: BorderRadius.circular(_borderRadius), ), duration: Duration(milliseconds: 500), curve: Curves.easeInOut, child: Center( child: Text('탭하여 변경'), ), ), ), ); } } ``` #### 주요 암시적 애니메이션 위젯 | 위젯 | 용도 | | -------------------------- | ----------------------------------------------- | | `AnimatedContainer` | 컨테이너 속성 애니메이션(크기, 색상, 테두리 등) | | `AnimatedOpacity` | 투명도 애니메이션 | | `AnimatedPadding` | 패딩 애니메이션 | | `AnimatedPositioned` | `Stack` 내에서 위치 애니메이션 | | `AnimatedSwitcher` | 위젯 전환 애니메이션 | | `AnimatedDefaultTextStyle` | 텍스트 스타일 애니메이션 | | `AnimatedCrossFade` | 두 위젯 간 교차 페이드 애니메이션 | | `TweenAnimationBuilder` | 사용자 정의 암시적 애니메이션 | ### 2. 명시적(Explicit) 애니메이션 명시적 애니메이션은 더 세밀한 제어가 필요한 경우 사용합니다. `AnimationController`를 직접 조작하여 애니메이션의 시작, 중지, 역방향 재생 등을 제어할 수 있습니다. ```dart class ExplicitAnimationExample extends StatefulWidget { @override _ExplicitAnimationExampleState createState() => _ExplicitAnimationExampleState(); } class _ExplicitAnimationExampleState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _sizeAnimation; late Animation _colorAnimation; late Animation _borderRadiusAnimation; @override void initState() { super.initState(); _controller = AnimationController( duration: Duration(milliseconds: 500), vsync: this, ); // 크기 애니메이션 _sizeAnimation = Tween(begin: 100.0, end: 200.0).animate( CurvedAnimation( parent: _controller, curve: Curves.elasticOut, ), ); // 색상 애니메이션 _colorAnimation = ColorTween(begin: Colors.blue, end: Colors.red).animate( CurvedAnimation( parent: _controller, curve: Curves.easeInOut, ), ); // 테두리 반경 애니메이션 _borderRadiusAnimation = Tween(begin: 8.0, end: 60.0).animate( CurvedAnimation( parent: _controller, curve: Curves.easeInOut, ), ); } @override void dispose() { _controller.dispose(); super.dispose(); } void _toggleAnimation() { if (_controller.isCompleted) { _controller.reverse(); } else { _controller.forward(); } } @override Widget build(BuildContext context) { return GestureDetector( onTap: _toggleAnimation, child: Center( child: AnimatedBuilder( animation: _controller, builder: (context, child) { return Container( width: _sizeAnimation.value, height: _sizeAnimation.value, decoration: BoxDecoration( color: _colorAnimation.value, borderRadius: BorderRadius.circular(_borderRadiusAnimation.value), ), child: child, ); }, child: Center( child: Text('탭하여 애니메이션'), ), ), ), ); } } ``` #### AnimationController의 주요 메서드 - `forward()`: 애니메이션을 시작하거나 계속 진행 - `reverse()`: 애니메이션을 역방향으로 재생 - `repeat()`: 애니메이션을 반복 - `reset()`: 애니메이션을 초기 상태로 재설정 - `stop()`: 애니메이션을 현재 위치에서 정지 ### 3. Hero 애니메이션 Hero 애니메이션은 두 화면 간에 위젯을 자연스럽게 전환하는 데 사용됩니다. 사용자가 목록에서 세부 정보 화면으로 이동할 때 특히 유용합니다. ```dart // 목록 화면 class HeroListScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Hero 애니메이션')), body: ListView.builder( itemCount: 10, itemBuilder: (context, index) { return ListTile( leading: Hero( tag: 'hero-$index', // 고유한 태그 child: CircleAvatar( backgroundImage: NetworkImage( 'https://picsum.photos/id/${index + 10}/200/200', ), ), ), title: Text('아이템 $index'), onTap: () { Navigator.of(context).push( MaterialPageRoute( builder: (context) => HeroDetailScreen( imageUrl: 'https://picsum.photos/id/${index + 10}/400/400', heroTag: 'hero-$index', title: '아이템 $index', ), ), ); }, ); }, ), ); } } // 상세 화면 class HeroDetailScreen extends StatelessWidget { final String imageUrl; final String heroTag; final String title; const HeroDetailScreen({ required this.imageUrl, required this.heroTag, required this.title, }); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(title)), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Hero( tag: heroTag, child: Image.network( imageUrl, width: 300, height: 300, fit: BoxFit.cover, ), ), SizedBox(height: 20), Text( title, style: TextStyle(fontSize: 24), ), ], ), ), ); } } ``` ### 4. 페이지 전환 애니메이션 Flutter에서는 페이지 간의 전환도 애니메이션으로 꾸밀 수 있습니다. `PageRouteBuilder`를 사용하여 사용자 정의 전환 효과를 만들 수 있습니다. ```dart // 페이드 전환 Navigator.of(context).push( PageRouteBuilder( pageBuilder: (context, animation, secondaryAnimation) => DetailPage(), transitionsBuilder: (context, animation, secondaryAnimation, child) { const begin = 0.0; const end = 1.0; var tween = Tween(begin: begin, end: end); var fadeAnimation = animation.drive(tween); return FadeTransition( opacity: fadeAnimation, child: child, ); }, transitionDuration: Duration(milliseconds: 500), ), ); // 슬라이드 전환 Navigator.of(context).push( PageRouteBuilder( pageBuilder: (context, animation, secondaryAnimation) => DetailPage(), transitionsBuilder: (context, animation, secondaryAnimation, child) { var begin = Offset(1.0, 0.0); var end = Offset.zero; var curve = Curves.ease; var tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve)); var offsetAnimation = animation.drive(tween); return SlideTransition( position: offsetAnimation, child: child, ); }, transitionDuration: Duration(milliseconds: 500), ), ); ``` go_router를 사용하는 경우 다음과 같이 페이지 전환을 설정할 수 있습니다: ```dart final router = GoRouter( routes: [ GoRoute( path: '/', builder: (context, state) => HomeScreen(), ), GoRoute( path: '/detail/:id', pageBuilder: (context, state) { return CustomTransitionPage( key: state.pageKey, child: DetailScreen(id: state.params['id']!), transitionsBuilder: (context, animation, secondaryAnimation, child) { return FadeTransition( opacity: CurveTween(curve: Curves.easeInOut).animate(animation), child: child, ); }, ); }, ), ], ); ``` ## 고급 애니메이션 기법 ### 1. Staggered Animation (단계별 애니메이션) 단계별 애니메이션은 여러 애니메이션이 서로 다른 타이밍으로 실행되도록 조정하는 기법입니다. 더 복잡하고 흥미로운 효과를 만들 수 있습니다. ```dart class StaggeredAnimationExample extends StatefulWidget { @override _StaggeredAnimationExampleState createState() => _StaggeredAnimationExampleState(); } class _StaggeredAnimationExampleState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _heightAnimation; late Animation _widthAnimation; late Animation _borderRadiusAnimation; late Animation _colorAnimation; @override void initState() { super.initState(); _controller = AnimationController( duration: Duration(milliseconds: 1500), vsync: this, ); // 단계별 애니메이션 간격 설정 var _heightInterval = Interval(0.0, 0.3, curve: Curves.easeInOut); var _widthInterval = Interval(0.2, 0.5, curve: Curves.easeInOut); var _borderInterval = Interval(0.5, 0.8, curve: Curves.easeInOut); var _colorInterval = Interval(0.7, 1.0, curve: Curves.easeInOut); // 애니메이션 정의 _heightAnimation = Tween(begin: 100.0, end: 300.0).animate( CurvedAnimation(parent: _controller, curve: _heightInterval), ); _widthAnimation = Tween(begin: 100.0, end: 300.0).animate( CurvedAnimation(parent: _controller, curve: _widthInterval), ); _borderRadiusAnimation = BorderRadiusTween( begin: BorderRadius.circular(0.0), end: BorderRadius.circular(150.0), ).animate( CurvedAnimation(parent: _controller, curve: _borderInterval), ); _colorAnimation = ColorTween( begin: Colors.blue, end: Colors.purple, ).animate( CurvedAnimation(parent: _controller, curve: _colorInterval), ); } @override void dispose() { _controller.dispose(); super.dispose(); } void _playAnimation() { if (_controller.status == AnimationStatus.completed) { _controller.reverse(); } else { _controller.forward(); } } @override Widget build(BuildContext context) { return GestureDetector( onTap: _playAnimation, child: Center( child: AnimatedBuilder( animation: _controller, builder: (context, child) { return Container( height: _heightAnimation.value, width: _widthAnimation.value, decoration: BoxDecoration( color: _colorAnimation.value, borderRadius: _borderRadiusAnimation.value, ), child: Center( child: Text( '탭하여 애니메이션', style: TextStyle(color: Colors.white), ), ), ); }, ), ), ); } } ``` ### 2. 물리 기반 애니메이션 Flutter는 실제 물리 법칙에 기반한 자연스러운 애니메이션을 위한 `SpringSimulation` 및 `GravitySimulation` 등을 제공합니다. ```dart class PhysicsBasedAnimationExample extends StatefulWidget { @override _PhysicsBasedAnimationExampleState createState() => _PhysicsBasedAnimationExampleState(); } class _PhysicsBasedAnimationExampleState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; late SpringSimulation _simulation; double _position = 0.0; @override void initState() { super.initState(); // 스프링 시뮬레이션 설정 // 매개변수: 질량, 강성, 감쇠, 초기 위치 _simulation = SpringSimulation( SpringDescription( mass: 1.0, // 질량 stiffness: 100.0, // 강성 (높을수록 빠른 진동) damping: 10.0, // 감쇠 (높을수록 빠르게 안정화) ), 0.0, // 시작 위치 1.0, // 목표 위치 0.0, // 초기 속도 ); _controller = AnimationController( vsync: this, duration: Duration(milliseconds: 1500), )..addListener(() { setState(() { // 현재 시뮬레이션 위치 계산 _position = _simulation.x(_controller.value * 1.5); }); }); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return GestureDetector( onTap: () { if (_controller.status == AnimationStatus.completed) { _controller.reset(); } _controller.forward(); }, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('스프링 애니메이션'), SizedBox(height: 20), Container( height: 300, width: double.infinity, child: Stack( alignment: Alignment.topCenter, children: [ Positioned( top: 200 * _position, child: Container( width: 50, height: 50, decoration: BoxDecoration( color: Colors.blue, shape: BoxShape.circle, ), ), ), ], ), ), Text('탭하여 공을 떨어뜨리세요'), ], ), ); } } ``` ### 3. 애니메이션 시퀀스 여러 애니메이션을 순차적으로 실행해야 할 때는 다음과 같이 작성할 수 있습니다: ```dart class SequentialAnimationExample extends StatefulWidget { @override _SequentialAnimationExampleState createState() => _SequentialAnimationExampleState(); } class _SequentialAnimationExampleState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _animation1; late Animation _animation2; late Animation _animation3; @override void initState() { super.initState(); _controller = AnimationController( duration: Duration(milliseconds: 3000), vsync: this, ); // 애니메이션 1: 0초~1초 (0.0~0.33) _animation1 = Tween(begin: 100, end: 200).animate( CurvedAnimation( parent: _controller, curve: Interval(0.0, 0.33, curve: Curves.easeInOut), ), ); // 애니메이션 2: 1초~2초 (0.33~0.66) _animation2 = Tween(begin: 0, end: 2 * pi).animate( CurvedAnimation( parent: _controller, curve: Interval(0.33, 0.66, curve: Curves.easeInOut), ), ); // 애니메이션 3: 2초~3초 (0.66~1.0) _animation3 = Tween(begin: 1.0, end: 0.3).animate( CurvedAnimation( parent: _controller, curve: Interval(0.66, 1.0, curve: Curves.easeInOut), ), ); } @override void dispose() { _controller.dispose(); super.dispose(); } void _startAnimation() { _controller.reset(); _controller.forward(); } @override Widget build(BuildContext context) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ AnimatedBuilder( animation: _controller, builder: (context, child) { return Transform.scale( scale: _animation3.value, child: Transform.rotate( angle: _animation2.value, child: Container( width: _animation1.value, height: _animation1.value, decoration: BoxDecoration( color: Colors.blue, borderRadius: BorderRadius.circular(24), ), child: Icon( Icons.star, color: Colors.yellow, size: 80, ), ), ), ); }, ), SizedBox(height: 40), ElevatedButton( onPressed: _startAnimation, child: Text('시퀀스 애니메이션 시작'), ), ], ); } } ``` ### 4. flutter_animate 패키지 사용하기 `flutter_animate` 패키지는 복잡한 애니메이션을 간편하게 구현할 수 있도록 도와줍니다. ```dart import 'package:flutter_animate/flutter_animate.dart'; class FlutterAnimateExample extends StatelessWidget { @override Widget build(BuildContext context) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // 체인 애니메이션 Text('Flutter Animate') .animate() .fadeIn(duration: 500.ms) .slideY(begin: -0.3, end: 0, duration: 500.ms) .then(delay: 200.ms) // 지연 .shimmer(duration: 1000.ms) // 반짝임 효과 .scale(delay: 200.ms, duration: 600.ms), // 크기 변경 SizedBox(height: 48), // 병렬 애니메이션 Row( mainAxisAlignment: MainAxisAlignment.center, children: [ for (int i = 0; i < 5; i++) Container( width: 50, height: 50, margin: EdgeInsets.all(8), color: Colors.blue, ) .animate(delay: (100 * i).ms) // 각 항목마다 지연 증가 .fade(duration: 500.ms) .scale(begin: 0.5, end: 1) .move(begin: Offset(0, 100), end: Offset.zero) ], ), ], ), ); } } ``` ## 애니메이션 성능 최적화 ### 1. RepaintBoundary 사용하기 복잡한 애니메이션의 경우, `RepaintBoundary`를 사용하여 다시 그려야 하는 영역을 제한합니다: ```dart RepaintBoundary( child: AnimatedBuilder( animation: _controller, builder: (context, child) { return Transform.rotate( angle: _controller.value * 2 * pi, child: child, ); }, child: Container( width: 200, height: 200, color: Colors.blue, ), ), ) ``` ### 2. Transform 활용하기 `setState`를 호출하는 대신 `Transform` 위젯을 사용하면 위젯 트리를 재구성하지 않고 변형만 적용할 수 있습니다: ```dart AnimatedBuilder( animation: _animation, builder: (context, child) { return Transform.translate( offset: Offset(0, 100 * _animation.value), child: child, // 변경되지 않는 부분 ); }, child: const MyComplexWidget(), // 재사용 가능한 위젯 ) ``` ### 3. ValueListenableBuilder 사용하기 단일 값 변경에 반응할 때는 `AnimatedBuilder` 대신 `ValueListenableBuilder`를 사용하는 것이 더 효율적일 수 있습니다: ```dart ValueListenableBuilder( valueListenable: _progressNotifier, builder: (context, value, child) { return CircularProgressIndicator(value: value); }, ) ``` ## 애니메이션 디자인 패턴 및 모범 사례 ### 1. 상태별 애니메이션 모델 분리 복잡한 애니메이션은 로직을 별도의 클래스로 분리하는 것이 좋습니다: ```dart class LoadingAnimationModel { final AnimationController controller; late final Animation scaleAnimation; late final Animation colorAnimation; LoadingAnimationModel({required this.controller}) { scaleAnimation = Tween(begin: 1.0, end: 1.5).animate( CurvedAnimation(parent: controller, curve: Curves.elasticInOut), ); colorAnimation = ColorTween(begin: Colors.blue, end: Colors.purple).animate( CurvedAnimation(parent: controller, curve: Curves.easeInOut), ); } void startLoading() { controller.repeat(reverse: true); } void stopLoading() { controller.stop(); controller.reset(); } void dispose() { controller.dispose(); } } // 사용 예시 class LoadingScreen extends StatefulWidget { @override _LoadingScreenState createState() => _LoadingScreenState(); } class _LoadingScreenState extends State with SingleTickerProviderStateMixin { late LoadingAnimationModel _animationModel; @override void initState() { super.initState(); _animationModel = LoadingAnimationModel( controller: AnimationController( duration: Duration(milliseconds: 800), vsync: this, ), ); _animationModel.startLoading(); } @override void dispose() { _animationModel.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _animationModel.controller, builder: (context, child) { return Transform.scale( scale: _animationModel.scaleAnimation.value, child: Container( width: 50, height: 50, decoration: BoxDecoration( color: _animationModel.colorAnimation.value, shape: BoxShape.circle, ), ), ); }, ); } } ``` ### 2. Riverpod와 애니메이션 통합 Riverpod을 사용할 때는 애니메이션 컨트롤러를 프로바이더로 관리할 수 있습니다: ```dart // 애니메이션 컨트롤러 프로바이더 final animationControllerProvider = Provider.autoDispose((ref) { final controller = AnimationController( duration: Duration(milliseconds: 500), vsync: ref.watch(tickerProvider), ); ref.onDispose(() { controller.dispose(); }); return controller; }); // TickerProvider 프로바이더 final tickerProvider = Provider((ref) { throw UnimplementedError('TickerProvider가 설정되지 않았습니다.'); }); // 애니메이션 프로바이더 final fadeAnimationProvider = Provider.autoDispose>((ref) { final controller = ref.watch(animationControllerProvider); return Tween(begin: 0.0, end: 1.0).animate(controller); }); // 사용 예시 class AnimatedPage extends ConsumerStatefulWidget { @override _AnimatedPageState createState() => _AnimatedPageState(); } class _AnimatedPageState extends ConsumerState with TickerProviderStateMixin { @override void initState() { super.initState(); // TickerProvider 설정 ref.read(tickerProvider.overrideWithValue(this)); // 애니메이션 시작 WidgetsBinding.instance.addPostFrameCallback((_) { ref.read(animationControllerProvider).forward(); }); } @override Widget build(BuildContext context) { final fadeAnimation = ref.watch(fadeAnimationProvider); return FadeTransition( opacity: fadeAnimation, child: const MyPageContent(), ); } } ``` ## 애니메이션 UX 지침 ### 1. 애니메이션 지속 시간 애니메이션의 적절한 지속 시간은 애니메이션 유형과 목적에 따라 다릅니다: - **기본 UI 전환**: 150ms ~ 300ms - **엘리먼트 입장/퇴장**: 200ms ~ 500ms - **복잡한 애니메이션**: 500ms ~ 1000ms - **강조 애니메이션**: 800ms ~ 1500ms ### 2. 애니메이션 곡선 선택 적절한 Curve는 애니메이션의 느낌을 결정합니다: - **Curves.easeInOut**: 자연스러운 가속 및 감속, 대부분의 UI 전환에 적합 - **Curves.easeOut**: 빠른 시작과 부드러운 종료, 요소가 화면에 등장할 때 좋음 - **Curves.easeIn**: 부드러운 시작과 빠른 종료, 요소가 화면을 떠날 때 적합 - **Curves.elasticOut**: 탄력 있는 효과, 강조하거나 놀라운 요소에 적합 - **Curves.bounceOut**: 튀는 효과, 재미있고 활기찬 느낌을 줄 때 사용 ### 3. 모바일 고려사항 모바일 디바이스에서는 다음 사항을 고려하세요: - **배터리 수명**: 과도한 애니메이션은 배터리 소모를 증가시킴 - **성능**: 저사양 기기에서도 원활하게 작동하는지 확인 - **사용자 설정**: 사용자가 애니메이션을 줄이거나 비활성화할 수 있는 옵션 제공 ## 결론 Flutter의 애니메이션 시스템은 풍부하고 유연합니다. 암시적 애니메이션부터 복잡한 물리 기반 애니메이션까지, 다양한 사용자 경험을 구현할 수 있는 도구를 제공합니다. 애니메이션을 효과적으로 사용하려면 목적을 명확히 하고, 과도하게 사용하지 않으며, 성능을 고려해야 합니다. 적절한 애니메이션은 앱의 사용성과 매력을 크게 향상시키는 강력한 도구입니다. 다음 장에서는 Flutter에서 접근성을 구현하여 더 많은 사용자가 앱을 이용할 수 있도록 하는 방법에 대해 알아보겠습니다.