Flutter의 애니메이션

1. 소개

애니메이션은 앱의 사용자 환경을 개선하고, 사용자에게 중요한 정보를 전달하고, 앱을 더 세련되고 사용하기 즐거운 앱으로 만드는 데 효과적인 방법입니다.

Flutter의 애니메이션 프레임워크 개요

Flutter는 각 프레임에서 위젯 트리의 일부를 다시 빌드하여 애니메이션 효과를 표시합니다. 애니메이션을 더 쉽게 만들고 구성할 수 있도록 사전 빌드된 애니메이션 효과와 기타 API를 제공합니다.

  • 암시적 애니메이션은 전체 애니메이션을 자동으로 실행하는 사전 빌드된 애니메이션 효과입니다. 애니메이션의 타겟 값이 변경되면 현재 값에서 타겟 값으로 애니메이션을 실행하고, 위젯이 원활하게 애니메이션 처리되도록 그 사이의 각 값을 표시합니다. 암시적 애니메이션의 예로는 AnimatedSize, AnimatedScale, AnimatedPositioned가 있습니다.
  • 명시적 애니메이션도 사전 빌드된 애니메이션 효과이지만 작동하려면 Animation 객체가 필요합니다. 예를 들면 SizeTransition, ScaleTransition 또는 PositionedTransition입니다.
  • Animation은 실행 중인 애니메이션 또는 중지된 애니메이션을 나타내는 클래스이며, 애니메이션이 실행 중인 타겟 값을 나타내는 과 특정 시점에 애니메이션이 화면에 표시하는 현재 값을 나타내는 상태로 구성됩니다. Listenable의 서브클래스이며 애니메이션이 실행되는 동안 상태가 변경되면 리스너에게 알립니다.
  • AnimationController는 애니메이션을 만들고 상태를 제어하는 방법입니다. forward(), reset(), stop(), repeat()와 같은 메서드를 사용하면 크기, 위치와 같이 표시되는 애니메이션 효과를 정의하지 않고도 애니메이션을 제어할 수 있습니다.
  • 트윈은 시작 값과 끝 값 간에 값을 보간하는 데 사용되며 double, Offset, Color와 같은 모든 유형을 나타낼 수 있습니다.
  • 곡선은 시간 경과에 따른 매개변수의 변화율을 조정하는 데 사용됩니다. 애니메이션이 실행될 때는 일반적으로 이징 커브를 적용하여 애니메이션 시작 또는 종료 시 변경 속도를 더 빠르게 또는 더 느리게 만듭니다. 곡선은 0.0과 1.0 사이의 입력 값을 사용하고 0.0과 1.0 사이의 출력 값을 반환합니다.

빌드할 항목

이 Codelab에서는 다양한 애니메이션 효과와 기법이 포함된 객관식 퀴즈 게임을 빌드합니다.

3026390ad413769c.gif

다음 작업을 수행하는 방법을 알아보세요.

  • 크기와 색상에 애니메이션 효과를 주는 위젯 빌드
  • 3D 카드 플립 효과 빌드
  • 애니메이션 패키지의 멋진 사전 빌드된 애니메이션 효과 사용
  • 최신 버전의 Android에서 사용할 수 있는 뒤로 탐색 예측 동작 지원 추가

학습할 내용

이 Codelab에서는 다음을 학습합니다.

  • 암시적 애니메이션 효과를 사용하여 많은 코드 없이도 멋진 애니메이션을 만드는 방법
  • 명시적으로 애니메이션이 적용된 효과를 사용하여 AnimatedSwitcher 또는 AnimationController와 같은 사전 빌드된 애니메이션 위젯을 사용하여 자체 효과를 구성하는 방법
  • AnimationController를 사용하여 3D 효과를 표시하는 자체 위젯을 정의하는 방법
  • animations 패키지를 사용하여 최소한의 설정으로 멋진 애니메이션 효과를 표시하는 방법

필요한 항목

  • Flutter SDK
  • IDE(예: VSCode, Android 스튜디오 / IntelliJ)

2. Flutter 개발 환경 설정

이 실습을 완료하려면 Flutter SDK편집기라는 두 가지 소프트웨어가 필요합니다.

다음 기기 중 하나를 사용하여 이 Codelab을 실행할 수 있습니다.

  • 컴퓨터에 연결되어 있고 개발자 모드로 설정된 실제 Android (7단계에서 뒤로 탐색 예측을 구현하는 경우 권장) 또는 iOS 기기
  • iOS 시뮬레이터 (Xcode 도구 설치 필요)
  • Android Emulator(Android 스튜디오 설정 필요)
  • 브라우저(디버깅 시 Chrome 필요)
  • Windows, Linux 또는 macOS 데스크톱 컴퓨터 배포에 사용할 플랫폼에서 개발해야 합니다. 따라서 Windows 데스크톱 앱을 개발하려면 적절한 빌드 체인에 액세스할 수 있도록 Windows에서 개발해야 합니다. docs.flutter.dev/desktop에 운영체제별 요구사항이 자세히 설명되어 있습니다.

설치 확인하기

Flutter SDK가 올바르게 구성되었고 위의 대상 플랫폼 중 하나 이상이 설치되어 있는지 확인하려면 Flutter Doctor 도구를 사용하세요.

$ flutter doctor

Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.24.2, on macOS 14.6.1 23G93 darwin-arm64, locale
    en)
[✓] Android toolchain - develop for Android devices
[✓] Xcode - develop for iOS and macOS
[✓] Chrome - develop for the web
[✓] Android Studio
[✓] IntelliJ IDEA Ultimate Edition
[✓] VS Code
[✓] Connected device (4 available)
[✓] Network resources

