Flutter 튜토리얼 25편: 암시적 애니메이션

요약#

핵심 요지#

  • 문제 정의: 애니메이션을 구현하려면 AnimationController, Tween, Ticker 등 복잡한 설정이 필요해서 진입 장벽이 높다.
  • 핵심 주장: ImplicitlyAnimatedWidget1 계열 위젯을 사용하면 setState()만으로 부드러운 애니메이션을 구현할 수 있다.
  • 주요 근거: AnimatedContainer2, AnimatedOpacity3, AnimatedAlign 등은 속성 변경을 자동으로 애니메이션 처리한다.
  • 실무 기준: 간단한 속성 변화에는 암시적 애니메이션을, 복잡한 제어가 필요하면 명시적 애니메이션을 사용한다.
  • 한계: 애니메이션 중간에 멈추거나, 역방향 재생 같은 세밀한 제어는 어렵다.

문서가 설명하는 범위#

  • 암시적 애니메이션의 개념과 동작 원리
  • AnimatedContainer, AnimatedOpacity 등 주요 위젯 사용법
  • Curves를 사용한 이징(easing) 적용
  • TweenAnimationBuilder로 커스텀 애니메이션 구현

읽는 시간: 12분 | 난이도: 초급


참고 자료#


문제 상황#

UI에 애니메이션을 추가하면 사용자 경험이 향상됩니다. 하지만 Flutter에서 애니메이션을 구현하려면 여러 개념을 이해해야 합니다.

명시적 애니메이션의 복잡성#

AnimationController 생성 → Tween 정의 → Animation 객체 생성
→ addListener로 setState 연결 → dispose에서 정리

간단한 크기 변경 애니메이션도 많은 보일러플레이트 코드가 필요합니다.


해결 방법#

Flutter는 암시적 애니메이션(Implicit Animation) 위젯을 제공합니다. 속성 값이 변경되면 자동으로 이전 값에서 새 값으로 애니메이션됩니다.

챕터 1: 암시적 애니메이션 이해하기#

Why#

NOTE

암시적 애니메이션은 “값이 변경되면 알아서 애니메이션하라”는 선언적 방식입니다.
복잡한 애니메이션 로직을 숨기고 간단한 API를 제공합니다.

graph LR A[이전 값] --> B[setState로 새 값 설정] B --> C[자동 애니메이션] C --> D[새 값]

What#

NOTE

ImplicitlyAnimatedWidget을 상속하는 위젯들은 속성 변경 시 자동으로 애니메이션됩니다.
durationcurve로 애니메이션 동작을 제어합니다.

How#

TIP

주요 암시적 애니메이션 위젯

위젯애니메이션 속성
AnimatedContainer크기, 색상, 패딩, 마진, 테두리
AnimatedOpacity투명도
AnimatedAlign정렬
AnimatedPadding패딩
AnimatedPositionedStack 내 위치
AnimatedDefaultTextStyle텍스트 스타일
AnimatedSwitcher위젯 전환
AnimatedCrossFade두 위젯 간 크로스페이드

기본 사용 패턴

class AnimationExample extends StatefulWidget {
const AnimationExample({super.key});
@override
State<AnimationExample> createState() => _AnimationExampleState();
}
class _AnimationExampleState extends State<AnimationExample> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _isExpanded = !_isExpanded),
child: AnimatedContainer(
width: _isExpanded ? 200 : 100,
height: _isExpanded ? 200 : 100,
color: _isExpanded ? Colors.blue : Colors.red,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
),
);
}
}

Watch out#

WARNING

duration은 필수 속성입니다.
생략하면 컴파일 에러가 발생합니다.

// 에러 - duration 누락
AnimatedContainer(
width: 100,
height: 100,
)
// 올바른 사용
AnimatedContainer(
width: 100,
height: 100,
duration: const Duration(milliseconds: 300),
)

결론: 암시적 애니메이션은 속성 변경 시 자동으로 애니메이션되며, duration만 지정하면 동작합니다.


챕터 2: AnimatedContainer 활용#

Why#

NOTE

AnimatedContainer는 가장 다재다능한 암시적 애니메이션 위젯입니다.
Container의 거의 모든 속성을 애니메이션할 수 있습니다.

