Flutter에서 애니메이션 커브(Curve)와 보간(Interpolation)은 어떻게 동작하나요?

질문

Flutter에서 애니메이션 커브(Curve)와 보간(Interpolation)은 어떻게 동작하며 어떻게 활용할 수 있나요?

답변

애니메이션 커브(Curve)와 보간(Interpolation)은 Flutter 애니메이션의 핵심 개념으로, 시간에 따른 값의 변화를 자연스럽고 미적으로 만들어줍니다. 이러한 개념들을 이해하면 단순한 선형 애니메이션을 넘어 다양한 느낌과 역동성을 가진 애니메이션을 만들 수 있습니다.

보간(Interpolation)이란?

보간은 시작 값과 끝 값 사이의 중간 값을 계산하는 과정입니다. 애니메이션에서는 애니메이션 컨트롤러가 생성하는 0.0부터 1.0 사이의 값(t)을 기반으로 실제 애니메이션 값을 계산합니다.

가장 기본적인 선형 보간은 다음과 같이 계산됩니다:

value = start + (end - start) * t

예를 들어, 크기가 100에서 200으로 변하는 애니메이션에서 t가 0.5일 때 값은 150이 됩니다.

애니메이션 커브(Curve)란?

커브는 애니메이션 진행 비율(t)을 변형하여 애니메이션의 느낌을 변화시킵니다. 선형 커브(Linear)는 t를 그대로 사용하지만, 다른 커브들은 t를 변형하여 가속, 감속, 바운스 등 다양한 효과를 만듭니다.

Flutter의 Curve 클래스는 0.0에서 1.0 사이의 입력 값(t)을 받아 변형된 0.0에서 1.0 사이의 값을 반환하는 함수입니다.

Flutter에서 제공하는 주요 커브들

Flutter는 Curves 클래스를 통해 다양한 사전 정의된 커브를 제공합니다:

1. 기본 커브

  • linear: 일정한 속도로 변화 (t 그대로 사용)
  • decelerate: 시작은 빠르게, 끝에서 감속
  • ease: 부드러운 가속 후 감속 (가장 자연스러운 효과)
  • easeIn: 천천히 시작해서 빨라짐 (가속)
  • easeOut: 빠르게 시작해서 느려짐 (감속)
  • easeInOut: 천천히 시작해서 빨라졌다가 다시 느려짐

2. 물리적 커브

  • bounceIn: 마치 튕기듯이 목표 값에 도달
  • bounceOut: 목표 값에서 튕긴 후 정착
  • bounceInOut: 시작과 끝에서 모두 튕기는 효과
  • elasticIn: 탄성이 있는 시작 (고무줄처럼 늘어났다가 제자리로)
  • elasticOut: 탄성이 있는 끝 (목표를 지나쳤다가 돌아옴)
  • elasticInOut: 시작과 끝에 모두 탄성 효과 적용

커브 사용 예시

class CurveDemo extends StatefulWidget {
  @override
  _CurveDemoState createState() => _CurveDemoState();
}

class _CurveDemoState extends State<CurveDemo>
    with SingleTickerProviderStateMixin {

  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    );

    // 커브 적용
    _animation = CurvedAnimation(
      parent: _controller,
      curve: Curves.elasticOut,
    );

    // 실제 값 범위로 매핑
    _animation = Tween<double>(begin: 50, end: 200).animate(_animation);

    _controller.forward();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: AnimatedBuilder(
        animation: _animation,
        builder: (context, child) {
          return Container(
            width: _animation.value,
            height: _animation.value,
            color: Colors.blue,
          );
        },
      ),
    );
  }
}

위 예제에서 elasticOut 커브를 적용하면 상자가 목표 크기를 넘어서 진동한 후 최종 크기에 정착하는 효과를 볼 수 있습니다.

여러 커브 시각화하기

다양한 커브의 효과를 비교하려면 다음과 같은 예제를 사용할 수 있습니다:

class CurvesComparisonDemo extends StatefulWidget {
  @override
  _CurvesComparisonDemoState createState() => _CurvesComparisonDemoState();
}