• No issues found!

3. 시작 앱 실행

시작 앱 다운로드

git를 사용하여 GitHub의 flutter/samples 저장소에서 시작 앱을 클론합니다.

$ git clone https://github.com/flutter/codelabs.git
$ cd codelabs/animations/step_01/

또는 소스 코드를 .zip 파일로 다운로드할 수 있습니다.

앱 실행

앱을 실행하려면 flutter run 명령어를 사용하고 android, ios 또는 chrome와 같은 대상 기기를 지정합니다. 지원되는 플랫폼의 전체 목록은 지원되는 플랫폼 페이지를 참고하세요.

$ flutter run -d android

원하는 IDE를 사용하여 앱을 실행하고 디버그할 수도 있습니다. 자세한 내용은 공식 Flutter 문서를 참고하세요.

코드 둘러보기

시작 앱은 모델-뷰-뷰-모델(MVVM) 디자인 패턴에 따라 두 화면으로 구성된 객관식 퀴즈 게임입니다. QuestionScreen (뷰)는 QuizViewModel (뷰 모델) 클래스를 사용하여 사용자에게 QuestionBank (모델) 클래스의 객관식 질문을 합니다.

  • home_screen.dart - New Game(새 게임) 버튼이 있는 화면을 표시합니다.
  • main.dart - Material 3을 사용하고 홈 화면을 표시하도록 MaterialApp를 구성합니다.
  • model.dart - 앱 전체에서 사용되는 핵심 클래스를 정의합니다.
  • question_screen.dart: 퀴즈 게임의 UI를 표시합니다.
  • view_model.dart - QuestionScreen로 표시되는 퀴즈 게임의 상태와 로직을 저장합니다.

fbb1e1f7b6c91e21.png

앱은 아직 애니메이션 효과를 지원하지 않습니다. 단, 사용자가 New Game 버튼을 누르면 Flutter의 Navigator 클래스에 의해 표시되는 기본 뷰 전환은 예외입니다.

4. 암시적 애니메이션 효과 사용

암시적 애니메이션은 특별한 구성이 필요하지 않으므로 많은 상황에서 유용합니다. 이 섹션에서는 애니메이션 스코어보드를 표시하도록 StatusBar 위젯을 업데이트합니다. 일반적인 암시적 애니메이션 효과를 찾으려면 ImplicitlyAnimatedWidget API 문서를 둘러보세요.

206dd8d9c1fae95.gif

애니메이션이 적용되지 않은 스코어보드 위젯 만들기

다음 코드를 사용하여 새 파일 lib/scoreboard.dart를 만듭니다.

lib/scoreboard.dart

import 'package:flutter/material.dart';

class Scoreboard extends StatelessWidget {
  final int score;
  final int totalQuestions;

  const Scoreboard({
    super.key,
    required this.score,
    required this.totalQuestions,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          for (var i = 0; i < totalQuestions; i++)
            Icon(
              Icons.star,
              size: 50,
              color:
                  score < i + 1 ? Colors.grey.shade400 : Colors.yellow.shade700,
            )
        ],
      ),
    );
  }
}

그런 다음 StatusBar 위젯의 하위 요소에 Scoreboard 위젯을 추가하여 이전에 점수와 총문항 수를 표시했던 Text 위젯을 대체합니다. 편집기에서 파일 상단에 필요한 import "scoreboard.dart"를 자동으로 추가합니다.

lib/question_screen.dart

class StatusBar extends StatelessWidget {
  final QuizViewModel viewModel;

  const StatusBar({required this.viewModel, super.key});

  @override
  Widget build(BuildContext context) {
    return Card(
      elevation: 4,
      child: Padding(
        padding: EdgeInsets.all(8.0),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            Scoreboard(                                        // NEW
              score: viewModel.score,                          // NEW
              totalQuestions: viewModel.totalQuestions,        // NEW
            ),
          ],
        ),
      ),
    );
  }
}

이 위젯에는 각 질문에 별표 아이콘이 표시됩니다. 질문에 올바르게 답변하면 애니메이션 없이 별표가 즉시 켜집니다. 다음 단계에서는 크기와 색상에 애니메이션을 적용하여 사용자에게 점수가 변경되었음을 알립니다.

암시적 애니메이션 효과 사용하기

별표가 활성화되면 AnimatedScale 위젯을 사용하여 scale 값을 0.5에서 1.0로 변경하는 AnimatedStar라는 새 위젯을 만듭니다.

lib/scoreboard.dart

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

class Scoreboard extends StatelessWidget {
  final int score;
  final int totalQuestions;

  const Scoreboard({
    super.key,
    required this.score,
    required this.totalQuestions,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          for (var i = 0; i < totalQuestions; i++)
            AnimatedStar(                                      // NEW
              isActive: score > i,                             // NEW
            )                                                  // NEW
        ],
      ),
    );
  }
}

class AnimatedStar extends StatelessWidget {                   // Add from here...
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      duration: _duration,
      child: Icon(
        Icons.star,
        size: 50,
        color: isActive ? _activatedColor : _deactivatedColor,
      ),
    );
  }
}                                                              // To here.

이제 사용자가 질문에 올바르게 답변하면 AnimatedStar 위젯이 암시적 애니메이션을 사용하여 크기를 업데이트합니다. 여기서는 Iconcolor가 애니메이션되지 않고 AnimatedScale 위젯에서 실행하는 scale만 애니메이션됩니다.

84aec4776e70b870.gif

트윈을 사용하여 두 값 사이를 보간