크기, 색상, 패딩, 마진, 테두리, 그림자 → 모두 애니메이션 가능

What#

NOTE

AnimatedContainerContainer와 동일한 속성을 가지면서 값 변경 시 자동으로 애니메이션됩니다.
BoxDecoration을 사용해서 복잡한 시각 효과도 애니메이션할 수 있습니다.

How#

TIP

크기와 색상 애니메이션

import 'dart:math';
import 'package:flutter/material.dart';
class AnimatedContainerDemo extends StatefulWidget {
const AnimatedContainerDemo({super.key});
@override
State<AnimatedContainerDemo> createState() => _AnimatedContainerDemoState();
}
class _AnimatedContainerDemoState extends State<AnimatedContainerDemo> {
double _width = 100;
double _height = 100;
Color _color = Colors.blue;
BorderRadiusGeometry _borderRadius = BorderRadius.circular(8);
void _randomize() {
final random = Random();
setState(() {
_width = random.nextInt(200).toDouble() + 100;
_height = random.nextInt(200).toDouble() + 100;
_color = Color.fromRGBO(
random.nextInt(256),
random.nextInt(256),
random.nextInt(256),
1,
);
_borderRadius = BorderRadius.circular(
random.nextInt(50).toDouble(),
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: AnimatedContainer(
width: _width,
height: _height,
decoration: BoxDecoration(
color: _color,
borderRadius: _borderRadius,
boxShadow: [
BoxShadow(
color: _color.withOpacity(0.5),
blurRadius: 20,
spreadRadius: 5,
),
],
),
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
),
),
floatingActionButton: FloatingActionButton(
onPressed: _randomize,
child: const Icon(Icons.refresh),
),
);
}
}

토글 상태 애니메이션

class ExpandableCard extends StatefulWidget {
const ExpandableCard({super.key});
@override
State<ExpandableCard> createState() => _ExpandableCardState();
}
class _ExpandableCardState extends State<ExpandableCard> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _isExpanded = !_isExpanded),
child: AnimatedContainer(
width: double.infinity,
height: _isExpanded ? 200 : 80,
margin: EdgeInsets.all(_isExpanded ? 8 : 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _isExpanded ? Colors.blue[100] : Colors.white,
borderRadius: BorderRadius.circular(_isExpanded ? 16 : 8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(_isExpanded ? 0.2 : 0.1),
blurRadius: _isExpanded ? 10 : 4,
),
],
),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: const Text('탭하여 확장/축소'),
),
);
}
}

Watch out#

WARNING

AnimatedContainer 안의 child는 애니메이션되지 않습니다.
자식 위젯의 변화는 AnimatedSwitcher를 사용하세요.

// child 변경은 애니메이션되지 않음
AnimatedContainer(
duration: const Duration(milliseconds: 300),
child: _isExpanded
? const Text('확장됨') // 즉시 전환
: const Text('축소됨'),
)
// AnimatedSwitcher 사용
AnimatedContainer(
duration: const Duration(milliseconds: 300),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _isExpanded
? const Text('확장됨', key: ValueKey('expanded'))
: const Text('축소됨', key: ValueKey('collapsed')),
),
)

결론: AnimatedContainer로 크기, 색상, 데코레이션 등 다양한 속성을 쉽게 애니메이션합니다.


챕터 3: AnimatedOpacity와 AnimatedAlign#

Why#

NOTE

특정 속성만 애니메이션하고 싶을 때는 전문화된 위젯이 더 효율적입니다.
AnimatedOpacity는 투명도를, AnimatedAlign은 정렬을 애니메이션합니다.

AnimatedOpacity → 페이드 인/아웃
AnimatedAlign → 위치 이동

What#

NOTE

전문화된 암시적 애니메이션 위젯은 특정 속성에 최적화되어 있습니다.
불필요한 계산을 줄여 성능이 향상될 수 있습니다.

How#

TIP

AnimatedOpacity로 페이드 효과

class FadeDemo extends StatefulWidget {
const FadeDemo({super.key});
@override
State<FadeDemo> createState() => _FadeDemoState();
}
class _FadeDemoState extends State<FadeDemo> {
bool _visible = true;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedOpacity(
opacity: _visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 500),
child: Container(
width: 200,
height: 200,
color: Colors.blue,
child: const Center(
child: Text('Hello', style: TextStyle(color: Colors.white)),
),
),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () => setState(() => _visible = !_visible),
child: Text(_visible ? '숨기기' : '보이기'),
),
],
);
}
}

