본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 10. 29. · 376 Views
Flutter 애니메이션 구현 완벽 가이드
Flutter에서 애니메이션을 구현하는 다양한 방법을 실무 중심으로 배워봅니다. 기본적인 암묵적 애니메이션부터 고급 컨트롤러 기반 애니메이션까지, 실제 프로젝트에 바로 적용할 수 있는 예제와 팁을 제공합니다.
목차
- AnimatedContainer - 가장 쉬운 암묵적 애니메이션
- AnimationController - 정밀한 애니메이션 제어
- Tween - 값의 보간을 담당하는 핵심
- Hero - 화면 간 공유 요소 전환 애니메이션
- AnimatedBuilder - 효율적인 애니메이션 렌더링
- TweenAnimationBuilder - 코드 없이 빠른 애니메이션
- SlideTransition과 FadeTransition - 전환 애니메이션 전용 위젯
- Staggered Animation - 순차적으로 실행되는 다단계 애니메이션
- AnimatedSwitcher - 위젯 교체 시 자동 전환
- CurvedAnimation과 Curves - 자연스러운 움직임 만들기
1. AnimatedContainer - 가장 쉬운 암묵적 애니메이션
시작하며
여러분이 버튼을 누르면 크기가 부드럽게 변하거나, 색상이 자연스럽게 전환되는 UI를 만들고 싶었던 적 있나요? 예를 들어 사용자가 좋아요 버튼을 누르면 하트 아이콘이 커지면서 색이 바뀌는 그런 효과 말이죠.
처음 Flutter를 접하면 이런 애니메이션을 구현하려고 복잡한 AnimationController를 쓰거나, 타이머로 값을 조금씩 바꾸는 방법을 생각하게 됩니다. 하지만 이렇게 하면 코드가 길어지고 관리하기 어려워집니다.
바로 이럴 때 필요한 것이 AnimatedContainer입니다. 속성 값만 바꿔주면 Flutter가 자동으로 부드러운 애니메이션을 만들어줍니다.
개요
간단히 말해서, AnimatedContainer는 Container의 속성이 변경될 때 자동으로 애니메이션을 적용해주는 위젯입니다. 일반 Container를 사용하면 크기나 색상이 즉시 변경되어 사용자 경험이 딱딱해 보입니다.
예를 들어, 설정 화면에서 옵션을 토글할 때 박스가 갑자기 커지면 어색하죠. AnimatedContainer를 사용하면 이런 변화가 부드럽게 전환되어 훨씬 전문적인 앱처럼 보입니다.
기존에는 애니메이션을 위해 StatefulWidget에서 AnimationController를 초기화하고, Tween을 설정하고, 리스너를 달아야 했다면, 이제는 그냥 setState로 속성 값만 바꾸면 됩니다. AnimatedContainer의 핵심 특징은 자동 애니메이션 적용, 커스텀 가능한 duration과 curve, 그리고 모든 Container 속성 지원입니다.
이러한 특징들이 개발 시간을 크게 단축시켜주고 코드를 간결하게 유지할 수 있게 해줍니다.
코드 예제
class AnimatedBox extends StatefulWidget {
@override
_AnimatedBoxState createState() => _AnimatedBoxState();
}
class _AnimatedBoxState extends State<AnimatedBox> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
setState(() {
_isExpanded = !_isExpanded;
});
},
child: AnimatedContainer(
// duration: 애니메이션이 실행될 시간
duration: Duration(milliseconds: 300),
// curve: 애니메이션의 가속도 곡선
curve: Curves.easeInOut,
// 확장 상태에 따라 크기 변경
width: _isExpanded ? 200 : 100,
height: _isExpanded ? 200 : 100,
// 색상도 자동으로 전환됨
decoration: BoxDecoration(
color: _isExpanded ? Colors.blue : Colors.red,
borderRadius: BorderRadius.circular(_isExpanded ? 50 : 10),
),
// 내부 콘텐츠도 부드럽게 정렬됨
child: Center(
child: Text(
_isExpanded ? '축소' : '확장',
style: TextStyle(color: Colors.white),
),
),
),
);
}
}
설명
이것이 하는 일: 위젯의 상태(_isExpanded)가 변경되면 AnimatedContainer가 자동으로 이전 값과 새로운 값 사이를 보간하여 부드러운 애니메이션을 생성합니다. 첫 번째로, GestureDetector의 onTap에서 setState를 호출하여 _isExpanded 값을 토글합니다.
이것이 애니메이션의 시작점입니다. setState가 호출되면 Flutter는 build 메서드를 다시 실행하게 되고, 이때 AnimatedContainer는 이전 속성 값과 새로운 속성 값을 비교합니다.
그 다음으로, AnimatedContainer가 duration에 지정된 300밀리초 동안 자동으로 값을 보간합니다. width가 100에서 200으로, height가 100에서 200으로, color가 빨강에서 파랑으로, borderRadius가 10에서 50으로 동시에 변화합니다.
curve 속성에 지정된 Curves.easeInOut 덕분에 애니메이션이 천천히 시작했다가 빨라지고 다시 천천히 끝나는 자연스러운 움직임을 만듭니다. 마지막으로, 모든 속성이 목표 값에 도달하면 애니메이션이 완료되고 최종 상태가 화면에 표시됩니다.
이 과정에서 여러분은 AnimationController나 Tween을 직접 관리할 필요가 전혀 없습니다. 여러분이 이 코드를 사용하면 복잡한 애니메이션 로직 없이도 전문적인 UI 전환 효과를 구현할 수 있습니다.
사용자가 탭할 때마다 박스가 부드럽게 확장되고 축소되며, 색상과 모서리 둥글기까지 자연스럽게 변화합니다. 코드 라인 수도 적고 이해하기 쉬워서 유지보수가 편리하며, 다른 개발자가 봐도 바로 이해할 수 있습니다.
실전 팁
💡 duration은 보통 200-400ms가 적당합니다. 너무 짧으면 애니메이션을 인지하기 어렵고, 너무 길면 답답해 보입니다.
💡 curve는 Curves.easeInOut이 가장 자연스럽지만, Curves.bounceOut(튕기는 효과)이나 Curves.elasticOut(탄성 효과)도 상황에 맞게 사용해보세요.
💡 여러 속성을 동시에 애니메이션할 때 성능 문제가 생기면, 변경이 필요한 속성만 조건문으로 분리하세요.
💡 AnimatedContainer는 자식 위젯의 내부 상태는 애니메이션하지 않습니다. 텍스트 크기나 아이콘 변경도 애니메이션하려면 AnimatedDefaultTextStyle이나 AnimatedSwitcher를 함께 사용하세요.
💡 디버그할 때는 duration을 일시적으로 길게(예: 2초) 설정하면 애니메이션의 각 단계를 눈으로 확인하기 쉽습니다.
2. AnimationController - 정밀한 애니메이션 제어
시작하며
여러분이 로딩 스피너를 만들거나, 무한 반복되는 애니메이션, 또는 여러 위젯이 순차적으로 나타나는 복잡한 시퀀스를 구현해야 할 때가 있나요? 예를 들어 스플래시 화면에서 로고가 페이드인되고, 그 다음 텍스트가 슬라이드 인되는 그런 효과 말이죠.
AnimatedContainer 같은 암묵적 애니메이션은 간단하지만, 애니메이션의 진행 상태를 직접 제어하거나 반복, 역재생, 일시정지 같은 고급 기능이 필요할 때는 한계가 있습니다. 또한 여러 위젯의 애니메이션을 동기화하기도 어렵습니다.
바로 이럴 때 필요한 것이 AnimationController입니다. 애니메이션의 타임라인을 직접 제어하고, 정확한 시점에 원하는 동작을 트리거할 수 있습니다.
개요
간단히 말해서, AnimationController는 애니메이션의 진행 상태를 0.0에서 1.0 사이의 값으로 관리하는 컨트롤러입니다. 애니메이션을 비디오 플레이어에 비유하면, AnimationController는 재생, 일시정지, 되감기 버튼을 제공하는 컨트롤러입니다.
예를 들어, 음악 플레이어 앱에서 재생 버튼을 누르면 프로그레스 바가 채워지는 애니메이션을 만들 때, AnimationController로 정확한 진행 상태를 추적하고 제어할 수 있습니다. 기존에는 Timer를 사용해서 주기적으로 값을 업데이트했다면, 이제는 AnimationController가 vsync에 맞춰 자동으로 최적화된 프레임 레이트로 값을 업데이트합니다.
AnimationController의 핵심 특징은 정밀한 타임라인 제어, 반복 및 역재생 지원, 그리고 vsync를 통한 성능 최적화입니다. 이러한 특징들이 복잡한 애니메이션을 만들 때 필수적이며, 배터리와 CPU를 효율적으로 사용하게 해줍니다.
코드 예제
class RotatingLogo extends StatefulWidget {
@override
_RotatingLogoState createState() => _RotatingLogoState();
}
class _RotatingLogoState extends State<RotatingLogo>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
// AnimationController 초기화: 2초 동안 0.0에서 1.0으로
_controller = AnimationController(
duration: Duration(seconds: 2),
vsync: this, // 화면 밖에서는 애니메이션 정지로 배터리 절약
);
// Tween: 0도에서 360도로 회전값 매핑
_animation = Tween<double>(begin: 0, end: 2 * 3.14159)
.animate(CurvedAnimation(
parent: _controller,
curve: Curves.linear, // 일정한 속도로 회전
));
// 무한 반복 시작
_controller.repeat();
}
@override
void dispose() {
_controller.dispose(); // 메모리 누수 방지를 위한 정리
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.rotate(
angle: _animation.value, // 현재 회전 각도 적용
child: child,
);
},
child: FlutterLogo(size: 100), // 캐싱되어 매번 재생성되지 않음
);
}
}
설명
이것이 하는 일: AnimationController가 매 프레임마다 0.0에서 1.0 사이의 값을 업데이트하고, Tween이 이를 실제 회전 각도로 변환하여, AnimatedBuilder가 이 값으로 위젯을 다시 그립니다. 첫 번째로, initState에서 AnimationController를 생성합니다.
duration은 한 번의 애니메이션 사이클이 얼마나 걸릴지 정의하고, vsync는 위젯이 화면에 보일 때만 애니메이션을 실행하도록 합니다. SingleTickerProviderStateMixin을 with으로 추가해야 vsync: this를 사용할 수 있습니다.
이것이 없으면 컴파일 에러가 발생합니다. 그 다음으로, Tween을 사용해서 컨트롤러의 0.01.0 값을 실제 사용할 각도 값(02π 라디안)으로 매핑합니다.
CurvedAnimation으로 감싸면 원하는 가속도 곡선을 적용할 수 있는데, 여기서는 Curves.linear를 사용해 일정한 속도로 회전하게 했습니다. _controller.repeat()을 호출하면 애니메이션이 끝날 때마다 자동으로 처음부터 다시 시작됩니다.
세 번째로, AnimatedBuilder가 매 프레임마다 builder 함수를 호출하면서 현재 애니메이션 값을 전달합니다. Transform.rotate의 angle에 _animation.value를 적용하면 로고가 회전하게 됩니다.
child 매개변수로 전달된 FlutterLogo는 매번 재생성되지 않고 캐싱되어 성능이 향상됩니다. 마지막으로, dispose에서 _controller.dispose()를 반드시 호출해야 합니다.
이것을 빼먹으면 위젯이 제거된 후에도 AnimationController가 계속 실행되어 메모리 누수가 발생합니다. 여러분이 이 코드를 사용하면 정밀하게 제어 가능한 애니메이션을 만들 수 있습니다.
로딩 스피너, 프로그레스 바, 펄스 효과 등 다양한 반복 애니메이션을 구현할 수 있습니다. forward(), reverse(), stop() 메서드로 애니메이션을 동적으로 제어할 수 있어, 사용자 인터랙션에 반응하는 복잡한 UI도 쉽게 만들 수 있습니다.
실전 팁
💡 AnimationController를 사용할 때는 항상 dispose에서 정리해야 합니다. 이것을 빼먹으면 메모리 누수의 주범이 됩니다.
💡 여러 애니메이션을 동시에 사용한다면 TickerProviderStateMixin 대신 SingleTickerProviderStateMixin을 사용하세요. 하나의 컨트롤러만 있다면 성능이 더 좋습니다.
💡 _controller.forward()는 비동기로 동작하므로 await를 사용할 수 있습니다. 애니메이션이 끝난 후 다른 작업을 하려면 await _controller.forward();로 대기하세요.
💡 디버그할 때는 timeDilation을 사용해서 애니메이션을 슬로우 모션으로 볼 수 있습니다. import 'package:flutter/scheduler.dart';를 추가하고 timeDilation = 5.0;을 설정하면 5배 느리게 재생됩니다.
💡 AnimationController의 value 속성으로 현재 진행 상태를 직접 읽거나 설정할 수 있습니다. 예를 들어 _controller.value = 0.5;로 50% 지점으로 점프할 수 있습니다.
3. Tween - 값의 보간을 담당하는 핵심
시작하며
여러분이 AnimationController를 사용해서 애니메이션을 만들 때, 컨트롤러가 제공하는 0.0~1.0 값을 실제 필요한 값(크기, 색상, 위치 등)으로 어떻게 변환할지 고민해본 적 있나요? 예를 들어 위젯을 50픽셀에서 200픽셀로 이동시키려면 어떻게 해야 할까요?
직접 계산식을 작성할 수도 있지만(예: 50 + (200-50) * controller.value), 이런 코드가 여러 곳에 반복되면 가독성이 떨어지고 실수하기 쉽습니다. 또한 색상이나 오프셋 같은 복잡한 타입은 보간 계산이 더 어렵습니다.
바로 이럴 때 필요한 것이 Tween입니다. AnimationController의 정규화된 값을 실제 사용할 값으로 매핑해주는 변환기 역할을 합니다.
개요
간단히 말해서, Tween은 시작값(begin)과 끝값(end) 사이를 부드럽게 보간하여 중간 값들을 생성하는 유틸리티입니다. 애니메이션의 값 변환이 필요한 모든 곳에서 Tween을 사용합니다.
예를 들어, 화면 전환 애니메이션에서 페이지가 오른쪽에서 왼쪽으로 슬라이드될 때, Offset(1.0, 0.0)에서 Offset(0.0, 0.0)으로 변하는 모든 중간 위치 값들을 Tween<Offset>이 자동으로 계산해줍니다. 기존에는 lerp(선형 보간) 함수를 직접 호출하거나 수동으로 계산했다면, 이제는 Tween이 타입별로 최적화된 보간 로직을 제공합니다.
Tween의 핵심 특징은 다양한 타입 지원(int, double, Color, Offset, Size 등), 체이닝 가능한 API, 그리고 CurvedAnimation과의 조합입니다. 이러한 특징들이 복잡한 값 변환을 단 몇 줄로 처리할 수 있게 해줍니다.
코드 예제
class SlidingBox extends StatefulWidget {
@override
_SlidingBoxState createState() => _SlidingBoxState();
}
class _SlidingBoxState extends State<SlidingBox>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _offsetAnimation;
late Animation<Color?> _colorAnimation;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(seconds: 2),
vsync: this,
);
// Offset Tween: 화면 오른쪽 밖에서 중앙으로
_offsetAnimation = Tween<Offset>(
begin: Offset(1.5, 0.0), // 오른쪽 밖
end: Offset.zero, // 원래 위치
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut, // 탄성 효과
));
// Color Tween: 빨강에서 파랑으로
_colorAnimation = ColorTween(
begin: Colors.red,
end: Colors.blue,
).animate(_controller);
// Double Tween: 크기 0.5배에서 1.0배로
_scaleAnimation = Tween<double>(
begin: 0.5,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
_controller.forward(); // 애니메이션 시작
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SlideTransition(
position: _offsetAnimation, // Offset 애니메이션 적용
child: ScaleTransition(
scale: _scaleAnimation, // 크기 애니메이션 적용
child: AnimatedBuilder(
animation: _colorAnimation,
builder: (context, child) {
return Container(
width: 100,
height: 100,
color: _colorAnimation.value, // 색상 애니메이션 적용
child: Center(
child: Text('Hello', style: TextStyle(color: Colors.white)),
),
);
},
),
),
);
}
}
설명
이것이 하는 일: 여러 Tween 객체가 AnimationController의 0.0~1.0 값을 각각 위치, 색상, 크기 값으로 변환하여, 세 가지 애니메이션이 동시에 부드럽게 실행됩니다. 첫 번째로, Tween<Offset>은 위젯의 위치를 제어합니다.
begin: Offset(1.5, 0.0)은 위젯의 너비만큼 오른쪽 밖에 있다는 의미이고, end: Offset.zero는 원래 위치입니다. animate() 메서드에 CurvedAnimation을 전달하면 Curves.elasticOut 효과가 적용되어 위젯이 튕기면서 들어오게 됩니다.
컨트롤러 값이 0.3일 때 Tween은 자동으로 중간 위치를 계산해줍니다. 그 다음으로, ColorTween은 색상 전환을 처리합니다.
Color 타입은 RGB 각 채널을 별도로 보간해야 하는데, ColorTween이 이 복잡한 계산을 자동으로 처리합니다. 빨강(255, 0, 0)에서 파랑(0, 0, 255)으로 변할 때 중간에 보라색 계열의 색상들이 자연스럽게 나타납니다.
세 번째로, Tween<double>은 크기 배율을 제어합니다. 0.5에서 1.0으로 변하면서 위젯이 작은 크기에서 시작해 점점 커집니다.
ScaleTransition이 이 값을 받아서 자동으로 Transform.scale을 적용합니다. 마지막으로, _controller.forward()가 호출되면 세 개의 애니메이션이 동시에 시작됩니다.
모든 Tween이 같은 AnimationController를 공유하기 때문에 완벽하게 동기화됩니다. 2초 동안 위치, 색상, 크기가 모두 부드럽게 변화하며, 각각 다른 curve를 사용해서 더 역동적인 효과를 만듭니다.
여러분이 이 코드를 사용하면 복잡한 다중 속성 애니메이션을 쉽게 구현할 수 있습니다. 직접 보간 계산을 작성할 필요 없이 Tween이 모든 타입에 맞는 최적의 보간을 제공합니다.
여러 Tween을 조합하면 훨씬 풍부하고 전문적인 애니메이션 효과를 만들 수 있습니다.
실전 팁
💡 IntTween도 있어서 정수 값을 애니메이션할 수 있습니다. 카운터 숫자가 증가하는 효과를 만들 때 유용합니다.
💡 커스텀 Tween을 만들려면 Tween 클래스를 상속하고 lerp 메서드를 오버라이드하세요. 예를 들어 BorderRadius나 커스텀 클래스에 대한 Tween을 만들 수 있습니다.
💡 Tween.chain()을 사용하면 여러 Tween을 순차적으로 연결할 수 있습니다. 0.00.5에서는 빨강→노랑, 0.51.0에서는 노랑→초록 같은 다단계 애니메이션이 가능합니다.
💡 ColorTween 대신 TweenSequence를 사용하면 여러 색상을 거치는 그라데이션 애니메이션을 만들 수 있습니다. 무지개 색상 전환 같은 효과에 유용합니다.
💡 성능을 위해 const 생성자를 사용할 수 없는 위젯은 late 변수로 initState에서 한 번만 생성하세요. 매 프레임마다 Tween을 새로 만들면 불필요한 객체 생성으로 성능이 저하됩니다.
4. Hero - 화면 간 공유 요소 전환 애니메이션
시작하며
여러분이 쇼핑 앱에서 상품 썸네일을 탭했을 때, 그 이미지가 부드럽게 확대되면서 상세 페이지로 전환되는 걸 본 적 있나요? 또는 Instagram에서 피드의 작은 프로필 사진이 프로필 페이지의 큰 사진으로 자연스럽게 이동하는 그런 효과 말이죠.
일반적인 화면 전환에서는 이전 화면이 사라지고 새 화면이 나타나면서 같은 요소가 두 번 그려져 연결감이 없습니다. 이를 수동으로 구현하려면 위치 계산, 크기 조정, 애니메이션 타이밍을 직접 관리해야 해서 매우 복잡합니다.
바로 이럴 때 필요한 것이 Hero 위젯입니다. 같은 tag를 가진 위젯끼리 자동으로 연결되어 화면 전환 시 부드럽게 이동합니다.
개요
간단히 말해서, Hero는 두 화면에서 같은 tag를 가진 위젯을 찾아서 자동으로 전환 애니메이션을 만들어주는 위젯입니다. 화면 전환의 연속성을 제공하여 사용자 경험을 크게 향상시킵니다.
예를 들어, 뉴스 앱에서 기사 목록의 작은 이미지를 탭하면 상세 페이지의 큰 이미지로 부드럽게 확대되면서 이동하는 효과를 단 몇 줄로 구현할 수 있습니다. 사용자는 어떤 항목을 선택했는지 시각적으로 명확하게 알 수 있습니다.
기존에는 커스텀 PageRouteBuilder를 만들고 AnimationController로 위치와 크기를 계산했다면, 이제는 양쪽 화면의 위젯을 Hero로 감싸고 같은 tag만 지정하면 됩니다. Hero의 핵심 특징은 자동 위치 및 크기 계산, 다양한 화면 전환과 호환, 그리고 커스터마이즈 가능한 전환 애니메이션입니다.
이러한 특징들이 복잡한 공유 요소 전환을 간단하게 만들어주고, 앱이 훨씬 세련되게 보이도록 합니다.
코드 예제
// 목록 화면
class ProductListPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('상품 목록')),
body: GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
),
itemCount: 10,
itemBuilder: (context, index) {
return GestureDetector(
onTap: () {
// 상세 페이지로 이동
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ProductDetailPage(index: index),
),
);
},
child: Hero(
// tag: 고유한 식별자 (양쪽 화면에서 동일해야 함)
tag: 'product-$index',
// Material: Hero 애니메이션 중 깜빡임 방지
child: Material(
color: Colors.transparent,
child: Container(
margin: EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.primaries[index % Colors.primaries.length],
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'상품 $index',
style: TextStyle(color: Colors.white, fontSize: 20),
),
),
),
),
),
);
},
),
);
}
}
// 상세 화면
class ProductDetailPage extends StatelessWidget {
final int index;
const ProductDetailPage({required this.index});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('상품 상세')),
body: Column(
children: [
Hero(
// tag: 목록 화면과 동일한 식별자
tag: 'product-$index',
child: Material(
color: Colors.transparent,
child: Container(
width: double.infinity,
height: 300,
decoration: BoxDecoration(
color: Colors.primaries[index % Colors.primaries.length],
),
child: Center(
child: Text(
'상품 $index',
style: TextStyle(color: Colors.white, fontSize: 40),
),
),
),
),
),
Padding(
padding: EdgeInsets.all(16),
child: Text(
'이곳에 상품 상세 정보가 표시됩니다.',
style: TextStyle(fontSize: 16),
),
),
],
),
);
}
}
설명
이것이 하는 일: Navigator.push로 새 화면이 열릴 때, Flutter는 두 화면에서 같은 tag를 가진 Hero를 찾아서 자동으로 크기와 위치를 보간하며 전환 애니메이션을 실행합니다. 첫 번째로, 목록 화면에서 상품을 탭하면 Navigator.push가 호출됩니다.
이 순간 Flutter는 현재 화면과 다음 화면의 위젯 트리를 스캔하여 Hero 위젯들을 찾습니다. 'product-$index'라는 동일한 tag를 가진 두 개의 Hero를 발견하면 이것들을 연결할 준비를 합니다.
그 다음으로, 화면 전환 애니메이션이 시작되는 동안 Flutter는 원래 화면의 Hero 위치와 크기(작은 그리드 아이템)에서 새 화면의 Hero 위치와 크기(전체 너비의 큰 배너)로 부드럽게 보간합니다. 이 과정에서 Hero 위젯은 임시로 오버레이 레이어에 그려져서 두 화면 위를 날아가는 것처럼 보입니다.
Material 위젯으로 감싼 이유는 Hero 애니메이션 중에 텍스트나 색상이 깜빡이는 것을 방지하기 위함입니다. 세 번째로, 애니메이션이 완료되면 임시 Hero가 제거되고 새 화면의 실제 Hero 위젯이 표시됩니다.
사용자는 자신이 탭한 상품이 부드럽게 확대되며 상세 페이지로 전환되는 것을 보게 됩니다. 뒤로 가기 버튼을 누르면 역방향 애니메이션이 자동으로 실행되어 큰 이미지가 다시 작은 그리드 아이템으로 축소됩니다.
마지막으로, tag가 고유하지 않으면(예: 모든 아이템에 같은 tag 사용) 예상치 못한 애니메이션이 발생할 수 있으므로, 인덱스나 ID를 사용해서 각 Hero를 구분해야 합니다. 여러분이 이 코드를 사용하면 전문적인 앱에서 볼 수 있는 공유 요소 전환을 쉽게 구현할 수 있습니다.
복잡한 계산 없이 Flutter가 자동으로 최적의 애니메이션 경로를 찾아줍니다. 사용자는 화면 전환의 맥락을 시각적으로 이해하게 되어 앱이 더 직관적이고 사용하기 편해집니다.
실전 팁
💡 Hero의 child는 가능하면 같은 타입의 위젯을 사용하세요. 예를 들어 Image에서 Container로 전환하면 깜빡일 수 있습니다.
💡 복잡한 위젯(많은 자식 위젯)을 Hero로 감싸면 성능이 저하될 수 있습니다. 가능하면 이미지나 아이콘 같은 단순한 위젯만 Hero로 만드세요.
💡 createRectTween 속성으로 Hero의 이동 경로를 커스터마이즈할 수 있습니다. 직선이 아닌 곡선 경로로 이동하는 효과도 만들 수 있습니다.
💡 flightShuttleBuilder를 사용하면 Hero가 날아가는 동안의 모습을 완전히 다르게 만들 수 있습니다. 예를 들어 이동 중에만 그림자를 추가하는 식입니다.
💡 Hero 애니메이션 중에 에러가 발생하면 'Multiple heroes share the same tag' 메시지를 확인하세요. 같은 화면에 중복된 tag가 있으면 안 됩니다.
5. AnimatedBuilder - 효율적인 애니메이션 렌더링
시작하며
여러분이 AnimationController와 Tween으로 애니메이션을 만들었는데, setState를 호출하면 위젯 트리 전체가 다시 빌드되어 성능이 떨어지는 걸 경험해본 적 있나요? 예를 들어 작은 아이콘 하나만 회전하는데 전체 페이지가 매 프레임마다 재빌드되는 그런 상황 말이죠.
애니메이션은 초당 60프레임으로 실행되므로, 불필요한 위젯까지 재빌드하면 성능 문제가 바로 눈에 띕니다. 특히 복잡한 UI에서 애니메이션을 여러 개 동시에 실행하면 앱이 버벅거리고 배터리도 빨리 닳습니다.
바로 이럴 때 필요한 것이 AnimatedBuilder입니다. 애니메이션이 변경될 때 필요한 부분만 정확히 재빌드하여 성능을 최적화합니다.
개요
간단히 말해서, AnimatedBuilder는 Animation 객체를 감시하다가 값이 변경되면 builder 함수만 호출하여 효율적으로 위젯을 업데이트하는 위젯입니다. 애니메이션 성능을 최적화하는 가장 중요한 도구입니다.
예를 들어, 화면에 100개의 아이템이 있는 리스트에서 맨 위의 로딩 스피너만 회전하게 만들 때, AnimatedBuilder를 사용하면 스피너 부분만 재빌드되고 나머지 99개 아이템은 그대로 유지됩니다. 기존에는 AnimationController에 리스너를 추가하고 setState를 호출했다면, 이제는 AnimatedBuilder가 자동으로 리스너를 관리하고 필요한 부분만 업데이트합니다.
AnimatedBuilder의 핵심 특징은 선택적 재빌드를 통한 성능 최적화, child 캐싱 기능, 그리고 간결한 코드 구조입니다. 이러한 특징들이 복잡한 애니메이션에서도 60fps를 유지할 수 있게 해주며, 코드의 가독성도 향상시킵니다.
코드 예제
class OptimizedSpinner extends StatefulWidget {
@override
_OptimizedSpinnerState createState() => _OptimizedSpinnerState();
}
class _OptimizedSpinnerState extends State<OptimizedSpinner>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(seconds: 1),
vsync: this,
)..repeat(); // repeat(): 무한 반복
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// 애니메이션되지 않는 무거운 위젯들
ExpensiveWidget(),
ComplexList(),
// AnimatedBuilder: 이 부분만 재빌드됨
AnimatedBuilder(
animation: _controller,
// child: 애니메이션과 무관한 부분은 캐싱됨
child: Icon(Icons.refresh, size: 50),
builder: (context, child) {
// builder는 매 프레임마다 호출되지만
// 이 함수 내부의 위젯만 재빌드됨
return Transform.rotate(
angle: _controller.value * 2 * 3.14159,
child: child, // 캐싱된 Icon 재사용
);
},
),
// 애니메이션되지 않는 더 많은 위젯들
AnotherExpensiveWidget(),
],
);
}
}
// 무거운 위젯 예시
class ExpensiveWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
print('ExpensiveWidget 빌드됨'); // AnimatedBuilder 사용 시 한 번만 출력됨
return Container(
height: 200,
color: Colors.grey[300],
child: Center(child: Text('무거운 위젯')),
);
}
}
설명
이것이 하는 일: AnimatedBuilder가 AnimationController를 감시하다가 값이 변경되면 builder 함수만 호출하여 Transform.rotate 위젯만 재빌드하고, 나머지 위젯들은 그대로 유지합니다. 첫 번째로, AnimatedBuilder의 animation 매개변수에 _controller를 전달하면, AnimatedBuilder가 내부적으로 _controller에 리스너를 추가합니다.
컨트롤러의 값이 변경될 때마다 이 리스너가 호출되어 builder 함수를 다시 실행하게 됩니다. 직접 addListener나 setState를 호출할 필요가 없습니다.
그 다음으로, child 매개변수로 전달된 Icon(Icons.refresh, size: 50)은 한 번만 생성되고 캐싱됩니다. builder 함수가 매 프레임마다 호출되어도 이 Icon은 재생성되지 않고 재사용됩니다.
이것이 성능 최적화의 핵심입니다. child가 복잡한 위젯 트리라면 이 효과가 더욱 크게 나타납니다.
세 번째로, builder 함수는 매 프레임마다 호출되면서 현재 애니메이션 값으로 Transform.rotate를 생성합니다. 하지만 이 함수의 범위를 벗어난 ExpensiveWidget, ComplexList, AnotherExpensiveWidget은 전혀 재빌드되지 않습니다.
print 문을 넣어보면 'ExpensiveWidget 빌드됨'이 처음 한 번만 출력되는 것을 확인할 수 있습니다. 마지막으로, Transform.rotate가 캐싱된 child(Icon)를 받아서 회전만 적용합니다.
결과적으로 화면에는 60fps로 부드럽게 회전하는 아이콘이 보이지만, 실제로 재빌드되는 것은 Transform.rotate 위젯 하나뿐입니다. Column의 다른 모든 자식들은 메모리에 그대로 유지됩니다.
여러분이 이 코드를 사용하면 복잡한 화면에서도 부드러운 애니메이션을 유지할 수 있습니다. CPU 사용량과 배터리 소모가 크게 줄어들며, Flutter DevTools의 성능 오버레이에서 재빌드 영역이 최소화된 것을 확인할 수 있습니다.
특히 리스트나 그리드처럼 많은 위젯이 있는 화면에서 AnimatedBuilder는 필수입니다.
실전 팁
💡 child 매개변수는 선택사항이지만 가능하면 항상 사용하세요. 애니메이션과 무관한 위젯을 child로 빼면 성능이 크게 향상됩니다.
💡 AnimatedBuilder 여러 개를 중첩해서 사용할 수 있습니다. 예를 들어 위치 애니메이션과 회전 애니메이션을 각각 다른 AnimatedBuilder로 분리하면 더 유연합니다.
💡 Listenable을 구현한 모든 객체를 animation으로 사용할 수 있습니다. AnimationController뿐만 아니라 ValueNotifier나 ChangeNotifier도 가능합니다.
💡 Flutter DevTools의 'Highlight Repaints'를 켜서 실제로 어느 부분이 재빌드되는지 확인하세요. AnimatedBuilder를 제대로 사용하면 애니메이션 부분만 하이라이트됩니다.
💡 매우 복잡한 애니메이션에서는 RepaintBoundary를 AnimatedBuilder 주변에 추가하면 레이어 분리로 성능이 더 향상될 수 있습니다.
6. TweenAnimationBuilder - 코드 없이 빠른 애니메이션
시작하며
여러분이 간단한 애니메이션을 만들고 싶은데, AnimationController를 설정하고 dispose 관리하는 게 너무 번거롭다고 느낀 적 있나요? 예를 들어 버튼을 누르면 숫자가 0에서 100으로 증가하는 카운터 애니메이션이나, 진행률 바가 채워지는 간단한 효과를 만들 때 말이죠.
AnimatedContainer는 암묵적 애니메이션이지만 Container의 속성만 애니메이션할 수 있고, AnimationController는 너무 보일러플레이트 코드가 많습니다. 단순히 값 하나를 애니메이션하고 싶을 뿐인데 StatefulWidget, initState, dispose를 모두 작성해야 합니다.
바로 이럴 때 필요한 것이 TweenAnimationBuilder입니다. 어떤 타입의 값이든 간단하게 애니메이션하면서도 컨트롤러 관리는 자동으로 처리됩니다.
개요
간단히 말해서, TweenAnimationBuilder는 Tween의 시작값과 끝값을 지정하면 자동으로 애니메이션을 실행하고, 매 프레임마다 builder 함수에 현재 값을 전달하는 위젯입니다. 일회성이거나 상태 변경에 따른 애니메이션을 빠르게 구현할 때 최고의 선택입니다.
예를 들어, 대시보드에서 매출 지표가 업데이트될 때 숫자가 부드럽게 증가하는 효과, 프로그레스 바가 0%에서 완료 퍼센트까지 채워지는 효과, 또는 평점이 별 모양으로 천천히 표시되는 효과를 단 몇 줄로 만들 수 있습니다. 기존에는 StatefulWidget과 AnimationController가 필수였다면, 이제는 StatelessWidget에서도 복잡한 값 애니메이션을 구현할 수 있습니다.
TweenAnimationBuilder의 핵심 특징은 자동 컨트롤러 관리, 모든 타입 지원, 그리고 선언적 API입니다. 이러한 특징들이 개발 속도를 높이고 코드 라인 수를 줄여주며, 리소스 정리를 자동으로 처리해줍니다.
코드 예제
class AnimatedCounter extends StatefulWidget {
@override
_AnimatedCounterState createState() => _AnimatedCounterState();
}
class _AnimatedCounterState extends State<AnimatedCounter> {
double _targetValue = 0;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// TweenAnimationBuilder: double 값 애니메이션
TweenAnimationBuilder<double>(
// tween: 0에서 _targetValue까지
tween: Tween<double>(begin: 0, end: _targetValue),
// duration: 애니메이션 실행 시간
duration: Duration(seconds: 2),
// curve: 가속도 곡선
curve: Curves.easeOutCubic,
// builder: 매 프레임마다 호출됨
builder: (context, value, child) {
return Column(
children: [
Text(
value.toStringAsFixed(0), // 소수점 제거
style: TextStyle(
fontSize: 72,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
// child: 변경되지 않는 부분 캐싱
if (child != null) child,
],
);
},
// 캐싱될 자식 위젯
child: Text(
'매출 건수',
style: TextStyle(fontSize: 24, color: Colors.grey),
),
),
SizedBox(height: 40),
// 프로그레스 바 애니메이션
TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: _targetValue / 100),
duration: Duration(seconds: 2),
curve: Curves.easeInOut,
builder: (context, value, child) {
return Column(
children: [
LinearProgressIndicator(
value: value, // 0.0 ~ 1.0
backgroundColor: Colors.grey[300],
color: Colors.green,
minHeight: 10,
),
SizedBox(height: 8),
Text('${(value * 100).toStringAsFixed(0)}%'),
],
);
},
),
SizedBox(height: 40),
ElevatedButton(
onPressed: () {
setState(() {
// 목표값 변경 시 자동으로 애니메이션 시작
_targetValue = _targetValue == 0 ? 100 : 0;
});
},
child: Text('애니메이션 시작'),
),
],
);
}
}
설명
이것이 하는 일: _targetValue가 변경되어 setState가 호출되면, TweenAnimationBuilder가 내부적으로 AnimationController를 생성하여 지정된 duration 동안 0에서 _targetValue까지 값을 증가시키며 builder를 호출합니다. 첫 번째로, 버튼을 누르면 _targetValue가 0에서 100으로 변경되고 setState가 호출됩니다.
이때 TweenAnimationBuilder의 build가 실행되며, 새로운 Tween<double>(begin: 0, end: 100)이 전달됩니다. TweenAnimationBuilder는 이전 end 값과 새로운 end 값을 비교하여 다르면 애니메이션을 시작합니다.
그 다음으로, TweenAnimationBuilder가 내부적으로 AnimationController를 생성하고 2초 동안 0.0에서 1.0까지 값을 증가시킵니다. 이 값이 Tween에 전달되어 실제 숫자(0~100)로 변환되고, Curves.easeOutCubic이 적용되어 처음엔 빠르게 증가하다가 점점 느려집니다.
builder 함수는 매 프레임마다 호출되면서 현재 값을 받아 Text 위젯의 내용을 업데이트합니다. 세 번째로, child 매개변수로 전달된 '매출 건수' 텍스트는 한 번만 생성되고 builder 함수에 전달되어 재사용됩니다.
60fps로 실행되는 동안 이 텍스트는 매번 재생성되지 않아 성능이 향상됩니다. 두 번째 TweenAnimationBuilder는 동일한 _targetValue를 사용하지만 0~1 범위로 나누어 프로그레스 바에 적용합니다.
마지막으로, 애니메이션이 완료되면 TweenAnimationBuilder는 자동으로 내부 컨트롤러를 정리합니다. dispose를 직접 호출할 필요가 없으며, 위젯이 제거되면 리소스도 자동으로 해제됩니다.
버튼을 다시 누르면 _targetValue가 100에서 0으로 바뀌며 역방향 애니메이션이 자동으로 실행됩니다. 여러분이 이 코드를 사용하면 보일러플레이트 없이 간단하게 값 애니메이션을 구현할 수 있습니다.
카운터, 진행률 바, 평점 별, 게이지 등 숫자로 표현되는 모든 것을 애니메이션할 수 있습니다. StatelessWidget에서도 사용 가능하여 코드 구조가 단순해지고, 여러 TweenAnimationBuilder를 조합하면 복잡한 다중 값 애니메이션도 쉽게 만들 수 있습니다.
실전 팁
💡 onEnd 콜백을 사용하면 애니메이션이 완료된 후 추가 작업을 할 수 있습니다. 예를 들어 카운터가 목표에 도달하면 축하 메시지를 표시하는 식입니다.
💡 ColorTween, AlignmentTween 등 모든 Tween 타입을 사용할 수 있습니다. 색상 전환, 위치 이동 등 다양한 애니메이션이 가능합니다.
💡 begin 값을 명시적으로 지정하지 않으면 이전 end 값이 자동으로 begin이 됩니다. 부드러운 값 전환을 원할 때 유용합니다.
💡 여러 값을 동시에 애니메이션하려면 TweenAnimationBuilder를 중첩하거나, 커스텀 클래스를 만들어 Tween<MyClass>를 사용하세요.
💡 성능을 위해 builder 내부에서 새로운 객체를 생성하지 마세요. 가능하면 값만 변경하고 위젯 구조는 유지하세요.
7. SlideTransition과 FadeTransition - 전환 애니메이션 전용 위젯
시작하며
여러분이 위젯을 페이드 인/아웃하거나 슬라이드 방식으로 나타나게 하고 싶을 때, Transform이나 Opacity를 AnimatedBuilder로 감싸서 매번 구현하는 게 반복적이라고 느낀 적 있나요? 예를 들어 사이드 메뉴가 왼쪽에서 슬라이드되어 나타나거나, 알림 배너가 서서히 투명해지며 사라지는 그런 효과 말이죠.
수동으로 Transform.translate와 Opacity를 사용할 수도 있지만, 코드가 장황해지고 매번 비슷한 패턴을 작성하게 됩니다. 또한 성능 최적화를 위한 RepaintBoundary나 레이어 처리를 직접 관리해야 합니다.
바로 이럴 때 필요한 것이 SlideTransition과 FadeTransition입니다. 가장 흔한 전환 효과를 최적화된 형태로 제공하는 전용 위젯들입니다.
개요
간단히 말해서, SlideTransition과 FadeTransition은 위치 이동과 투명도 전환을 위해 최적화된 전용 애니메이션 위젯으로, Animation 객체만 전달하면 자동으로 효율적인 애니메이션을 실행합니다. 위젯 등장/퇴장 애니메이션을 만들 때 가장 자주 사용됩니다.
예를 들어, 모달 다이얼로그가 아래에서 위로 슬라이드되며 나타나거나, 툴팁이 서서히 나타났다가 사라지는 효과를 구현할 때, 이 위젯들은 내부적으로 Flutter의 렌더링 파이프라인과 최적화된 방식으로 동작합니다. 기존에는 AnimatedBuilder 안에서 Transform이나 Opacity를 직접 조작했다면, 이제는 전용 위젯이 더 적은 코드로 더 나은 성능을 제공합니다.
이 위젯들의 핵심 특징은 하드웨어 가속 최적화, 레이어 캐싱 자동 처리, 그리고 간결한 API입니다. 이러한 특징들이 매끄러운 60fps 애니메이션을 보장하며, 특히 모바일 기기에서 배터리 효율을 높여줍니다.
코드 예제
class AnimatedMenu extends StatefulWidget {
@override
_AnimatedMenuState createState() => _AnimatedMenuState();
}
class _AnimatedMenuState extends State<AnimatedMenu>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _slideAnimation;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(milliseconds: 400),
vsync: this,
);
// SlideTransition용: Offset 애니메이션
// Offset(0, 0)은 원래 위치, Offset(-1, 0)은 왼쪽 밖
_slideAnimation = Tween<Offset>(
begin: Offset(-1.0, 0.0), // 왼쪽 밖에서 시작
end: Offset.zero, // 원래 위치로
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic, // 부드러운 감속
));
// FadeTransition용: 투명도 애니메이션
_fadeAnimation = Tween<double>(
begin: 0.0, // 완전 투명
end: 1.0, // 완전 불투명
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeIn, // 페이드는 easeIn이 자연스러움
));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _toggleMenu() {
if (_controller.isCompleted) {
_controller.reverse(); // 메뉴 닫기
} else {
_controller.forward(); // 메뉴 열기
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('전환 애니메이션'),
leading: IconButton(
icon: Icon(Icons.menu),
onPressed: _toggleMenu,
),
),
body: Stack(
children: [
Center(child: Text('메인 콘텐츠')),
// SlideTransition과 FadeTransition 조합
SlideTransition(
position: _slideAnimation, // Offset 애니메이션
child: FadeTransition(
opacity: _fadeAnimation, // 투명도 애니메이션
child: Container(
width: 250,
height: double.infinity,
color: Colors.blue[800],
child: Column(
children: [
SizedBox(height: 100),
ListTile(
leading: Icon(Icons.home, color: Colors.white),
title: Text('홈', style: TextStyle(color: Colors.white)),
),
ListTile(
leading: Icon(Icons.settings, color: Colors.white),
title: Text('설정', style: TextStyle(color: Colors.white)),
),
],
),
),
),
),
],
),
);
}
}
설명
이것이 하는 일: AnimationController가 0.0에서 1.0으로 변하면서 SlideTransition은 메뉴를 왼쪽 밖에서 원래 위치로 이동시키고, 동시에 FadeTransition은 투명에서 불투명하게 만듭니다. 첫 번째로, _slideAnimation은 Tween<Offset>으로 정의됩니다.
Offset(-1.0, 0.0)은 위젯의 너비만큼 왼쪽에 있다는 의미입니다. 위젯이 250픽셀이면 화면 밖 왼쪽 250픽셀 위치에서 시작합니다.
Offset(0, -1)이면 위쪽 밖, Offset(1, 0)이면 오른쪽 밖입니다. 상대적인 값이라 화면 크기에 관계없이 동작합니다.
그 다음으로, 메뉴 버튼을 누르면 _toggleMenu()가 호출되어 _controller.forward()가 실행됩니다. 컨트롤러가 0.0에서 1.0으로 400밀리초 동안 변화하며, SlideTransition은 매 프레임마다 _slideAnimation.value를 읽어서 위젯의 위치를 업데이트합니다.
Curves.easeOutCubic 덕분에 처음엔 빠르게 움직이다가 점점 느려져 자연스럽게 멈춥니다. 세 번째로, FadeTransition이 SlideTransition의 자식으로 감싸져 있어 두 효과가 동시에 적용됩니다.
_fadeAnimation은 0.0(투명)에서 1.0(불투명)으로 변하며, Curves.easeIn으로 서서히 나타나는 효과를 줍니다. 두 애니메이션이 같은 _controller를 공유하므로 완벽하게 동기화됩니다.
마지막으로, 이미 열린 상태에서 버튼을 다시 누르면 _controller.reverse()가 호출되어 애니메이션이 역방향으로 실행됩니다. 메뉴가 다시 왼쪽으로 슬라이드되며 사라지고 투명해집니다.
이 모든 과정에서 SlideTransition과 FadeTransition은 내부적으로 하드웨어 가속을 사용하고 레이어를 효율적으로 관리하여 성능을 최적화합니다. 여러분이 이 코드를 사용하면 전문적인 메뉴 애니메이션을 쉽게 구현할 수 있습니다.
Transform이나 Opacity를 직접 조작하는 것보다 코드가 간결하고 성능도 더 좋습니다. RotateTransition, ScaleTransition 등 다른 전환 위젯들과도 자유롭게 조합하여 복잡한 등장 효과를 만들 수 있습니다.
실전 팁
💡 AlignTransition, PositionedTransition, ScaleTransition, RotateTransition 등 다양한 전환 위젯이 있습니다. 필요에 맞는 것을 선택하세요.
💡 여러 Transition 위젯을 중첩할 때는 순서가 중요합니다. 보통 SlideTransition을 바깥에, FadeTransition을 안쪽에 두는 게 자연스럽습니다.
💡 DecoratedBoxTransition을 사용하면 BoxDecoration을 애니메이션할 수 있어 색상, 그림자, 테두리 등을 부드럽게 전환할 수 있습니다.
💡 FadeTransition의 opacity가 0.0일 때도 위젯은 여전히 레이아웃 공간을 차지합니다. 완전히 제거하려면 Visibility나 AnimatedSwitcher를 사용하세요.
💡 성능을 최대화하려면 Transition 위젯의 자식을 const로 만들거나 캐싱하세요. 매 프레임마다 재생성되는 위젯이 없도록 주의하세요.
8. Staggered Animation - 순차적으로 실행되는 다단계 애니메이션
시작하며
여러분이 복잡한 UI 등장 효과를 만들고 싶을 때가 있나요? 예를 들어 카드가 나타날 때 먼저 슬라이드 인되고, 그 다음 페이드 인되며, 마지막으로 크기가 커지는 그런 순차적인 효과 말이죠.
또는 리스트 아이템들이 하나씩 차례대로 나타나는 캐스케이드 애니메이션을 본 적 있을 겁니다. 각 애니메이션 단계마다 별도의 AnimationController를 만들고 순차적으로 트리거하는 방법도 있지만, 타이밍 관리가 복잡하고 코드가 지저분해집니다.
여러 애니메이션의 시작과 끝 시점을 정확히 조율하기도 어렵습니다. 바로 이럴 때 필요한 것이 Staggered Animation(엇갈린 애니메이션) 패턴입니다.
하나의 AnimationController로 여러 애니메이션을 다른 시간대에 실행하여 풍부한 시퀀스를 만듭니다.
개요
간단히 말해서, Staggered Animation은 하나의 AnimationController를 여러 개의 Tween과 Interval로 분할하여, 타임라인의 서로 다른 구간에서 각각의 애니메이션이 실행되도록 하는 기법입니다. 전문적인 앱의 화면 전환이나 복잡한 UI 등장 효과에서 자주 사용됩니다.
예를 들어, 프로필 화면에 들어갈 때 프로필 사진이 먼저 나타나고(0.00.3), 이름이 페이드 인되고(0.20.5), 설명 텍스트가 슬라이드 인되는(0.4~0.8) 식의 겹치면서도 순차적인 효과를 만들 수 있습니다. 기존에는 여러 AnimationController를 만들거나 Timer를 사용해서 순차 실행했다면, 이제는 단일 컨트롤러로 모든 애니메이션을 동기화하고 관리할 수 있습니다.
Staggered Animation의 핵심 특징은 단일 컨트롤러로 다중 애니메이션 조율, Interval을 통한 정밀한 타이밍 제어, 그리고 자연스러운 겹침 효과입니다. 이러한 특징들이 복잡해 보이는 애니메이션을 체계적으로 구현할 수 있게 하며, 타이밍을 쉽게 조정할 수 있게 해줍니다.
코드 예제
class StaggeredCard extends StatefulWidget {
@override
_StaggeredCardState createState() => _StaggeredCardState();
}
class _StaggeredCardState extends State<StaggeredCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
// 각 단계별 애니메이션
late Animation<double> _scaleAnimation;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
late Animation<double> _rotateAnimation;
@override
void initState() {
super.initState();
// 전체 애니메이션을 제어하는 단일 컨트롤러
_controller = AnimationController(
duration: Duration(milliseconds: 2000), // 전체 2초
vsync: this,
);
// 1단계 (0.0 ~ 0.3): 크기 애니메이션
_scaleAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Interval(0.0, 0.3, curve: Curves.easeOut), // 0~30% 구간
));
// 2단계 (0.2 ~ 0.5): 페이드 인 (1단계와 겹침)
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Interval(0.2, 0.5, curve: Curves.easeIn), // 20~50% 구간
));
// 3단계 (0.4 ~ 0.7): 슬라이드 인
_slideAnimation = Tween<Offset>(
begin: Offset(0, 0.5),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _controller,
curve: Interval(0.4, 0.7, curve: Curves.easeOutCubic), // 40~70% 구간
));
// 4단계 (0.6 ~ 1.0): 회전
_rotateAnimation = Tween<double>(
begin: -0.1,
end: 0.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Interval(0.6, 1.0, curve: Curves.elasticOut), // 60~100% 구간
));
// 애니메이션 시작
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _rotateAnimation.value,
child: Transform.scale(
scale: _scaleAnimation.value,
child: FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: Card(
elevation: 8,
child: Container(
width: 200,
height: 150,
padding: EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.star, size: 50, color: Colors.amber),
SizedBox(height: 8),
Text(
'Staggered!',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
),
),
),
);
},
);
}
}
설명
이것이 하는 일: AnimationController가 0.0에서 1.0으로 진행되는 동안, Interval로 분할된 각 애니메이션이 지정된 타임라인 구간에서만 실행되어 순차적이면서도 겹치는 복잡한 효과를 만듭니다. 첫 번째로, Interval의 첫 두 매개변수는 애니메이션이 활성화되는 구간을 정의합니다.
예를 들어 Interval(0.2, 0.5)는 컨트롤러 값이 0.20.5일 때만 0.01.0으로 매핑됩니다. 컨트롤러가 0.2 미만일 때는 애니메이션 값이 0.0으로 고정되고, 0.5를 넘으면 1.0으로 고정됩니다.
이것이 각 애니메이션을 타임라인의 특정 부분에만 활성화시키는 핵심 메커니즘입니다. 그 다음으로, 애니메이션이 시작되면 0.0~0.3 구간(첫 600ms)에서 _scaleAnimation이 카드를 0에서 1배 크기로 확대합니다.
0.2 지점(400ms)에 도달하면 _fadeAnimation이 시작되어 투명에서 불투명으로 전환됩니다. 이때 크기 애니메이션은 아직 진행 중이므로 두 효과가 겹칩니다.
0.3 지점에서 크기 애니메이션이 완료되지만 페이드는 0.5까지 계속됩니다. 세 번째로, 0.4 지점(800ms)에서 _slideAnimation이 시작되어 카드가 아래에서 위로 슬라이드됩니다.
이때도 페이드 애니메이션이 여전히 진행 중입니다. 0.6 지점(1200ms)에서 _rotateAnimation이 시작되어 카드가 -0.1 라디안에서 0으로 회전하며 바르게 서는 효과를 줍니다.
Curves.elasticOut 덕분에 약간 튕기는 듯한 마무리가 됩니다. 마지막으로, 1.0 지점(2000ms)에서 모든 애니메이션이 완료되고 카드가 최종 상태로 안정됩니다.
이 전체 과정에서 단 하나의 AnimationController만 사용했지만, 네 가지 서로 다른 효과가 타이밍에 맞춰 조화롭게 실행됩니다. 타이밍을 조정하려면 Interval의 값만 바꾸면 되므로 매우 유연합니다.
여러분이 이 코드를 사용하면 영화 같은 화려한 UI 전환을 만들 수 있습니다. 온보딩 화면, 프로필 화면, 상세 페이지 등 사용자의 시선을 끌어야 하는 곳에서 효과적입니다.
여러 애니메이션을 동기화하기 쉽고, 전체 duration만 바꾸면 모든 애니메이션 속도가 비례적으로 조정되어 관리가 편리합니다.
실전 팁
💡 Interval의 구간을 겹치게 설정하면 더 자연스러운 흐름을 만들 수 있습니다. 각 애니메이션이 완전히 끝나기 전에 다음 것을 시작하세요.
💡 리스트 아이템들을 차례대로 나타나게 하려면 각 아이템의 Interval을 약간씩 지연시키세요. 예: 첫 번째 (0.00.3), 두 번째 (0.10.4), 세 번째 (0.2~0.5).
💡 TweenSequence를 사용하면 Interval 없이도 여러 단계를 정의할 수 있지만, Interval이 더 유연하고 이해하기 쉽습니다.
💡 디버그할 때는 각 Interval의 시작/끝 지점에 print를 넣어서 타이밍이 의도대로인지 확인하세요.
💡 복잡한 Staggered Animation은 별도의 AnimationClass를 만들어 관리하면 코드가 깔끔해집니다. 모든 애니메이션을 하나의 클래스에 캡슐화하세요.
9. AnimatedSwitcher - 위젯 교체 시 자동 전환
시작하며
여러분이 버튼을 누르면 텍스트가 바뀌거나, 조건에 따라 다른 아이콘이 표시되는 UI를 만들 때, 즉각적인 변경이 아닌 부드러운 전환을 원한 적 있나요? 예를 들어 좋아요 버튼을 누르면 빈 하트가 채워진 하트로 페이드 전환되거나, 로딩이 완료되면 스피너가 체크 아이콘으로 바뀌는 그런 효과 말이죠.
조건문으로 위젯을 교체하면 순간적으로 깜빡이며 바뀌어 사용자 경험이 좋지 않습니다. 이를 애니메이션하려면 FadeTransition과 조건 로직을 조합해야 하는데 코드가 복잡해집니다.
바로 이럴 때 필요한 것이 AnimatedSwitcher입니다. 자식 위젯이 바뀔 때 자동으로 이전 위젯은 페이드 아웃하고 새 위젯은 페이드 인합니다.
개요
간단히 말해서, AnimatedSwitcher는 child 위젯이 변경되는 것을 감지하여 자동으로 이전 위젯과 새 위젯 사이에 전환 애니메이션을 적용하는 위젯입니다. 동적으로 변하는 콘텐츠를 표시할 때 매우 유용합니다.
예를 들어, 사용자 인증 상태에 따라 로그인 버튼과 프로필 아바타를 전환하거나, 검색 결과가 로딩 중일 때와 완료되었을 때의 UI를 부드럽게 교체하거나, 에러 메시지와 성공 메시지를 자연스럽게 바꿀 때 사용합니다. 기존에는 조건문으로 위젯을 분기하고 각각에 FadeTransition을 적용했다면, 이제는 AnimatedSwitcher로 감싸고 child만 바꾸면 됩니다.
AnimatedSwitcher의 핵심 특징은 자동 위젯 변경 감지, 커스터마이즈 가능한 전환 효과, 그리고 Key 기반 위젯 식별입니다. 이러한 특징들이 동적 UI를 만들 때 코드를 대폭 단순화하고, 일관된 전환 경험을 제공합니다.
코드 예제
class FavoriteButton extends StatefulWidget {
@override
_FavoriteButtonState createState() => _FavoriteButtonState();
}
class _FavoriteButtonState extends State<FavoriteButton> {
bool _isFavorite = false;
int _count = 0;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// AnimatedSwitcher: child가 바뀌면 자동 전환
AnimatedSwitcher(
// duration: 전환 애니메이션 시간
duration: Duration(milliseconds: 300),
// transitionBuilder: 전환 효과 커스터마이즈
transitionBuilder: (child, animation) {
// 기본은 FadeTransition이지만 ScaleTransition으로 변경
return ScaleTransition(
scale: animation,
child: child,
);
},
// child: Key가 다르면 새로운 위젯으로 인식
child: IconButton(
key: ValueKey<bool>(_isFavorite), // Key가 위젯 식별자
iconSize: 60,
icon: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
color: _isFavorite ? Colors.red : Colors.grey,
),
onPressed: () {
setState(() {
_isFavorite = !_isFavorite;
});
},
),
),
SizedBox(height: 40),
// 숫자 카운터 애니메이션
AnimatedSwitcher(
duration: Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
// 슬라이드 + 페이드 조합
return SlideTransition(
position: Tween<Offset>(
begin: Offset(0, 0.3),
end: Offset.zero,
).animate(animation),
child: FadeTransition(
opacity: animation,
child: child,
),
);
},
child: Text(
'$_count',
// Key: 숫자가 바뀔 때마다 새 위젯으로 인식
key: ValueKey<int>(_count),
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
),
SizedBox(height: 20),
ElevatedButton(
onPressed: () {
setState(() {
_count++;
});
},
child: Text('카운트 증가'),
),
],
);
}
}
설명
이것이 하는 일: child 위젯의 Key가 변경되는 것을 AnimatedSwitcher가 감지하면, 이전 child에 reverse 애니메이션을 적용하며 페이드 아웃시키고, 동시에 새 child에 forward 애니메이션을 적용하며 페이드 인시킵니다. 첫 번째로, _isFavorite 값이 변경되어 setState가 호출되면 IconButton의 key인 ValueKey<bool>(_isFavorite)도 바뀝니다.
예를 들어 ValueKey(false)에서 ValueKey(true)로 변경됩니다. AnimatedSwitcher는 이전 child와 새 child의 key를 비교하여 다르면 위젯이 교체되었다고 판단합니다.
Key가 없으면 위젯 타입만으로 비교하므로, 같은 Icon 위젯끼리는 교체로 인식되지 않습니다. 그 다음으로, 위젯 교체가 감지되면 AnimatedSwitcher는 두 개의 애니메이션을 동시에 실행합니다.
이전 위젯(빈 하트)에는 1.0에서 0.0으로 가는 역방향 애니메이션을 적용하고, 새 위젯(채워진 하트)에는 0.0에서 1.0으로 가는 정방향 애니메이션을 적용합니다. transitionBuilder에서 ScaleTransition을 사용했으므로 하트가 작아지며 사라지고, 동시에 새 하트가 커지며 나타납니다.
세 번째로, 카운터 예제에서는 숫자가 증가할 때마다 ValueKey<int>(_count)가 바뀝니다. ValueKey(0), ValueKey(1), ValueKey(2)로 계속 변하므로 매번 새로운 위젯으로 인식됩니다.
transitionBuilder에서 SlideTransition과 FadeTransition을 조합했기 때문에, 이전 숫자는 위로 슬라이드되며 사라지고 새 숫자는 아래에서 슬라이드되며 나타나는 카운터 롤링 효과가 생깁니다. 마지막으로, duration은 300밀리초로 설정되어 있어 빠르지만 부드러운 전환이 이루어집니다.
너무 짧으면 깜빡이는 것처럼 보이고, 너무 길면 답답하므로 200~400ms가 적당합니다. AnimatedSwitcher는 자동으로 이전 위젯을 제거하고 메모리를 정리하므로 누수 걱정이 없습니다.
여러분이 이 코드를 사용하면 상태에 따라 변하는 UI를 전문적으로 만들 수 있습니다. 로딩 상태, 에러 상태, 성공 상태 간 전환, 다국어 텍스트 전환, 테마 변경 시 아이콘 전환 등 다양한 곳에 활용할 수 있습니다.
Key만 제대로 설정하면 Flutter가 알아서 부드러운 전환을 만들어줍니다.
실전 팁
💡 Key를 빼먹으면 AnimatedSwitcher가 위젯 변경을 감지하지 못합니다. ValueKey, ObjectKey, UniqueKey 중 상황에 맞게 선택하세요.
💡 switchInCurve와 switchOutCurve를 따로 설정하면 들어올 때와 나갈 때 다른 가속도 곡선을 사용할 수 있습니다.
💡 layoutBuilder를 커스터마이즈하면 이전 위젯과 새 위젯이 동시에 표시되는 동안의 레이아웃을 제어할 수 있습니다. 기본은 Stack입니다.
💡 복잡한 위젯을 교체할 때는 성능을 위해 RepaintBoundary로 감싸세요. 전환 애니메이션 중 불필요한 리페인트를 줄일 수 있습니다.
💡 같은 타입의 위젯을 교체할 때는 반드시 Key를 사용하세요. 예를 들어 Text('A')에서 Text('B')로 바뀔 때 Key가 없으면 AnimatedSwitcher가 감지하지 못합니다.
10. CurvedAnimation과 Curves - 자연스러운 움직임 만들기
시작하며
여러분이 만든 애니메이션이 기계적으로 느껴지거나 부자연스럽게 보인 적 있나요? 예를 들어 박스가 일정한 속도로만 움직여서 생동감이 없거나, 멈출 때 갑자기 멈춰서 어색한 그런 느낌 말이죠.
실제 물리 세계에서 물체는 가속과 감속을 하며 움직입니다. 선형 애니메이션(linear)만 사용하면 로봇 같은 움직임이 되어 사용자 경험이 떨어집니다.
모든 애니메이션이 같은 속도로 진행되면 지루하고 평면적으로 느껴집니다. 바로 이럴 때 필요한 것이 CurvedAnimation과 Curves입니다.
시간에 따른 속도 변화를 조절하여 자연스럽고 생동감 있는 움직임을 만듭니다.
개요
간단히 말해서, Curves는 애니메이션의 가속도 곡선을 정의하는 사전 정의된 함수들의 모음이고, CurvedAnimation은 AnimationController에 이 곡선을 적용하는 래퍼입니다. 애니메이션의 느낌을 결정짓는 가장 중요한 요소입니다.
예를 들어, Curves.easeOut을 사용하면 빠르게 시작했다가 천천히 끝나서 부드럽게 멈추는 느낌을 주고, Curves.bounceOut을 사용하면 목적지에 도착한 후 튕기는 재미있는 효과를 낼 수 있습니다. 같은 애니메이션이라도 curve만 바꾸면 완전히 다른 인상을 줍니다.
기존에는 linear curve만 사용하거나 직접 수학 함수를 작성했다면, 이제는 Flutter가 제공하는 40가지 이상의 사전 정의된 curve 중에서 선택할 수 있습니다. Curves의 핵심 특징은 물리 법칙을 반영한 자연스러운 움직임, 다양한 개성 있는 효과, 그리고 간단한 적용 방법입니다.
이러한 특징들이 앱을 더 생동감 있고 전문적으로 만들어주며, 사용자에게 즐거운 경험을 제공합니다.
코드 예제
class CurvesDemo extends StatefulWidget {
@override
_CurvesDemoState createState() => _CurvesDemoState();
}
class _CurvesDemoState extends State<CurvesDemo>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _linearAnimation;
late Animation<double> _easeInOutAnimation;
late Animation<double> _bounceAnimation;
late Animation<double> _elasticAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(seconds: 2),
vsync: this,
);
// 1. Linear: 일정한 속도 (기본값)
_linearAnimation = Tween<double>(begin: 0, end: 300).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.linear, // 일정한 속도
),
);
// 2. EaseInOut: 천천히 시작, 빠르게 진행, 천천히 끝 (가장 자연스러움)
_easeInOutAnimation = Tween<double>(begin: 0, end: 300).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut, // 가속 후 감속
),
);
// 3. BounceOut: 도착 후 튕김
_bounceAnimation = Tween<double>(begin: 0, end: 300).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.bounceOut, // 튕기는 효과
),
);
// 4. ElasticOut: 도착 후 탄성 효과
_elasticAnimation = Tween<double>(begin: 0, end: 300).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut, // 고무줄처럼 늘어남
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _startAnimation() {
_controller.reset(); // 0으로 리셋
_controller.forward(); // 애니메이션 시작
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Curves 비교')),
body: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Column(
children: [
SizedBox(height: 20),
_buildRow('Linear', _linearAnimation.value, Colors.red),
_buildRow('EaseInOut', _easeInOutAnimation.value, Colors.blue),
_buildRow('BounceOut', _bounceAnimation.value, Colors.green),
_buildRow('ElasticOut', _elasticAnimation.value, Colors.orange),
],
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: _startAnimation,
child: Icon(Icons.play_arrow),
),
);
}
Widget _buildRow(String label, double value, Color color) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 10, horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 5),
Stack(
children: [
Container(
width: 300,
height: 40,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
),
Positioned(
left: value, // 현재 애니메이션 값만큼 이동
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
],
),
);
}
}
설명
이것이 하는 일: AnimationController가 0.0에서 1.0으로 선형적으로 증가하지만, CurvedAnimation이 각 curve 함수를 적용하여 실제 출력 값은 다른 속도로 변화하게 만듭니다. 첫 번째로, Curves.linear는 입력과 출력이 1:1로 매핑됩니다.
컨트롤러가 0.5일 때 애니메이션 값도 정확히 0.5입니다. 시간이 절반 지나면 거리도 절반 이동한 상태입니다.
그래프로 그리면 직선이 되며, 일정한 속도로 움직입니다. 이것은 기계적이고 생명감이 없어 보통 사용하지 않습니다.
그 다음으로, Curves.easeInOut는 S자 곡선입니다. 처음 0.00.3 구간에서는 천천히 시작하여(ease in) 가속하고, 0.30.7 구간에서는 빠른 속도로 진행하며, 0.7~1.0 구간에서는 감속하여(ease out) 부드럽게 멈춥니다.
실제 물체의 움직임과 비슷해서 가장 자연스럽고, UI 애니메이션의 기본 선택으로 권장됩니다. 세 번째로, Curves.bounceOut는 끝 지점에 도달한 후 여러 번 튕깁니다.
컨트롤러가 1.0에 가까워지면 출력 값이 1.0을 넘었다가 돌아오고, 다시 넘었다가 돌아오는 식으로 진동합니다. 마치 공이 바닥에 떨어져 튕기는 것처럼 보여 재미있는 효과를 줍니다.
버튼 클릭 피드백이나 성공 메시지에 적합합니다. 마지막으로, Curves.elasticOut는 목표를 지나쳤다가 돌아오는 오버슈트(overshoot) 효과를 만듭니다.
bounceOut보다 더 과장되어 고무줄처럼 늘어났다가 다시 수축하는 느낌입니다. 사용자의 주의를 끌어야 하는 알림이나 중요한 UI 요소에 사용하면 효과적이지만, 너무 자주 사용하면 산만해 보일 수 있습니다.
여러분이 이 코드를 실행하면 네 개의 박스가 동시에 출발하지만 각각 다른 방식으로 이동하는 것을 볼 수 있습니다. linear는 로봇처럼, easeInOut은 자연스럽게, bounceOut은 통통 튀며, elasticOut은 흔들리며 도착합니다.
같은 duration과 거리지만 curve만 다를 뿐인데 느낌이 완전히 달라집니다.
실전 팁
💡 대부분의 경우 Curves.easeInOut, Curves.easeOut, Curves.easeInOutCubic 중 하나를 선택하면 무난합니다. 이것들이 가장 자연스럽습니다.
💡 장난스러운 효과가 필요하면 Curves.bounceOut, Curves.elasticOut, Curves.elasticInOut을 사용하세요. 하지만 남용하면 앱이 가벼워 보일 수 있으니 신중하게 사용하세요.
💡 Cubic 베지어 곡선으로 커스텀 curve를 만들 수 있습니다. Cubic(0.25, 0.1, 0.25, 1.0) 같은 식으로 직접 정의하면 웹의 CSS transition과 동일한 효과를 낼 수 있습니다.
💡 Curves.fastOutSlowIn은 Material Design의 표준 curve입니다. Google 앱과 동일한 느낌을 원한다면 이것을 사용하세요.
💡 애니메이션이 너무 과하다고 느껴지면 duration을 줄이거나 더 부드러운 curve로 바꾸세요. elasticOut 대신 easeOutBack을 사용하면 비슷하지만 덜 과장된 효과를 얻을 수 있습니다.
댓글 (0)
함께 보면 좋은 카드 뉴스
Riverpod 3.0 쇼핑 앱 종합 프로젝트 완벽 가이드
Flutter와 Riverpod 3.0을 활용한 실무 수준의 쇼핑 앱 개발 과정을 단계별로 학습합니다. 상품 목록, 장바구니, 주문, 인증, 검색 기능까지 모든 핵심 기능을 구현하며 상태 관리의 실전 노하우를 익힙니다.
Riverpod 3.0 Retry 자동 재시도 완벽 가이드
Riverpod 3.0에 새로 추가된 Retry 기능을 활용하여 네트워크 오류나 일시적인 실패 상황에서 자동으로 재시도하는 방법을 배웁니다. 초급 개발자도 쉽게 따라할 수 있도록 실무 예제와 함께 설명합니다.
Riverpod 3.0 requireValue로 Provider 결합하기
Riverpod 3.0에 새로 추가된 requireValue를 활용하여 여러 Provider의 데이터를 효율적으로 결합하는 방법을 배웁니다. 비동기 데이터를 마치 동기 데이터처럼 다루는 실전 패턴을 소개합니다.
Flutter 3.0 Offline 데이터 영속화 완벽 가이드
Flutter 3.0에서 새롭게 추가된 Offline 데이터 영속화 기능을 배웁니다. Storage 인터페이스부터 SharedPreferences 활용, 실전 예제까지 실무에서 바로 사용할 수 있는 패턴을 배워봅시다.
Riverpod 3.0 Mutation으로 폼 제출 완벽 가이드
Riverpod 3.0의 새로운 Mutation 기능으로 로그인과 회원가입 폼을 우아하게 처리하는 방법을 배웁니다. 로딩 상태, 에러 처리, 성공 처리까지 실무에서 바로 쓸 수 있는 패턴을 익혀보세요.