isActive 필드가 true로 변경된 직후 AnimatedStar 위젯의 색상이 변경됩니다.

애니메이션 색상 효과를 구현하려면 AnimatedContainer 위젯 (ImplicitlyAnimatedWidget의 또 다른 서브클래스)을 사용해 보세요. 색상을 비롯한 모든 속성을 자동으로 애니메이션할 수 있기 때문입니다. 안타깝게도 위젯은 컨테이너가 아닌 아이콘을 표시해야 합니다.

아이콘 도형 간의 전환 효과를 구현하는 AnimatedIcon도 시도해 볼 수 있습니다. 하지만 AnimatedIcons 클래스에는 별표 아이콘의 기본 구현이 없습니다.

대신 Tween를 매개변수로 사용하는 ImplicitlyAnimatedWidget의 또 다른 서브클래스인 TweenAnimationBuilder를 사용합니다. 트윈은 두 값 (beginend)을 사용하여 애니메이션에서 표시할 수 있도록 중간 값을 계산하는 클래스입니다. 이 예에서는 애니메이션 효과를 빌드하는 데 필요한 Tween<Color> 인터페이스를 충족하는 ColorTween를 사용합니다.

Icon 위젯을 선택하고 IDE에서 '빌더로 래핑' 빠른 작업을 사용하여 이름을 TweenAnimationBuilder로 변경합니다. 그런 다음 기간과 ColorTween를 제공합니다.

lib/scoreboard.dart

class AnimatedStar extends StatelessWidget {
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      duration: _duration,
      child: TweenAnimationBuilder(                            // Add from here...
        duration: _duration,
        tween: ColorTween(
          begin: _deactivatedColor,
          end: isActive ? _activatedColor : _deactivatedColor,
        ),
        builder: (context, value, child) {                     // To here.
          return Icon(
            Icons.star,
            size: 50,
            color: value,                                      // Modify from here...
          );
        },                                                     // To here.
      ),
    );
  }
}

이제 앱을 핫 리로드하여 새 애니메이션을 확인합니다.

8b0911f4af299a60.gif

ColorTweenend 값은 isActive 매개변수의 값에 따라 변경됩니다. TweenAnimationBuilderTween.end 값이 변경될 때마다 애니메이션을 다시 실행하기 때문입니다. 이 경우 새 애니메이션이 현재 애니메이션 값에서 새 끝 값으로 실행되므로 언제든지 (애니메이션이 실행 중인 경우에도) 색상을 변경하고 올바른 중간 값으로 부드러운 애니메이션 효과를 표시할 수 있습니다.

곡선 적용

두 애니메이션 효과 모두 일정한 속도로 실행되지만 애니메이션은 속도를 높이거나 낮추면 시각적으로 더 흥미롭고 유용한 정보를 전달할 수 있습니다.

Curve는 시간 경과에 따른 매개변수의 변화율을 정의하는 이완 함수를 적용합니다. Flutter는 Curves 클래스에 사전 빌드된 이음선 곡선 모음(예: easeIn 또는 easeOut)을 제공합니다.

5dabe68d1210b8a1.gif

3a9e7490c594279a.gif

이 다이어그램 (Curves API 문서 페이지에서 확인 가능)은 곡선이 작동하는 방식을 보여줍니다. 곡선은 0.0과 1.0 사이의 입력 값 (x축에 표시됨)을 0.0과 1.0 사이의 출력 값 (y축에 표시됨)으로 변환합니다. 이 다이어그램은 이완 곡선을 사용할 때 다양한 애니메이션 효과가 어떻게 표시되는지 미리 보여줍니다.

AnimatedStar에 _curve라는 새 필드를 만들고 이를 AnimatedScaleTweenAnimationBuilder 위젯에 매개변수로 전달합니다.

lib/scoreboard.dart

class AnimatedStar extends StatelessWidget {
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;
  final Curve _curve = Curves.elasticOut;                       // NEW

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      curve: _curve,                                           // NEW
      duration: _duration,
      child: TweenAnimationBuilder(
        curve: _curve,                                         // NEW
        duration: _duration,
        tween: ColorTween(
          begin: _deactivatedColor,
          end: isActive ? _activatedColor : _deactivatedColor,
        ),
        builder: (context, value, child) {
          return Icon(
            Icons.star,
            size: 50,
            color: value,
          );
        },
      ),
    );
  }
}

이 예에서 elasticOut 곡선은 스프링 모션으로 시작하여 끝쪽으로 균형을 맞추는 과장된 스프링 효과를 제공합니다.

8f84142bff312373.gif

앱을 핫 리로드하면 이 커브가 AnimatedSizeTweenAnimationBuilder에 적용된 것을 확인할 수 있습니다.

206dd8d9c1fae95.gif

DevTools를 사용하여 느린 애니메이션 사용 설정하기

애니메이션 효과를 디버그하기 위해 Flutter DevTools에서는 앱의 모든 애니메이션을 느리게 재생하여 애니메이션을 더 명확하게 볼 수 있는 방법을 제공합니다.

DevTools를 열려면 앱이 디버그 모드로 실행 중인지 확인하고 VSCode의 디버그 툴바에서 Widget Inspector를 선택하거나 IntelliJ / Android 스튜디오의 디버그 도구 창에서 Open Flutter DevTools 버튼을 선택하여 엽니다.

3ce33dc01d096b14.png

363ae0fbcd0c2395.png

위젯 검사기가 열리면 툴바에서 애니메이션 느리게 재생 버튼을 클릭합니다.

adea0a16d01127ad.png

5. 명시적 애니메이션 효과 사용

