개요
AnimatedPositioned는 Stack 위젯 내에서 자식 위젯의 위치와 크기가 변경될 때 자동으로 애니메이션을 적용하는 위젯입니다. Flutter의 AnimatedPositioned 위젯에서 영감을 받아 구현되었습니다.
Stack의 자식으로만 사용할 수 있습니다.
이 위젯은 애니메이션 결과로 자식의 크기가 변경될 때 좋은 선택입니다. 크기는 그대로 두고 위치만 변경하려면 SlideTransition을 고려하세요. SlideTransition은 애니메이션 프레임마다 리페인트만 트리거하지만, AnimatedPositioned는 리레이아웃도 함께 트리거합니다.
Flutter 참조: https://api.flutter.dev/flutter/widgets/AnimatedPositioned-class.html
언제 사용하나요?
- Stack 내에서 위젯의 위치를 동적으로 이동시킬 때
- 드래그 앤 드롭이나 이동 애니메이션을 구현할 때
- 모달이나 팝업의 위치를 애니메이션으로 전환할 때
- 플로팅 액션 버튼을 다른 위치로 이동시킬 때
- 마이크로 인터랙션에 따라 이름지나 비번박 텐법을 이동시킬 때
- 리사이즈 죄이나 크기 변경 효과를 애니메이션으로 구현할 때
기본 사용법
import { AnimatedPositioned, Stack, Container, StatefulWidget } from '@meursyphus/flitter';
class PositionedExample extends StatefulWidget {
createState() {
return new PositionedExampleState();
}
}
class PositionedExampleState extends State<PositionedExample> {
isTopLeft = true;
build() {
return Column({
children: [
ElevatedButton({
onPressed: () => {
this.setState(() => {
this.isTopLeft = !this.isTopLeft;
});
},
child: Text("위치 이동"),
}),
Container({
width: 300,
height: 300,
child: Stack({
children: [
Container({
width: 300,
height: 300,
color: "lightgray",
}),
AnimatedPositioned({
top: this.isTopLeft ? 20 : 220,
left: this.isTopLeft ? 20 : 220,
width: 60,
height: 60,
duration: 1000, // 1초
child: Container({
color: "blue",
child: Center({
child: Text("이동", {
style: TextStyle({ color: "white" })
}),
}),
}),
}),
],
}),
}),
],
});
}
}
Props
duration (필수)
값: number
애니메이션 지속 시간을 밀리초 단위로 지정합니다.
top (선택)
값: number | undefined
Stack의 상단에서의 거리를 지정합니다. bottom과 동시에 사용할 수 없습니다.
left (선택)
값: number | undefined
Stack의 좌측에서의 거리를 지정합니다. right와 동시에 사용할 수 없습니다.
right (선택)
값: number | undefined
Stack의 우측에서의 거리를 지정합니다. left와 동시에 사용할 수 없습니다.
bottom (선택)
값: number | undefined
Stack의 하단에서의 거리를 지정합니다. top과 동시에 사용할 수 없습니다.
width (선택)
값: number | undefined
위젯의 너비를 지정합니다. 지정하지 않으면 자식 위젯의 고유 너비를 사용합니다.
height (선택)
값: number | undefined
위젯의 높이를 지정합니다. 지정하지 않으면 자식 위젯의 고유 높이를 사용합니다.
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 { AnimatedPositioned, Stack, Container, GestureDetector, Curves } from '@meursyphus/flitter';
class DragDropSimulation extends StatefulWidget {
createState() {
return new DragDropSimulationState();
}
}
class DragDropSimulationState extends State<DragDropSimulation> {
boxPosition = { x: 50, y: 50 };
targetPosition = { x: 200, y: 200 };
isAtTarget = false;
build() {
return Container({
width: 400,
height: 400,
color: "#f0f0f0",
child: Stack({
children: [
// 타겟 영역
Positioned({
left: this.targetPosition.x,
top: this.targetPosition.y,
child: Container({
width: 100,
height: 100,
decoration: BoxDecoration({
color: this.isAtTarget ? "green" : "lightgreen",
borderRadius: BorderRadius.circular(8),
border: Border.all({
color: "green",
width: 2,
style: "dashed"
}),
}),
child: Center({
child: Text(
"드롭 영역",
{ style: TextStyle({ color: "darkgreen", fontWeight: "bold" }) }
),
}),
}),
}),
// 드래그 가능한 박스
AnimatedPositioned({
left: this.boxPosition.x,
top: this.boxPosition.y,
width: 60,
height: 60,
duration: 500,
curve: Curves.easeInOut,
child: GestureDetector({
onTap: () => this.moveToTarget(),
child: Container({
decoration: BoxDecoration({
color: "blue",
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow({
color: "rgba(0,0,0,0.3)",
blurRadius: 4,
offset: Offset({ x: 2, y: 2 }),
}),
],
}),
child: Center({
child: Icon({
icon: Icons.touch_app,
color: "white",
size: 24,
}),
}),
}),
}),
}),
// 설명 텍스트
Positioned({
bottom: 20,
left: 20,
child: Container({
padding: EdgeInsets.all(12),
decoration: BoxDecoration({
color: "rgba(0,0,0,0.7)",
borderRadius: BorderRadius.circular(8),
}),
child: Text(
"파란 사각형을 클릭하여 이동시키세요",
{ style: TextStyle({ color: "white", fontSize: 14 }) }
),
}),
}),
],
}),
});
}
moveToTarget() {
this.setState(() => {
if (!this.isAtTarget) {
this.boxPosition = { x: this.targetPosition.x + 20, y: this.targetPosition.y + 20 };
this.isAtTarget = true;
} else {
this.boxPosition = { x: 50, y: 50 };
this.isAtTarget = false;
}
});
}
}
예제 2: 다중 위젯 오케스트레이션
import { AnimatedPositioned, Stack, Container, Curves } from '@meursyphus/flitter';
class MultiWidgetOrchestration extends StatefulWidget {
createState() {
return new MultiWidgetOrchestrationState();
}
}
class MultiWidgetOrchestrationState extends State<MultiWidgetOrchestration> {
pattern = 0; // 0: 원형, 1: 사각형, 2: 대각선, 3: 사선형
getPositions(pattern: number) {
switch (pattern) {
case 0: // 원형
return [
{ x: 150, y: 50 }, // 상단
{ x: 250, y: 150 }, // 우측
{ x: 150, y: 250 }, // 하단
{ x: 50, y: 150 }, // 좌측
];
case 1: // 사각형
return [
{ x: 100, y: 100 }, // 좌상
{ x: 200, y: 100 }, // 우상
{ x: 200, y: 200 }, // 우하
{ x: 100, y: 200 }, // 좌하
];
case 2: // 대각선
return [
{ x: 50, y: 50 },
{ x: 120, y: 120 },
{ x: 190, y: 190 },
{ x: 260, y: 260 },
];
case 3: // 사선형
return [
{ x: 50, y: 200 },
{ x: 120, y: 120 },
{ x: 190, y: 180 },
{ x: 260, y: 100 },
];
default:
return [
{ x: 150, y: 150 },
{ x: 150, y: 150 },
{ x: 150, y: 150 },
{ x: 150, y: 150 },
];
}
}
build() {
const positions = this.getPositions(this.pattern);
const colors = ["#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4"];
const icons = [Icons.star, Icons.favorite, Icons.diamond, Icons.hexagon];
return Column({
children: [
// 패턴 선택 버튼들
Padding({
padding: EdgeInsets.all(16),
child: Row({
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton({
onPressed: () => this.changePattern(0),
child: Text("원형"),
style: ButtonStyle({
backgroundColor: this.pattern === 0 ? "blue" : "gray",
}),
}),
ElevatedButton({
onPressed: () => this.changePattern(1),
child: Text("사각형"),
style: ButtonStyle({
backgroundColor: this.pattern === 1 ? "blue" : "gray",
}),
}),
ElevatedButton({
onPressed: () => this.changePattern(2),
child: Text("대각선"),
style: ButtonStyle({
backgroundColor: this.pattern === 2 ? "blue" : "gray",
}),
}),
ElevatedButton({
onPressed: () => this.changePattern(3),
child: Text("사선형"),
style: ButtonStyle({
backgroundColor: this.pattern === 3 ? "blue" : "gray",
}),
}),
],
}),
}),
// 애니메이션 영역
Container({
width: 350,
height: 350,
decoration: BoxDecoration({
color: "#2C3E50",
borderRadius: BorderRadius.circular(16),
}),
child: Stack({
children: positions.map((pos, index) => {
return AnimatedPositioned({
key: ValueKey(index),
left: pos.x,
top: pos.y,
width: 50,
height: 50,
duration: 800 + (index * 100), // 시차를 두고 애니메이션
curve: Curves.easeInOut,
child: Container({
decoration: BoxDecoration({
color: colors[index],
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow({
color: "rgba(0,0,0,0.3)",
blurRadius: 8,
offset: Offset({ x: 0, y: 4 }),
}),
],
}),
child: Center({
child: Icon({
icon: icons[index],
color: "white",
size: 28,
}),
}),
}),
});
}),
}),
}),
],
});
}
changePattern(newPattern: number) {
this.setState(() => {
this.pattern = newPattern;
});
}
}
예제 3: 모달 대화상자 애니메이션
import { AnimatedPositioned, Stack, Container, Card, Curves } from '@meursyphus/flitter';
class ModalAnimation extends StatefulWidget {
createState() {
return new ModalAnimationState();
}
}
class ModalAnimationState extends State<ModalAnimation> {
showModal = false;
build() {
return Stack({
children: [
// 메인 컨텐츠
Container({
width: double.infinity,
height: double.infinity,
color: "#f5f5f5",
child: Center({
child: ElevatedButton({
onPressed: () => {
this.setState(() => {
this.showModal = true;
});
},
child: Text("모달 열기"),
}),
}),
}),
// 모달 배경
if (this.showModal) AnimatedOpacity({
opacity: this.showModal ? 0.7 : 0.0,
duration: 300,
child: Container({
width: double.infinity,
height: double.infinity,
color: "black",
}),
}),
// 모달 대화상자
AnimatedPositioned({
left: 50,
right: 50,
top: this.showModal ? 150 : 600, // 아래에서 올라오는 효과
duration: 400,
curve: Curves.easeOut,
child: this.showModal ? Card({
child: Container({
padding: EdgeInsets.all(20),
child: Column({
mainAxisSize: MainAxisSize.min,
children: [
Text(
"모달 대화상자",
{ style: TextStyle({ fontSize: 24, fontWeight: "bold" }) }
),
SizedBox({ height: 16 }),
Text(
"이것은 애니메이션된 모달 대화상자입니다. "
"아래에서 올라오는 효과로 나타납니다."
),
SizedBox({ height: 20 }),
Row({
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton({
onPressed: () => {
this.setState(() => {
this.showModal = false;
});
},
child: Text("취소"),
}),
SizedBox({ width: 8 }),
ElevatedButton({
onPressed: () => {
this.setState(() => {
this.showModal = false;
});
},
child: Text("확인"),
}),
],
}),
],
}),
}),
}) : SizedBox.shrink(),
}),
],
});
}
}
예제 4: 사이즈와 위치 동시 애니메이션
import { AnimatedPositioned, Stack, Container, Curves } from '@meursyphus/flitter';
class SizeAndPositionAnimation extends StatefulWidget {
createState() {
return new SizeAndPositionAnimationState();
}
}
class SizeAndPositionAnimationState extends State<SizeAndPositionAnimation> {
isExpanded = false;
build() {
return GestureDetector({
onTap: () => {
this.setState(() => {
this.isExpanded = !this.isExpanded;
});
},
child: Container({
width: 400,
height: 400,
color: "#34495e",
child: Stack({
children: [
AnimatedPositioned({
left: this.isExpanded ? 50 : 175,
top: this.isExpanded ? 50 : 175,
width: this.isExpanded ? 300 : 50,
height: this.isExpanded ? 300 : 50,
duration: 1000,
curve: Curves.elasticOut,
child: Container({
decoration: BoxDecoration({
gradient: LinearGradient({
colors: this.isExpanded
? ["#e74c3c", "#c0392b"]
: ["#3498db", "#2980b9"],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
}),
borderRadius: BorderRadius.circular(this.isExpanded ? 16 : 25),
boxShadow: [
BoxShadow({
color: "rgba(0,0,0,0.3)",
blurRadius: this.isExpanded ? 20 : 10,
offset: Offset({ x: 0, y: this.isExpanded ? 10 : 5 }),
}),
],
}),
child: Center({
child: Icon({
icon: this.isExpanded ? Icons.close : Icons.add,
color: "white",
size: this.isExpanded ? 48 : 24,
}),
}),
}),
}),
// 안내 텍스트
Positioned({
bottom: 20,
left: 20,
right: 20,
child: Container({
padding: EdgeInsets.all(12),
decoration: BoxDecoration({
color: "rgba(255,255,255,0.9)",
borderRadius: BorderRadius.circular(8),
}),
child: Text(
"화면을 탭하여 사이즈와 위치 애니메이션을 확인하세요",
{
style: TextStyle({
color: "#2c3e50",
fontSize: 14,
textAlign: "center"
})
}
),
}),
}),
],
}),
}),
});
}
}
주의사항
- Stack의 직접 자식이어야 함: AnimatedPositioned는 Stack 위젯의 직접 자식으로만 사용할 수 있습니다
- 상촩되는 속성 주의: top/bottom, left/right는 동시에 설정할 수 없습니다
- 성능 고려사항: 위치 변경 시 리레이아웃이 다시 계산되므로 많은 위젯에 동시 적용 시 성능 영향이 있을 수 있습니다
- 애니메이션 중단: 새로운 속성 값이 설정되면 현재 값에서 새 값으로 부드럽게 전환됩니다
- 음수 값 허용: left, top, right, bottom 속성은 음수 값도 가능합니다
관련 위젯
- Positioned: 애니메이션 없이 Stack 내에서 위치를 지정하는 기본 위젯
- AnimatedAlign: 정렬을 애니메이션으로 전환
- SlideTransition: 위치만 애니메이션하고 리레이아웃은 그대로 두는 경우 사용
- Stack: AnimatedPositioned를 사용하기 위한 부모 위젯
- AnimatedContainer: 다양한 속성을 동시에 애니메이션하는 범용 위젯