Saturday, July 19, 2025

핵심부터 파헤치는 플러터 애니메이션: 암시적과 명시적

사용자 경험(UX)의 시대에, 정적인 화면은 더 이상 사용자의 시선을 사로잡기 어렵습니다. 부드럽고 직관적인 애니메이션은 앱에 생명력을 불어넣고, 사용자에게 시각적 피드백을 제공하며, 앱의 전반적인 품질을 한 차원 높여주는 핵심 요소입니다. Flutter는 강력하고 유연한 애니메이션 시스템을 제공하여 개발자가 아름다운 UI를 쉽게 만들 수 있도록 지원합니다. 하지만 많은 개발자들이 어디서부터 시작해야 할지, 어떤 애니메이션 기법을 사용해야 할지 막막해합니다.

Flutter 애니메이션의 세계는 크게 두 가지 갈래로 나뉩니다: 암시적(Implicit) 애니메이션명시적(Explicit) 애니메이션. 이 두 가지 접근법은 각각의 장단점과 사용 사례가 명확하며, 이 둘의 차이를 이해하는 것이 Flutter 애니메이션을 효과적으로 활용하는 첫걸음입니다. 이 글에서는 암시적 애니메이션의 간편함부터 명시적 애니메이션의 정교한 제어까지, 두 가지 방법을 심층적으로 파헤치고 실제 코드 예제를 통해 완벽하게 이해할 수 있도록 돕겠습니다.

1부: 가장 쉬운 시작, 암시적 애니메이션 (Implicit Animations)

암시적 애니메이션은 '알아서 해주는' 애니메이션입니다. 개발자는 애니메이션의 시작과 끝 상태만 정의하면, Flutter 프레임워크가 그 사이의 전환 과정을 자동으로 부드럽게 처리해 줍니다. 애니메이션의 진행 상태를 직접 제어하기 위한 AnimationController 같은 복잡한 객체를 만들 필요가 없습니다. 그래서 '암시적'이라는 이름이 붙었습니다.

언제 암시적 애니메이션을 사용해야 할까요?

  • 위젯의 속성(크기, 색상, 위치 등)이 변경될 때 간단한 전환 효과를 주고 싶을 때
  • 사용자의 특정 행동(예: 버튼 클릭)에 대한 일회성 피드백이 필요할 때
  • 복잡한 제어 없이, 최소한의 코드로 빠르게 애니메이션을 구현하고 싶을 때

핵심 위젯: AnimatedContainer

암시적 애니메이션의 가장 대표적인 예는 AnimatedContainer입니다. 이 위젯은 일반 Container와 거의 동일한 속성을 가지지만, durationcurve 속성이 추가되어 있습니다. width, height, color, decoration, padding 등과 같은 속성값이 변경되면, AnimatedContainer는 지정된 duration(지속 시간)과 curve(가속도 곡선)에 따라 이전 상태에서 새로운 상태로 부드럽게 전환됩니다.

예제 1: 탭할 때마다 색상과 크기가 변하는 상자

가장 기본적인 AnimatedContainer 사용 예제입니다. 버튼을 누를 때마다 상자의 크기와 색상이 랜덤하게 바뀌는 애니메이션을 만들어 보겠습니다.


import 'package:flutter/material.dart';
import 'dart:math';

class ImplicitAnimationExample extends StatefulWidget {
  const ImplicitAnimationExample({Key? key}) : super(key: key);

  @override
  _ImplicitAnimationExampleState createState() => _ImplicitAnimationExampleState();
}

class _ImplicitAnimationExampleState extends State {
  double _width = 100.0;
  double _height = 100.0;
  Color _color = Colors.blue;
  BorderRadiusGeometry _borderRadius = BorderRadius.circular(8.0);