AnimatedAlign으로 위치 이동

class AlignDemo extends StatefulWidget {
const AlignDemo({super.key});
@override
State<AlignDemo> createState() => _AlignDemoState();
}
class _AlignDemoState extends State<AlignDemo> {
AlignmentGeometry _alignment = Alignment.topLeft;
void _moveToCorner() {
setState(() {
_alignment = _alignment == Alignment.topLeft
? Alignment.bottomRight
: Alignment.topLeft;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: AnimatedAlign(
alignment: _alignment,
duration: const Duration(seconds: 1),
curve: Curves.elasticOut,
child: Container(
width: 50,
height: 50,
color: Colors.red,
),
),
floatingActionButton: FloatingActionButton(
onPressed: _moveToCorner,
child: const Icon(Icons.swap_horiz),
),
);
}
}

AnimatedPadding으로 패딩 애니메이션

class PaddingDemo extends StatefulWidget {
const PaddingDemo({super.key});
@override
State<PaddingDemo> createState() => _PaddingDemoState();
}
class _PaddingDemoState extends State<PaddingDemo> {
double _padding = 8;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() {
_padding = _padding == 8 ? 32 : 8;
}),
child: AnimatedPadding(
padding: EdgeInsets.all(_padding),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: Container(
color: Colors.blue,
child: const Center(
child: Text('탭하여 패딩 변경'),
),
),
),
);
}
}

Watch out#

WARNING

AnimatedOpacity에서 opacity: 0.0이어도 위젯은 여전히 공간을 차지하고 탭 이벤트를 받습니다.
완전히 숨기려면 Visibility 또는 조건부 렌더링을 사용하세요.

// opacity 0이어도 공간 차지 + 탭 가능
AnimatedOpacity(
opacity: _visible ? 1.0 : 0.0,
child: ElevatedButton(...), // 안 보이지만 탭됨
)
// 완전히 숨기기
AnimatedOpacity(
opacity: _visible ? 1.0 : 0.0,
child: IgnorePointer(
ignoring: !_visible, // 안 보일 때 탭 무시
child: ElevatedButton(...),
),
)

결론: 특정 속성만 애니메이션할 때는 전문화된 위젯을 사용하면 더 간결하고 효율적입니다.


챕터 4: Curves로 애니메이션 스타일링#

Why#

NOTE

기본 선형 애니메이션은 기계적으로 느껴집니다.
Curves를 사용하면 자연스러운 가속/감속 효과를 줄 수 있습니다.

linear → 일정한 속도 (기계적)
easeIn → 천천히 시작, 빠르게 끝
easeOut → 빠르게 시작, 천천히 끝
easeInOut → 천천히 시작, 천천히 끝

What#

NOTE

Curve는 0.0에서 1.0 사이의 시간값을 변환하는 함수입니다.
Flutter는 다양한 사전 정의 커브를 Curves 클래스에서 제공합니다.

How#

TIP

주요 Curves 종류

Curve설명사용 예
linear일정한 속도로딩 인디케이터
easeIn천천히 시작등장 애니메이션
easeOut천천히 끝퇴장 애니메이션
easeInOut부드러운 시작/끝대부분의 UI
bounceOut끝에서 튀는 효과강조 효과
elasticOut탄성 효과재미있는 UI
fastOutSlowInMaterial 기본Material Design

커브 비교 데모

class CurvesDemo extends StatefulWidget {
const CurvesDemo({super.key});
@override
State<CurvesDemo> createState() => _CurvesDemoState();
}
class _CurvesDemoState extends State<CurvesDemo> {
bool _moved = false;
final List<Curve> _curves = [
Curves.linear,
Curves.easeIn,
Curves.easeOut,
Curves.easeInOut,
Curves.bounceOut,
Curves.elasticOut,
];
final List<String> _names = [
'linear',
'easeIn',
'easeOut',
'easeInOut',
'bounceOut',
'elasticOut',
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: List.generate(_curves.length, (index) {
return Expanded(
child: Row(
children: [
SizedBox(
width: 100,
child: Text(_names[index], textAlign: TextAlign.center),
),
Expanded(
child: AnimatedAlign(
alignment: _moved
? Alignment.centerRight
: Alignment.centerLeft,
duration: const Duration(seconds: 1),
curve: _curves[index],
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
);
}),
),
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() => _moved = !_moved),
child: const Icon(Icons.play_arrow),
),
);
}
}

