애니메이션
애니메이션은 사용자 경험(UX)을 향상시키는 중요한 요소입니다. 적절한 애니메이션은 사용자 인터페이스에 생동감을 부여하고, 상태 변화를 자연스럽게 표현하며, 사용자의 주의를 필요한 곳으로 유도합니다. Flutter는 다양한 애니메이션 기법을 쉽게 구현할 수 있는 풍부한 API를 제공합니다.
Flutter 애니메이션의 기본 개념
Section titled “Flutter 애니메이션의 기본 개념”Flutter 애니메이션을 이해하기 위한 핵심 개념들을 살펴보겠습니다.
주요 구성 요소
Section titled “주요 구성 요소”- AnimationController: 애니메이션의 시작, 정지, 반복 등을 제어하는 중앙 관리자
- Animation<T>: 시간에 따라 변화하는 값(double, Color, Offset 등)을 제공
- Tween: 시작 값과 끝 값 사이의 보간(interpolation)을 정의
- Curve: 애니메이션의 가속도와 시간 흐름을 결정하는 곡선
기본 애니메이션 구현하기
Section titled “기본 애니메이션 구현하기”1. 암시적(Implicit) 애니메이션
Section titled “1. 암시적(Implicit) 애니메이션”Flutter는 많은 내장 암시적 애니메이션 위젯을 제공합니다. 이 위젯들은 Animated
접두사로 시작하며, 값이 변경될 때 자동으로 애니메이션이 적용됩니다.
class ImplicitAnimationExample extends StatefulWidget { @override _ImplicitAnimationExampleState createState() => _ImplicitAnimationExampleState();}
class _ImplicitAnimationExampleState extends State<ImplicitAnimationExample> { 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('탭하여 변경'), ), ), ), ); }}
주요 암시적 애니메이션 위젯
Section titled “주요 암시적 애니메이션 위젯”위젯 | 용도 |
---|---|
AnimatedContainer | 컨테이너 속성 애니메이션(크기, 색상, 테두리 등) |
AnimatedOpacity | 투명도 애니메이션 |
AnimatedPadding | 패딩 애니메이션 |
AnimatedPositioned | Stack 내에서 위치 애니메이션 |
AnimatedSwitcher | 위젯 전환 애니메이션 |
AnimatedDefaultTextStyle | 텍스트 스타일 애니메이션 |
AnimatedCrossFade | 두 위젯 간 교차 페이드 애니메이션 |
TweenAnimationBuilder | 사용자 정의 암시적 애니메이션 |
2. 명시적(Explicit) 애니메이션
Section titled “2. 명시적(Explicit) 애니메이션”명시적 애니메이션은 더 세밀한 제어가 필요한 경우 사용합니다. AnimationController
를 직접 조작하여 애니메이션의 시작, 중지, 역방향 재생 등을 제어할 수 있습니다.
class ExplicitAnimationExample extends StatefulWidget { @override _ExplicitAnimationExampleState createState() => _ExplicitAnimationExampleState();}
class _ExplicitAnimationExampleState extends State<ExplicitAnimationExample> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation<double> _sizeAnimation; late Animation<Color?> _colorAnimation; late Animation<double> _borderRadiusAnimation;
@override void initState() { super.initState();
_controller = AnimationController( duration: Duration(milliseconds: 500), vsync: this, );
// 크기 애니메이션 _sizeAnimation = Tween<double>(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<double>(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의 주요 메서드
Section titled “AnimationController의 주요 메서드”forward()
: 애니메이션을 시작하거나 계속 진행reverse()
: 애니메이션을 역방향으로 재생repeat()
: 애니메이션을 반복reset()
: 애니메이션을 초기 상태로 재설정stop()
: 애니메이션을 현재 위치에서 정지
3. Hero 애니메이션
Section titled “3. Hero 애니메이션”Hero 애니메이션은 두 화면 간에 위젯을 자연스럽게 전환하는 데 사용됩니다. 사용자가 목록에서 세부 정보 화면으로 이동할 때 특히 유용합니다.
// 목록 화면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. 페이지 전환 애니메이션
Section titled “4. 페이지 전환 애니메이션”Flutter에서는 페이지 간의 전환도 애니메이션으로 꾸밀 수 있습니다. PageRouteBuilder
를 사용하여 사용자 정의 전환 효과를 만들 수 있습니다.
// 페이드 전환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를 사용하는 경우 다음과 같이 페이지 전환을 설정할 수 있습니다:
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, ); }, ); }, ), ],);
고급 애니메이션 기법
Section titled “고급 애니메이션 기법”1. Staggered Animation (단계별 애니메이션)
Section titled “1. Staggered Animation (단계별 애니메이션)”단계별 애니메이션은 여러 애니메이션이 서로 다른 타이밍으로 실행되도록 조정하는 기법입니다. 더 복잡하고 흥미로운 효과를 만들 수 있습니다.
class StaggeredAnimationExample extends StatefulWidget { @override _StaggeredAnimationExampleState createState() => _StaggeredAnimationExampleState();}
class _StaggeredAnimationExampleState extends State<StaggeredAnimationExample> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation<double> _heightAnimation; late Animation<double> _widthAnimation; late Animation<BorderRadius?> _borderRadiusAnimation; late Animation<Color?> _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<double>(begin: 100.0, end: 300.0).animate( CurvedAnimation(parent: _controller, curve: _heightInterval), );
_widthAnimation = Tween<double>(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. 물리 기반 애니메이션
Section titled “2. 물리 기반 애니메이션”Flutter는 실제 물리 법칙에 기반한 자연스러운 애니메이션을 위한 SpringSimulation
및 GravitySimulation
등을 제공합니다.
class PhysicsBasedAnimationExample extends StatefulWidget { @override _PhysicsBasedAnimationExampleState createState() => _PhysicsBasedAnimationExampleState();}
class _PhysicsBasedAnimationExampleState extends State<PhysicsBasedAnimationExample> 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. 애니메이션 시퀀스
Section titled “3. 애니메이션 시퀀스”여러 애니메이션을 순차적으로 실행해야 할 때는 다음과 같이 작성할 수 있습니다:
class SequentialAnimationExample extends StatefulWidget { @override _SequentialAnimationExampleState createState() => _SequentialAnimationExampleState();}
class _SequentialAnimationExampleState extends State<SequentialAnimationExample> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation<double> _animation1; late Animation<double> _animation2; late Animation<double> _animation3;
@override void initState() { super.initState();
_controller = AnimationController( duration: Duration(milliseconds: 3000), vsync: this, );
// 애니메이션 1: 0초~1초 (0.0~0.33) _animation1 = Tween<double>(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<double>(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<double>(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 패키지 사용하기
Section titled “4. flutter_animate 패키지 사용하기”flutter_animate
패키지는 복잡한 애니메이션을 간편하게 구현할 수 있도록 도와줍니다.
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) ], ), ], ), ); }}
애니메이션 성능 최적화
Section titled “애니메이션 성능 최적화”1. RepaintBoundary 사용하기
Section titled “1. RepaintBoundary 사용하기”복잡한 애니메이션의 경우, RepaintBoundary
를 사용하여 다시 그려야 하는 영역을 제한합니다:
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 활용하기
Section titled “2. Transform 활용하기”setState
를 호출하는 대신 Transform
위젯을 사용하면 위젯 트리를 재구성하지 않고 변형만 적용할 수 있습니다:
AnimatedBuilder( animation: _animation, builder: (context, child) { return Transform.translate( offset: Offset(0, 100 * _animation.value), child: child, // 변경되지 않는 부분 ); }, child: const MyComplexWidget(), // 재사용 가능한 위젯)
3. ValueListenableBuilder 사용하기
Section titled “3. ValueListenableBuilder 사용하기”단일 값 변경에 반응할 때는 AnimatedBuilder
대신 ValueListenableBuilder
를 사용하는 것이 더 효율적일 수 있습니다:
ValueListenableBuilder<double>( valueListenable: _progressNotifier, builder: (context, value, child) { return CircularProgressIndicator(value: value); },)
애니메이션 디자인 패턴 및 모범 사례
Section titled “애니메이션 디자인 패턴 및 모범 사례”1. 상태별 애니메이션 모델 분리
Section titled “1. 상태별 애니메이션 모델 분리”복잡한 애니메이션은 로직을 별도의 클래스로 분리하는 것이 좋습니다:
class LoadingAnimationModel { final AnimationController controller; late final Animation<double> scaleAnimation; late final Animation<Color?> colorAnimation;
LoadingAnimationModel({required this.controller}) { scaleAnimation = Tween<double>(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<LoadingScreen> 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와 애니메이션 통합
Section titled “2. Riverpod와 애니메이션 통합”Riverpod을 사용할 때는 애니메이션 컨트롤러를 프로바이더로 관리할 수 있습니다:
// 애니메이션 컨트롤러 프로바이더final animationControllerProvider = Provider.autoDispose<AnimationController>((ref) { final controller = AnimationController( duration: Duration(milliseconds: 500), vsync: ref.watch(tickerProvider), );
ref.onDispose(() { controller.dispose(); });
return controller;});
// TickerProvider 프로바이더final tickerProvider = Provider<TickerProvider>((ref) { throw UnimplementedError('TickerProvider가 설정되지 않았습니다.');});
// 애니메이션 프로바이더final fadeAnimationProvider = Provider.autoDispose<Animation<double>>((ref) { final controller = ref.watch(animationControllerProvider); return Tween<double>(begin: 0.0, end: 1.0).animate(controller);});
// 사용 예시class AnimatedPage extends ConsumerStatefulWidget { @override _AnimatedPageState createState() => _AnimatedPageState();}
class _AnimatedPageState extends ConsumerState<AnimatedPage> 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 지침
Section titled “애니메이션 UX 지침”1. 애니메이션 지속 시간
Section titled “1. 애니메이션 지속 시간”애니메이션의 적절한 지속 시간은 애니메이션 유형과 목적에 따라 다릅니다:
- 기본 UI 전환: 150ms ~ 300ms
- 엘리먼트 입장/퇴장: 200ms ~ 500ms
- 복잡한 애니메이션: 500ms ~ 1000ms
- 강조 애니메이션: 800ms ~ 1500ms
2. 애니메이션 곡선 선택
Section titled “2. 애니메이션 곡선 선택”적절한 Curve는 애니메이션의 느낌을 결정합니다:
- Curves.easeInOut: 자연스러운 가속 및 감속, 대부분의 UI 전환에 적합
- Curves.easeOut: 빠른 시작과 부드러운 종료, 요소가 화면에 등장할 때 좋음
- Curves.easeIn: 부드러운 시작과 빠른 종료, 요소가 화면을 떠날 때 적합
- Curves.elasticOut: 탄력 있는 효과, 강조하거나 놀라운 요소에 적합
- Curves.bounceOut: 튀는 효과, 재미있고 활기찬 느낌을 줄 때 사용
3. 모바일 고려사항
Section titled “3. 모바일 고려사항”모바일 디바이스에서는 다음 사항을 고려하세요:
- 배터리 수명: 과도한 애니메이션은 배터리 소모를 증가시킴
- 성능: 저사양 기기에서도 원활하게 작동하는지 확인
- 사용자 설정: 사용자가 애니메이션을 줄이거나 비활성화할 수 있는 옵션 제공
Flutter의 애니메이션 시스템은 풍부하고 유연합니다. 암시적 애니메이션부터 복잡한 물리 기반 애니메이션까지, 다양한 사용자 경험을 구현할 수 있는 도구를 제공합니다.
애니메이션을 효과적으로 사용하려면 목적을 명확히 하고, 과도하게 사용하지 않으며, 성능을 고려해야 합니다. 적절한 애니메이션은 앱의 사용성과 매력을 크게 향상시키는 강력한 도구입니다.
다음 장에서는 Flutter에서 접근성을 구현하여 더 많은 사용자가 앱을 이용할 수 있도록 하는 방법에 대해 알아보겠습니다.