티스토리 뷰

Flutter에서는 UI에 생동감을 불어넣기 위해 다양한 애니메이션 기능을 제공합니다. 그중에서도 '스태거드 애니메이션(Staggered Animation)'은 개별 애니메이션을 순차적으로 실행하여, 사용자가 복잡한 UI 요소들을 한 번에 이해하고 쉽게 접근할 수 있도록 합니다. 이 글에서는 Flutter의 스태거드 메뉴 애니메이션을 구현하는 방법과 중요한 개념들을 살펴보겠습니다.

참고. Create a staggered menu animation

스태거드 애니메이션의 정의

스태거드 애니메이션은 여러 애니메이션이 순차적으로 실행되지만, 서로 겹치는 형태로 구현되어 짧은 시간 안에 각 요소가 한 번에 등장하지 않고 순서대로 나타나는 방식입니다. 이는 앱의 시각적 흐름을 부드럽게 만들어 주며, 사용자가 자연스럽게 콘텐츠를 인지할 수 있게 돕습니다.

스태거드 메뉴 애니메이션 구현 단계

1단계: 기본 메뉴 구성

먼저 애니메이션이 없는 기본 메뉴를 구성합니다. Menu라는 상태 유지형 위젯을 만들고, 리스트 형태로 메뉴 항목과 'Get Started' 버튼을 추가합니다.

class Menu extends StatefulWidget {
 const Menu({super.key});
 @override
 State<Menu> createState() => _MenuState();
}

class _MenuState extends State<Menu> {
 static const _menuTitles = [
   'Declarative Style',
   'Premade Widgets',
   'Stateful Hot Reload',
   'Native Performance',
   'Great Community',
 ];

 @override
 Widget build(BuildContext context) {
   return Container(
     color: Colors.white,
     child: Stack(
       fit: StackFit.expand,
       children: [
         _buildFlutterLogo(),
         _buildContent(),
       ],
     ),
   );
 }

 Widget _buildFlutterLogo() {
   return Container(); // 로고 구현은 생략
 }

 Widget _buildContent() {
   return Column(
     crossAxisAlignment: CrossAxisAlignment.start,
     children: [
       const SizedBox(height: 16),
       ..._buildListItems(),
       const Spacer(),
       _buildGetStartedButton(),
     ],
   );
 }

 List<Widget> _buildListItems() {
   return _menuTitles.map((title) {
     return Padding(
       padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 16),
       child: Text(
         title,
         style: const TextStyle(
           fontSize: 24,
           fontWeight: FontWeight.w500,
         ),
       ),
     );
   }).toList();
 }

 Widget _buildGetStartedButton() {
   return SizedBox(
     width: double.infinity,
     child: Padding(
       padding: const EdgeInsets.all(24),
       child: ElevatedButton(
         style: ElevatedButton.styleFrom(
           shape: const StadiumBorder(),
           backgroundColor: Colors.blue,
           padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 14),
         ),
         onPressed: () {},
         child: const Text(
           'Get Started',
           style: TextStyle(
             color: Colors.white,
             fontSize: 22,
           ),
         ),
       ),
     ),
   );
 }
}

2단계: 애니메이션을 위한 준비

애니메이션의 타이밍을 제어하기 위해 AnimationController를 사용합니다. 이를 통해 각 메뉴 항목의 등장 시점을 지연시키고, 특정 애니메이션 구간을 지정하여 부드러운 전환을 설정합니다.

애니메이션 컨트롤러 정의

class _MenuState extends State<Menu> with SingleTickerProviderStateMixin {
 late AnimationController _staggeredController;

