paint-brush
Flutter에서 시차 애니메이션 만들기: 마이크로 상호작용 가이드~에 의해@nikkieke
460 판독값
460 판독값

Flutter에서 시차 애니메이션 만들기: 마이크로 상호작용 가이드

~에 의해 19m2024/06/04
Read on Terminal Reader

너무 오래; 읽다

마이크로 인터랙션의 개념은 작은 세부 사항이 전체 사용자 경험에 큰 영향을 미칠 수 있다는 생각에 기반을 두고 있습니다. Flutter에서는 암시적 또는 명시적 애니메이션을 사용하여 미묘한 애니메이션을 제작하여 마이크로 상호작용을 구축할 수 있습니다. 이 문서에서는 해당 페이지를 스와이프할 때 열 위젯의 하위 항목에 애니메이션을 적용하는 시차 애니메이션을 만들어 보겠습니다.
featured image - Flutter에서 시차 애니메이션 만들기: 마이크로 상호작용 가이드
undefined HackerNoon profile picture
0-item

엇갈린 애니메이션은 순차적이거나 겹치는 애니메이션으로 구성됩니다. 애니메이션은 하나의 변경이 다음 변경 후에 발생하는 순전히 순차적일 수도 있고 부분적으로 또는 완전히 겹칠 수도 있습니다. 애니메이션이 순차적인 경우 요소는 각 요소의 시작 시간 사이에 약간의 지연을 두고 순차적으로 애니메이션됩니다. 이렇게 하면 애니메이션이 한꺼번에 요소 전체가 아닌 단계적으로 요소를 통해 이동하는 것처럼 보이는 계단식 또는 파급 효과가 생성됩니다.


시차 애니메이션은 인터페이스를 통해 사용자를 안내하는 미묘하고 대화형 피드백을 제공하여 사용자 경험을 향상시키기 때문에 일종의 마이크로 상호 작용으로 간주됩니다. Flutter에서는 암시적 또는 명시적 애니메이션을 사용하여 미묘한 애니메이션을 제작하여 마이크로 상호작용을 구축할 수 있습니다.


상황에 따라 암시적 애니메이션은 애니메이션 세부 정보가 추상화되므로 간단하고 사용하기 쉽도록 설계되는 반면, 명시적 애니메이션은 애니메이션 프로세스를 완벽하게 제어할 수 있기 때문에 복잡한 애니메이션에 더 적합합니다. 이는 특히 애니메이션을 더욱 세밀하게 제어해야 할 때 사용됩니다.


이 기사에서는 마이크로 인터랙션의 개념을 살펴본 다음 마이크로 인터랙션 사용 사례의 경우 명시적 애니메이션을 사용하여 열 위젯에 포함된 하위 항목에 애니메이션을 적용하는 시차 애니메이션을 만듭니다.

마이크로 인터랙션의 개념은 무엇입니까?

좋은 제품은 기능과 디테일 모두를 잘 전달하는 제품입니다. 기능은 사람들을 제품으로 끌어들이지만 세부 사항은 사람들을 제품으로 끌어들입니다. 이러한 세부 사항은 귀하의 앱을 다른 앱보다 돋보이게 만드는 것입니다. 마이크로 인터랙션을 사용하면 사용자에게 즐거운 피드백을 제공하여 이러한 세부 정보를 만들 수 있습니다.


마이크로 인터랙션의 개념은 작은 세부 사항이 전체 사용자 경험에 큰 영향을 미칠 수 있다는 생각에 기반을 두고 있습니다. 마이크로 인터랙션은 피드백 전달이나 작업 결과와 같은 필수 기능을 제공하는 데 사용될 수 있습니다. 마이크로 상호작용의 예는 다음과 같습니다.


  • 버튼 애니메이션: 버튼을 가리키거나 누르면 버튼의 색상이나 크기가 변경됩니다.


  • 로딩 표시기: 프로세스가 진행 중임을 사용자에게 나타내는 애니메이션입니다.


  • 스와이프 제스처: 스와이프 제스처에 반응하는 애니메이션입니다.


  • 탐색 전환: 화면 간 전환 시 애니메이션이 부드러워집니다.

실제 응용 프로그램의 마이크로 상호 작용

아래에서는 마이크로 상호작용의 실제 적용을 볼 수 있습니다. 이러한 미묘한 애니메이션은 Flutter에서 생성되어 사용자 경험을 향상시킵니다. 👇🏻

이 앱의 디자인 참조는 Dribbble에서 얻었습니다.