  void _randomize() {
    final random = Random();
    setState(() {
      _width = random.nextDouble() * 200 + 50; // 50에서 250 사이
      _height = random.nextDouble() * 200 + 50; // 50에서 250 사이
      _color = Color.fromRGBO(
        random.nextInt(256),
        random.nextInt(256),
        random.nextInt(256),
        1,
      );
      _borderRadius = BorderRadius.circular(random.nextDouble() * 50);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('AnimatedContainer 예제'),
      ),
      body: Center(
        child: AnimatedContainer(
          width: _width,
          height: _height,
          decoration: BoxDecoration(
            color: _color,
            borderRadius: _borderRadius,
          ),
          // 애니메이션의 핵심!
          duration: const Duration(seconds: 1),
          curve: Curves.fastOutSlowIn, // 부드러운 시작과 끝
          child: const Center(
            child: Text(
              'Animate Me!',
              style: TextStyle(color: Colors.white),
            ),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _randomize,
        child: const Icon(Icons.play_arrow),
      ),
    );
  }
}

코드 분석:

  1. 상태 변수 선언: _width, _height, _color, _borderRadius는 컨테이너의 현재 상태를 저장합니다.
  2. _randomize 메소드: 버튼을 누르면 호출됩니다. Random 객체를 사용하여 새로운 크기, 색상, 테두리 반경 값을 생성합니다.
  3. setState 호출: 가장 중요한 부분입니다. setState 내에서 상태 변수들을 업데이트하면 Flutter는 위젯 트리를 다시 빌드(rebuild)합니다.
  4. AnimatedContainer의 마법: 위젯이 다시 빌드될 때, AnimatedContainer는 자신의 새로운 속성값(_width, _color 등)이 이전 빌드 때의 값과 다른 것을 감지합니다. 그러면 내부적으로 애니메이션을 트리거하여 duration에 설정된 1초 동안 이전 값에서 새 값으로 부드럽게 변경합니다.
  5. curve 속성: 애니메이션의 '느낌'을 결정합니다. Curves.fastOutSlowIn은 시작은 빨랐다가 끝에서 천천히 마무리되는, 매우 자연스러운 느낌을 줍니다.

다양한 애니메이션 효과: Curves

Curve는 애니메이션의 시간 대비 값의 변화율을 정의합니다. 단순히 직선적으로 변하는 것(Curves.linear) 외에도 수십 가지의 미리 정의된 곡선이 있어 애니메이션에 개성을 더할 수 있습니다.

  • Curves.linear: 등속 운동. 기계적인 느낌을 줍니다.
  • Curves.easeIn: 천천히 시작해서 점점 빨라집니다.
  • Curves.easeOut: 빠르게 시작해서 점점 느려집니다.
  • Curves.easeInOut: 천천히 시작해서 중간에 빨라졌다가 다시 천천히 끝납니다. 가장 일반적으로 사용됩니다.
  • Curves.bounceOut: 목표 지점에 도달한 후 몇 번 튕기는 효과. 재미있는 느낌을 줍니다.
  • Curves.elasticOut: 고무줄처럼 목표 지점을 넘어갔다가 되돌아오는 탄성 효과.

위 예제의 curve: Curves.fastOutSlowIn 부분을 curve: Curves.bounceOut으로 바꾸고 실행해 보세요. 애니메이션의 느낌이 완전히 달라지는 것을 확인할 수 있습니다.

더 많은 암시적 애니메이션 위젯들

AnimatedContainer 외에도 Flutter는 다양한 상황에 쓸 수 있는 'Animated' 접두사가 붙은 위젯들을 제공합니다. 이들은 모두 같은 원리로 동작합니다: 속성값을 바꾸고 setState를 호출하면 끝입니다.

  • AnimatedOpacity: opacity 값을 변경하여 위젯을 서서히 나타나거나 사라지게 합니다. 로딩 인디케이터를 숨기거나 보여줄 때 유용합니다.
  • AnimatedPositioned: Stack 위젯 내에서 자식 위젯의 위치(top, left, right, bottom)를 애니메이션으로 변경합니다.
  • AnimatedPadding: 위젯의 padding 값을 부드럽게 변경합니다.
  • AnimatedAlign: 부모 위젯 내에서 자식의 정렬(alignment)을 애니메이션으로 변경합니다.
  • AnimatedDefaultTextStyle: 자식 Text 위젯들의 기본 스타일(fontSize, color, fontWeight 등)을 부드럽게 변경합니다.

만능 해결사: TweenAnimationBuilder

만약 애니메이션을 적용하고 싶은 속성에 해당하는 AnimatedFoo 위젯이 없다면 어떻게 할까요? 예를 들어, Transform.rotateangle 값이나 ShaderMask의 그라데이션을 애니메이션하고 싶을 수 있습니다. 이럴 때 TweenAnimationBuilder가 활약합니다.

TweenAnimationBuilder는 특정 타입의 값(예: double, Color, Offset)을 시작(begin) 값에서 끝(end) 값으로 애니메이션합니다. 핵심 속성은 다음과 같습니다.

  • tween: 애니메이션할 값의 범위를 정의합니다. (예: Tween(begin: 0, end: 1))
  • duration: 애니메이션 지속 시간입니다.
  • builder: 애니메이션의 매 프레임마다 호출되는 함수입니다. 현재 애니메이션 값, 그리고 애니메이션의 대상이 될 자식 위젯(선택 사항)을 인자로 받습니다. 이 빌더 함수 안에서 현재 값을 사용하여 위젯을 변형시킵니다.

예제 2: 숫자가 카운트업되는 애니메이션


class CountUpAnimation extends StatefulWidget {
  const CountUpAnimation({Key? key}) : super(key: key);

  @override
  _CountUpAnimationState createState() => _CountUpAnimationState();
}

class _CountUpAnimationState extends State {
  double _targetValue = 100.0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('TweenAnimationBuilder 예제')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TweenAnimationBuilder(
              tween: Tween(begin: 0, end: _targetValue),
              duration: const Duration(seconds: 2),
              builder: (BuildContext context, double value, Widget? child) {
                // value는 0에서 _targetValue까지 2초 동안 변합니다.
                return Text(
                  value.toStringAsFixed(1), // 소수점 첫째 자리까지 표시
                  style: const TextStyle(
                    fontSize: 50,
                    fontWeight: FontWeight.bold,
                  ),
                );
              },
            ),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                setState(() {
                  _targetValue = _targetValue == 100.0 ? 200.0 : 100.0;
                });
              },
              child: const Text('목표값 변경'),
            )
          ],
        ),
      ),
    );
  }
}

