AnimatedContainer로 부드러운 애니메이션
이번 튜토리얼에서는 Flitter의 AnimatedContainer를 사용해 웹페이지에 생동감을 불어넣는 부드러운 애니메이션을 만들어봅시다.
🎯 학습 목표
이 튜토리얼을 완료하면 다음을 할 수 있게 됩니다:
- AnimatedContainer의 기본 사용법 이해하기
- duration과 curve로 애니메이션 속도와 느낌 조절하기
- 크기, 색상, 위치 애니메이션 구현하기
- StatefulWidget과 AnimatedContainer 조합하기
- 사용자 상호작용으로 애니메이션 트리거하기
🚀 애니메이션이란?
웹에서 애니메이션은 사용자 경험을 크게 향상시킵니다. 버튼이 클릭될 때 부드럽게 변화하고, 카드가 호버될 때 살짝 확대되는 것들이 모두 애니메이션의 예시입니다.
Flitter에서는 두 가지 방식의 애니메이션을 제공합니다:
- 암시적 애니메이션: AnimatedContainer, AnimatedOpacity 등 (오늘 배울 내용)
- 명시적 애니메이션: AnimationController 사용 (다음 튜토리얼에서)
🎨 AnimatedContainer 기본 개념
AnimatedContainer는 Container의 모든 속성을 애니메이션할 수 있는 특별한 위젯입니다. 가장 큰 장점은 속성 값만 바꿔주면 자동으로 부드럽게 변화한다는 점입니다.
기본 구조
AnimatedContainer({
duration: 300, // 애니메이션 지속 시간 (밀리초)
width: 100, // 변화할 속성들
height: 100,
color: '#4ECDC4',
curve: "easeInOut", // 애니메이션 곡선 (선택사항)
child: Text("내용")
})
AnimatedContainer의 주요 속성들
필수 속성:
duration
(number): 애니메이션 지속 시간 (밀리초)
애니메이션 가능한 속성들:
width
,height
(number): 컨테이너 크기color
(string): 배경색 (decoration과 함께 사용 불가)decoration
(BoxDecoration): 테두리, 그림자, 그라데이션 등margin
,padding
(EdgeInsets): 외부/내부 여백alignment
(Alignment): 자식 위젯 정렬constraints
(Constraints): 제약 조건transform
(Matrix4): 변환 행렬 (회전, 크기, 이동)
기타 속성들:
curve
(string): 애니메이션 곡선child
(Widget): 애니메이션되지 않는 자식 위젯clipped
(boolean): 경계 클리핑 여부
📋 단계별 실습
1단계: 클릭하면 크기가 변하는 박스
먼저 클릭할 때마다 크기가 변하는 간단한 박스를 만들어봅시다:
import { AnimatedContainer, Text, GestureDetector } from "@meursyphus/flitter";
import { StatefulWidget, State } from "@meursyphus/flitter";
class ExpandingBox extends StatefulWidget {
createState() {
return new ExpandingBoxState();
}
}
class ExpandingBoxState extends State<ExpandingBox> {
isLarge = false;
build(context) {
return GestureDetector({
onClick: () => {
this.setState(() => {
this.isLarge = !this.isLarge;
});
},
child: AnimatedContainer({
duration: 500,
width: this.isLarge ? 200 : 100,
height: this.isLarge ? 200 : 100,
color: '#4ECDC4',
child: Text(this.isLarge ? "큰 박스!" : "작은 박스")
})
});
}
}
// 팩토리 함수로 내보내기
export default function ExpandingBox() {
return new _ExpandingBox();
}
2단계: 색상도 함께 변화시키기
크기뿐만 아니라 색상도 동시에 변화시켜봅시다:
class ColorfulBox extends StatefulWidget {
createState() {
return new ColorfulBoxState();
}
}
class ColorfulBoxState extends State<ColorfulBox> {
isExpanded = false;
build(context) {
return GestureDetector({
onClick: () => {
this.setState(() => {
this.isExpanded = !this.isExpanded;
});
},
child: AnimatedContainer({
duration: 600,
width: this.isExpanded ? 180 : 120,
height: this.isExpanded ? 180 : 120,
color: this.isExpanded ? '#FF6B6B' : '#4ECDC4',
curve: "bounceOut", // 바운스 효과
child: Text(
this.isExpanded ? "🎉 확장됨!" : "👆 클릭하세요"
)
})
});
}
}
3단계: 테두리와 둥근 모서리 애니메이션
더 정교한 애니메이션을 위해 BoxDecoration을 사용해봅시다:
import { AnimatedContainer, Text, GestureDetector, BoxDecoration, Border, BorderSide } from "@meursyphus/flitter";
class FancyBox extends StatefulWidget {
createState() {
return new FancyBoxState();
}
}
class FancyBoxState extends State<FancyBox> {
isActive = false;
build(context) {
return GestureDetector({
onClick: () => {
this.setState(() => {
this.isActive = !this.isActive;
});
},
child: AnimatedContainer({
duration: 400,
width: this.isActive ? 200 : 150,
height: this.isActive ? 120 : 80,
decoration: BoxDecoration({
color: this.isActive ? '#FFE66D' : '#A8E6CF',
borderRadius: this.isActive ? 25 : 10,
border: Border.all({
color: this.isActive ? '#FF6B6B' : '#4ECDC4',
width: this.isActive ? 3 : 1
})
}),
child: Text(
this.isActive ? "✨ 활성화!" : "💤 비활성화"
)
})
});
}
}
4단계: 여백(Padding)과 정렬 애니메이션
내부 여백과 정렬도 애니메이션할 수 있습니다:
import { EdgeInsets, Alignment } from "@meursyphus/flitter";
class PaddingBox extends StatefulWidget {
createState() {
return new PaddingBoxState();
}
}
class PaddingBoxState extends State<PaddingBox> {
isPadded = false;
build(context) {
return GestureDetector({
onClick: () => {
this.setState(() => {
this.isPadded = !this.isPadded;
});
},
child: AnimatedContainer({
duration: 300,
width: 200,
height: 200,
color: '#E8F4FD',
padding: this.isPadded
? EdgeInsets.all(40)
: EdgeInsets.all(10),
alignment: this.isPadded
? Alignment.center
: Alignment.topLeft,
child: Container({
color: '#2196F3',
child: Text("패딩 변화!")
})
})
});
}
}
⚙️ duration과 curve 완전 이해하기
duration (지속 시간)
애니메이션이 완료되는 데 걸리는 시간을 밀리초(ms) 단위로 지정합니다.
duration: 200 // 빠른 애니메이션 (0.2초)
duration: 500 // 보통 속도 (0.5초)
duration: 1000 // 느린 애니메이션 (1초)
권장 사항:
- 너무 짧으면 (< 150ms): 애니메이션을 인지하기 어려움
- 너무 길면 (> 1000ms): 사용자가 답답함을 느낌
- 일반적 권장: 200-800ms
curve (애니메이션 곡선)
애니메이션의 진행 속도를 시간에 따라 어떻게 변화시킬지 결정합니다.
기본 곡선들:
curve: "linear" // 일정한 속도
curve: "easeIn" // 부드러운 시작, 빠른 끝
curve: "easeOut" // 빠른 시작, 부드러운 끝
curve: "easeInOut" // 부드러운 시작과 끝
특수 효과 곡선들:
curve: "bounceOut" // 바운스 효과
curve: "bounceIn" // 역방향 바운스
curve: "bounceInOut" // 양방향 바운스
curve: "elasticOut" // 탄성 효과
curve: "elasticIn" // 역방향 탄성
curve: "elasticInOut" // 양방향 탄성
curve: "backOut" // 오버슛 효과
curve: "backIn" // 역방향 오버슛
curve: "backInOut" // 양방향 오버슛
실제 curve 비교 예제
class CurveComparison extends StatefulWidget {
createState() {
return new CurveComparisonState();
}
}
class CurveComparisonState extends State<CurveComparison> {
isAnimated = false;
build(context) {
return Column({
children: [
// Linear
GestureDetector({
onClick: () => this.toggleAnimation(),
child: AnimatedContainer({
duration: 1000,
curve: "linear",
width: this.isAnimated ? 300 : 100,
height: 50,
color: '#FF5722',
child: Text("Linear")
})
}),
// EaseInOut
GestureDetector({
onClick: () => this.toggleAnimation(),
child: AnimatedContainer({
duration: 1000,
curve: "easeInOut",
width: this.isAnimated ? 300 : 100,
height: 50,
color: '#2196F3',
child: Text("EaseInOut")
})
}),
// BounceOut
GestureDetector({
onClick: () => this.toggleAnimation(),
child: AnimatedContainer({
duration: 1000,
curve: "bounceOut",
width: this.isAnimated ? 300 : 100,
height: 50,
color: '#4CAF50',
child: Text("BounceOut")
})
})
]
});
}
toggleAnimation() {
this.setState(() => {
this.isAnimated = !this.isAnimated;
});
}
}
🎯 실습 도전 과제
TODO 1: 3단계 변화 버튼 만들기
클릭할 때마다 3가지 상태로 순환하는 버튼을 만들어보세요:
class TripleStateButton extends StatefulWidget {
createState() {
return new TripleStateButtonState();
}
}
class TripleStateButtonState extends State<TripleStateButton> {
currentState = 0; // 0, 1, 2
getStateConfig() {
const states = [
{ width: 120, height: 60, color: '#3498db', text: "시작" },
{ width: 160, height: 80, color: '#f39c12', text: "진행중" },
{ width: 200, height: 100, color: '#27ae60', text: "완료" }
];
return states[this.currentState];
}
build(context) {
const config = this.getStateConfig();
return GestureDetector({
onClick: () => {
this.setState(() => {
this.currentState = (this.currentState + 1) % 3;
});
},
child: AnimatedContainer({
duration: 400,
curve: "easeInOut",
width: config.width,
height: config.height,
color: config.color,
child: Text(config.text)
})
});
}
}
TODO 2: 호버 효과가 있는 카드
마우스를 올렸을 때 살짝 커지는 카드를 만들어보세요:
class HoverCard extends StatefulWidget {
createState() {
return new HoverCardState();
}
}
class HoverCardState extends State<HoverCard> {
isHovered = false;
build(context) {
return GestureDetector({
onMouseEnter: () => {
this.setState(() => {
this.isHovered = true;
});
},
onMouseLeave: () => {
this.setState(() => {
this.isHovered = false;
});
},
child: AnimatedContainer({
duration: 200,
curve: "easeOut",
width: this.isHovered ? 270 : 250,
height: this.isHovered ? 170 : 150,
decoration: BoxDecoration({
color: '#ffffff',
borderRadius: 10,
border: Border.all({
color: this.isHovered ? '#2196F3' : '#e0e0e0',
width: this.isHovered ? 2 : 1
})
}),
padding: EdgeInsets.all(20),
child: Text("호버해보세요!")
})
});
}
}
TODO 3: 진행률 표시 바
버튼을 클릭할 때마다 진행률이 증가하는 프로그레스 바를 만들어보세요:
class ProgressBar extends StatefulWidget {
createState() {
return new ProgressBarState();
}
}
class ProgressBarState extends State<ProgressBar> {
progress = 0; // 0 ~ 100
build(context) {
return Column({
children: [
Container({
width: 300,
height: 20,
decoration: BoxDecoration({
color: '#f0f0f0',
borderRadius: 10
}),
child: Stack({
children: [
AnimatedContainer({
duration: 500,
curve: "easeOut",
width: (this.progress / 100) * 300,
height: 20,
decoration: BoxDecoration({
color: '#4CAF50',
borderRadius: 10
})
})
]
})
}),
GestureDetector({
onClick: () => {
this.setState(() => {
this.progress = Math.min(this.progress + 20, 100);
if (this.progress >= 100) {
// 완료 후 리셋
setTimeout(() => {
this.setState(() => {
this.progress = 0;
});
}, 1000);
}
});
},
child: Container({
width: 100,
height: 40,
color: '#2196F3',
child: Text("진행 +20%")
})
}),
Text(`진행률: ${this.progress}%`)
]
});
}
}
🎨 예상 결과
완성하면 다음과 같은 기능들이 작동해야 합니다:
- 기본 박스: 클릭할 때마다 크기가 부드럽게 변함
- 컬러풀 박스: 크기와 색상이 동시에 변화
- 고급 박스: 테두리, 둥근 모서리까지 애니메이션
- 3단계 버튼: 3가지 상태를 순환하며 변화
- 호버 카드: 마우스 호버 시 부드러운 반응
- 진행률 바: 클릭에 따라 진행률이 시각적으로 증가
💡 추가 도전
더 도전하고 싶다면:
- 연쇄 애니메이션: 여러 박스가 순서대로 애니메이션되도록 하기
- 복합 애니메이션: padding, margin, 그림자까지 모두 애니메이션하기
- 조건부 애니메이션: 특정 조건에서만 애니메이션 실행하기
- 무한 애니메이션: 자동으로 반복되는 애니메이션 만들기
⚠️ 흔한 실수와 해결법
1. 너무 빠른 애니메이션
// ❌ 너무 빨라서 보이지 않음
duration: 50
// ✅ 적절한 속도
duration: 300
2. setState() 사용 실수
// ❌ setState() 없이 직접 변경
this.isExpanded = !this.isExpanded;
// ✅ setState() 사용
this.setState(() => {
this.isExpanded = !this.isExpanded;
});
3. curve 문자열 오타
// ❌ 오타
curve: "easeInout" // 'O' 대문자여야 함
// ✅ 정확한 문자열
curve: "easeInOut"
4. color와 decoration 동시 사용
// ❌ 동시 사용 불가
AnimatedContainer({
color: '#FF0000',
decoration: BoxDecoration({
color: '#0000FF' // 충돌!
})
})
// ✅ decoration만 사용
AnimatedContainer({
decoration: BoxDecoration({
color: '#FF0000'
})
})
5. 위젯에 new 키워드 사용
// ❌ 위젯에 new 사용 금지
new AnimatedContainer({ ... })
// ✅ 팩토리 함수 사용
AnimatedContainer({ ... })
🎓 핵심 정리
- AnimatedContainer: Container의 모든 속성을 애니메이션할 수 있는 위젯
- duration: 애니메이션 지속 시간 (밀리초), 200-800ms 권장
- curve: 애니메이션 진행 곡선, 자연스러운 움직임 연출
- StatefulWidget: 상태 변화로 애니메이션 트리거
- GestureDetector: 사용자 상호작용 처리
AnimatedContainer는 Flitter에서 가장 사용하기 쉬운 애니메이션 위젯입니다. 복잡한 애니메이션 컨트롤러 없이도 부드럽고 자연스러운 애니메이션을 만들 수 있어, UI/UX를 크게 향상시킬 수 있습니다.
🚀 다음 단계
다음 튜토리얼에서는 다양한 Animated 위젯들을 배워봅시다:
- AnimatedOpacity로 투명도 애니메이션
- AnimatedPadding으로 여백 애니메이션
- AnimatedAlign으로 위치 애니메이션
- 여러 애니메이션 동시 실행하기