Flutter에서 시차 애니메이션을 만드는 방법

이 예에서는 해당 페이지를 스와이프할 때 열 위젯의 하위 항목에 애니메이션을 적용하는 시차 애니메이션을 만듭니다. 이는 명시적 애니메이션 접근 방식을 사용하여 구축될 것입니다. 왜냐하면 이 경우 애니메이션 실행 방법을 완전히 제어해야 하기 때문입니다.


완성되면 애니메이션의 모습은 다음과 같습니다 👇👇

전제 조건

이 튜토리얼을 최대한 활용하려면 다음이 필요합니다.


  • Flutter에서 애니메이션이 생성되는 방식에 대한 기본 이해
  • Flutter 및 Dart 기본 사항을 잘 이해하고 있습니다.
  • 코드 편집기(VScode 또는 Android Studio)
  • 빌드할 에뮬레이터 또는 장치


프로젝트의 전제조건을 모두 확인했으면 이제 시작해 보겠습니다.


먼저, 이 두 페이지를 포함하는 메인 화면을 구축하겠습니다. 이 두 페이지는 스와이프 시 표시되는 페이지를 제어하는 페이지뷰 위젯에 래핑됩니다. 또한 메인 화면 하단에는 현재 진행 중인 페이지를 보여주는 표시기가 있습니다.

 class MainScreen extends StatefulWidget { const MainScreen({super.key}); @override State<MainScreen> createState() => _MainScreenState(); } class _MainScreenState extends State<MainScreen>{ final controller = PageController(keepPage: true); @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: PageView( controller: controller, children: [ Page1(pageController: controller,), Page2(pageController: controller,), ], ), ), bottomNavigationBar: Container( alignment: Alignment.topCenter, padding: const EdgeInsets.only(top: 10), height: 30, child: SmoothPageIndicator( controller: controller, count: 2, effect: const JumpingDotEffect( dotHeight: 10, dotWidth: 10, activeDotColor: Colors.grey, dotColor: Colors.black12, ), ), ) ); } }


위의 코드 조각에 사용된 SmoothPageIndicator pub.dev 에서 찾을 수 있습니다. 여기서 SmoothPageIndicator 보기에 현재 페이지를 표시하는 데 사용됩니다. 다음과 같이 pubspec.yaml 에 패키지를 추가할 수 있습니다 👇👇

 dependencies: flutter: sdk: flutter smooth_page_indicator: ^1.1.0


두 페이지에는 여러 개의 빈 카드 위젯이 있는 열 위젯이 있습니다. 이러한 빈 카드 위젯은 열을 채우는 데 사용되므로 엇갈린 애니메이션을 완전히 보고 감상할 수 있습니다. 빈 카드 위젯을 재사용 가능한 구성요소로 생성하여 사용하는 모든 장소에서 처음부터 새로 구축할 필요가 없도록 하겠습니다.

 class EmptyCard extends StatelessWidget { const EmptyCard({super.key, required this.width, required this.height}); final double width; final double height; @override Widget build(BuildContext context) { return Container( height: height, width: width, decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(10)), color: Colors.blue.shade200, boxShadow: const [ BoxShadow( color: Colors.black12, blurRadius: 4, offset: Offset(0,4), ) ] ), ); } }


이러한 상용구 코드를 모두 제거하고 이제 애니메이션 제작을 시작하겠습니다. 먼저 AnimateWidget 이라고 부르는 새로운 상태 저장 위젯을 생성하겠습니다. 애니메이션 실행 방법을 제어하는 여러 매개변수가 있습니다.

 class AnimateWidget extends StatefulWidget { const AnimateWidget({super.key, required this.duration, required this.position, required this.horizontalOffset, required this.child, required this.controller, }); final Duration duration; final int position; final double? horizontalOffset; final Widget child; final PageController controller; @override State<AnimateWidget> createState() => _AnimateWidgetState(); }


위의 스니펫에 표시된 AnimateWidget 의 매개변수를 사용하면 다음을 성공적으로 제어할 수 있습니다.

  • 애니메이션 지속 시간.
  • pageController - 페이지를 스와이프할 때 애니메이션을 트리거합니다.
  • 애니메이션 요소의 수평 오프셋 양입니다.