명시적 애니메이션은 암시적 애니메이션과 마찬가지로 사전 빌드된 애니메이션 효과이지만 타겟 값을 사용하는 대신 Animation 객체를 매개변수로 사용합니다. 따라서 애니메이션이 이미 탐색 전환, AnimatedSwitcher 또는 AnimationController로 정의된 경우에 유용합니다.

명시적 애니메이션 효과 사용하기

명시적 애니메이션 효과를 시작하려면 Card 위젯을 AnimatedSwitcher로 래핑합니다.

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({
    required this.question,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return AnimatedSwitcher(                                 // NEW
      duration: const Duration(milliseconds: 300),           // NEW
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),                                                     // NEW
    );
  }
}

AnimatedSwitcher는 기본적으로 크로스페이드 효과를 사용하지만 transitionBuilder 매개변수를 사용하여 재정의할 수 있습니다. 전환 빌더는 AnimatedSwitcher에 전달된 하위 위젯과 Animation 객체를 제공합니다. 이때 명시적 애니메이션을 사용하는 것이 좋습니다.

이 Codelab에서는 첫 번째 명시적 애니메이션으로 SlideTransition를 사용합니다. 이 애니메이션은 수신 및 발신 위젯이 이동하는 시작 및 종료 오프셋을 정의하는 Animation<Offset>를 사용합니다.

트윈에는 모든 Animation를 트윈이 적용된 다른 Animation로 변환하는 도우미 함수 animate()가 있습니다. 즉, Tween<Offset>를 사용하여 AnimatedSwitcher에서 제공하는 Animation<double>Animation<Offset>로 변환하여 SlideTransition 위젯에 제공할 수 있습니다.

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({
    required this.question,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return AnimatedSwitcher(
      transitionBuilder: (child, animation) {               // Add from here...
        final curveAnimation =
            CurveTween(curve: Curves.easeInCubic).animate(animation);
        final offsetAnimation =
            Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero)
                .animate(curveAnimation);
        return SlideTransition(position: offsetAnimation, child: child);
      },                                                    // To here.
      duration: const Duration(milliseconds: 300),
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),
    );
  }
}

여기서는 Tween.animate를 사용하여 CurveAnimation에 적용한 다음 0.0~1.0 범위의 Tween<double>에서 x축에서 -0.1~0.0으로 전환되는 Tween<Offset>로 변환합니다.

또는 Animation 클래스에는 Tween (또는 Animatable)을 받아 새로운 Animation로 변환하는 drive() 함수가 있습니다. 이렇게 하면 트윈을 '연쇄'하여 결과 코드를 더 간결하게 만들 수 있습니다.

lib/question_screen.dart

transitionBuilder: (child, animation) {
  var offsetAnimation = animation
      .drive(CurveTween(curve: Curves.easeInCubic))
      .drive(Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero));
  return SlideTransition(position: offsetAnimation, child: child);
},

명시적 애니메이션을 사용하는 또 다른 이점은 쉽게 함께 구성할 수 있다는 것입니다. SlideTransition 위젯을 래핑하여 동일한 곡선 애니메이션을 사용하는 또 다른 명시적 애니메이션인 FadeTransition을 추가합니다.

lib/question_screen.dart

return AnimatedSwitcher(
  transitionBuilder: (child, animation) {
    final curveAnimation =
        CurveTween(curve: Curves.easeInCubic).animate(animation);
    final offsetAnimation =
        Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero)
            .animate(curveAnimation);
    final fadeInAnimation = curveAnimation;                            // NEW
    return FadeTransition(                                             // NEW
      opacity: fadeInAnimation,                                        // NEW
      child: SlideTransition(position: offsetAnimation, child: child), // NEW
    );                                                                 // NEW
  },

layoutBuilder 맞춤설정

AnimationSwitcher에 약간의 문제가 있을 수 있습니다. QuestionCard가 새 질문으로 전환되면 애니메이션이 실행되는 동안 사용 가능한 공간의 중앙에 배치되지만 애니메이션이 중지되면 위젯이 화면 상단으로 이동합니다. 이렇게 하면 질문 카드의 최종 위치가 애니메이션이 실행되는 동안의 위치와 일치하지 않아 애니메이션이 끊기게 됩니다.

d77de181bdde58f7.gif

