개요
AnimatedContainer는 Container의 애니메이션 버전으로, 속성 값이 변경될 때 자동으로 애니메이션을 적용합니다.
AnimatedContainer는 지정된 duration과 curve를 사용하여 이전 값과 새로운 값 사이를 자동으로 애니메이션합니다. null인 속성은 애니메이션되지 않으며, 자식 위젯과 그 하위 요소들은 애니메이션되지 않습니다. 내부 AnimationController를 사용하여 Container의 다양한 속성들 간에 간단한 암시적 전환을 생성하는 데 유용합니다.
참고: https://api.flutter.dev/flutter/widgets/AnimatedContainer-class.html
언제 사용하나요?
- 위젯의 크기, 색상, 여백 등을 부드럽게 전환할 때
- 사용자 상호작용에 반응하는 시각적 피드백을 제공할 때
- 상태 변화를 부드럽게 표현하고 싶을 때
- 복잡한 AnimationController 없이 간단한 애니메이션이 필요할 때
- hover 효과나 선택 상태를 표현할 때
기본 사용법
// StatefulWidget을 생성하여 상태를 관리합니다
class AnimatedContainerExample extends StatefulWidget {
createState() {
return new AnimatedContainerExampleState();
}
}
class AnimatedContainerExampleState extends State<AnimatedContainerExample> {
isExpanded = false;
build() {
return GestureDetector({
onTap: () => {
this.setState(() => {
this.isExpanded = !this.isExpanded;
});
},
child: AnimatedContainer({
duration: 300, // 300ms
curve: Curves.easeInOut,
width: this.isExpanded ? 200 : 100,
height: this.isExpanded ? 200 : 100,
color: this.isExpanded ? 'blue' : 'red',
child: Center({
child: Text('탭하세요')
})
})
});
}
}
// 팩토리 함수로 내보내기
export default classToFunction(AnimatedContainerExample);
Props
duration (필수)
값: number
애니메이션이 완료되는 데 걸리는 시간(밀리초)입니다.
AnimatedContainer({
duration: 500, // 0.5초
width: 100,
height: 100
})
curve
값: Curve (기본값: Curves.linear)
애니메이션의 진행 곡선을 정의합니다. 다양한 내장 곡선을 사용할 수 있습니다.
AnimatedContainer({
duration: 300,
curve: Curves.easeInOut, // 부드러운 시작과 끝
width: expanded ? 200 : 100
})
사용 가능한 Curves:
- Curves.linear: 일정한 속도
- Curves.easeIn: 천천히 시작
- Curves.easeOut: 천천히 끝남
- Curves.easeInOut: 천천히 시작하고 끝남
- Curves.bounceIn: 바운스하며 시작
- Curves.bounceOut: 바운스하며 끝남
- Curves.bounceInOut: 바운스하며 시작하고 끝남
- Curves.anticipate: 뒤로 갔다가 앞으로
- Curves.backIn: 뒤로 당겼다가 시작
- Curves.backOut: 목표를 넘었다가 돌아옴
width
값: number | undefined
컨테이너의 너비입니다. 변경 시 애니메이션됩니다.
AnimatedContainer({
duration: 200,
width: isSelected ? 150 : 100,
height: 50
})
height
값: number | undefined
컨테이너의 높이입니다. 변경 시 애니메이션됩니다.
AnimatedContainer({
duration: 200,
width: 100,
height: isExpanded ? 200 : 100
})
color
값: string | undefined
컨테이너의 배경색입니다. decoration과 함께 사용할 수 없습니다.
AnimatedContainer({
duration: 300,
color: isActive ? 'blue' : 'gray',
child: Text('상태 표시')
})
decoration
값: Decoration | undefined
컨테이너의 장식입니다. BoxDecoration의 모든 속성이 애니메이션됩니다.
AnimatedContainer({
duration: 400,
decoration: BoxDecoration({
color: isHovered ? 'lightblue' : 'white',
borderRadius: BorderRadius.circular(isHovered ? 20 : 10),
boxShadow: isHovered ? [
BoxShadow({
color: 'rgba(0, 0, 0, 0.2)',
blurRadius: 10,
offset: { x: 0, y: 5 }
})
] : []
})
})
margin
값: EdgeInsets | undefined
컨테이너 외부 여백입니다. 변경 시 애니메이션됩니다.
AnimatedContainer({
duration: 300,
margin: EdgeInsets.all(isSelected ? 20 : 10),
color: 'blue'
})
padding
값: EdgeInsets | undefined
컨테이너 내부 여백입니다. 변경 시 애니메이션됩니다.
AnimatedContainer({
duration: 300,
padding: EdgeInsets.all(isExpanded ? 20 : 10),
child: Text('내용')
})
alignment
값: Alignment | undefined
자식 위젯의 정렬 위치입니다. 변경 시 애니메이션됩니다.
AnimatedContainer({
duration: 500,
alignment: isLeft ? Alignment.centerLeft : Alignment.centerRight,
child: Icon({ icon: Icons.star })
})
constraints
값: Constraints | undefined
컨테이너의 제약 조건입니다. 최소/최대 크기가 애니메이션됩니다.
AnimatedContainer({
duration: 300,
constraints: Constraints({
minWidth: 100,
maxWidth: isExpanded ? 300 : 150,
minHeight: 50,
maxHeight: isExpanded ? 200 : 100
})
})
clipped
값: boolean (기본값: false)
컨테이너 경계를 벗어나는 콘텐츠를 잘라낼지 여부입니다.
AnimatedContainer({
duration: 300,
clipped: true,
width: 100,
height: 100,
child: Image({ src: 'large-image.jpg' })
})
transform
값: Matrix4 | undefined
컨테이너에 적용할 변환 행렬입니다. 회전, 크기 조정, 이동 등이 애니메이션됩니다.
AnimatedContainer({
duration: 600,
transform: isRotated
? Matrix4.rotationZ(Math.PI / 4) // 45도 회전
: Matrix4.identity(),
child: Icon({ icon: Icons.refresh })
})
transformAlignment
값: Alignment | undefined
변환의 기준점입니다. transform과 함께 사용됩니다.
AnimatedContainer({
duration: 500,
transform: Matrix4.rotationZ(angle),
transformAlignment: Alignment.center, // 중심점 기준 회전
child: Text('회전')
})
child
값: Widget | undefined
애니메이션되지 않는 자식 위젯입니다.
AnimatedContainer({
duration: 300,
width: 100,
height: 100,
child: Icon({ icon: Icons.favorite })
})
실제 사용 예제
예제 1: 호버 효과 카드
class HoverCard extends StatefulWidget {
createState() {
return new HoverCardState();
}
}
class HoverCardState extends State<HoverCard> {
isHovered = false;
build() {
return GestureDetector({
onMouseEnter: () => this.setState(() => { this.isHovered = true; }),
onMouseLeave: () => this.setState(() => { this.isHovered = false; }),
child: AnimatedContainer({
duration: 200,
curve: Curves.easeOut,
width: 200,
height: this.isHovered ? 250 : 200,
decoration: BoxDecoration({
color: 'white',
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow({
color: this.isHovered ? 'rgba(0, 0, 0, 0.15)' : 'rgba(0, 0, 0, 0.05)',
blurRadius: this.isHovered ? 20 : 10,
offset: { x: 0, y: this.isHovered ? 10 : 5 }
})
]
}),
child: Padding({
padding: EdgeInsets.all(16),
child: Column({
children: [
Icon({
icon: Icons.shopping_cart,
size: 48,
color: this.isHovered ? 'blue' : 'gray'
}),
SizedBox({ height: 12 }),
Text('상품 카드')
]
})
})
})
});
}
}
예제 2: 확장 가능한 패널
class ExpandablePanel extends StatefulWidget {
createState() {
return new ExpandablePanelState();
}
}
class ExpandablePanelState extends State<ExpandablePanel> {
isExpanded = false;
build() {
return AnimatedContainer({
duration: 300,
curve: Curves.easeInOut,
width: 300,
height: this.isExpanded ? 400 : 80,
decoration: BoxDecoration({
color: 'white',
borderRadius: BorderRadius.circular(8),
border: Border.all({ color: '#E0E0E0' })
}),
child: Column({
children: [
GestureDetector({
onTap: () => this.setState(() => { this.isExpanded = !this.isExpanded; }),
child: Container({
height: 80,
padding: EdgeInsets.symmetric({ horizontal: 16 }),
child: Row({
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('패널 제목'),
AnimatedContainer({
duration: 300,
transform: Matrix4.rotationZ(this.isExpanded ? Math.PI : 0),
transformAlignment: Alignment.center,
child: Icon({ icon: Icons.expand_more })
})
]
})
})
}),
if (this.isExpanded) Expanded({
child: Padding({
padding: EdgeInsets.all(16),
child: Text('확장된 내용이 여기에 표시됩니다.')
})
})
]
})
});
}
}
예제 3: 진행 표시기
const ProgressIndicator = ({ progress }: { progress: number }) => {
return Container({
width: 300,
height: 20,
decoration: BoxDecoration({
color: '#E0E0E0',
borderRadius: BorderRadius.circular(10)
}),
child: Stack({
children: [
AnimatedContainer({
duration: 500,
curve: Curves.easeOut,
width: 300 * progress,
height: 20,
decoration: BoxDecoration({
color: progress < 0.5 ? 'orange' : 'green',
borderRadius: BorderRadius.circular(10)
})
}),
Center({
child: Text(`${Math.round(progress * 100)}%`, {
style: TextStyle({ color: 'white', fontWeight: 'bold' })
})
})
]
})
});
};
예제 4: 선택 가능한 칩
const SelectableChip = ({ label, selected, onTap }) => {
return GestureDetector({
onTap: onTap,
child: AnimatedContainer({
duration: 200,
curve: Curves.easeOut,
padding: EdgeInsets.symmetric({ horizontal: 16, vertical: 8 }),
decoration: BoxDecoration({
color: selected ? 'blue' : 'transparent',
border: Border.all({
color: selected ? 'blue' : 'gray',
width: selected ? 2 : 1
}),
borderRadius: BorderRadius.circular(20)
}),
child: Text(label, {
style: TextStyle({
color: selected ? 'white' : 'black',
fontWeight: selected ? 'bold' : 'normal'
})
})
})
});
};
예제 5: 로딩 스켈레톤
class LoadingSkeleton extends StatefulWidget {
createState() {
return new LoadingSkeletonState();
}
}
class LoadingSkeletonState extends State<LoadingSkeleton> {
animationPhase = 0;
Timer? timer;
initState() {
super.initState();
this.timer = Timer.periodic(Duration({ milliseconds: 800 }), () => {
this.setState(() => {
this.animationPhase = (this.animationPhase + 1) % 3;
});
});
}
dispose() {
this.timer?.cancel();
super.dispose();
}
build() {
return AnimatedContainer({
duration: 800,
curve: Curves.easeInOut,
width: 300,
height: 100,
decoration: BoxDecoration({
gradient: LinearGradient({
colors: ['#E0E0E0', '#F5F5F5', '#E0E0E0'],
stops: [0, 0.5, 1],
begin: Alignment.centerLeft.add(Alignment(-1 + this.animationPhase * 0.5, 0)),
end: Alignment.centerRight.add(Alignment(-1 + this.animationPhase * 0.5, 0))
}),
borderRadius: BorderRadius.circular(8)
})
});
}
}
예제 6: 다단계 온보딩 화면
class OnboardingScreen extends StatefulWidget {
createState() {
return new OnboardingScreenState();
}
}
class OnboardingScreenState extends State<OnboardingScreen> {
currentStep = 0;
steps = [
{ color: '#3F51B5', icon: Icons.rocket_launch, title: '시작하기', description: 'Flitter로 멋진 앱을 만들어보세요' },
{ color: '#00BCD4', icon: Icons.palette, title: '디자인', description: '아름다운 UI를 쉽게 구현하세요' },
{ color: '#4CAF50', icon: Icons.speed, title: '성능', description: '빠르고 부드러운 애니메이션' },
{ color: '#FF5722', icon: Icons.done_all, title: '완성', description: '당신의 첫 앱이 준비되었습니다!' }
];
build() {
const step = this.steps[this.currentStep];
return Container({
width: 400,
height: 600,
child: Column({
children: [
// 애니메이션 배경
AnimatedContainer({
duration: 600,
curve: Curves.easeInOut,
width: 400,
height: 400,
decoration: BoxDecoration({
gradient: RadialGradient({
colors: [step.color, this.darkenColor(step.color)],
radius: 1.5,
}),
}),
child: Center({
child: AnimatedContainer({
duration: 400,
curve: Curves.bounceOut,
transform: Matrix4.identity()
.scaled(this.currentStep === 3 ? 1.2 : 1.0)
.rotateZ(this.currentStep * Math.PI / 8),
transformAlignment: Alignment.center,
child: Icon({
icon: step.icon,
size: 100,
color: 'white',
}),
}),
}),
}),
// 텍스트 영역
Expanded({
child: Container({
padding: EdgeInsets.all(24),
child: Column({
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column({
children: [
AnimatedContainer({
duration: 300,
height: 40,
child: Text(step.title, {
style: TextStyle({
fontSize: 28,
fontWeight: 'bold',
color: step.color,
}),
}),
}),
SizedBox({ height: 12 }),
AnimatedContainer({
duration: 400,
curve: Curves.easeOut,
padding: EdgeInsets.symmetric({ horizontal: 20 }),
child: Text(step.description, {
style: TextStyle({
fontSize: 16,
color: '#666',
textAlign: 'center',
}),
}),
}),
],
}),
// 진행 표시 및 버튼
Column({
children: [
// 진행 점
Row({
mainAxisAlignment: MainAxisAlignment.center,
children: this.steps.map((_, index) => {
return Container({
margin: EdgeInsets.symmetric({ horizontal: 4 }),
child: AnimatedContainer({
duration: 300,
width: index === this.currentStep ? 24 : 8,
height: 8,
decoration: BoxDecoration({
color: index === this.currentStep ? step.color : '#E0E0E0',
borderRadius: BorderRadius.circular(4),
}),
}),
});
}),
}),
SizedBox({ height: 24 }),
// 네비게이션 버튼
Row({
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
AnimatedContainer({
duration: 200,
opacity: this.currentStep > 0 ? 1 : 0,
child: TextButton({
onPressed: () => {
if (this.currentStep > 0) {
this.setState(() => {
this.currentStep--;
});
}
},
child: Text('이전'),
}),
}),
GestureDetector({
onTap: () => {
if (this.currentStep < this.steps.length - 1) {
this.setState(() => {
this.currentStep++;
});
}
},
child: AnimatedContainer({
duration: 300,
padding: EdgeInsets.symmetric({ horizontal: 32, vertical: 12 }),
decoration: BoxDecoration({
color: step.color,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow({
color: `${step.color}40`,
blurRadius: 12,
offset: Offset({ x: 0, y: 4 }),
}),
],
}),
child: Text(
this.currentStep === this.steps.length - 1 ? '시작하기' : '다음',
{ style: TextStyle({ color: 'white', fontWeight: 'bold' }) }
),
}),
}),
],
}),
],
}),
],
}),
}),
}),
],
}),
});
}
darkenColor(color: string): string {
// 색상을 어둡게 만드는 간단한 헬퍼
const colors: { [key: string]: string } = {
'#3F51B5': '#303F9F',
'#00BCD4': '#0097A7',
'#4CAF50': '#388E3C',
'#FF5722': '#D84315',
};
return colors[color] || color;
}
}
예제 7: 반응형 그리드 아이템
class ResponsiveGridItem extends StatefulWidget {
item: { id: string; title: string; color: string; icon: any };
constructor({ item }: { item: any }) {
super();
this.item = item;
}
createState() {
return new ResponsiveGridItemState();
}
}
class ResponsiveGridItemState extends State<ResponsiveGridItem> {
isPressed = false;
isHovered = false;
build() {
return GestureDetector({
onTapDown: () => this.setState(() => { this.isPressed = true; }),
onTapUp: () => this.setState(() => { this.isPressed = false; }),
onTapCancel: () => this.setState(() => { this.isPressed = false; }),
onMouseEnter: () => this.setState(() => { this.isHovered = true; }),
onMouseLeave: () => this.setState(() => { this.isHovered = false; }),
child: AnimatedContainer({
duration: 200,
curve: Curves.easeOut,
margin: EdgeInsets.all(this.isPressed ? 12 : 8),
transform: Matrix4.identity()
.scaled(this.isPressed ? 0.95 : (this.isHovered ? 1.05 : 1.0)),
transformAlignment: Alignment.center,
decoration: BoxDecoration({
color: this.widget.item.color,
borderRadius: BorderRadius.circular(this.isHovered ? 20 : 12),
boxShadow: [
BoxShadow({
color: this.isPressed
? 'rgba(0,0,0,0.1)'
: (this.isHovered ? 'rgba(0,0,0,0.3)' : 'rgba(0,0,0,0.15)'),
blurRadius: this.isPressed ? 5 : (this.isHovered ? 20 : 10),
offset: Offset({
x: 0,
y: this.isPressed ? 2 : (this.isHovered ? 8 : 4)
}),
}),
],
}),
child: Stack({
children: [
// 배경 패턴
Positioned({
right: -20,
bottom: -20,
child: AnimatedContainer({
duration: 400,
curve: Curves.easeOut,
width: 100,
height: 100,
transform: Matrix4.identity()
.rotateZ(this.isHovered ? Math.PI / 6 : 0),
transformAlignment: Alignment.center,
decoration: BoxDecoration({
color: 'rgba(255,255,255,0.1)',
borderRadius: BorderRadius.circular(20),
}),
}),
}),
// 콘텐츠
Padding({
padding: EdgeInsets.all(20),
child: Column({
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedContainer({
duration: 300,
padding: EdgeInsets.all(this.isHovered ? 12 : 8),
decoration: BoxDecoration({
color: 'rgba(255,255,255,0.2)',
borderRadius: BorderRadius.circular(this.isHovered ? 16 : 12),
}),
child: Icon({
icon: this.widget.item.icon,
size: 32,
color: 'white',
}),
}),
Spacer(),
AnimatedContainer({
duration: 200,
transform: Matrix4.identity()
.translate(this.isHovered ? -4 : 0, 0),
child: Text(this.widget.item.title, {
style: TextStyle({
color: 'white',
fontSize: 18,
fontWeight: 'bold',
}),
}),
}),
AnimatedContainer({
duration: 300,
height: this.isHovered ? 20 : 0,
child: this.isHovered ? Text('자세히 보기 →', {
style: TextStyle({
color: 'rgba(255,255,255,0.8)',
fontSize: 14,
}),
}) : null,
}),
],
}),
}),
],
}),
}),
});
}
}
// 사용 예시
GridView.count({
crossAxisCount: 3,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
padding: EdgeInsets.all(16),
children: [
{ id: '1', title: '대시보드', color: '#3F51B5', icon: Icons.dashboard },
{ id: '2', title: '분석', color: '#00BCD4', icon: Icons.analytics },
{ id: '3', title: '보고서', color: '#4CAF50', icon: Icons.description },
{ id: '4', title: '설정', color: '#FF5722', icon: Icons.settings },
{ id: '5', title: '사용자', color: '#9C27B0', icon: Icons.people },
{ id: '6', title: '알림', color: '#FF9800', icon: Icons.notifications },
].map(item => ResponsiveGridItem({ item })),
});
예제 8: 플로팅 액션 버튼 메뉴
class FloatingActionMenu extends StatefulWidget {
createState() {
return new FloatingActionMenuState();
}
}
class FloatingActionMenuState extends State<FloatingActionMenu> {
isOpen = false;
menuItems = [
{ icon: Icons.photo_camera, label: '사진', color: '#4CAF50' },
{ icon: Icons.videocam, label: '동영상', color: '#2196F3' },
{ icon: Icons.mic, label: '음성', color: '#FF5722' },
{ icon: Icons.attach_file, label: '파일', color: '#FF9800' },
];
build() {
return Container({
width: 200,
height: 300,
child: Stack({
alignment: Alignment.bottomRight,
children: [
// 메뉴 아이템들
...this.menuItems.map((item, index) => {
const angle = (Math.PI / 2) * (index / (this.menuItems.length - 1));
const distance = 80;
return AnimatedContainer({
duration: 300 + index * 50,
curve: this.isOpen ? Curves.easeOut : Curves.easeIn,
transform: Matrix4.identity()
.translate(
this.isOpen ? -Math.cos(angle) * distance : 0,
this.isOpen ? -Math.sin(angle) * distance : 0,
),
child: AnimatedContainer({
duration: 200,
opacity: this.isOpen ? 1 : 0,
transform: Matrix4.identity()
.scaled(this.isOpen ? 1 : 0.5),
transformAlignment: Alignment.center,
child: Row({
mainAxisSize: MainAxisSize.min,
children: [
if (this.isOpen) Container({
padding: EdgeInsets.symmetric({ horizontal: 8, vertical: 4 }),
decoration: BoxDecoration({
color: 'white',
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow({
color: 'rgba(0,0,0,0.1)',
blurRadius: 4,
offset: Offset({ x: 0, y: 2 }),
}),
],
}),
child: Text(item.label, {
style: TextStyle({ fontSize: 12 }),
}),
}),
SizedBox({ width: 8 }),
Container({
width: 48,
height: 48,
decoration: BoxDecoration({
color: item.color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow({
color: `${item.color}40`,
blurRadius: 8,
offset: Offset({ x: 0, y: 4 }),
}),
],
}),
child: Center({
child: Icon({
icon: item.icon,
color: 'white',
size: 24,
}),
}),
}),
],
}),
}),
});
}),
// 메인 FAB
GestureDetector({
onTap: () => {
this.setState(() => {
this.isOpen = !this.isOpen;
});
},
child: AnimatedContainer({
duration: 300,
width: 56,
height: 56,
transform: Matrix4.identity()
.rotateZ(this.isOpen ? Math.PI / 4 : 0),
transformAlignment: Alignment.center,
decoration: BoxDecoration({
color: this.isOpen ? '#F44336' : '#2196F3',
shape: BoxShape.circle,
boxShadow: [
BoxShadow({
color: this.isOpen ? 'rgba(244,67,54,0.4)' : 'rgba(33,150,243,0.4)',
blurRadius: 12,
offset: Offset({ x: 0, y: 6 }),
}),
],
}),
child: Center({
child: Icon({
icon: Icons.add,
color: 'white',
size: 28,
}),
}),
}),
}),
],
}),
});
}
}
주의사항
- duration은 필수 속성입니다. 애니메이션 시간을 반드시 지정해야 합니다.
- color와 decoration을 동시에 사용할 수 없습니다.
- 자식 위젯은 애니메이션되지 않습니다. 자식도 애니메이션하려면 별도의 AnimatedWidget을 사용하세요.
- 너무 많은 속성을 동시에 애니메이션하면 성능에 영향을 줄 수 있습니다.
- 빠른 상태 변경 시 애니메이션이 중단되고 새로운 애니메이션이 시작됩니다.
- transform 사용 시 레이아웃 크기는 변하지 않습니다. 실제 공간을 차지하는 애니메이션이 필요하면 width/height를 사용하세요.
- 복잡한 decoration 애니메이션은 성능에 영향을 줄 수 있으므로 주의가 필요합니다.
관련 위젯
- Container: 애니메이션이 없는 기본 컨테이너
- AnimatedPadding: padding만 애니메이션하는 위젯
- AnimatedAlign: alignment만 애니메이션하는 위젯
- AnimatedOpacity: 투명도만 애니메이션하는 위젯
- AnimatedPositioned: Stack 내에서 위치를 애니메이션하는 위젯
- AnimatedPhysicalModel: 그림자와 elevation을 애니메이션하는 위젯
- AnimatedDefaultTextStyle: 텍스트 스타일을 애니메이션하는 위젯