 @override
 void initState() {
   super.initState();
   _staggeredController = AnimationController(
     vsync: this,
     duration: Duration(milliseconds: 800), // 전체 애니메이션 지속 시간
   )..forward();
 }

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

3단계: 리스트 항목과 버튼 애니메이션

각 항목과 버튼에 대해 각각의 애니메이션 구간(Interval)을 정의하고, 이를 통해 리스트 항목이 서서히 나타나고, 버튼은 팝업 효과를 통해 등장하도록 만듭니다. 이를 위해 AnimatedBuilderInterval 클래스를 활용합니다.

List<Widget> _buildListItems() {
 final listItems = <Widget>[];
 for (var i = 0; i < _menuTitles.length; ++i) {
   listItems.add(
     AnimatedBuilder(
       animation: _staggeredController,
       builder: (context, child) {
         final animationPercent = Curves.easeOut.transform(
           Interval(
             0.1 * i, 0.1 * (i + 1),
           ).transform(_staggeredController.value),
         );
         final opacity = animationPercent;
         final slideDistance = (1.0 - animationPercent) * 150;
         return Opacity(
           opacity: opacity,
           child: Transform.translate(
             offset: Offset(slideDistance, 0),
             child: child,
           ),
         );
       },
       child: Padding(
         padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 16),
         child: Text(
           _menuTitles[i],
           style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w500),
         ),
       ),
     ),
   );
 }
 return listItems;
}

결론 및 주요 기능 정리

Flutter의 스태거드 애니메이션을 구현하면 복잡한 애니메이션을 한 번에 처리하는 대신 순차적으로 자연스럽게 표시할 수 있어, UI의 이해도와 사용자 경험을 높일 수 있습니다. 애니메이션 타이밍과 개별 요소의 구간을 정밀하게 제어하는 AnimationControllerInterval 클래스를 통해 효과적인 전환 효과를 제공할 수 있습니다.

 

Flutter를 사용하면서 애니메이션 관련 기능을 깊이 있게 이해하는 것은 전체적인 앱의 품질을 높이는 데 중요한 요소로 작용합니다. Flutter 개발자라면, 이러한 애니메이션 기법을 프로젝트에 적용해보고 새로운 기능들을 실험해 보세요.

 

import 'package:flutter/material.dart';

void main() {
  runApp(
    const MaterialApp(
      home: ExampleStaggeredAnimations(),
      debugShowCheckedModeBanner: false,
    ),
  );
}

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

  @override
  State<ExampleStaggeredAnimations> createState() =>
      _ExampleStaggeredAnimationsState();
}

class _ExampleStaggeredAnimationsState extends State<ExampleStaggeredAnimations>
    with SingleTickerProviderStateMixin {
  late AnimationController _drawerSlideController;

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

    _drawerSlideController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 150),
    );
  }

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

  bool _isDrawerOpen() {
    return _drawerSlideController.value == 1.0;
  }

  bool _isDrawerOpening() {
    return _drawerSlideController.status == AnimationStatus.forward;
  }

  bool _isDrawerClosed() {
    return _drawerSlideController.value == 0.0;
  }

  void _toggleDrawer() {
    if (_isDrawerOpen() || _isDrawerOpening()) {
      _drawerSlideController.reverse();
    } else {
      _drawerSlideController.forward();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: _buildAppBar(),
      body: Stack(
        children: [
          _buildContent(),
          _buildDrawer(),
        ],
      ),
    );
  }

  PreferredSizeWidget _buildAppBar() {
    return AppBar(
      title: const Text(
        'Flutter Menu',
        style: TextStyle(
          color: Colors.black,
        ),
      ),
      backgroundColor: Colors.transparent,
      elevation: 0.0,
      automaticallyImplyLeading: false,
      actions: [
        AnimatedBuilder(
          animation: _drawerSlideController,
          builder: (context, child) {
            return IconButton(
              onPressed: _toggleDrawer,
              icon: _isDrawerOpen() || _isDrawerOpening()
                  ? const Icon(
                      Icons.clear,
                      color: Colors.black,
                    )
                  : const Icon(
                      Icons.menu,
                      color: Colors.black,
                    ),
            );
          },
        ),
      ],
    );
  }

  Widget _buildContent() {
    // Put page content here.
    return const SizedBox();
  }

  Widget _buildDrawer() {
    return AnimatedBuilder(
      animation: _drawerSlideController,
      builder: (context, child) {
        return FractionalTranslation(
          translation: Offset(1.0 - _drawerSlideController.value, 0.0),
          child: _isDrawerClosed() ? const SizedBox() : const Menu(),
        );
      },
    );
  }
}

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

  @override
  State<Menu> createState() => _MenuState();
}