이를 해결하기 위해 AnimatedSwitcher에는 레이아웃을 정의하는 데 사용할 수 있는 layoutBuilder 매개변수도 있습니다. 이 함수를 사용하여 카드를 화면 상단에 정렬하도록 레이아웃 빌더를 구성합니다.

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return AnimatedSwitcher(
    layoutBuilder: (currentChild, previousChildren) {
      return Stack(
        alignment: Alignment.topCenter,
        children: <Widget>[
          ...previousChildren,
          if (currentChild != null) currentChild,
        ],
      );
    },

이 코드는 AnimatedSwitcher 클래스의 defaultLayoutBuilder를 수정한 버전이지만 Alignment.center 대신 Alignment.topCenter를 사용합니다.

요약

  • 명시적 애니메이션은 Animation 객체를 사용하는 애니메이션 효과입니다 (타겟 값과 기간을 사용하는 ImplicitlyAnimatedWidgets와 달리).
  • Animation 클래스는 실행 중인 애니메이션을 나타내지만 특정 효과는 정의하지 않습니다.
  • Tween().animate 또는 Animation.drive()를 사용하여 애니메이션에 트윈과 곡선을 적용합니다 (CurveTween 사용).
  • AnimatedSwitcher의 layoutBuilder 매개변수를 사용하여 하위 요소의 레이아웃을 조정합니다.

6. 애니메이션 상태 제어

지금까지는 모든 애니메이션이 프레임워크에서 자동으로 실행되었습니다. 암시적 애니메이션은 자동으로 실행되며 명시적 애니메이션 효과는 애니메이션이 올바르게 작동해야 합니다. 이 섹션에서는 AnimationController를 사용하여 자체 Animation 객체를 만들고 TweenSequence를 사용하여 트윈을 결합하는 방법을 알아봅니다.

AnimationController를 사용하여 애니메이션 실행

AnimationController를 사용하여 애니메이션을 만들려면 다음 단계를 따르세요.

  1. StatefulWidget 만들기
  2. State 클래스에서 SingleTickerProviderStateMixin 믹스인을 사용하여 AnimationController에 Ticker 제공
  3. initState 수명 주기 메서드에서 AnimationController를 초기화하여 현재 State 객체를 vsync (TickerProvider) 매개변수에 제공합니다.
  4. AnimatedBuilder를 사용하거나 listen() 및 setState를 수동으로 호출하여 AnimationController가 리스너에게 알릴 때마다 위젯이 다시 빌드되도록 합니다.

flip_effect.dart라는 새 파일을 만들고 다음 코드를 복사하여 붙여넣습니다.

lib/flip_effect.dart

import 'dart:math' as math;

import 'package:flutter/widgets.dart';

class CardFlipEffect extends StatefulWidget {
  final Widget child;
  final Duration duration;

  const CardFlipEffect({
    super.key,
    required this.child,
    required this.duration,
  });

  @override
  State<CardFlipEffect> createState() => _CardFlipEffectState();
}

class _CardFlipEffectState extends State<CardFlipEffect>
    with SingleTickerProviderStateMixin {
  late final AnimationController _animationController;
  Widget? _previousChild;

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

    _animationController =
        AnimationController(vsync: this, duration: widget.duration);

    _animationController.addListener(() {
      if (_animationController.value == 1) {
        _animationController.reset();
      }
    });
  }

  @override
  void didUpdateWidget(covariant CardFlipEffect oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (widget.child.key != oldWidget.child.key) {
      _handleChildChanged(widget.child, oldWidget.child);
    }
  }

  void _handleChildChanged(Widget newChild, Widget previousChild) {
    _previousChild = previousChild;
    _animationController.forward();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animationController,
      builder: (context, child) {
        return Transform(
          alignment: Alignment.center,
          transform: Matrix4.identity()
            ..rotateX(_animationController.value * math.pi),
          child: _animationController.isAnimating
              ? _animationController.value < 0.5
                  ? _previousChild
                  : Transform.flip(flipY: true, child: child)
              : child,
        );
      },
      child: widget.child,
    );
  }
}

이 클래스는 AnimationController를 설정하고 프레임워크가 didUpdateWidget을 호출하여 위젯 구성이 변경되었으며 새 하위 위젯이 있을 수 있음을 알릴 때마다 애니메이션을 다시 실행합니다.

AnimatedBuilder는 AnimationController가 리스너에게 알릴 때마다 위젯 트리가 다시 빌드되도록 하고, Transform 위젯은 3D 회전 효과를 적용하여 카드가 뒤집어지는 것을 시뮬레이션하는 데 사용됩니다.

이 위젯을 사용하려면 각 답변 카드를 CardFlipEffect 위젯으로 래핑합니다. 카드 위젯에 key를 제공해야 합니다.

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return GridView.count(
    shrinkWrap: true,
    crossAxisCount: 2,
    childAspectRatio: 5 / 2,
    children: List.generate(answers.length, (index) {
      var color = Theme.of(context).colorScheme.primaryContainer;
      if (correctAnswer == index) {
        color = Theme.of(context).colorScheme.tertiaryContainer;
      }
      return CardFlipEffect(                                    // NEW
        duration: const Duration(milliseconds: 300),            // NEW
        child: Card.filled(                                     // NEW
          key: ValueKey(answers[index]),                        // NEW
          color: color,
          elevation: 2,
          margin: EdgeInsets.all(8),
          clipBehavior: Clip.hardEdge,
          child: InkWell(
            onTap: () => onTapped(index),
            child: Padding(
              padding: EdgeInsets.all(16.0),
              child: Center(
                child: Text(
                  answers.length > index ? answers[index] : '',
                  style: Theme.of(context).textTheme.titleMedium,
                  overflow: TextOverflow.clip,
                ),
              ),
            ),
          ),
        ),                                                      // NEW
      );
    }),
  );
}

이제 앱을 핫 리로드하여 CardFlipEffect 위젯을 사용하여 답변 카드가 뒤집어지는 것을 확인합니다.

5455def725b866f6.gif

이 클래스는 명시적 애니메이션 효과와 매우 유사해 보입니다. 실제로 AnimatedWidget 클래스를 직접 확장하여 자체 버전을 구현하는 것이 좋습니다. 안타깝게도 이 클래스는 이전 위젯을 스테이트에 저장해야 하므로 StatefulWidget을 사용해야 합니다. 자체 명시적 애니메이션 효과를 만드는 방법에 관한 자세한 내용은 AnimatedWidget의 API 문서를 참고하세요.

TweenSequence를 사용하여 지연 추가

이 섹션에서는 각 카드가 한 번에 하나씩 뒤집어지도록 CardFlipEffect 위젯에 지연을 추가합니다. 시작하려면 delayAmount라는 새 필드를 추가합니다.

lib/flip_effect.dart

class CardFlipEffect extends StatefulWidget {
  final Widget child;
  final Duration duration;
  final double delayAmount;                      // NEW