class _CurvesComparisonDemoState extends State<CurvesComparisonDemo>
    with SingleTickerProviderStateMixin {

  late AnimationController _controller;

  final List<Map<String, dynamic>> curves = [
    {'name': 'linear', 'curve': Curves.linear},
    {'name': 'ease', 'curve': Curves.ease},
    {'name': 'easeIn', 'curve': Curves.easeIn},
    {'name': 'easeOut', 'curve': Curves.easeOut},
    {'name': 'easeInOut', 'curve': Curves.easeInOut},
    {'name': 'bounceIn', 'curve': Curves.bounceIn},
    {'name': 'bounceOut', 'curve': Curves.bounceOut},
    {'name': 'elasticIn', 'curve': Curves.elasticIn},
    {'name': 'elasticOut', 'curve': Curves.elasticOut},
  ];

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _playAnimation() {
    _controller.reset();
    _controller.forward();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('애니메이션 커브 비교')),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              itemCount: curves.length,
              itemBuilder: (context, index) {
                final curveData = curves[index];

                final animation = CurvedAnimation(
                  parent: _controller,
                  curve: curveData['curve'],
                );

                return Padding(
                  padding: EdgeInsets.all(16.0),
                  child: Row(
                    children: [
                      SizedBox(
                        width: 100,
                        child: Text(curveData['name']),
                      ),
                      Expanded(
                        child: AnimatedBuilder(
                          animation: animation,
                          builder: (context, child) {
                            return Container(
                              height: 50,
                              alignment: Alignment.centerLeft,
                              child: Container(
                                width: 50,
                                height: 50,
                                margin: EdgeInsets.only(
                                  left: animation.value *
                                      (MediaQuery.of(context).size.width - 200),
                                ),
                                decoration: BoxDecoration(
                                  color: Colors.blue,
                                  shape: BoxShape.circle,
                                ),
                              ),
                            );
                          },
                        ),
                      ),
                    ],
                  ),
                );
              },
            ),
          ),
          Padding(
            padding: EdgeInsets.all(16.0),
            child: ElevatedButton(
              onPressed: _playAnimation,
              child: Text('애니메이션 실행'),
            ),
          ),
        ],
      ),
    );
  }
}

커스텀 커브 만들기

특별한 애니메이션 효과가 필요하다면 자신만의 커스텀 커브를 정의할 수 있습니다:

class CustomBounce extends Curve {
  final double a;
  final double b;

  CustomBounce({this.a = 0.5, this.b = 1.5});

  @override
  double transformInternal(double t) {
    if (t < a) {
      // 처음 a까지는 천천히 가속
      return (b * t * t);
    } else {
      // a 이후부터는 튕기는 효과
      t = t - a;
      return 1.0 - b * (1.0 - t) * (1.0 - t);
    }
  }
}

// 사용 예시
final animation = CurvedAnimation(
  parent: controller,
  curve: CustomBounce(a: 0.3, b: 2.0),
);

2D 커브 그래프 시각화

커브의 동작을 더 직관적으로 이해하기 위해 2D 그래프로 시각화할 수 있습니다:

class CurveVisualizerPainter extends CustomPainter {
  final Curve curve;
  final Color color;

  CurveVisualizerPainter({
    required this.curve,
    this.color = Colors.blue,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = color
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2;

    // 축 그리기
    canvas.drawLine(
      Offset(0, size.height),
      Offset(size.width, size.height),
      paint..color = Colors.black,
    );

    canvas.drawLine(
      Offset(0, size.height),
      Offset(0, 0),
      paint..color = Colors.black,
    );

    // 커브 그리기
    final path = Path();
    path.moveTo(0, size.height);

    for (double t = 0.0; t <= 1.0; t += 0.01) {
      final x = t * size.width;
      final y = size.height - curve.transform(t) * size.height;
      path.lineTo(x, y);
    }

    canvas.drawPath(path, paint..color = color);
  }

  @override
  bool shouldRepaint(covariant CurveVisualizerPainter oldDelegate) {
    return curve != oldDelegate.curve || color != oldDelegate.color;
  }
}

class CurveVisualizer extends StatelessWidget {
  final Curve curve;
  final String name;

  CurveVisualizer({
    required this.curve,
    required this.name,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text(name),
        SizedBox(height: 8),
        Container(
          width: 200,
          height: 200,
          decoration: BoxDecoration(
            border: Border.all(color: Colors.grey),
          ),
          child: CustomPaint(
            painter: CurveVisualizerPainter(curve: curve),
          ),
        ),
      ],
    );
  }
}

