개요
AnimatedSlide는 위젯의 위치(offset)가 변경될 때 자동으로 애니메이션을 적용하여 부드럽게 슬라이드 전환하는 위젯입니다. Flutter의 AnimatedSlide 위젯에서 영감을 받아 구현되었습니다.
Animated version of FractionalTranslation which automatically transitions the child’s offset over a given duration whenever the given offset changes.
Flutter 참조: https://api.flutter.dev/flutter/widgets/AnimatedSlide-class.html
언제 사용하나요?
- 화면에서 요소가 등장하거나 사라질 때 슬라이드 효과를 주고 싶을 때
- 탭이나 페이지 전환 시 부드러운 슬라이드 애니메이션을 구현할 때
- 알림이나 토스트 메시지가 화면에 나타나거나 사라질 때
- 슬라이더나 캐러셀의 아이템 전환 효과를 만들 때
- 드래그 앤 드롭이나 스와이프 제스처의 피드백을 제공할 때
- 메뉴나 사이드바의 열기/닫기 애니메이션을 구현할 때
기본 사용법
import { AnimatedSlide, Container, StatefulWidget, Offset } from '@meursyphus/flitter';
class SlideExample extends StatefulWidget {
createState() {
return new SlideExampleState();
}
}
class SlideExampleState extends State<SlideExample> {
offset = Offset({ x: 0, y: 0 });
build() {
return Column({
children: [
ElevatedButton({
onPressed: () => {
this.setState(() => {
this.offset = this.offset.x === 0
? Offset({ x: 1, y: 0 }) // 오른쪽으로 이동
: Offset({ x: 0, y: 0 }); // 원래 위치로
});
},
child: Text("슬라이드 이동"),
}),
Container({
width: 300,
height: 200,
color: "lightblue",
child: AnimatedSlide({
offset: this.offset,
duration: 500, // 0.5초
child: Container({
width: 100,
height: 100,
color: "blue",
child: Center({
child: Icon({
icon: Icons.star,
color: "white",
size: 40,
}),
}),
}),
}),
}),
],
});
}
}
Props
offset (필수)
값: Offset
위젯의 이동 거리를 지정합니다. 이는 상대적 위치로, 위젯의 크기에 대한 비율로 계산됩니다.
Offset({ x: 0, y: 0 })
: 원래 위치Offset({ x: 1, y: 0 })
: 위젯 너비만큼 오른쪽으로 이동Offset({ x: -1, y: 0 })
: 위젯 너비만큼 왼쪽으로 이동Offset({ x: 0, y: 1 })
: 위젯 높이만큼 아래로 이동Offset({ x: 0, y: -1 })
: 위젯 높이만큼 위로 이동Offset({ x: 0.5, y: 0.5 })
: 너비의 절반만큼 오른쪽, 높이의 절반만큼 아래로 이동
duration (필수)
값: number
애니메이션 지속 시간을 밀리초 단위로 지정합니다.
curve (선택)
값: Curve (기본값: Curves.linear)
애니메이션의 진행 곡선을 지정합니다. 사용 가능한 곡선:
Curves.linear
: 일정한 속도Curves.easeIn
: 천천히 시작Curves.easeOut
: 천천히 종료Curves.easeInOut
: 천천히 시작하고 종료Curves.circIn
: 원형 가속 시작Curves.circOut
: 원형 감속 종료Curves.circInOut
: 원형 가속/감속Curves.backIn
: 뒤로 갔다가 시작Curves.backOut
: 목표를 지나쳤다가 돌아옴Curves.backInOut
: backIn + backOutCurves.anticipate
: 예비 동작 후 진행Curves.bounceIn
: 바운스하며 시작Curves.bounceOut
: 바운스하며 종료Curves.bounceInOut
: 바운스 시작/종료
child (선택)
값: Widget | undefined
슬라이드가 적용될 자식 위젯입니다.
key (선택)
값: any
위젯의 고유 식별자입니다.
실제 사용 예제
예제 1: 토스트 알림 애니메이션
import { AnimatedSlide, Container, Text, Curves, Offset } from '@meursyphus/flitter';
class ToastNotification extends StatefulWidget {
createState() {
return new ToastNotificationState();
}
}
class ToastNotificationState extends State<ToastNotification> {
isVisible = false;
offset = Offset({ x: 0, y: -1 }); // 화면 위쪽에 숨김
showToast() {
this.setState(() => {
this.isVisible = true;
this.offset = Offset({ x: 0, y: 0 }); // 화면에 나타남
});
// 3초 후 자동으로 숨김
setTimeout(() => {
this.setState(() => {
this.isVisible = false;
this.offset = Offset({ x: 0, y: -1 });
});
}, 3000);
}
build() {
return Column({
children: [
ElevatedButton({
onPressed: () => this.showToast(),
child: Text("토스트 표시"),
}),
SizedBox({ height: 20 }),
Container({
width: double.infinity,
height: 100,
child: Stack({
children: [
if (this.isVisible) AnimatedSlide({
offset: this.offset,
duration: 300,
curve: Curves.easeOut,
child: Container({
margin: EdgeInsets.symmetric({ horizontal: 20 }),
padding: EdgeInsets.all(16),
decoration: BoxDecoration({
color: "#2ecc71",
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow({
color: "rgba(0,0,0,0.3)",
blurRadius: 8,
offset: Offset({ x: 0, y: 4 }),
}),
],
}),
child: Row({
children: [
Icon({
icon: Icons.check_circle,
color: "white",
size: 24,
}),
SizedBox({ width: 12 }),
Expanded({
child: Text(
"성공적으로 저장되었습니다!",
{ style: TextStyle({ color: "white", fontSize: 16, fontWeight: "bold" }) }
),
}),
],
}),
}),
}),
],
}),
}),
],
});
}
}
예제 2: 탭 전환 애니메이션
import { AnimatedSlide, Container, GestureDetector, Curves, Offset } from '@meursyphus/flitter';
class TabSwitcher extends StatefulWidget {
createState() {
return new TabSwitcherState();
}
}
class TabSwitcherState extends State<TabSwitcher> {
activeTab = 0;
tabs = ["홈", "검색", "프로필", "설정"];
getTabOffset(index: number): Offset {
const diff = index - this.activeTab;
return Offset({ x: diff, y: 0 });
}
build() {
return Column({
children: [
// 탭 헤더
Row({
children: this.tabs.map((tab, index) => {
const isActive = this.activeTab === index;
return Expanded({
key: ValueKey(index),
child: GestureDetector({
onTap: () => {
this.setState(() => {
this.activeTab = index;
});
},
child: Container({
padding: EdgeInsets.symmetric({ vertical: 16 }),
decoration: BoxDecoration({
color: isActive ? "#3498db" : "#ecf0f1",
borderRadius: BorderRadius.circular(8),
}),
child: Center({
child: Text(
tab,
{ style: TextStyle({
color: isActive ? "white" : "#34495e",
fontWeight: isActive ? "bold" : "normal",
fontSize: 16
}) }
),
}),
}),
}),
});
}),
}),
SizedBox({ height: 20 }),
// 탭 콘텐츠
Container({
height: 300,
width: double.infinity,
child: Stack({
children: this.tabs.map((tab, index) => {
return AnimatedSlide({
key: ValueKey(index),
offset: this.getTabOffset(index),
duration: 400,
curve: Curves.easeInOut,
child: Container({
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration({
color: ["#e74c3c", "#f39c12", "#9b59b6", "#1abc9c"][index],
borderRadius: BorderRadius.circular(12),
}),
child: Center({
child: Column({
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon({
icon: [Icons.home, Icons.search, Icons.person, Icons.settings][index],
color: "white",
size: 64,
}),
SizedBox({ height: 16 }),
Text(
`${tab} 탭`,
{ style: TextStyle({ color: "white", fontSize: 24, fontWeight: "bold" }) }
),
SizedBox({ height: 8 }),
Text(
`이곳은 ${tab} 탭의 내용입니다.`,
{ style: TextStyle({ color: "white", fontSize: 16, opacity: 0.8 }) }
),
],
}),
}),
}),
});
}),
}),
}),
],
});
}
}
예제 3: 사이드 메뉴 애니메이션
import { AnimatedSlide, Container, GestureDetector, Curves, Offset } from '@meursyphus/flitter';
class SideMenu extends StatefulWidget {
createState() {
return new SideMenuState();
}
}
class SideMenuState extends State<SideMenu> {
isMenuOpen = false;
toggleMenu() {
this.setState(() => {
this.isMenuOpen = !this.isMenuOpen;
});
}
build() {
return Container({
width: double.infinity,
height: 400,
child: Stack({
children: [
// 메인 콘텐츠
Container({
color: "#ecf0f1",
child: Column({
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton({
onPressed: () => this.toggleMenu(),
child: Text(this.isMenuOpen ? "메뉴 닫기" : "메뉴 열기"),
}),
SizedBox({ height: 20 }),
Text(
"메인 콘텐츠 영역",
{ style: TextStyle({ fontSize: 18, color: "#34495e" }) }
),
],
}),
}),
// 사이드 메뉴
AnimatedSlide({
offset: this.isMenuOpen
? Offset({ x: 0, y: 0 }) // 화면에 나타남
: Offset({ x: -1, y: 0 }), // 왼쪽으로 숨김
duration: 300,
curve: Curves.easeInOut,
child: Container({
width: 250,
height: double.infinity,
decoration: BoxDecoration({
color: "#2c3e50",
boxShadow: [
BoxShadow({
color: "rgba(0,0,0,0.3)",
blurRadius: 10,
offset: Offset({ x: 2, y: 0 }),
}),
],
}),
child: Column({
children: [
Container({
padding: EdgeInsets.all(20),
decoration: BoxDecoration({
color: "#34495e",
}),
child: Row({
children: [
CircleAvatar({
backgroundColor: "#3498db",
child: Icon({
icon: Icons.person,
color: "white",
}),
}),
SizedBox({ width: 12 }),
Expanded({
child: Column({
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"사용자명",
{ style: TextStyle({ color: "white", fontSize: 16, fontWeight: "bold" }) }
),
Text(
"[email protected]",
{ style: TextStyle({ color: "#bdc3c7", fontSize: 12 }) }
),
],
}),
}),
],
}),
}),
...["홈", "프로필", "설정", "도움말", "로그아웃"].map((item, index) => {
const icons = [Icons.home, Icons.person, Icons.settings, Icons.help, Icons.exit_to_app];
return ListTile({
key: ValueKey(index),
leading: Icon({
icon: icons[index],
color: "#bdc3c7",
}),
title: Text(
item,
{ style: TextStyle({ color: "white", fontSize: 16 }) }
),
onTap: () => {
// 메뉴 아이템 클릭 처리
this.toggleMenu();
},
});
}),
],
}),
}),
}),
// 오버레이 (메뉴가 열려있을 때)
if (this.isMenuOpen) GestureDetector({
onTap: () => this.toggleMenu(),
child: Container({
color: "rgba(0,0,0,0.5)",
}),
}),
],
}),
});
}
}
예제 4: 카드 스와이프 애니메이션
import { AnimatedSlide, Container, GestureDetector, Curves, Offset } from '@meursyphus/flitter';
class SwipeableCard extends StatefulWidget {
createState() {
return new SwipeableCardState();
}
}
class SwipeableCardState extends State<SwipeableCard> {
cards = [
{ title: "카드 1", color: "#e74c3c", content: "첫 번째 카드입니다." },
{ title: "카드 2", color: "#f39c12", content: "두 번째 카드입니다." },
{ title: "카드 3", color: "#27ae60", content: "세 번째 카드입니다." },
{ title: "카드 4", color: "#3498db", content: "네 번째 카드입니다." },
];
currentIndex = 0;
offset = Offset({ x: 0, y: 0 });
isAnimating = false;
swipeLeft() {
if (this.isAnimating || this.currentIndex >= this.cards.length - 1) return;
this.setState(() => {
this.isAnimating = true;
this.offset = Offset({ x: -1, y: 0 });
});
setTimeout(() => {
this.setState(() => {
this.currentIndex += 1;
this.offset = Offset({ x: 1, y: 0 });
});
setTimeout(() => {
this.setState(() => {
this.offset = Offset({ x: 0, y: 0 });
this.isAnimating = false;
});
}, 50);
}, 250);
}
swipeRight() {
if (this.isAnimating || this.currentIndex <= 0) return;
this.setState(() => {
this.isAnimating = true;
this.offset = Offset({ x: 1, y: 0 });
});
setTimeout(() => {
this.setState(() => {
this.currentIndex -= 1;
this.offset = Offset({ x: -1, y: 0 });
});
setTimeout(() => {
this.setState(() => {
this.offset = Offset({ x: 0, y: 0 });
this.isAnimating = false;
});
}, 50);
}, 250);
}
build() {
const currentCard = this.cards[this.currentIndex];
return Column({
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container({
width: 300,
height: 200,
child: AnimatedSlide({
offset: this.offset,
duration: 250,
curve: Curves.easeInOut,
child: Container({
decoration: BoxDecoration({
color: currentCard.color,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow({
color: "rgba(0,0,0,0.3)",
blurRadius: 10,
offset: Offset({ x: 0, y: 5 }),
}),
],
}),
child: Padding({
padding: EdgeInsets.all(20),
child: Column({
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
currentCard.title,
{ style: TextStyle({ color: "white", fontSize: 24, fontWeight: "bold" }) }
),
SizedBox({ height: 16 }),
Text(
currentCard.content,
{ style: TextStyle({ color: "white", fontSize: 16, opacity: 0.8 }) }
),
],
}),
}),
}),
}),
}),
SizedBox({ height: 30 }),
Row({
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton({
onPressed: this.currentIndex > 0 ? () => this.swipeRight() : null,
child: Row({
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.arrow_back),
SizedBox({ width: 8 }),
Text("이전"),
],
}),
}),
Text(
`${this.currentIndex + 1} / ${this.cards.length}`,
{ style: TextStyle({ fontSize: 16, fontWeight: "bold" }) }
),
ElevatedButton({
onPressed: this.currentIndex < this.cards.length - 1 ? () => this.swipeLeft() : null,
child: Row({
mainAxisSize: MainAxisSize.min,
children: [
Text("다음"),
SizedBox({ width: 8 }),
Icon(Icons.arrow_forward),
],
}),
}),
],
}),
],
});
}
}
주의사항
- offset 값의 의미: Offset 값은 위젯의 크기에 대한 상대적 비율입니다 (절대 픽셀 값이 아님)
- 레이아웃 영향: 슬라이드는 시각적 변화만 주고 레이아웃에는 영향을 주지 않습니다
- 성능 고려: 많은 위젯에 동시에 슬라이드 애니메이션을 적용하면 성능에 영향을 줄 수 있습니다
- 경계 처리: 슬라이드된 위젯이 부모 컨테이너를 벗어날 수 있으므로 ClipRect 등으로 경계를 제한할 수 있습니다
- 연속 애니메이션: 빠른 연속 호출 시 애니메이션이 겹치지 않도록 상태 관리가 필요합니다
관련 위젯
- FractionalTranslation: 애니메이션 없이 상대적 위치 이동을 적용하는 기본 위젯
- AnimatedContainer: 여러 변환을 동시에 애니메이션하는 범용 위젯
- SlideTransition: 명시적인 Animation 컨트롤러를 사용하는 슬라이드 애니메이션
- AnimatedPositioned: Stack 내에서 절대 위치를 애니메이션하는 위젯
- Transform.translate: 절대 픽셀 값으로 위치를 이동하는 기본 위젯