이 예제에서 버튼을 누르면 _targetValue가 변경되고, TweenAnimationBuilder는 새로운 end 값을 감지하여 현재 값에서 새로운 목표값까지 다시 애니메이션을 실행합니다. 이처럼 TweenAnimationBuilder는 특정 위젯에 종속되지 않고 '값' 자체를 애니메이션하기 때문에 활용도가 매우 높습니다.

암시적 애니메이션 요약

  • 장점: 배우기 쉽고, 코드가 간결하며, 빠르게 구현할 수 있습니다.
  • 단점: 애니메이션을 중간에 멈추거나, 되감거나, 반복하는 등의 복잡한 제어가 불가능합니다. 오직 시작과 끝 상태 사이의 전환만 가능합니다.

이제 더 강력한 제어 기능을 제공하는 명시적 애니메이션의 세계로 넘어가 보겠습니다.


2부: 완벽한 제어를 원한다면, 명시적 애니메이션 (Explicit Animations)

명시적 애니메이션은 개발자가 애니메이션의 모든 측면을 직접 제어하는 방식입니다. 애니메이션의 생명주기(시작, 정지, 반복, 역재생)를 관리하는 AnimationController를 사용해야 하므로 '명시적'이라는 이름이 붙었습니다. 초기 설정은 암시적 애니메이션보다 복잡하지만, 그만큼 훨씬 더 정교하고 복잡한 애니메이션을 구현할 수 있습니다.

언제 명시적 애니메이션을 사용해야 할까요?

  • 애니메이션을 무한히 반복하고 싶을 때 (예: 로딩 스피너)
  • 사용자의 제스처(예: 드래그)에 따라 애니메이션을 제어하고 싶을 때
  • 여러 애니메이션을 순차적으로 또는 동시에 실행하는 복합적인 애니메이션(Staggered Animation)을 만들고 싶을 때
  • 애니메이션을 중간에 멈추거나, 특정 지점으로 이동하거나, 역재생해야 할 때

명시적 애니메이션의 핵심 구성 요소

명시적 애니메이션을 이해하려면 다음 네 가지 핵심 요소를 알아야 합니다.

  1. TickerTickerProvider: Ticker는 화면이 새로고침될 때마다(보통 1초에 60번) 콜백을 호출하는 신호기입니다. 애니메이션은 이 신호에 맞춰 값을 변경하여 부드럽게 보입니다. TickerProvider(주로 SingleTickerProviderStateMixin)는 State 클래스에 Ticker를 제공하는 역할을 합니다. 화면이 보이지 않을 때는 Ticker를 비활성화하여 불필요한 배터리 소모를 막아줍니다.
  2. AnimationController: 애니메이션의 '지휘자'입니다. 지정된 duration 동안 0.0에서 1.0 사이의 값을 생성합니다. .forward()(재생), .reverse()(역재생), .repeat()(반복), .stop()(정지)과 같은 메소드를 통해 애니메이션을 직접 제어할 수 있습니다.
  3. Tween: '보간'을 의미합니다. AnimationController가 생성하는 0.0 ~ 1.0 범위의 값을 우리가 실제로 사용하고 싶은 값의 범위(예: 0px ~ 150px, 파란색 ~ 빨간색)로 변환해 줍니다. ColorTween, SizeTween, RectTween 등 다양한 종류가 있습니다.
  4. AnimatedBuilder 또는 ...Transition 위젯: Tween에 의해 변환된 값을 사용하여 실제로 UI를 그리는 역할을 합니다. 애니메이션 값이 변경될 때마다 위젯 트리의 특정 부분만 효율적으로 다시 빌드합니다.

