개요

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 + backOut
  • Curves.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: 절대 픽셀 값으로 위치를 이동하는 기본 위젯