// 사용 예시
class CurveVisualizerDemo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('커브 시각화')),
      body: SingleChildScrollView(
        child: Padding(
          padding: EdgeInsets.all(16.0),
          child: Wrap(
            spacing: 16,
            runSpacing: 16,
            children: [
              CurveVisualizer(curve: Curves.linear, name: 'linear'),
              CurveVisualizer(curve: Curves.ease, name: 'ease'),
              CurveVisualizer(curve: Curves.easeIn, name: 'easeIn'),
              CurveVisualizer(curve: Curves.easeOut, name: 'easeOut'),
              CurveVisualizer(curve: Curves.easeInOut, name: 'easeInOut'),
              CurveVisualizer(curve: Curves.bounceIn, name: 'bounceIn'),
              CurveVisualizer(curve: Curves.bounceOut, name: 'bounceOut'),
              CurveVisualizer(curve: Curves.elasticIn, name: 'elasticIn'),
              CurveVisualizer(curve: Curves.elasticOut, name: 'elasticOut'),
            ],
          ),
        ),
      ),
    );
  }
}

커브 결합하기 (Interval과 함께 사용)

Interval 클래스를 사용하면 전체 애니메이션의 특정 부분에서만 커브를 적용할 수 있습니다:

// 애니메이션의 첫 40%에서만 easeOut 커브 적용
final animation = CurvedAnimation(
  parent: controller,
  curve: Interval(0.0, 0.4, curve: Curves.easeOut),
);

이 방식은 복합 애니메이션을 만들 때 유용합니다.

역방향 커브

FlippedCurve를 사용하면 기존 커브의 방향을 반대로 뒤집을 수 있습니다:

final flippedEaseIn = FlippedCurve(Curves.easeIn);
// 이제 flippedEaseIn은 easeOut과 같은 동작을 보입니다

커브와 TweenSequence 결합하기

다단계 애니메이션에서 각 단계마다 다른 커브를 적용할 수 있습니다:

final animation = TweenSequence<double>([
  TweenSequenceItem(
    tween: Tween<double>(begin: 0, end: 100)
      .chain(CurveTween(curve: Curves.easeOut)),
    weight: 40,
  ),
  TweenSequenceItem(
    tween: Tween<double>(begin: 100, end: 50)
      .chain(CurveTween(curve: Curves.bounceOut)),
    weight: 30,
  ),
  TweenSequenceItem(
    tween: Tween<double>(begin: 50, end: 200)
      .chain(CurveTween(curve: Curves.elasticOut)),
    weight: 30,
  ),
]).animate(controller);

이 예제에서는 세 단계의 애니메이션이 각각 다른 커브를 사용합니다.

실제 애플리케이션에서의 활용

1. 자연스러운 UI 전환

class SmoothTransitionDemo extends StatefulWidget {
  @override
  _SmoothTransitionDemoState createState() => _SmoothTransitionDemoState();
}

class _SmoothTransitionDemoState extends State<SmoothTransitionDemo>
    with SingleTickerProviderStateMixin {

  late AnimationController _controller;
  late Animation<double> _heightAnimation;
  late Animation<double> _opacityAnimation;
  bool _expanded = false;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
      duration: Duration(milliseconds: 500),
      vsync: this,
    );

    _heightAnimation = CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    ).drive(Tween<double>(begin: 100, end: 300));

    _opacityAnimation = CurvedAnimation(
      parent: _controller,
      // 애니메이션의 후반부에 투명도 증가
      curve: Interval(0.5, 1.0, curve: Curves.easeIn),
    ).drive(Tween<double>(begin: 0.0, end: 1.0));
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _toggleExpand() {
    setState(() {
      _expanded = !_expanded;
      if (_expanded) {
        _controller.forward();
      } else {
        _controller.reverse();
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('자연스러운 UI 전환')),
      body: Center(
        child: AnimatedBuilder(
          animation: _controller,
          builder: (context, child) {
            return Container(
              width: 300,
              height: _heightAnimation.value,
              padding: EdgeInsets.all(16),
              decoration: BoxDecoration(
                color: Colors.blue,
                borderRadius: BorderRadius.circular(12),
              ),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    '카드 제목',
                    style: TextStyle(
                      color: Colors.white,
                      fontSize: 20,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  SizedBox(height: 8),
                  Text(
                    '이것은 기본 내용입니다.',
                    style: TextStyle(color: Colors.white),
                  ),
                  Opacity(
                    opacity: _opacityAnimation.value,
                    child: Padding(
                      padding: EdgeInsets.only(top: 16),
                      child: Text(
                        '이것은 확장되었을 때만 보이는 추가 내용입니다. '
                        '자연스러운 애니메이션 효과를 위해 커브를 적용했습니다.',
                        style: TextStyle(color: Colors.white),
                      ),
                    ),
                  ),
                  Spacer(),
                  Align(
                    alignment: Alignment.center,
                    child: ElevatedButton(
                      onPressed: _toggleExpand,
                      style: ElevatedButton.styleFrom(
                        primary: Colors.white,
                        onPrimary: Colors.blue,
                      ),
                      child: Text(_expanded ? '접기' : '더 보기'),
                    ),
                  ),
                ],
              ),
            );
          },
        ),
      ),
    );
  }
}