  const CardFlipEffect({
    super.key,
    required this.child,
    required this.duration,
    required this.delayAmount,                   // NEW
  });

  @override
  State<CardFlipEffect> createState() => _CardFlipEffectState();
}

그런 다음 AnswerCards 빌드 메서드에 delayAmount를 추가합니다.

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return GridView.count(
    shrinkWrap: true,
    crossAxisCount: 2,
    childAspectRatio: 5 / 2,
    children: List.generate(answers.length, (index) {
      var color = Theme.of(context).colorScheme.primaryContainer;
      if (correctAnswer == index) {
        color = Theme.of(context).colorScheme.tertiaryContainer;
      }
      return CardFlipEffect(
        delayAmount: index.toDouble() / 2,                     // NEW
        duration: const Duration(milliseconds: 300),
        child: Card.filled(
          key: ValueKey(answers[index]),

그런 다음 _CardFlipEffectState에서 TweenSequence를 사용하여 지연을 적용하는 새 애니메이션을 만듭니다. 이 경우 Future.delayed와 같은 dart:async 라이브러리의 유틸리티는 사용하지 않습니다. 지연은 애니메이션의 일부이며 위젯이 AnimationController를 사용할 때 명시적으로 제어하는 것이 아니기 때문입니다. 이렇게 하면 동일한 TickerProvider를 사용하므로 DevTools에서 느린 애니메이션을 사용 설정할 때 애니메이션 효과를 더 쉽게 디버그할 수 있습니다.

TweenSequence를 사용하려면 두 개의 TweenSequenceItem를 만듭니다. 하나는 상대 시간 동안 애니메이션을 0으로 유지하는 ConstantTween를 포함하고 다른 하나는 0.0에서 1.0로 전환되는 일반 Tween입니다.

lib/flip_effect.dart

class _CardFlipEffectState extends State<CardFlipEffect>
    with SingleTickerProviderStateMixin {
  late final AnimationController _animationController;
  Widget? _previousChild;
  late final Animation<double> _animationWithDelay; // NEW

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

    _animationController = AnimationController(
        vsync: this, duration: widget.duration * (widget.delayAmount + 1));

    _animationController.addListener(() {
      if (_animationController.value == 1) {
        _animationController.reset();
      }
    });

    _animationWithDelay = TweenSequence<double>([   // NEW
      if (widget.delayAmount > 0)                   // NEW
        TweenSequenceItem(                          // NEW
          tween: ConstantTween<double>(0.0),        // NEW
          weight: widget.delayAmount,               // NEW
        ),                                          // NEW
      TweenSequenceItem(                            // NEW
        tween: Tween(begin: 0.0, end: 1.0),         // NEW
        weight: 1.0,                                // NEW
      ),                                            // NEW
    ]).animate(_animationController);               // NEW
  }

마지막으로 build 메서드에서 AnimationController의 애니메이션을 새로운 지연된 애니메이션으로 바꿉니다.

lib/flip_effect.dart

@override
Widget build(BuildContext context) {
  return AnimatedBuilder(
    animation: _animationWithDelay,                            // Modify this line
    builder: (context, child) {
      return Transform(
        alignment: Alignment.center,
        transform: Matrix4.identity()
          ..rotateX(_animationWithDelay.value * math.pi),      // And this line
        child: _animationController.isAnimating
            ? _animationWithDelay.value < 0.5                  // And this one.
                ? _previousChild
                : Transform.flip(flipY: true, child: child)
            : child,
      );
    },
    child: widget.child,
  );
}

이제 앱을 핫 리로드하고 카드가 하나씩 펼쳐지는 것을 확인합니다. Transform 위젯에서 제공하는 3D 효과의 관점을 변경해 보세요.

28b5291de9b3f55f.gif

7. 맞춤 탐색 전환 사용

지금까지는 단일 화면에서 효과를 맞춤설정하는 방법을 알아봤지만 애니메이션을 사용하는 또 다른 방법은 애니메이션을 사용하여 화면 간에 전환하는 것입니다. 이 섹션에서는 내장 애니메이션 효과와 pub.dev의 공식 애니메이션 패키지에서 제공하는 멋진 사전 빌드된 애니메이션 효과를 사용하여 화면 전환에 애니메이션 효과를 적용하는 방법을 알아봅니다.

탐색 전환 애니메이션 처리

PageRouteBuilder 클래스는 전환 애니메이션을 맞춤설정할 수 있는 Route입니다. 이를 통해 transitionBuilder 콜백을 재정의할 수 있습니다. 이 콜백은 Navigator에서 실행되는 수신 및 발신 애니메이션을 나타내는 두 개의 Animation 객체를 제공합니다.

전환 애니메이션을 맞춤설정하려면 MaterialPageRoutePageRouteBuilder로 바꾸고 사용자가 HomeScreen에서 QuestionScreen로 이동할 때 전환 애니메이션을 맞춤설정합니다. FadeTransition (명시적으로 애니메이션이 적용된 위젯)을 사용하여 새 화면이 이전 화면 위에 페이드 인되도록 합니다.

lib/home_screen.dart

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      PageRouteBuilder(                                         // NEW
        pageBuilder: (context, animation, secondaryAnimation) { // NEW
          return QuestionScreen();                              // NEW
        },                                                      // NEW
        transitionsBuilder:                                     // NEW
            (context, animation, secondaryAnimation, child) {   // NEW
          return FadeTransition(                                // NEW
            opacity: animation,                                 // NEW
            child: child,                                       // NEW
          );                                                    // NEW
        },                                                      // NEW
      ),                                                        // NEW
    );
  },
  child: Text('New Game'),
),

애니메이션 패키지는 FadeThroughTransition과 같은 멋진 사전 빌드된 애니메이션 효과를 제공합니다. 애니메이션 패키지를 가져오고 FadeTransition을 FadeThroughTransition 위젯으로 바꿉니다.

lib/home_screen.dart

import 'package;animations/animations.dart';

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      PageRouteBuilder(
        pageBuilder: (context, animation, secondaryAnimation) {
          return const QuestionScreen();
        },
        transitionsBuilder:
            (context, animation, secondaryAnimation, child) {
          return FadeThroughTransition(                          // NEW
            animation: animation,                                // NEW
            secondaryAnimation: secondaryAnimation,              // NEW
            child: child,                                        // NEW
          );                                                     // NEW
        },
      ),
    );
  },
  child: Text('New Game'),
),