class _MenuState extends State<Menu> with SingleTickerProviderStateMixin {
  static const _menuTitles = [
    'Declarative style',
    'Premade widgets',
    'Stateful hot reload',
    'Native performance',
    'Great community',
  ];

  static const _initialDelayTime = Duration(milliseconds: 50);
  static const _itemSlideTime = Duration(milliseconds: 250);
  static const _staggerTime = Duration(milliseconds: 50);
  static const _buttonDelayTime = Duration(milliseconds: 150);
  static const _buttonTime = Duration(milliseconds: 500);
  final _animationDuration = _initialDelayTime +
      (_staggerTime * _menuTitles.length) +
      _buttonDelayTime +
      _buttonTime;

  late AnimationController _staggeredController;
  final List<Interval> _itemSlideIntervals = [];
  late Interval _buttonInterval;

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

    _createAnimationIntervals();

    _staggeredController = AnimationController(
      vsync: this,
      duration: _animationDuration,
    )..forward();
  }

  void _createAnimationIntervals() {
    for (var i = 0; i < _menuTitles.length; ++i) {
      final startTime = _initialDelayTime + (_staggerTime * i);
      final endTime = startTime + _itemSlideTime;
      _itemSlideIntervals.add(
        Interval(
          startTime.inMilliseconds / _animationDuration.inMilliseconds,
          endTime.inMilliseconds / _animationDuration.inMilliseconds,
        ),
      );
    }

    final buttonStartTime =
        Duration(milliseconds: (_menuTitles.length * 50)) + _buttonDelayTime;
    final buttonEndTime = buttonStartTime + _buttonTime;
    _buttonInterval = Interval(
      buttonStartTime.inMilliseconds / _animationDuration.inMilliseconds,
      buttonEndTime.inMilliseconds / _animationDuration.inMilliseconds,
    );
  }

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: Stack(
        fit: StackFit.expand,
        children: [
          _buildFlutterLogo(),
          _buildContent(),
        ],
      ),
    );
  }

  Widget _buildFlutterLogo() {
    return const Positioned(
      right: -100,
      bottom: -30,
      child: Opacity(
        opacity: 0.2,
        child: FlutterLogo(
          size: 400,
        ),
      ),
    );
  }

  Widget _buildContent() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const SizedBox(height: 16),
        ..._buildListItems(),
        const Spacer(),
        _buildGetStartedButton(),
      ],
    );
  }

  List<Widget> _buildListItems() {
    final listItems = <Widget>[];
    for (var i = 0; i < _menuTitles.length; ++i) {
      listItems.add(
        AnimatedBuilder(
          animation: _staggeredController,
          builder: (context, child) {
            final animationPercent = Curves.easeOut.transform(
              _itemSlideIntervals[i].transform(_staggeredController.value),
            );
            final opacity = animationPercent;
            final slideDistance = (1.0 - animationPercent) * 150;

            return Opacity(
              opacity: opacity,
              child: Transform.translate(
                offset: Offset(slideDistance, 0),
                child: child,
              ),
            );
          },
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 16),
            child: Text(
              _menuTitles[i],
              textAlign: TextAlign.left,
              style: const TextStyle(
                fontSize: 24,
                fontWeight: FontWeight.w500,
              ),
            ),
          ),
        ),
      );
    }
    return listItems;
  }

  Widget _buildGetStartedButton() {
    return SizedBox(
      width: double.infinity,
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: AnimatedBuilder(
          animation: _staggeredController,
          builder: (context, child) {
            final animationPercent = Curves.elasticOut.transform(
                _buttonInterval.transform(_staggeredController.value));
            final opacity = animationPercent.clamp(0.0, 1.0);
            final scale = (animationPercent * 0.5) + 0.5;

            return Opacity(
              opacity: opacity,
              child: Transform.scale(
                scale: scale,
                child: child,
              ),
            );
          },
          child: ElevatedButton(
            style: ElevatedButton.styleFrom(
              shape: const StadiumBorder(),
              backgroundColor: Colors.blue,
              padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 14),
            ),
            onPressed: () {},
            child: const Text(
              'Get started',
              style: TextStyle(
                color: Colors.white,
                fontSize: 22,
              ),
            ),
          ),
        ),
      ),
    );
  }
}