명시적 애니메이션 구현 단계

명시적 애니메이션은 보통 다음 단계를 따릅니다.

  1. StatefulWidget을 만들고, State 클래스에 with SingleTickerProviderStateMixin을 추가합니다.
  2. AnimationControllerAnimation 객체를 상태 변수로 선언합니다.
  3. initState() 메소드에서 AnimationControllerAnimation을 초기화합니다.
  4. dispose() 메소드에서 AnimationController를 반드시 해제(dispose)하여 메모리 누수를 방지합니다.
  5. build() 메소드에서 AnimatedBuilder...Transition 위젯을 사용하여 애니메이션 값을 UI에 적용합니다.
  6. 적절한 시점(예: 버튼 클릭)에 _controller.forward() 등을 호출하여 애니메이션을 시작합니다.

예제 3: 계속해서 회전하는 로고 (AnimatedBuilder 사용)

가장 일반적이고 권장되는 방법인 AnimatedBuilder를 사용하여 무한히 회전하는 로고를 만들어 보겠습니다.


import 'package:flutter/material.dart';
import 'dart:math' as math;

class ExplicitAnimationExample extends StatefulWidget {
  const ExplicitAnimationExample({Key? key}) : super(key: key);

  @override
  _ExplicitAnimationExampleState createState() => _ExplicitAnimationExampleState();
}

// 1. SingleTickerProviderStateMixin 추가
class _ExplicitAnimationExampleState extends State
    with SingleTickerProviderStateMixin {
  // 2. 컨트롤러와 애니메이션 변수 선언
  late final AnimationController _controller;

  @override
  void initState() {
    super.initState();
    // 3. 컨트롤러 초기화
    _controller = AnimationController(
      duration: const Duration(seconds: 5),
      vsync: this, // this는 TickerProvider를 의미
    )..repeat(); // 생성과 동시에 반복 실행
  }

  @override
  void dispose() {
    // 4. 컨트롤러 해제
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('명시적 애니메이션 예제'),
      ),
      body: Center(
        // 5. AnimatedBuilder로 UI 빌드
        child: AnimatedBuilder(
          animation: _controller,
          builder: (BuildContext context, Widget? child) {
            return Transform.rotate(
              angle: _controller.value * 2.0 * math.pi, // 0.0~1.0 값을 0~2PI 라디안으로 변환
              child: child, // child는 다시 빌드되지 않음
            );
          },
          // 이 child는 builder가 호출될 때마다 다시 생성되지 않으므로 성능에 유리합니다.
          child: const FlutterLogo(size: 150),
        ),
      ),
    );
  }
}

코드 분석:

  • with SingleTickerProviderStateMixin: 이 믹스인은 State 객체에 Ticker를 제공하는 역할을 합니다. AnimationControllervsync 속성에 this를 전달하기 위해 필수적입니다.
  • _controller.repeat(): initState에서 컨트롤러를 생성하고 바로 repeat()을 호출하여 애니메이션이 위젯이 생성되자마자 시작되고 무한히 반복되도록 합니다.
  • AnimatedBuilder: 이 위젯은 animation 속성으로 _controller를 구독합니다. 컨트롤러의 값이 변경될 때마다(즉, 매 프레임마다) builder 함수를 다시 호출합니다.
  • builder 함수: _controller.value는 0.0에서 1.0 사이의 값을 가집니다. 이 값을 Transform.rotateangle 속성에 사용하기 위해 * 2.0 * math.pi를 곱하여 0에서 360도(2π 라디안) 사이의 값으로 변환합니다.
  • child 속성 최적화: AnimatedBuilderchild 속성에 FlutterLogo를 전달했습니다. 이렇게 하면 builder가 다시 호출될 때마다 FlutterLogo 위젯이 새로 생성되는 것을 방지할 수 있습니다. builder 함수는 child 인자를 통해 이 위젯에 접근할 수 있습니다. 이는 애니메이션과 관련 없는 무거운 위젯이 불필요하게 다시 빌드되는 것을 막아 성능을 최적화하는 중요한 기법입니다.

더 간결한 방법: ...Transition 위젯