2. 로딩 애니메이션

class PulsingLoaderDemo extends StatefulWidget {
  @override
  _PulsingLoaderDemoState createState() => _PulsingLoaderDemoState();
}

class _PulsingLoaderDemoState extends State<PulsingLoaderDemo>
    with SingleTickerProviderStateMixin {

  late AnimationController _controller;
  late Animation<double> _scaleAnimation;
  late Animation<double> _opacityAnimation;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
      duration: Duration(seconds: 1),
      vsync: this,
    )..repeat(reverse: true);

    // 사인파 형태의 커브 사용
    _scaleAnimation = TweenSequence<double>([
      TweenSequenceItem(
        tween: Tween<double>(begin: 1.0, end: 1.5)
          .chain(CurveTween(curve: Curves.easeInOut)),
        weight: 1,
      ),
    ]).animate(_controller);

    _opacityAnimation = TweenSequence<double>([
      TweenSequenceItem(
        tween: Tween<double>(begin: 0.5, end: 1.0)
          .chain(CurveTween(curve: Curves.easeIn)),
        weight: 0.5,
      ),
      TweenSequenceItem(
        tween: Tween<double>(begin: 1.0, end: 0.5)
          .chain(CurveTween(curve: Curves.easeOut)),
        weight: 0.5,
      ),
    ]).animate(_controller);
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('펄싱 로더')),
      body: Center(
        child: AnimatedBuilder(
          animation: _controller,
          builder: (context, child) {
            return Opacity(
              opacity: _opacityAnimation.value,
              child: Transform.scale(
                scale: _scaleAnimation.value,
                child: Container(
                  width: 100,
                  height: 100,
                  decoration: BoxDecoration(
                    color: Colors.blue,
                    shape: BoxShape.circle,
                    boxShadow: [
                      BoxShadow(
                        color: Colors.blue.withOpacity(0.3),
                        blurRadius: 20,
                        spreadRadius: 5,
                      ),
                    ],
                  ),
                  child: Icon(
                    Icons.sync,
                    color: Colors.white,
                    size: 50,
                  ),
                ),
              ),
            );
          },
        ),
      ),
    );
  }
}

결론

Flutter의 애니메이션 커브와 보간은 애니메이션에 자연스러움과 생동감을 불어넣는 핵심 도구입니다. 올바른 커브를 선택하거나 만들면 UI 요소의 움직임에 물리적인 특성을 부여하여 사용자 경험을 크게 향상시킬 수 있습니다.

간단한 애니메이션에는 easeInOut과 같은 기본 커브가 적합하며, 더 생동감 있는 효과를 위해서는 bounceOut이나 elasticOut 같은 물리적 커브를 사용할 수 있습니다. 애니메이션의 각 부분마다 다른 커브를 적용하거나 자신만의 커스텀 커브를 만들어 더욱 독특하고 미적인 애니메이션을 만들 수 있습니다.

애니메이션 커브를 효과적으로 활용하면 Flutter 앱의 UI가 더 세련되고 전문적으로 보이게 할 수 있으며, 작은 애니메이션 디테일의 차이가 앱의 전반적인 품질 인식에 큰 영향을 미칠 수 있습니다.

results matching ""

    No results matching ""