다양한 Animated 위젯들로 특별한 효과 만들기
이번 튜토리얼에서는 AnimatedContainer 외에도 Flitter가 제공하는 다양한 Animated 위젯들을 배워서 더욱 정교하고 전문적인 애니메이션 효과를 만들어봅시다.
🎯 학습 목표
이 튜토리얼을 완료하면 다음을 할 수 있게 됩니다:
- AnimatedOpacity로 페이드 인/아웃 효과 구현하기
- AnimatedPadding으로 여백 변화 애니메이션 만들기
- AnimatedAlign으로 위치 이동 애니메이션 구현하기
- AnimatedScale로 크기 변화 효과 만들기
- AnimatedRotation으로 회전 애니메이션 구현하기
- 여러 Animated 위젯을 조합해서 복합 효과 만들기
🎨 Animated 위젯들의 특징
각 Animated 위젯은 특정한 속성에 특화되어 있어, 해당 효과를 매우 효율적이고 부드럽게 구현할 수 있습니다:
- AnimatedOpacity: 투명도 변화 (페이드 효과)
- AnimatedPadding: 내부 여백 변화
- AnimatedAlign: 정렬 위치 변화 (위치 이동)
- AnimatedScale: 크기 비율 변화
- AnimatedRotation: 회전 변화
- AnimatedSlide: 상대적 위치 이동
- AnimatedPositioned: Stack 내에서 절대 위치 변화
📋 단계별 실습
1단계: AnimatedOpacity - 페이드 효과
투명도를 부드럽게 변화시켜 요소가 나타나거나 사라지는 효과를 만들어봅시다:
import { AnimatedOpacity, Container, Text, GestureDetector } from "@meursyphus/flitter";
class FadeEffect extends StatefulWidget {
createState() {
return new FadeEffectState();
}
}
class FadeEffectState extends State<FadeEffect> {
isVisible = true;
build(context) {
return Column({
children: [
GestureDetector({
onClick: () => {
this.setState(() => {
this.isVisible = !this.isVisible;
});
},
child: AnimatedOpacity({
duration: 500, // 애니메이션 지속 시간
opacity: this.isVisible ? 1.0 : 0.0, // 투명도 (0.0~1.0)
curve: "easeInOut", // 애니메이션 곡선
child: Container({
width: 200,
height: 100,
color: '#FF6B6B',
child: Text(this.isVisible ? "보임!" : "숨김!")
})
})
}),
Text("클릭해서 페이드 효과 확인")
]
});
}
}
AnimatedOpacity 주요 속성:
opacity
(number): 투명도 (0.0 = 완전 투명, 1.0 = 완전 불투명)duration
(number): 애니메이션 지속 시간 (밀리초)curve
(string): 애니메이션 곡선child
(Widget): 애니메이션될 자식 위젯
2단계: AnimatedPadding - 여백 애니메이션
내부 여백을 부드럽게 변화시켜 콘텐츠의 배치를 조정해봅시다:
import { AnimatedPadding, EdgeInsets } from "@meursyphus/flitter";
class PaddingAnimation extends StatefulWidget {
createState() {
return new PaddingAnimationState();
}
}
class PaddingAnimationState extends State<PaddingAnimation> {
isExpanded = false;
build(context) {
return GestureDetector({
onClick: () => {
this.setState(() => {
this.isExpanded = !this.isExpanded;
});
},
child: Container({
width: 300,
height: 200,
color: '#E8F4FD',
child: AnimatedPadding({
duration: 400,
curve: "easeOut",
padding: this.isExpanded
? EdgeInsets.all(50) // 확장 시 큰 여백
: EdgeInsets.all(10), // 축소 시 작은 여백
child: Container({
color: '#2196F3',
child: Text(
this.isExpanded ? "확장된 패딩" : "기본 패딩"
)
})
})
})
});
}
}
EdgeInsets 사용법:
// 모든 방향 동일
EdgeInsets.all(20)
// 대칭적 여백
EdgeInsets.symmetric({
horizontal: 30, // 좌우
vertical: 15 // 상하
})
// 개별 지정
EdgeInsets.only({
top: 20,
left: 15,
right: 10,
bottom: 25
})
// 직접 지정
EdgeInsets.fromLTRB(10, 20, 15, 25) // 좌, 상, 우, 하
3단계: AnimatedAlign - 위치 이동 애니메이션
자식 위젯의 정렬 위치를 부드럽게 변화시켜 위치 이동 효과를 만들어봅시다:
import { AnimatedAlign, Alignment } from "@meursyphus/flitter";
class AlignAnimation extends StatefulWidget {
createState() {
return new AlignAnimationState();
}
}
class AlignAnimationState extends State<AlignAnimation> {
alignmentIndex = 0;
// 9개의 기본 정렬 위치
alignments = [
Alignment.topLeft,
Alignment.topCenter,
Alignment.topRight,
Alignment.centerLeft,
Alignment.center,
Alignment.centerRight,
Alignment.bottomLeft,
Alignment.bottomCenter,
Alignment.bottomRight
];
build(context) {
return GestureDetector({
onClick: () => {
this.setState(() => {
this.alignmentIndex = (this.alignmentIndex + 1) % this.alignments.length;
});
},
child: Container({
width: 300,
height: 200,
color: '#F0F0F0',
child: AnimatedAlign({
duration: 600,
curve: "easeInOut",
alignment: this.alignments[this.alignmentIndex],
child: Container({
width: 80,
height: 50,
color: '#9B59B6',
child: Text(`위치 ${this.alignmentIndex + 1}`)
})
})
})
});
}
}
Alignment 상수들:
// 9개 기본 위치
Alignment.topLeft // 좌상단
Alignment.topCenter // 상단 중앙
Alignment.topRight // 우상단
Alignment.centerLeft // 좌측 중앙
Alignment.center // 정중앙
Alignment.centerRight // 우측 중앙
Alignment.bottomLeft // 좌하단
Alignment.bottomCenter // 하단 중앙
Alignment.bottomRight // 우하단
// 커스텀 정렬 (x, y: -1.0 ~ 1.0)
Alignment(0.5, -0.5) // 우상단 쪽
4단계: AnimatedScale - 크기 비율 애니메이션
위젯의 크기를 비율로 확대/축소하는 애니메이션을 만들어봅시다:
import { AnimatedScale } from "@meursyphus/flitter";
class ScaleAnimation extends StatefulWidget {
createState() {
return new ScaleAnimationState();
}
}
class ScaleAnimationState extends State<ScaleAnimation> {
isScaled = false;
build(context) {
return GestureDetector({
onClick: () => {
this.setState(() => {
this.isScaled = !this.isScaled;
});
},
child: AnimatedScale({
duration: 400,
curve: "bounceOut",
scale: this.isScaled ? 1.5 : 1.0, // 스케일 비율
alignment: Alignment.center, // 스케일 기준점
child: Container({
width: 100,
height: 100,
color: '#E74C3C',
child: Text(
this.isScaled ? "1.5배!" : "원본"
)
})
})
});
}
}
5단계: AnimatedRotation - 회전 애니메이션
위젯을 부드럽게 회전시키는 애니메이션을 구현해봅시다:
import { AnimatedRotation } from "@meursyphus/flitter";
class RotationAnimation extends StatefulWidget {
createState() {
return new RotationAnimationState();
}
}
class RotationAnimationState extends State<RotationAnimation> {
rotationCount = 0;
build(context) {
return GestureDetector({
onClick: () => {
this.setState(() => {
this.rotationCount += 1; // 매번 1회전 추가
});
},
child: AnimatedRotation({
duration: 800,
curve: "easeInOut",
turns: this.rotationCount, // 회전 수 (1 = 360도)
alignment: Alignment.center, // 회전 기준점
child: Container({
width: 80,
height: 80,
color: '#3498DB',
child: Text("🔄")
})
})
});
}
}
회전량 이해하기:
turns: 0.25 // 90도 회전
turns: 0.5 // 180도 회전
turns: 1 // 360도 (1회전)
turns: 1.5 // 540도 (1.5회전)
turns: -0.5 // 반시계 방향 180도
6단계: AnimatedSlide - 상대적 위치 이동
위젯의 크기에 상대적인 거리만큼 이동시키는 애니메이션입니다:
import { AnimatedSlide, Offset } from "@meursyphus/flitter";
class SlideAnimation extends StatefulWidget {
createState() {
return new SlideAnimationState();
}
}
class SlideAnimationState extends State<SlideAnimation> {
isSlid = false;
build(context) {
return Container({
width: 300,
height: 150,
color: '#F8F9FA',
child: GestureDetector({
onClick: () => {
this.setState(() => {
this.isSlid = !this.isSlid;
});
},
child: AnimatedSlide({
duration: 500,
curve: "easeInOut",
offset: this.isSlid
? Offset(1.0, 0) // 위젯 너비만큼 오른쪽으로
: Offset(0, 0), // 원래 위치
child: Container({
width: 100,
height: 60,
color: '#FF9F43',
child: Text("슬라이드!")
})
})
})
});
}
}
Offset 이해하기:
Offset(0, 0) // 원래 위치
Offset(1, 0) // 위젯 너비만큼 오른쪽
Offset(-1, 0) // 위젯 너비만큼 왼쪽
Offset(0, 1) // 위젯 높이만큼 아래쪽
Offset(0, -1) // 위젯 높이만큼 위쪽
Offset(1, 1) // 대각선 우하단
🎯 실습 도전 과제
TODO 1: 다중 효과 카드
여러 애니메이션을 조합한 인터랙티브 카드를 만들어보세요:
class MultiEffectCard extends StatefulWidget {
createState() {
return new MultiEffectCardState();
}
}
class MultiEffectCardState extends State<MultiEffectCard> {
isHovered = false;
isClicked = false;
build(context) {
return GestureDetector({
onMouseEnter: () => {
this.setState(() => {
this.isHovered = true;
});
},
onMouseLeave: () => {
this.setState(() => {
this.isHovered = false;
});
},
onClick: () => {
this.setState(() => {
this.isClicked = !this.isClicked;
});
},
child: AnimatedScale({
duration: 200,
scale: this.isHovered ? 1.05 : 1.0,
child: AnimatedRotation({
duration: 300,
turns: this.isClicked ? 0.02 : 0, // 살짝 기울이기
child: AnimatedOpacity({
duration: 250,
opacity: this.isHovered ? 0.9 : 1.0,
child: Container({
width: 200,
height: 120,
color: '#6C5CE7',
child: AnimatedPadding({
duration: 200,
padding: this.isHovered
? EdgeInsets.all(25)
: EdgeInsets.all(20),
child: Text("다중 효과 카드")
})
})
})
})
})
});
}
}
TODO 2: 순차적 애니메이션 메뉴
여러 메뉴 아이템이 순서대로 나타나는 애니메이션을 만들어보세요:
class SequentialMenu extends StatefulWidget {
createState() {
return new SequentialMenuState();
}
}
class SequentialMenuState extends State<SequentialMenu> {
isMenuOpen = false;
createMenuItem(text, delay) {
return AnimatedSlide({
duration: 400,
curve: "easeOut",
offset: this.isMenuOpen
? Offset(0, 0)
: Offset(-1, 0),
child: AnimatedOpacity({
duration: 300,
opacity: this.isMenuOpen ? 1.0 : 0.0,
child: Container({
width: 150,
height: 50,
color: '#1DD1A1',
margin: EdgeInsets.only({ bottom: 10 }),
child: Text(text)
})
})
});
}
build(context) {
// 지연 애니메이션을 위해 실제로는 더 복잡한 구현이 필요합니다
// 여기서는 기본 개념만 보여줍니다
return Column({
children: [
GestureDetector({
onClick: () => {
this.setState(() => {
this.isMenuOpen = !this.isMenuOpen;
});
},
child: Container({
width: 150,
height: 50,
color: '#FF6B6B',
child: Text(this.isMenuOpen ? "메뉴 닫기" : "메뉴 열기")
})
}),
this.createMenuItem("홈", 0),
this.createMenuItem("서비스", 100),
this.createMenuItem("소개", 200),
this.createMenuItem("연락처", 300)
]
});
}
}
TODO 3: 로딩 인디케이터
여러 Animated 위젯을 조합한 로딩 애니메이션을 만들어보세요:
class LoadingIndicator extends StatefulWidget {
createState() {
return new LoadingIndicatorState();
}
}
class LoadingIndicatorState extends State<LoadingIndicator> {
isLoading = false;
pulseCount = 0;
startLoading() {
this.setState(() => {
this.isLoading = true;
});
// 펄스 애니메이션을 위한 타이머
const interval = setInterval(() => {
if (this.isLoading) {
this.setState(() => {
this.pulseCount += 1;
});
} else {
clearInterval(interval);
}
}, 800);
}
build(context) {
return Column({
children: [
GestureDetector({
onClick: () => {
if (!this.isLoading) {
this.startLoading();
// 3초 후 자동 완료
setTimeout(() => {
this.setState(() => {
this.isLoading = false;
});
}, 3000);
}
},
child: Container({
width: 120,
height: 50,
color: '#3742FA',
child: Text(this.isLoading ? "로딩 중..." : "로딩 시작")
})
}),
// 로딩 인디케이터
AnimatedOpacity({
duration: 300,
opacity: this.isLoading ? 1.0 : 0.0,
child: AnimatedRotation({
duration: 1000,
turns: this.isLoading ? this.pulseCount : 0,
child: AnimatedScale({
duration: 800,
curve: "easeInOut",
scale: (this.pulseCount % 2 === 0) ? 1.0 : 1.2,
child: Container({
width: 60,
height: 60,
color: '#FF3838',
child: Text("⭐")
})
})
})
})
]
});
}
}
🎨 예상 결과
완성하면 다음과 같은 효과들이 작동해야 합니다:
- 페이드 효과: 부드러운 투명도 변화로 나타나고 사라지는 효과
- 패딩 애니메이션: 여백 변화로 콘텐츠가 확장/축소되는 효과
- 위치 이동: 9개 위치로 부드럽게 이동하는 효과
- 크기 변화: 비율로 확대/축소되는 효과
- 회전 효과: 클릭할 때마다 회전하는 효과
- 슬라이드: 상대적 거리만큼 이동하는 효과
💡 추가 도전
더 도전하고 싶다면:
- 스테거드 애니메이션: 여러 요소가 순차적으로 애니메이션되도록 하기
- 무한 루프: 자동으로 반복되는 애니메이션 구현하기
- 복합 효과: 3개 이상의 Animated 위젯을 조합하기
- 조건부 애니메이션: 특정 조건에서만 특정 효과 실행하기
⚠️ 흔한 실수와 해결법
1. opacity 범위 초과
// ❌ 잘못된 범위
opacity: 1.5 // 1.0을 초과
opacity: -0.2 // 0.0 미만
// ✅ 올바른 범위
opacity: 1.0 // 최대값
opacity: 0.0 // 최소값
2. Alignment 좌표 오해
// ❌ 잘못된 이해
Alignment(100, 200) // 절대 좌표로 오해
// ✅ 올바른 이해
Alignment(1.0, 1.0) // 상대적 비율 (-1.0 ~ 1.0)
3. EdgeInsets 생성자 혼동
// ❌ 잘못된 사용
EdgeInsets(10, 20, 15, 25) // 직접 생성자 사용
// ✅ 올바른 사용
EdgeInsets.fromLTRB(10, 20, 15, 25) // 팩토리 메서드 사용
4. 여러 애니메이션 중첩 시 성능
// ⚠️ 주의: 너무 많은 중첩은 성능 저하
AnimatedScale({
child: AnimatedRotation({
child: AnimatedOpacity({
child: AnimatedSlide({
// 너무 많은 중첩
})
})
})
})
// ✅ 필요한 것만 선택해서 사용
AnimatedScale({
child: AnimatedOpacity({
// 필요한 효과만 조합
})
})
🎓 핵심 정리
각 위젯의 특화 영역
- AnimatedOpacity: 투명도 → 페이드 인/아웃
- AnimatedPadding: 여백 → 콘텐츠 확장/축소
- AnimatedAlign: 정렬 → 위치 이동
- AnimatedScale: 비율 → 크기 변화
- AnimatedRotation: 회전 → 회전 효과
- AnimatedSlide: 상대 이동 → 슬라이드 효과
조합 전략
- 단일 효과: 명확한 목적을 위해 하나의 위젯 사용
- 복합 효과: 2-3개 위젯 조합으로 풍부한 효과
- 성능 고려: 너무 많은 중첩은 피하기
- 사용자 경험: 자연스럽고 의미 있는 애니메이션
이번 튜토리얼에서 배운 다양한 Animated 위젯들을 통해 전문적이고 세련된 사용자 인터페이스를 만들 수 있습니다. 각 위젯의 특성을 이해하고 적절히 조합하면 사용자에게 즐거운 경험을 제공할 수 있습니다.
🚀 다음 단계
다음 튜토리얼에서는 AnimationController 마스터를 배워봅시다:
- 명시적 애니메이션 제어하기
- Tween과 CurvedAnimation 활용하기
- 복잡한 애니메이션 시퀀스 구현하기
- 애니메이션 재생, 정지, 반복 제어하기