커스텀 커브 (Cubic Bezier)

// 커스텀 베지어 커브
const customCurve = Cubic(0.68, -0.55, 0.27, 1.55);
AnimatedContainer(
curve: customCurve,
duration: const Duration(milliseconds: 500),
// ...
)

Watch out#

WARNING

bounceOut이나 elasticOut 같은 커브는 값이 목표를 넘어갔다가 돌아옵니다.
크기 애니메이션에 사용하면 음수가 될 수 있어 레이아웃 에러가 발생할 수 있습니다.

// 위험 - bounceOut으로 크기 애니메이션
AnimatedContainer(
width: _expanded ? 200 : 50, // 바운스 중 음수 가능
curve: Curves.bounceOut,
// ...
)
// 안전 - 위치 애니메이션에 사용
AnimatedAlign(
alignment: _moved ? Alignment.centerRight : Alignment.centerLeft,
curve: Curves.bounceOut, // 정렬은 음수 가능
// ...
)

결론: Curves를 적절히 선택하면 애니메이션이 더 자연스럽고 생동감 있어집니다.


챕터 5: AnimatedSwitcher와 AnimatedCrossFade#

Why#

NOTE

위젯 자체가 교체될 때도 애니메이션이 필요합니다.
AnimatedSwitcherAnimatedCrossFade는 위젯 전환을 애니메이션합니다.

AnimatedSwitcher → 다양한 위젯 전환
AnimatedCrossFade → 두 위젯 간 페이드 전환

What#

NOTE

AnimatedSwitcher는 자식 위젯이 변경될 때 이전 위젯을 페이드 아웃하고 새 위젯을 페이드 인합니다.
위젯 변경을 감지하려면 key가 필요합니다.

How#

TIP

AnimatedSwitcher 기본 사용

class SwitcherDemo extends StatefulWidget {
const SwitcherDemo({super.key});
@override
State<SwitcherDemo> createState() => _SwitcherDemoState();
}
class _SwitcherDemoState extends State<SwitcherDemo> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return ScaleTransition(scale: animation, child: child);
},
child: Text(
'$_count',
key: ValueKey<int>(_count), // key 필수!
style: const TextStyle(fontSize: 48),
),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () => setState(() => _count++),
child: const Text('증가'),
),
],
);
}
}

커스텀 전환 효과

AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
transitionBuilder: (child, animation) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0), // 오른쪽에서 등장
end: Offset.zero,
).animate(animation),
child: child,
);
},
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.center,
children: [
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
child: Text(
_text,
key: ValueKey<String>(_text),
),
)

AnimatedCrossFade 사용

class CrossFadeDemo extends StatefulWidget {
const CrossFadeDemo({super.key});
@override
State<CrossFadeDemo> createState() => _CrossFadeDemoState();
}
class _CrossFadeDemoState extends State<CrossFadeDemo> {
bool _showFirst = true;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedCrossFade(
duration: const Duration(milliseconds: 300),
crossFadeState: _showFirst
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: Container(
width: 200,
height: 200,
color: Colors.blue,
child: const Center(child: Text('첫 번째')),
),
secondChild: Container(
width: 200,
height: 100, // 다른 크기도 부드럽게 전환
color: Colors.red,
child: const Center(child: Text('두 번째')),
),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () => setState(() => _showFirst = !_showFirst),
child: const Text('전환'),
),
],
);
}
}

Watch out#

WARNING

AnimatedSwitcher에서 자식 위젯이 같은 타입이면 key가 없으면 변경을 감지하지 못합니다.

// 문제 - 같은 Text 타입, key 없음
AnimatedSwitcher(
child: Text(_count.toString()), // 애니메이션 안 됨
)
// 해결 - key 추가
AnimatedSwitcher(
child: Text(
_count.toString(),
key: ValueKey<int>(_count), // 변경 감지
),
)

결론: AnimatedSwitcher는 위젯 전환을 애니메이션하며, key를 반드시 설정해야 변경을 감지합니다.