Flutter는 특정 변환에 대해 AnimatedBuilderTween을 미리 조합해 놓은 편리한 위젯들을 제공합니다. 이를 사용하면 코드가 더 간결해집니다.

  • RotationTransition: 회전 애니메이션을 적용합니다.
  • ScaleTransition: 크기(스케일) 애니메이션을 적용합니다.
  • FadeTransition: 투명도(opacity) 애니메이션을 적용합니다.
  • SlideTransition: 위치 이동 애니메이션을 적용합니다. (Tween 필요)

예제 4: RotationTransition으로 회전 로고 재작성

위 예제 3을 RotationTransition을 사용하면 훨씬 간단하게 만들 수 있습니다.


// ... (State 클래스 선언 및 initState, dispose는 동일)

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text('RotationTransition 예제'),
    ),
    body: Center(
      child: RotationTransition(
        // turns에 컨트롤러를 직접 전달합니다.
        // 컨트롤러의 0.0~1.0 값이 0~1회전으로 자동 매핑됩니다.
        turns: _controller,
        child: const FlutterLogo(size: 150),
      ),
    ),
  );
}

AnimatedBuilderTransform.rotate를 사용했던 부분이 단 하나의 RotationTransition 위젯으로 대체되었습니다. turns 속성은 Animation 타입을 받으며, 컨트롤러의 값이 1.0이 될 때 한 바퀴(360도) 회전을 의미합니다. 훨씬 직관적이고 코드가 깔끔해졌습니다.

명시적 애니메이션 요약

  • 장점: 애니메이션의 모든 측면(재생, 정지, 반복, 방향)을 완벽하게 제어할 수 있습니다. 복잡하고 정교한 연출이 가능합니다.
  • 단점: 초기 설정이 복잡하고, AnimationControllerTickerProvider 등 이해해야 할 개념이 많습니다. 코드가 길어집니다.

3부: 암시적 vs 명시적, 언제 무엇을 선택할까?

이제 두 가지 애니메이션 기법을 모두 배웠습니다. 마지막으로 어떤 상황에 어떤 것을 선택해야 할지 명확하게 정리해 보겠습니다.

기준 암시적 애니메이션 (Implicit) 명시적 애니메이션 (Explicit)
핵심 개념 상태 변경에 대한 자동 전환 AnimationController를 통한 수동 제어
주요 사용 사례 일회성 상태 변화 (예: 버튼 클릭 후 크기/색상 변경) 반복/지속적 애니메이션 (로딩 스피너), 사용자 상호작용 기반 애니메이션 (드래그)
제어 수준 낮음 (시작/정지/반복 제어 불가) 높음 (재생, 정지, 역재생, 반복, 특정 지점 이동 등 모든 제어 가능)
코드 복잡도 낮음 (setState만으로 충분) 높음 (AnimationController, TickerProvider, dispose 등 필요)
대표 위젯 AnimatedContainer, AnimatedOpacity, TweenAnimationBuilder AnimatedBuilder, RotationTransition, ScaleTransition

결정 가이드:

  1. "애니메이션이 계속 반복되어야 하는가?"
    • 예: 명시적 애니메이션을 사용하세요. (예: _controller.repeat())
    • 아니오: 다음 질문으로.
  2. "사용자의 입력(예: 드래그)에 따라 애니메이션이 실시간으로 변해야 하는가?"
    • 예: 명시적 애니메이션을 사용하세요. (예: 드래그 거리에 따라 _controller.value를 조절)
    • 아니오: 다음 질문으로.
  3. "단순히 위젯의 속성이 A에서 B로 한 번만 바뀌면 되는가?"
    • 예: 암시적 애니메이션이 완벽한 선택입니다. (예: AnimatedContainer)
    • 아니오: 당신의 요구사항은 아마도 명시적 애니메이션이 필요한 복잡한 시나리오일 가능성이 높습니다.

결론

Flutter의 애니메이션 시스템은 처음에는 복잡해 보일 수 있지만, 암시적과 명시적이라는 두 가지 핵심 개념을 이해하면 명확한 그림이 그려집니다. 간단하고 빠른 효과를 원할 때는 암시적 애니메이션으로 시작하고, 앱에 더 역동적이고 정교한 생명력을 불어넣고 싶을 때는 명시적 애니메이션의 강력한 제어 기능을 활용하세요.

이 두 가지 도구를 자유자재로 사용할 수 있게 되면, 여러분의 Flutter 앱은 기능적으로 뛰어날 뿐만 아니라 시각적으로도 매력적인, 사용자에게 사랑받는 앱으로 거듭날 것입니다. 이제 배운 내용을 바탕으로 여러분만의 아름다운 애니메이션을 만들어 보세요!


0 개의 댓글:

Post a Comment