다음으로 AnimateWiget 에서 다음을 정의합니다.

  • AnimationController : 애니메이션 시퀀스를 제어하는 데 사용됩니다.

  • 현재 페이지: 현재 페이지 데이터를 보관하는 데 사용됩니다.

  • 타이머: 약간의 지연 후 애니메이션을 트리거하는 데 사용됩니다. 이것이 엇갈린 애니메이션 계단식 효과를 가져오는 것입니다.


     class _AnimateWidgetState extends State<AnimateWidget> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin{ @override bool get wantKeepAlive => true; late AnimationController animationController; int currentPage = 0; Timer? _timer; @override void initState() { super.initState(); animationController = AnimationController(vsync: this, duration: widget.duration); _timer = Timer(getDelay(), animationController.forward); widget.controller.addListener(() { currentPage = widget.controller.page!.round(); if(currentPage == widget.controller.page){ _timer = Timer(getDelay(), animationController.forward); } }); } @override void dispose() { _timer?.cancel(); animationController.dispose(); super.dispose(); } Duration getDelay(){ var delayInMilliSec = widget.duration.inMilliseconds ~/ 6; int getStaggeredListDuration(){ return widget.position * delayInMilliSec; } return Duration(milliseconds: getStaggeredListDuration()); }

위의 코드 조각을 분석하면 다음을 알 수 있습니다.

  • AnimationController 사용하여 애니메이션 시퀀스를 제어했기 때문에 SingleTickerProviderStateMixin 을 도입했습니다.


  • init state 메소드에서는 AnimationController 초기화한 다음 페이지 입력 및 페이지 스와이프 시 animationController.forward 사용하여 애니메이션을 트리거했습니다.


  • 타이머를 사용하여 지연에 따라 애니메이션 트리거를 제어했습니다.


  • dispose 메서드에서는 리소스를 정리했습니다.


  • 우리는 위젯의 상태를 보존하기 위해 AutomaticKeepAliveClientMixin 을 사용했습니다. 이는 페이지를 스와이프하여 더 이상 표시되지 않을 때 AnimationController 가 삭제되는 것을 방지하기 위한 것입니다.


  • getDelay 함수에서 각 요소에 대해 애니메이션이 트리거되기 전의 지연 시간을 계산했습니다. 이를 달성하기 위해 우리는 밀리초 단위의 기간을 6으로 나누고 결과를 대략적으로 계산한 다음 여기에 위치를 곱했습니다. 이는 요소의 위치(이 경우 인덱스)입니다.


Build 메서드에서는 AnimatedBuilder 반환합니다. 이 애니메이션 빌더에서는 Transform.translate 위젯을 반환하는 _slideAnimation 이라는 위젯 함수를 반환합니다. _slideAnimation 함수에는 offsetAnimation 함수가 있습니다.


offsetAnimation 함수는 Transform.translate 위젯에서 사용되는 애니메이션 속성을 반환합니다. Transform.translate 위젯은 애니메이션의 값을 사용하여 하위 위젯에 애니메이션을 적용합니다.

 @override Widget build(BuildContext context) { super.build(context); return AnimatedBuilder( animation: animationController, builder: (context, child){ return _slideAnimation(animationController); } ); } Widget _slideAnimation(Animation<double> animationController){ Animation<double> offsetAnimation(double offset, Animation<double> animationController) { return Tween<double>(begin: offset, end: 0.0).animate( CurvedAnimation( parent: animationController, curve: const Interval(0.0, 1.0, curve: Curves.ease), ), ); } return Transform.translate( offset: Offset( widget.horizontalOffset == 0.0 ? 0.0 : offsetAnimation(widget.horizontalOffset!, animationController).value, 0.0, ), child: widget.child, ); }


AnimateWidget 클래스의 전체 코드입니다 👇👇

 class AnimateWidget extends StatefulWidget { const AnimateWidget({super.key, required this.duration, required this.position, required this.horizontalOffset, required this.child, required this.controller, }); final Duration duration; final int position; final double? horizontalOffset; final Widget child; final PageController controller; @override State<AnimateWidget> createState() => _AnimateWidgetState(); } class _AnimateWidgetState extends State<AnimateWidget> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin{ @override bool get wantKeepAlive => true; late AnimationController animationController; int currentPage = 0; Timer? _timer; @override void initState() { super.initState(); animationController = AnimationController(vsync: this, duration: widget.duration); _timer = Timer(getDelay(), animationController.forward); widget.controller.addListener(() { currentPage = widget.controller.page!.round(); if(currentPage == widget.controller.page){ _timer = Timer(getDelay(), animationController.forward); } }); } @override void dispose() { _timer?.cancel(); animationController.dispose(); super.dispose(); } Duration getDelay(){ var delayInMilliSec = widget.duration.inMilliseconds ~/ 6; int getStaggeredListDuration(){ return widget.position * delayInMilliSec; } return Duration(milliseconds: getStaggeredListDuration()); } @override Widget build(BuildContext context) { super.build(context); return AnimatedBuilder( animation: animationController, builder: (context, child){ return _slideAnimation(animationController); } ); } Widget _slideAnimation(Animation<double> animationController){ Animation<double> offsetAnimation(double offset, Animation<double> animationController) { return Tween<double>(begin: offset, end: 0.0).animate( CurvedAnimation( parent: animationController, curve: const Interval(0.0, 1.0, curve: Curves.ease), ), ); } return Transform.translate( offset: Offset( widget.horizontalOffset == 0.0 ? 0.0 : offsetAnimation(widget.horizontalOffset!, animationController).value, 0.0, ), child: widget.child, ); } }

다음으로 열 위젯에서 이 AnimateWidget 클래스를 사용하기 위해 목록을 반환하는 toStaggeredList 라는 정적 메서드를 사용하여 클래스를 생성합니다. 이 메서드에서는 children 목록을 포함하여 필요한 모든 매개변수를 전달합니다. children 매개변수는 애니메이션을 적용할 요소 목록을 전달하는 곳입니다.


다음으로, 각 어린이를 AnimateWidget 으로 래핑하여 어린이를 매핑합니다.

 class AnimateList{ static List<Widget>toStaggeredList({ required Duration duration, double? horizontalOffset, required PageController controller, required List<Widget>children, })=> children .asMap() .map((index, widget){ return MapEntry( index, AnimateWidget( duration: duration, position: index, horizontalOffset: horizontalOffset, controller: controller, child: widget, ) ); }) .values .toList(); }


AnimateWidget 에서는 목록의 각 하위 항목에 성공적으로 애니메이션을 적용하는 데 필요한 매개변수를 전달합니다. AnimateList.toStaggeredList 메서드를 사용하여 이제 작업 중인 두 페이지에 이를 구현할 수 있습니다.

 class Page1 extends StatelessWidget { const Page1({super.key, required this.pageController}); final PageController pageController; @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: AnimateList.toStaggeredList( duration: const Duration(milliseconds: 375), controller: pageController, horizontalOffset: MediaQuery.of(context).size.width / 2, children: [ const EmptyCard(width: 250, height: 50,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 180, height: 80,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 270, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), ], ), ), const EmptyCard(width: 250, height: 50,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 180, height: 80,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 270, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), ], ), ), ], ), ), ); } } class Page2 extends StatelessWidget { const Page2({super.key, required this.pageController}); final PageController pageController; @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: AnimateList.toStaggeredList( duration: const Duration(milliseconds: 375), controller: pageController, horizontalOffset: MediaQuery.of(context).size.width / 2, children: [ const EmptyCard(width: 220, height: 70,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 300, height: 70,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 200, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 70, width: 70), EmptyCard(height: 70, width: 70), ], ), ), const EmptyCard(width: 220, height: 70,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 300, height: 70,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 70, width: 70), EmptyCard(height: 70, width: 70), ], ), ), ], ), ), ); } }

열 위젯의 하위 항목에서는 AnimateList.toStaggeredList 를 전달하고 열에 표시될 위젯을 포함하여 필요한 매개변수를 전달합니다. 이를 통해 스와이프 시 트리거되는 시차 애니메이션을 성공적으로 만들었습니다. 여기에서 전체 코드를 확인할 수 있습니다.


이것이 우리의 최종 결과입니다:

결론

이제 이 튜토리얼이 끝났습니다. 이 시점에서 우리는 마이크로 인터랙션의 개념과 이것이 사용자 경험에 미치는 영향을 다루었습니다. 또한 페이지 스와이프 시 실행되는 열 위젯 항목의 시차 애니메이션을 구축하는 과정도 살펴보았습니다.


프로젝트의 사용자 경험을 향상시키기 위해 앱에 추가할 수 있는 마이크로 상호작용의 종류는 매우 많습니다. Flutter에서 더 많은 대화형 애니메이션을 만들어 실험해 볼 수 있습니다.


이 글이 도움이 되셨다면 좋아요나 댓글을 남겨주시면 도움을 드릴 수 있습니다. 더 많은 관련 기사를 보려면 나를 팔로우할 수도 있습니다.

참고자료

Flutter Staggered 애니메이션 패키지