챕터 6: TweenAnimationBuilder로 커스텀 애니메이션#

Why#

NOTE

기본 제공 위젯으로 원하는 애니메이션을 만들 수 없을 때 TweenAnimationBuilder를 사용합니다.
어떤 값이든 애니메이션할 수 있는 유연한 빌더입니다.

TweenAnimationBuilder → 값 보간 → builder에서 위젯 생성

What#

NOTE

TweenAnimationBuilderTween으로 정의한 시작값과 끝값 사이를 보간하면서 매 프레임 builder를 호출합니다.
AnimationController 없이 커스텀 애니메이션을 구현할 수 있습니다.

How#

TIP

기본 사용법

class TweenBuilderDemo extends StatefulWidget {
const TweenBuilderDemo({super.key});
@override
State<TweenBuilderDemo> createState() => _TweenBuilderDemoState();
}
class _TweenBuilderDemoState extends State<TweenBuilderDemo> {
double _targetValue = 0;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: _targetValue),
duration: const Duration(milliseconds: 500),
builder: (context, value, child) {
return Container(
width: 100 + value,
height: 100 + value,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(value / 2),
),
child: child,
);
},
child: const Center(
child: Text('Hello', style: TextStyle(color: Colors.white)),
),
),
const SizedBox(height: 20),
Slider(
value: _targetValue,
min: 0,
max: 100,
onChanged: (value) => setState(() => _targetValue = value),
),
],
);
}
}

색상 애니메이션

TweenAnimationBuilder<Color?>(
tween: ColorTween(
begin: Colors.red,
end: _isActive ? Colors.green : Colors.red,
),
duration: const Duration(milliseconds: 300),
builder: (context, color, child) {
return Container(
width: 100,
height: 100,
color: color,
);
},
)

회전 애니메이션

class SpinningWidget extends StatefulWidget {
const SpinningWidget({super.key});
@override
State<SpinningWidget> createState() => _SpinningWidgetState();
}
class _SpinningWidgetState extends State<SpinningWidget> {
double _turns = 0;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: _turns),
duration: const Duration(milliseconds: 500),
builder: (context, value, child) {
return Transform.rotate(
angle: value * 2 * 3.14159, // 라디안
child: child,
);
},
child: Container(
width: 100,
height: 100,
color: Colors.blue,
child: const Icon(Icons.star, color: Colors.white, size: 50),
),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () => setState(() => _turns += 0.25),
child: const Text('90도 회전'),
),
],
);
}
}

Watch out#

WARNING

TweenAnimationBuilderbegin 값은 처음 한 번만 사용됩니다.
이후에는 현재 값에서 새 end 값으로 애니메이션됩니다.

// begin은 최초에만 적용
TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: 0, // 처음에만 사용
end: _target, // 이후에는 현재값 → end로 애니메이션
),
duration: const Duration(milliseconds: 300),
builder: (context, value, child) {
return Text('$value');
},
)

결론: TweenAnimationBuilder로 어떤 값이든 AnimationController 없이 암시적으로 애니메이션할 수 있습니다.


한계#

암시적 애니메이션은 간단하지만 몇 가지 제약이 있습니다.

  • 제어 불가: 애니메이션을 중간에 멈추거나 역방향으로 재생할 수 없습니다.
  • 상태 접근 불가: 현재 애니메이션 진행률을 직접 읽을 수 없습니다.
  • 복잡한 시퀀스 불가: 여러 애니메이션을 순차적으로 연결하기 어렵습니다.
  • 성능 제한: 많은 위젯을 동시에 애니메이션하면 성능이 저하될 수 있습니다.

이런 경우에는 명시적 애니메이션(AnimationController)을 사용해야 합니다.

Footnotes#

  1. ImplicitlyAnimatedWidget: 속성 변경 시 자동으로 애니메이션되는 위젯의 기본 클래스다.

  2. AnimatedContainer: Container의 속성(크기, 색상, 패딩 등)을 자동으로 애니메이션하는 위젯이다.

  3. AnimatedOpacity: 위젯의 투명도를 자동으로 애니메이션하는 위젯이다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 25편: 암시적 애니메이션
https://moodturnpost.net/posts/flutter/flutter-implicit-animations/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차