엇갈린 애니메이션은 순차적이거나 겹치는 애니메이션으로 구성됩니다. 애니메이션은 하나의 변경이 다음 변경 후에 발생하는 순전히 순차적일 수도 있고 부분적으로 또는 완전히 겹칠 수도 있습니다. 애니메이션이 순차적인 경우 요소는 각 요소의 시작 시간 사이에 약간의 지연을 두고 순차적으로 애니메이션됩니다. 이렇게 하면 애니메이션이 한꺼번에 요소 전체가 아닌 단계적으로 요소를 통해 이동하는 것처럼 보이는 계단식 또는 파급 효과가 생성됩니다.
시차 애니메이션은 인터페이스를 통해 사용자를 안내하는 미묘하고 대화형 피드백을 제공하여 사용자 경험을 향상시키기 때문에 일종의 마이크로 상호 작용으로 간주됩니다. Flutter에서는 암시적 또는 명시적 애니메이션을 사용하여 미묘한 애니메이션을 제작하여 마이크로 상호작용을 구축할 수 있습니다.
상황에 따라 암시적 애니메이션은 애니메이션 세부 정보가 추상화되므로 간단하고 사용하기 쉽도록 설계되는 반면, 명시적 애니메이션은 애니메이션 프로세스를 완벽하게 제어할 수 있기 때문에 복잡한 애니메이션에 더 적합합니다. 이는 특히 애니메이션을 더욱 세밀하게 제어해야 할 때 사용됩니다.
이 기사에서는 마이크로 인터랙션의 개념을 살펴본 다음 마이크로 인터랙션 사용 사례의 경우 명시적 애니메이션을 사용하여 열 위젯에 포함된 하위 항목에 애니메이션을 적용하는 시차 애니메이션을 만듭니다.
좋은 제품은 기능과 디테일 모두를 잘 전달하는 제품입니다. 기능은 사람들을 제품으로 끌어들이지만 세부 사항은 사람들을 제품으로 끌어들입니다. 이러한 세부 사항은 귀하의 앱을 다른 앱보다 돋보이게 만드는 것입니다. 마이크로 인터랙션을 사용하면 사용자에게 즐거운 피드백을 제공하여 이러한 세부 정보를 만들 수 있습니다.
마이크로 인터랙션의 개념은 작은 세부 사항이 전체 사용자 경험에 큰 영향을 미칠 수 있다는 생각에 기반을 두고 있습니다. 마이크로 인터랙션은 피드백 전달이나 작업 결과와 같은 필수 기능을 제공하는 데 사용될 수 있습니다. 마이크로 상호작용의 예는 다음과 같습니다.
아래에서는 마이크로 상호작용의 실제 적용을 볼 수 있습니다. 이러한 미묘한 애니메이션은 Flutter에서 생성되어 사용자 경험을 향상시킵니다. 👇🏻
이 앱의 디자인 참조는 Dribbble에서 얻었습니다.
이 예에서는 해당 페이지를 스와이프할 때 열 위젯의 하위 항목에 애니메이션을 적용하는 시차 애니메이션을 만듭니다. 이는 명시적 애니메이션 접근 방식을 사용하여 구축될 것입니다. 왜냐하면 이 경우 애니메이션 실행 방법을 완전히 제어해야 하기 때문입니다.
완성되면 애니메이션의 모습은 다음과 같습니다 👇👇
이 튜토리얼을 최대한 활용하려면 다음이 필요합니다.
프로젝트의 전제조건을 모두 확인했으면 이제 시작해 보겠습니다.
먼저, 이 두 페이지를 포함하는 메인 화면을 구축하겠습니다. 이 두 페이지는 스와이프 시 표시되는 페이지를 제어하는 페이지뷰 위젯에 래핑됩니다. 또한 메인 화면 하단에는 현재 진행 중인 페이지를 보여주는 표시기가 있습니다.
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에서 더 많은 대화형 애니메이션을 만들어 실험해 볼 수 있습니다.
이 글이 도움이 되셨다면 좋아요나 댓글을 남겨주시면 도움을 드릴 수 있습니다. 더 많은 관련 기사를 보려면 나를 팔로우할 수도 있습니다.