다양한 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("")
              })
            })
          })
        })
      ]
    });
  }
}

🎨 예상 결과

완성하면 다음과 같은 효과들이 작동해야 합니다:

  1. 페이드 효과: 부드러운 투명도 변화로 나타나고 사라지는 효과
  2. 패딩 애니메이션: 여백 변화로 콘텐츠가 확장/축소되는 효과
  3. 위치 이동: 9개 위치로 부드럽게 이동하는 효과
  4. 크기 변화: 비율로 확대/축소되는 효과
  5. 회전 효과: 클릭할 때마다 회전하는 효과
  6. 슬라이드: 상대적 거리만큼 이동하는 효과

💡 추가 도전

더 도전하고 싶다면:

  1. 스테거드 애니메이션: 여러 요소가 순차적으로 애니메이션되도록 하기
  2. 무한 루프: 자동으로 반복되는 애니메이션 구현하기
  3. 복합 효과: 3개 이상의 Animated 위젯을 조합하기
  4. 조건부 애니메이션: 특정 조건에서만 특정 효과 실행하기

⚠️ 흔한 실수와 해결법

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({
    // 필요한 효과만 조합
  })
})

🎓 핵심 정리

각 위젯의 특화 영역

  1. AnimatedOpacity: 투명도 → 페이드 인/아웃
  2. AnimatedPadding: 여백 → 콘텐츠 확장/축소
  3. AnimatedAlign: 정렬 → 위치 이동
  4. AnimatedScale: 비율 → 크기 변화
  5. AnimatedRotation: 회전 → 회전 효과
  6. AnimatedSlide: 상대 이동 → 슬라이드 효과

조합 전략

  • 단일 효과: 명확한 목적을 위해 하나의 위젯 사용
  • 복합 효과: 2-3개 위젯 조합으로 풍부한 효과
  • 성능 고려: 너무 많은 중첩은 피하기
  • 사용자 경험: 자연스럽고 의미 있는 애니메이션

이번 튜토리얼에서 배운 다양한 Animated 위젯들을 통해 전문적이고 세련된 사용자 인터페이스를 만들 수 있습니다. 각 위젯의 특성을 이해하고 적절히 조합하면 사용자에게 즐거운 경험을 제공할 수 있습니다.

🚀 다음 단계

다음 튜토리얼에서는 AnimationController 마스터를 배워봅시다:

  • 명시적 애니메이션 제어하기
  • Tween과 CurvedAnimation 활용하기
  • 복잡한 애니메이션 시퀀스 구현하기
  • 애니메이션 재생, 정지, 반복 제어하기

다음: AnimationController 마스터 →