티스토리 뷰
Flutter 스태거드 메뉴 애니메이션 구현 가이드: 한 단계씩 따라하는 Flutter 애니메이션 마스터하기
플러터를 배워보자 2024. 10. 28. 17:00Flutter에서는 UI에 생동감을 불어넣기 위해 다양한 애니메이션 기능을 제공합니다. 그중에서도 '스태거드 애니메이션(Staggered Animation)'은 개별 애니메이션을 순차적으로 실행하여, 사용자가 복잡한 UI 요소들을 한 번에 이해하고 쉽게 접근할 수 있도록 합니다. 이 글에서는 Flutter의 스태거드 메뉴 애니메이션을 구현하는 방법과 중요한 개념들을 살펴보겠습니다.
참고. Create a staggered menu animation
스태거드 애니메이션의 정의
스태거드 애니메이션은 여러 애니메이션이 순차적으로 실행되지만, 서로 겹치는 형태로 구현되어 짧은 시간 안에 각 요소가 한 번에 등장하지 않고 순서대로 나타나는 방식입니다. 이는 앱의 시각적 흐름을 부드럽게 만들어 주며, 사용자가 자연스럽게 콘텐츠를 인지할 수 있게 돕습니다.
스태거드 메뉴 애니메이션 구현 단계
1단계: 기본 메뉴 구성
먼저 애니메이션이 없는 기본 메뉴를 구성합니다. Menu
라는 상태 유지형 위젯을 만들고, 리스트 형태로 메뉴 항목과 'Get Started' 버튼을 추가합니다.
class Menu extends StatefulWidget {
const Menu({super.key});
@override
State<Menu> createState() => _MenuState();
}
class _MenuState extends State<Menu> {
static const _menuTitles = [
'Declarative Style',
'Premade Widgets',
'Stateful Hot Reload',
'Native Performance',
'Great Community',
];
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
child: Stack(
fit: StackFit.expand,
children: [
_buildFlutterLogo(),
_buildContent(),
],
),
);
}
Widget _buildFlutterLogo() {
return Container(); // 로고 구현은 생략
}
Widget _buildContent() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
..._buildListItems(),
const Spacer(),
_buildGetStartedButton(),
],
);
}
List<Widget> _buildListItems() {
return _menuTitles.map((title) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 16),
child: Text(
title,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w500,
),
),
);
}).toList();
}
Widget _buildGetStartedButton() {
return SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.all(24),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
shape: const StadiumBorder(),
backgroundColor: Colors.blue,
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 14),
),
onPressed: () {},
child: const Text(
'Get Started',
style: TextStyle(
color: Colors.white,
fontSize: 22,
),
),
),
),
);
}
}
2단계: 애니메이션을 위한 준비
애니메이션의 타이밍을 제어하기 위해 AnimationController
를 사용합니다. 이를 통해 각 메뉴 항목의 등장 시점을 지연시키고, 특정 애니메이션 구간을 지정하여 부드러운 전환을 설정합니다.
애니메이션 컨트롤러 정의
class _MenuState extends State<Menu> with SingleTickerProviderStateMixin {
late AnimationController _staggeredController;
@override
void initState() {
super.initState();
_staggeredController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 800), // 전체 애니메이션 지속 시간
)..forward();
}
@override
void dispose() {
_staggeredController.dispose();
super.dispose();
}
}
3단계: 리스트 항목과 버튼 애니메이션
각 항목과 버튼에 대해 각각의 애니메이션 구간(Interval)을 정의하고, 이를 통해 리스트 항목이 서서히 나타나고, 버튼은 팝업 효과를 통해 등장하도록 만듭니다. 이를 위해 AnimatedBuilder
와 Interval
클래스를 활용합니다.
List<Widget> _buildListItems() {
final listItems = <Widget>[];
for (var i = 0; i < _menuTitles.length; ++i) {
listItems.add(
AnimatedBuilder(
animation: _staggeredController,
builder: (context, child) {
final animationPercent = Curves.easeOut.transform(
Interval(
0.1 * i, 0.1 * (i + 1),
).transform(_staggeredController.value),
);
final opacity = animationPercent;
final slideDistance = (1.0 - animationPercent) * 150;
return Opacity(
opacity: opacity,
child: Transform.translate(
offset: Offset(slideDistance, 0),
child: child,
),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 16),
child: Text(
_menuTitles[i],
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w500),
),
),
),
);
}
return listItems;
}
결론 및 주요 기능 정리
Flutter의 스태거드 애니메이션을 구현하면 복잡한 애니메이션을 한 번에 처리하는 대신 순차적으로 자연스럽게 표시할 수 있어, UI의 이해도와 사용자 경험을 높일 수 있습니다. 애니메이션 타이밍과 개별 요소의 구간을 정밀하게 제어하는 AnimationController
와 Interval
클래스를 통해 효과적인 전환 효과를 제공할 수 있습니다.
Flutter를 사용하면서 애니메이션 관련 기능을 깊이 있게 이해하는 것은 전체적인 앱의 품질을 높이는 데 중요한 요소로 작용합니다. Flutter 개발자라면, 이러한 애니메이션 기법을 프로젝트에 적용해보고 새로운 기능들을 실험해 보세요.
import 'package:flutter/material.dart';
void main() {
runApp(
const MaterialApp(
home: ExampleStaggeredAnimations(),
debugShowCheckedModeBanner: false,
),
);
}
class ExampleStaggeredAnimations extends StatefulWidget {
const ExampleStaggeredAnimations({
super.key,
});
@override
State<ExampleStaggeredAnimations> createState() =>
_ExampleStaggeredAnimationsState();
}
class _ExampleStaggeredAnimationsState extends State<ExampleStaggeredAnimations>
with SingleTickerProviderStateMixin {
late AnimationController _drawerSlideController;
@override
void initState() {
super.initState();
_drawerSlideController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 150),
);
}
@override
void dispose() {
_drawerSlideController.dispose();
super.dispose();
}
bool _isDrawerOpen() {
return _drawerSlideController.value == 1.0;
}
bool _isDrawerOpening() {
return _drawerSlideController.status == AnimationStatus.forward;
}
bool _isDrawerClosed() {
return _drawerSlideController.value == 0.0;
}
void _toggleDrawer() {
if (_isDrawerOpen() || _isDrawerOpening()) {
_drawerSlideController.reverse();
} else {
_drawerSlideController.forward();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: _buildAppBar(),
body: Stack(
children: [
_buildContent(),
_buildDrawer(),
],
),
);
}
PreferredSizeWidget _buildAppBar() {
return AppBar(
title: const Text(
'Flutter Menu',
style: TextStyle(
color: Colors.black,
),
),
backgroundColor: Colors.transparent,
elevation: 0.0,
automaticallyImplyLeading: false,
actions: [
AnimatedBuilder(
animation: _drawerSlideController,
builder: (context, child) {
return IconButton(
onPressed: _toggleDrawer,
icon: _isDrawerOpen() || _isDrawerOpening()
? const Icon(
Icons.clear,
color: Colors.black,
)
: const Icon(
Icons.menu,
color: Colors.black,
),
);
},
),
],
);
}
Widget _buildContent() {
// Put page content here.
return const SizedBox();
}
Widget _buildDrawer() {
return AnimatedBuilder(
animation: _drawerSlideController,
builder: (context, child) {
return FractionalTranslation(
translation: Offset(1.0 - _drawerSlideController.value, 0.0),
child: _isDrawerClosed() ? const SizedBox() : const Menu(),
);
},
);
}
}
class Menu extends StatefulWidget {
const Menu({super.key});
@override
State<Menu> createState() => _MenuState();
}
class _MenuState extends State<Menu> with SingleTickerProviderStateMixin {
static const _menuTitles = [
'Declarative style',
'Premade widgets',
'Stateful hot reload',
'Native performance',
'Great community',
];
static const _initialDelayTime = Duration(milliseconds: 50);
static const _itemSlideTime = Duration(milliseconds: 250);
static const _staggerTime = Duration(milliseconds: 50);
static const _buttonDelayTime = Duration(milliseconds: 150);
static const _buttonTime = Duration(milliseconds: 500);
final _animationDuration = _initialDelayTime +
(_staggerTime * _menuTitles.length) +
_buttonDelayTime +
_buttonTime;
late AnimationController _staggeredController;
final List<Interval> _itemSlideIntervals = [];
late Interval _buttonInterval;
@override
void initState() {
super.initState();
_createAnimationIntervals();
_staggeredController = AnimationController(
vsync: this,
duration: _animationDuration,
)..forward();
}
void _createAnimationIntervals() {
for (var i = 0; i < _menuTitles.length; ++i) {
final startTime = _initialDelayTime + (_staggerTime * i);
final endTime = startTime + _itemSlideTime;
_itemSlideIntervals.add(
Interval(
startTime.inMilliseconds / _animationDuration.inMilliseconds,
endTime.inMilliseconds / _animationDuration.inMilliseconds,
),
);
}
final buttonStartTime =
Duration(milliseconds: (_menuTitles.length * 50)) + _buttonDelayTime;
final buttonEndTime = buttonStartTime + _buttonTime;
_buttonInterval = Interval(
buttonStartTime.inMilliseconds / _animationDuration.inMilliseconds,
buttonEndTime.inMilliseconds / _animationDuration.inMilliseconds,
);
}
@override
void dispose() {
_staggeredController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
child: Stack(
fit: StackFit.expand,
children: [
_buildFlutterLogo(),
_buildContent(),
],
),
);
}
Widget _buildFlutterLogo() {
return const Positioned(
right: -100,
bottom: -30,
child: Opacity(
opacity: 0.2,
child: FlutterLogo(
size: 400,
),
),
);
}
Widget _buildContent() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
..._buildListItems(),
const Spacer(),
_buildGetStartedButton(),
],
);
}
List<Widget> _buildListItems() {
final listItems = <Widget>[];
for (var i = 0; i < _menuTitles.length; ++i) {
listItems.add(
AnimatedBuilder(
animation: _staggeredController,
builder: (context, child) {
final animationPercent = Curves.easeOut.transform(
_itemSlideIntervals[i].transform(_staggeredController.value),
);
final opacity = animationPercent;
final slideDistance = (1.0 - animationPercent) * 150;
return Opacity(
opacity: opacity,
child: Transform.translate(
offset: Offset(slideDistance, 0),
child: child,
),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 16),
child: Text(
_menuTitles[i],
textAlign: TextAlign.left,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w500,
),
),
),
),
);
}
return listItems;
}
Widget _buildGetStartedButton() {
return SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.all(24),
child: AnimatedBuilder(
animation: _staggeredController,
builder: (context, child) {
final animationPercent = Curves.elasticOut.transform(
_buttonInterval.transform(_staggeredController.value));
final opacity = animationPercent.clamp(0.0, 1.0);
final scale = (animationPercent * 0.5) + 0.5;
return Opacity(
opacity: opacity,
child: Transform.scale(
scale: scale,
child: child,
),
);
},
child: ElevatedButton(
style: ElevatedButton.styleFrom(
shape: const StadiumBorder(),
backgroundColor: Colors.blue,
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 14),
),
onPressed: () {},
child: const Text(
'Get started',
style: TextStyle(
color: Colors.white,
fontSize: 22,
),
),
),
),
),
);
}
}
'Flutter Cookbook' 카테고리의 다른 글
Flutter에서 확장 가능한 Floating Action Button(FAB) 구현하기 (0) | 2024.10.31 |
---|---|
Flutter에서 타이핑 인디케이터 구현하기: 사용자 인터페이스를 향상시키는 방법 (0) | 2024.10.29 |
Flutter로 쉬머 로딩 효과 구현하기: 상세 가이드 (0) | 2024.10.22 |
Flutter로 패럴랙스 스크롤 효과 구현하기: 단계별 가이드 (1) | 2024.10.21 |
Flutter로 사진 필터 캐러셀 구현하기: 단계별 가이드 (1) | 2024.10.20 |