Flutter 애니메이션: 물리, 암시적 및 사용자 정의 페인터
Flutter의 애니메이션은 3개 레이어 시스템을 기반으로 구축되었습니다. 암시적 애니메이션 (AnimatedContainer, AnimatedOpacity) 설정이 필요 없는 간단한 전환, 노골적인 애니메이션 (AnimationController, Tween, AnimatedBuilder)를 통해 완벽한 타이밍 제어가 가능합니다. 곡선과 방향, e 물리적 시뮬레이션 (스프링 시뮬레이션, FrictionSimulation) 법칙을 따르기 때문에 실제처럼 보이는 움직임 물리학. 이에 추가된 내용은 커스텀페인터 그리다 Canvas에서 직접 사용자 정의 그래픽을 만들 수 있습니다.
올바른 수준을 선택하면 유지 관리 가능한 코드와 코드가 달라집니다. 이유 없이 복잡하다. 이 가이드에서는 예제를 통해 4가지 수준을 모두 다룹니다. 프로젝트에서 직접 사용할 수 있어 실용적입니다.
무엇을 배울 것인가
- 암시적 애니메이션: 충분할 때와 제한할 때
- AnimationController + Tween: TickerProvider를 사용한 세부적인 제어
- 애니메이션 곡선: Curves.easeInOut, BounceOut, elasticIn 및 사용자 정의
- SpringSimulation: 자연스러운 움직임을 위한 물리적 스프링
- FrictionSimulation: 스크롤 및 스와이프에 대한 감속 시뮬레이션
- CustomPainter: Path, Paint, drawArc를 사용하여 캔버스에 그리기
- 히어로 애니메이션: 화면 간 전환 공유
암시적 애니메이션: 간단한 방법
Le 애니메이션 위젯 Flutter가 자동으로 처리합니다.
속성 변경 시 애니메이션: 컨트롤러 없음, 티커 없음,
딱 하나 duration 그리고 하나 curve. 완벽한 대상
일일 UI 전환의 90%.
// Catalog delle animated widgets piu utili
// AnimatedContainer: anima width, height, color, border, padding
class ExpandableCard extends StatefulWidget {
@override
State<ExpandableCard> createState() => _ExpandableCardState();
}
class _ExpandableCardState extends State<ExpandableCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
height: _expanded ? 200 : 80,
decoration: BoxDecoration(
color: _expanded ? Colors.blue : Colors.grey.shade200,
borderRadius: BorderRadius.circular(_expanded ? 16 : 8),
boxShadow: _expanded
? [BoxShadow(blurRadius: 12, color: Colors.blue.withOpacity(0.3))]
: [],
),
padding: EdgeInsets.all(_expanded ? 24 : 12),
child: const Text('Tap to expand'),
),
);
}
}
// AnimatedOpacity: fade in/out
AnimatedOpacity(
opacity: _isVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: const SomeWidget(),
)
// AnimatedSwitcher: anima il cambio di widget (crossfade)
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: child,
),
child: _showFirst
? const Text('First', key: ValueKey('first'))
: const Text('Second', key: ValueKey('second')),
)
// TweenAnimationBuilder: anima un valore personalizzato
TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: _progress),
duration: const Duration(milliseconds: 500),
curve: Curves.easeOut,
builder: (context, value, child) => LinearProgressIndicator(value: value),
)
AnimationController: 명시적 제어
// AnimationController: timing e direzione manuale
class PulseButton extends StatefulWidget {
final VoidCallback onPressed;
const PulseButton({required this.onPressed, super.key});
@override
State<PulseButton> createState() => _PulseButtonState();
}
class _PulseButtonState extends State<PulseButton>
with SingleTickerProviderStateMixin {
// SingleTickerProviderStateMixin: fornisce il vsync al controller
late final AnimationController _controller;
late final Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this, // collegato al VSync per 60fps
duration: const Duration(milliseconds: 150),
);
// Tween: mappa 0.0 - 1.0 del controller a 1.0 - 0.9 (shrink on press)
_scaleAnimation = Tween<double>(begin: 1.0, end: 0.9).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
reverseCurve: Curves.easeIn,
),
);
}
@override
void dispose() {
// CRITICO: disponi sempre il controller per evitare memory leak
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => _controller.forward(),
onTapUp: (_) async {
await _controller.reverse();
widget.onPressed();
},
onTapCancel: () => _controller.reverse(),
child: AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) => Transform.scale(
scale: _scaleAnimation.value,
child: child,
),
child: ElevatedButton(
onPressed: null, // Gestito da GestureDetector
child: const Text('Press Me'),
),
),
);
}
}
// Animazione in loop con repeat()
class LoadingDots extends StatefulWidget {
@override
State<LoadingDots> createState() => _LoadingDotsState();
}
class _LoadingDotsState extends State<LoadingDots>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat(); // Loop infinito
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (index) {
// Ogni punto ha un delay diverso
final delay = index * 0.2;
final value = (_controller.value - delay).clamp(0.0, 1.0);
final scale = Tween(begin: 0.5, end: 1.0)
.evaluate(CurvedAnimation(
parent: AlwaysStoppedAnimation(
(math.sin(value * math.pi * 2) + 1) / 2,
),
curve: Curves.easeInOut,
));
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Transform.scale(
scale: scale,
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
),
),
),
);
}),
);
},
);
}
}
SpringSimulation: 실제 물리학
// SpringSimulation: molla fisica per movimenti naturali
class SpringCard extends StatefulWidget {
@override
State<SpringCard> createState() => _SpringCardState();
}
class _SpringCardState extends State<SpringCard>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
double _dragOffset = 0;
late double _startOffset;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1), // massima durata della simulazione
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onPanStart(DragStartDetails details) {
_controller.stop();
_startOffset = _dragOffset;
}
void _onPanUpdate(DragUpdateDetails details) {
setState(() {
_dragOffset += details.delta.dx;
});
}
void _onPanEnd(DragEndDetails details) {
// Simula una molla che riporta la card alla posizione originale
final spring = SpringSimulation(
SpringDescription(
mass: 1.0, // massa della card (kg)
stiffness: 200.0, // rigidita della molla (N/m) - piu alto = piu rigida
damping: 20.0, // smorzamento - piu alto = meno rimbalzi
),
_dragOffset, // posizione iniziale
0.0, // posizione target (0 = centro)
details.velocity.pixelsPerSecond.dx, // velocita iniziale
);
_controller.animateWith(spring).then((_) {
setState(() => _dragOffset = 0.0);
});
_controller.addListener(() {
setState(() {
_dragOffset = spring.x(_controller.value);
});
});
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
child: Transform.translate(
offset: Offset(_dragOffset, 0),
child: Card(
child: const Padding(
padding: EdgeInsets.all(24),
child: Text('Drag me - I spring back!'),
),
),
),
);
}
}
CustomPainter: 캔버스에 그리기
// CustomPainter: grafico a ciambella animato
class DonutChart extends StatelessWidget {
final double progress; // 0.0 - 1.0
final Color color;
final String label;
const DonutChart({
required this.progress,
required this.color,
required this.label,
super.key,
});
@override
Widget build(BuildContext context) {
return CustomPaint(
size: const Size(120, 120),
painter: _DonutPainter(progress: progress, color: color),
child: Center(
child: Text(
'${(progress * 100).round()}%',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
);
}
}
class _DonutPainter extends CustomPainter {
final double progress;
final Color color;
const _DonutPainter({required this.progress, required this.color});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 10;
const strokeWidth = 12.0;
// Paint per il background (grigio)
final bgPaint = Paint()
..color = Colors.grey.shade200
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
// Paint per il progresso
final progressPaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
// Disegna il cerchio background
canvas.drawCircle(center, radius, bgPaint);
// Disegna l'arco di progresso
final sweepAngle = 2 * math.pi * progress;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-math.pi / 2, // start: ore 12 (top)
sweepAngle, // angolo spazzato
false, // non collegare al centro (false = arco solo)
progressPaint,
);
}
@override
bool shouldRepaint(_DonutPainter oldDelegate) {
// Ridisegna solo se i dati sono cambiati
return oldDelegate.progress != progress || oldDelegate.color != color;
}
}
// Animazione del grafico con TweenAnimationBuilder
class AnimatedDonutChart extends StatelessWidget {
final double targetProgress;
final Color color;
const AnimatedDonutChart({
required this.targetProgress,
required this.color,
super.key,
});
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: targetProgress),
duration: const Duration(milliseconds: 1200),
curve: Curves.easeInOut,
builder: (context, value, _) => DonutChart(
progress: value,
color: color,
label: '${(value * 100).round()}%',
),
);
}
}
사용할 애니메이션 유형
- 암시적(애니메이션*): 단순한 소유권 이전(80%의 경우)
- 명시적(AnimationController): 여러 속성 간 동기화를 통해 이벤트에 의해 트리거되는 반복 애니메이션
- 물리학(SpringSimulation): 드래그 앤 드롭, 스와이프하여 돌아가기, 물리적으로 보일 것 같은 움직임
- 커스텀페인터: 그래프, 사용자 정의 진행률 표시기, 표준 위젯으로는 얻을 수 없는 시각 효과
- 영웅: 눈에 띄는 시각적 요소에 대한 화면 간 전환 공유
결론
Flutter 애니메이션 시스템은 가장 강력하고 유연한 애니메이션 시스템 중 하나입니다. 모바일 프레임워크: 5줄의 코드가 필요한 암시적 위젯부터 현실과 구별할 수 없는 움직임을 생성하는 물리적 시뮬레이션 전체 Canvas API를 Dart에서 사용할 수 있게 만드는 CustomPainter에 연결합니다. 핵심은 각 사용 사례에 대해 올바른 추상화 수준을 선택하는 것입니다. AnimatedContainer로 충분할 경우 과도한 엔지니어링 없이도 가능합니다.