뒤로 탐색 예측 애니메이션 맞춤설정

1c0558ffa3b76439.gif

뒤로 탐색 예측은 사용자가 탐색하기 전에 현재 경로 또는 앱 뒤에 있는 항목을 살펴볼 수 있는 새로운 Android 기능입니다. 엿보기 애니메이션은 사용자가 화면을 뒤로 드래그할 때의 손가락 위치에 따라 실행됩니다.

Flutter는 탐색 스택에 팝할 경로가 없거나 뒤로 탐색하면 앱이 종료되는 경우 시스템 수준에서 이 기능을 사용 설정하여 시스템 뒤로 탐색 예측을 지원합니다. 이 애니메이션은 Flutter 자체가 아닌 시스템에서 처리합니다.

Flutter는 Flutter 앱 내에서 경로 간에 탐색할 때 뒤로 탐색 예측도 지원합니다. PredictiveBackPageTransitionsBuilder라는 특수 PageTransitionsBuilder가 시스템 뒤로 탐색 예측 동작을 리슨하고 동작의 진행률로 페이지 전환을 실행합니다.

뒤로 탐색 예측은 Android U 이상에서만 지원되지만 Flutter는 원래 뒤로 동작 동작 및 ZoomPageTransitionBuilder로 원활하게 대체됩니다. 자체 앱에서 설정하는 방법에 관한 섹션을 포함한 자세한 내용은 블로그 게시물을 참고하세요.

앱의 ThemeData 구성에서 PageTransitionsTheme를 Android에서 뒤로 탐색 예측을 사용하도록 구성하고 다른 플랫폼에서는 애니메이션 패키지의 페이드 전환 효과를 사용하도록 구성합니다.

lib/main.dart

import 'package:animations/animations.dart';                                 // NEW
import 'package:flutter/material.dart';

import 'home_screen.dart';

void main() {
  runApp(MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
        pageTransitionsTheme: PageTransitionsTheme(
          builders: {
            TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),  // NEW
            TargetPlatform.iOS: FadeThroughPageTransitionsBuilder(),         // NEW
            TargetPlatform.macOS: FadeThroughPageTransitionsBuilder(),       // NEW
            TargetPlatform.windows: FadeThroughPageTransitionsBuilder(),     // NEW
            TargetPlatform.linux: FadeThroughPageTransitionsBuilder(),       // NEW
          },
        ),
      ),
      home: HomeScreen(),
    );
  }
}

이제 Navigator.push() 콜백을 MaterialPageRoute로 변경할 수 있습니다.

lib/home_screen.dart

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      MaterialPageRoute(builder: (context) {       // NEW
        return const QuestionScreen();             // NEW
      }),                                          // NEW
    );
  },
  child: Text('New Game'),
),

FadeThroughTransition을 사용하여 현재 질문 변경하기

AnimatedSwitcher 위젯은 빌더 콜백에서 하나의 애니메이션만 제공합니다. 이를 해결하기 위해 animations 패키지는 PageTransitionSwitcher를 제공합니다.

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({
    required this.question,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return PageTransitionSwitcher(                                          // NEW
      layoutBuilder: (entries) {                                            // NEW
        return Stack(                                                       // NEW
          alignment: Alignment.topCenter,                                   // NEW
          children: entries,                                                // NEW
        );                                                                  // NEW
      },                                                                    // NEW
      transitionBuilder: (child, animation, secondaryAnimation) {           // NEW
        return FadeThroughTransition(                                       // NEW
          animation: animation,                                             // NEW
          secondaryAnimation: secondaryAnimation,                           // NEW
          child: child,                                                     // NEW
        );                                                                  // NEW
      },                                                                    // NEW
      duration: const Duration(milliseconds: 300),
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),
    );
  }
}

OpenContainer 사용

77358e5776eb104c.png

animations 패키지의 OpenContainer 위젯은 두 위젯 간에 시각적 연결을 만들기 위해 확장되는 컨테이너 변환 애니메이션 효과를 제공합니다.

closedBuilder에서 반환된 위젯이 처음에 표시되고 컨테이너를 탭하거나 openContainer 콜백이 호출되면 openBuilder에서 반환된 위젯으로 확장됩니다.

openContainer 콜백을 뷰 모델에 연결하려면 새 뷰 모델을 QuestionCard 위젯에 추가하고 'Game Over' 화면을 표시하는 데 사용할 콜백을 저장합니다.

lib/question_screen.dart

class QuestionScreen extends StatefulWidget {
  const QuestionScreen({super.key});

  @override
  State<QuestionScreen> createState() => _QuestionScreenState();
}

class _QuestionScreenState extends State<QuestionScreen> {
  late final QuizViewModel viewModel =
      QuizViewModel(onGameOver: _handleGameOver);
  VoidCallback? _showGameOverScreen;                                    // NEW

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: viewModel,
      builder: (context, child) {
        return Scaffold(
          appBar: AppBar(
            actions: [
              TextButton(
                onPressed:
                    viewModel.hasNextQuestion && viewModel.didAnswerQuestion
                        ? () {
                            viewModel.getNextQuestion();
                          }
                        : null,
                child: const Text('Next'),
              )
            ],
          ),
          body: Center(
            child: Column(
              children: [
                QuestionCard(                                           // NEW
                  onChangeOpenContainer: _handleChangeOpenContainer,    // NEW
                  question: viewModel.currentQuestion?.question,        // NEW
                  viewModel: viewModel,                                 // NEW
                ),                                                      // NEW
                Spacer(),
                AnswerCards(
                  onTapped: (index) {
                    viewModel.checkAnswer(index);
                  },
                  answers: viewModel.currentQuestion?.possibleAnswers ?? [],
                  correctAnswer: viewModel.didAnswerQuestion
                      ? viewModel.currentQuestion?.correctAnswer
                      : null,
                ),
                StatusBar(viewModel: viewModel),
              ],
            ),
          ),
        );
      },
    );
  }

  void _handleChangeOpenContainer(VoidCallback openContainer) {        // NEW
    _showGameOverScreen = openContainer;                               // NEW
  }                                                                    // NEW

  void _handleGameOver() {                                             // NEW
    if (_showGameOverScreen != null) {                                 // NEW
      _showGameOverScreen!();                                          // NEW
    }                                                                  // NEW
  }                                                                    // NEW
}

새 위젯 GameOverScreen을 추가합니다.

lib/question_screen.dart

class GameOverScreen extends StatelessWidget {
  final QuizViewModel viewModel;
  const GameOverScreen({required this.viewModel, super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        automaticallyImplyLeading: false,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Scoreboard(
              score: viewModel.score,
              totalQuestions: viewModel.totalQuestions,
            ),
            Text(
              'You Win!',
              style: Theme.of(context).textTheme.displayLarge,
            ),
            Text(
              'Score: ${viewModel.score} / ${viewModel.totalQuestions}',
              style: Theme.of(context).textTheme.displaySmall,
            ),
            ElevatedButton(
              child: Text('OK'),
              onPressed: () {
                Navigator.popUntil(context, (route) => route.isFirst);
              },
            ),
          ],
        ),
      ),
    );
  }
}

QuestionCard 위젯에서 Card를 애니메이션 패키지의 OpenContainer 위젯으로 대체하고 viewModel 및 open container 콜백에 두 가지 새 필드를 추가합니다.

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({
    required this.onChangeOpenContainer,
    required this.question,
    required this.viewModel,
    super.key,
  });

  final ValueChanged<VoidCallback> onChangeOpenContainer;
  final QuizViewModel viewModel;

  static const _backgroundColor = Color(0xfff2f3fa);

  @override
  Widget build(BuildContext context) {
    return PageTransitionSwitcher(
      duration: const Duration(milliseconds: 200),
      transitionBuilder: (child, animation, secondaryAnimation) {
        return FadeThroughTransition(
          animation: animation,
          secondaryAnimation: secondaryAnimation,
          child: child,
        );
      },
      child: OpenContainer(                                         // NEW
        key: ValueKey(question),                                    // NEW
        tappable: false,                                            // NEW
        closedColor: _backgroundColor,                              // NEW
        closedShape: const RoundedRectangleBorder(                  // NEW
          borderRadius: BorderRadius.all(Radius.circular(12.0)),    // NEW
        ),                                                          // NEW
        closedElevation: 4,                                         // NEW
        closedBuilder: (context, openContainer) {                   // NEW
          onChangeOpenContainer(openContainer);                     // NEW
          return ColoredBox(                                        // NEW
            color: _backgroundColor,                                // NEW
            child: Padding(                                         // NEW
              padding: const EdgeInsets.all(16.0),                  // NEW
              child: Text(
                question ?? '',
                style: Theme.of(context).textTheme.displaySmall,
              ),
            ),
          );
        },
        openBuilder: (context, closeContainer) {                    // NEW
          return GameOverScreen(viewModel: viewModel);              // NEW
        },                                                          // NEW
      ),
    );
  }
}

4120f9395857d218.gif

8. 축하합니다

축하합니다. Flutter 앱에 애니메이션 효과를 추가하고 Flutter 애니메이션 시스템의 핵심 구성요소에 대해 알아보았습니다. 구체적으로 다음 내용을 배웠습니다.

  • ImplicitlyAnimatedWidget 사용 방법
  • ExplicitlyAnimatedWidget 사용 방법
  • 애니메이션에 곡선 및 트윈을 적용하는 방법
  • AnimatedSwitcher 또는 PageRouteBuilder와 같은 사전 빌드된 전환 위젯을 사용하는 방법
  • FadeThroughTransition 및 OpenContainer와 같은 animations 패키지의 멋진 사전 빌드된 애니메이션 효과를 사용하는 방법
  • Android에서 뒤로 탐색 예측 지원을 추가하는 등 기본 전환 애니메이션을 맞춤설정하는 방법

3026390ad413769c.gif

다음 단계

다음 Codelab을 확인해 보세요.

또는 다양한 애니메이션 기법을 보여주는 애니메이션 샘플 앱을 다운로드하세요.

추가 자료

flutter.dev에서 더 많은 애니메이션 리소스를 확인할 수 있습니다.

또는 Medium의 다음 도움말을 확인하세요.

참조 문서