최신 모바일 앱의 사용자 경험(UX) 트렌드 중 하나는 단연 '콘텐츠 중심 디자인'입니다. 사용자가 화면의 콘텐츠에 최대한 집중할 수 있도록 불필요한 UI 요소를 동적으로 숨기는 기법은 이제 선택이 아닌 필수가 되었습니다. 특히 인스타그램, 페이스북, 최신 웹 브라우저 등에서 흔히 볼 수 있는, 아래로 스크롤할 때는 하단 탭 바(BottomNavigationBar)가 사라지고, 위로 스크롤할 때 다시 나타나는 기능은 화면 공간을 극대화하여 사용자에게 쾌적한 경험을 선사합니다.
Flutter로 앱을 개발하면서 이러한 동적 UI를 어떻게 구현할 수 있을지 고민해보셨을 겁니다. 단순히 '보였다/안 보였다'의 이분법적인 접근을 넘어, 부드러운 애니메이션과 함께 사용자의 스크롤 의도를 정확히 파악하여 반응하는 완성도 높은 BottomNavigationBar를 만드는 것이 중요합니다. 이 글에서는 Flutter의 ScrollController
, NotificationListener
, 그리고 AnimationController
를 조합하여, 어떤 복잡한 스크롤 뷰에서도 완벽하게 작동하는 '스크롤 연동 하단 바'를 구현하는 모든 과정을 A부터 Z까지 상세하게 다룰 것입니다. 단순히 코드를 복사 붙여넣기 하는 것을 넘어, 그 원리를 이해하고 다양한 예외 상황에 대처하는 방법까지 마스터하게 될 것입니다.
1. 기본 원리 이해: 어떻게 동작하는가?
구현에 앞서, 우리가 만들 기능의 핵심 원리를 이해하는 것이 중요합니다. 목표는 간단합니다. 사용자의 스크롤 방향을 감지하고, 그 방향에 따라 BottomNavigationBar의 위치를 화면 밖으로 밀어내거나 다시 안으로 가져오는 것입니다.
- 스크롤 방향 감지: 사용자가 손가락으로 화면을 위로 미는지(콘텐츠를 아래로 내리는 중), 아래로 당기는지(콘텐츠를 위로 올리는 중)를 알아내야 합니다.
- UI 위치 변경: 감지된 방향에 따라 BottomNavigationBar를 Y축으로 이동시킵니다. 아래로 스크롤할 때는 바의 높이만큼 아래로 내려 화면 밖으로 숨기고, 위로 스크롤할 때는 다시 원래 위치(Y=0)로 복귀시킵니다.
- 부드러운 전환 효과: 위치가 순간적으로 바뀌면 사용자는 부자연스러움을 느낍니다. 따라서 애니메이션을 적용하여 바가 부드럽게 슬라이드되도록 만들어야 합니다.
이 세 가지 원리를 구현하기 위해 Flutter는 다음과 같은 강력한 도구들을 제공합니다.
ScrollController
또는NotificationListener
: 스크롤 가능한 위젯(ListView
,GridView
,CustomScrollView
등)의 스크롤 이벤트를 감지하는 역할을 합니다. 특히ScrollController
는 스크롤 위치를 직접 제어할 수 있고,NotificationListener
는 위젯 트리 상위에서 자식 스크롤 위젯의 다양한 알림(Notification)을 받을 수 있습니다. 우리는 이 둘을 모두 살펴보고, 더 유연한 방식인NotificationListener
를 중심으로 구현할 것입니다.userScrollDirection
:ScrollPosition
객체에 포함된 속성으로, 사용자의 현재 스크롤 방향을ScrollDirection.forward
(위로 스크롤),ScrollDirection.reverse
(아래로 스croll),ScrollDirection.idle
(정지) 세 가지 상태로 알려줍니다.AnimationController
와Transform.translate
:AnimationController
는 애니메이션의 진행 상태(0.0 ~ 1.0)를 특정 시간 동안 관리해주는 컨트롤러입니다. 이 값을 이용하여Transform.translate
위젯의offset
을 조절하면, 어떤 위젯이든 원하는 축으로 부드럽게 이동시킬 수 있습니다.
이제 이 도구들을 사용하여 실제 코드를 작성해 보겠습니다.
2. 단계별 구현: 스크롤 감지부터 애니메이션까지
가장 기본적인 형태부터 시작하여 점차 기능을 고도화하는 방식으로 진행하겠습니다. 먼저, 스크롤 가능한 화면과 BottomNavigationBar를 갖춘 기본 앱 구조를 만듭니다.
2.1. 프로젝트 기본 구조 설정
먼저, 상태를 관리해야 하므로 StatefulWidget
으로 기본 페이지를 구성합니다. 이 페이지는 긴 목록을 가진 ListView
와 BottomNavigationBar
를 포함합니다.
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Scroll Aware Bottom Bar',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({Key? key}) : super(key: key);
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
int _selectedIndex = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Scroll Aware Bottom Bar'),
),
body: ListView.builder(
itemCount: 100, // 스크롤이 가능하도록 아이템 개수를 충분히 줍니다.
itemBuilder: (context, index) {
return ListTile(
title: Text('Item $index'),
);
},
),
bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.search),
label: 'Search',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: 'Profile',
),
],
currentIndex: _selectedIndex,
onTap: (index) {
setState(() {
_selectedIndex = index;
});
},
),
);
}
}
위 코드는 아직 아무런 기능이 없는, 평범한 Flutter 앱의 모습입니다. 이제 여기에 스크롤 감지 로직을 추가해 보겠습니다.
2.2. 스크롤 감지하기: NotificationListener
활용
ScrollController
를 ListView
에 직접 연결하고 리스너를 추가하는 방법도 있지만, NotificationListener
를 사용하면 위젯 트리를 더 깔끔하게 유지할 수 있습니다. ListView
를 NotificationListener<UserScrollNotification>
위젯으로 감싸주기만 하면 됩니다. UserScrollNotification
은 사용자의 직접적인 스크롤 액션에 의해서만 발생하는 알림이라, 코드에 의한 스크롤과 구분되어 더 정확한 제어가 가능합니다.
먼저, BottomNavigationBar의 가시성(visibility)을 제어할 상태 변수 _isVisible
을 추가합니다.
// _HomePageState 클래스 내부에 추가
bool _isVisible = true;
다음으로, ListView
를 NotificationListener
로 감싸고 onNotification
콜백을 구현합니다. 이 콜백 함수는 스크롤 이벤트가 발생할 때마다 호출됩니다.
// build 메소드 내부
// ...
body: NotificationListener<UserScrollNotification>(
onNotification: (notification) {
// 사용자가 아래로 스크롤 할 때 (리스트의 끝 방향)
if (notification.direction == ScrollDirection.reverse) {
if (_isVisible) {
setState(() {
_isVisible = false;
});
}
}
// 사용자가 위로 스크롤 할 때 (리스트의 시작 방향)
else if (notification.direction == ScrollDirection.forward) {
if (!_isVisible) {
setState(() {
_isVisible = true;
});
}
}
// true를 반환하면 상위로 알림이 전파되는 것을 막습니다.
return true;
},
child: ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
return ListTile(
title: Text('Item $index'),
);
},
),
),
// ...
이제 스크롤 방향에 따라 _isVisible
상태가 변경됩니다. 하지만 아직 UI에 아무런 변화가 없습니다. 이제 이 상태값을 이용하여 BottomNavigationBar를 실제로 움직여 보겠습니다.
2.3. 애니메이션으로 부드럽게 움직이기
_isVisible
상태가 바뀔 때마다 BottomNavigationBar가 부드럽게 나타나고 사라지게 하려면 애니메이션이 필요합니다. AnimationController
와 AnimatedContainer
또는 Transform.translate
를 사용할 수 있습니다. 여기서는 더 정교한 제어가 가능한 AnimationController
와 Transform.translate
를 AnimatedBuilder
와 함께 사용하는 방법을 소개합니다. 이 조합은 성능적으로도 매우 효율적입니다.
2.3.1. AnimationController
초기화
_HomePageState
에 AnimationController
를 추가하고 initState
에서 초기화합니다. vsync
를 사용해야 하므로 _HomePageState
에 TickerProviderStateMixin
을 추가해야 합니다.
// 클래스 선언부 수정
class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
// ... 기존 변수들
late AnimationController _animationController;
late Animation<Offset> _offsetAnimation;
final double _bottomNavBarHeight = 85.0; // BottomNavBar의 높이, kBottomNavigationBarHeight + 여유분
@override
void initState() {
super.initState();
// 애니메이션 컨트롤러 초기화
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300), // 애니메이션 속도
);
// 오프셋 애니메이션 초기화
// 시작(begin): Offset(0, 1) -> 화면 하단 밖
// 끝(end): Offset.zero -> 화면 안 원래 위치
_offsetAnimation = Tween<Offset>(
begin: Offset.zero,
end: Offset(0, 1), // Y축으로 자신의 높이만큼 아래로 이동
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeOut,
));
// 처음에는 바가 보이도록 애니메이션을 완료(보이는 상태)로 설정
_animationController.forward(); // 이 부분을 수정하여 시작 상태를 제어할 수 있습니다.
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
// ...
}
_animationController
는 애니메이션의 '엔진'과 같습니다. duration
을 설정하고 vsync
를 연결하여 화면 주사율에 맞춰 부드러운 애니메이션을 생성합니다. _offsetAnimation
은 컨트롤러의 값(0.0~1.0)을 실제 UI가 사용할 Offset
값으로 변환해주는 역할을 합니다. Offset(0, 1)
은 Y축으로 위젯 자신의 높이의 1배만큼 아래로 이동하라는 의미입니다. (SlideTransition
이 내부적으로 이렇게 계산합니다.)
2.3.2. 스크롤에 따른 애니메이션 트리거
이제 NotificationListener
에서 setState
를 호출하는 대신, _animationController
를 제어합니다.
// onNotification 콜백 수정
onNotification: (notification) {
if (notification.direction == ScrollDirection.reverse) {
// 아래로 스크롤 -> 숨기기
if (_animationController.isCompleted) { // 이미 보이는 상태일 때만 실행
_animationController.reverse(); // 0.0으로 (숨겨지는 방향으로)
}
} else if (notification.direction == ScrollDirection.forward) {
// 위로 스크롤 -> 보이기
if (_animationController.isDismissed) { // 이미 숨겨진 상태일 때만 실행
_animationController.forward(); // 1.0으로 (보이는 방향으로)
}
}
return true;
},
코드를 약간 수정했습니다. _animationController.forward()
는 애니메이션을 '끝' 상태(보이는 상태)로, reverse()
는 '시작' 상태(숨겨진 상태)로 만듭니다. isCompleted
와 isDismissed
로 현재 애니메이션 상태를 확인하여 불필요한 호출을 막습니다.
2.3.3. SlideTransition
으로 UI에 애니메이션 적용
마지막으로, BottomNavigationBar
를 SlideTransition
위젯으로 감싸서 애니메이션을 실제로 UI에 반영합니다.
// build 메소드의 bottomNavigationBar 부분 수정
// ...
bottomNavigationBar: SlideTransition(
position: _offsetAnimation,
child: BottomNavigationBar(
// ... 기존 BottomNavigationBar 코드
),
),
잠깐, 코드를 약간 수정해야겠습니다. Tween
의 begin/end를 Offset(0, 0)
(보임)과 Offset(0, 1)
(숨김)으로 설정하고, 컨트롤러의 forward/reverse를 그에 맞게 다시 조정하는 것이 더 직관적입니다. 수정된 전체 코드를 보겠습니다.
3. 완성된 전체 코드 및 상세 설명
지금까지의 개념들을 종합하여 완성된, 즉시 실행 가능한 코드는 다음과 같습니다. 직관성을 위해 애니메이션 방향을 다시 정리했습니다.
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Scroll Aware Bottom Bar',
theme: ThemeData(
primarySwatch: Colors.indigo,
scaffoldBackgroundColor: Colors.grey[200],
),
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({Key? key}) : super(key: key);
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
int _selectedIndex = 0;
// 애니메이션 관련
late final AnimationController _hideBottomBarAnimationController;
// 가시성 상태를 직접 관리하는 변수
bool _isBottomBarVisible = true;
@override
void initState() {
super.initState();
_hideBottomBarAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
// 초기값: 1.0 (완전히 보이는 상태)
value: 1.0,
);
}
@override
void dispose() {
_hideBottomBarAnimationController.dispose();
super.dispose();
}
// 스크롤 알림 처리 함수
bool _handleScrollNotification(ScrollNotification notification) {
// 사용자의 스크롤 액션일 때만 처리
if (notification is UserScrollNotification) {
final UserScrollNotification userScroll = notification;
switch (userScroll.direction) {
case ScrollDirection.forward:
// 위로 스크롤: 바를 보여줌
if (!_isBottomBarVisible) {
setState(() {
_isBottomBarVisible = true;
_hideBottomBarAnimationController.forward();
});
}
break;
case ScrollDirection.reverse:
// 아래로 스크롤: 바를 숨김
if (_isBottomBarVisible) {
setState(() {
_isBottomBarVisible = false;
_hideBottomBarAnimationController.reverse();
});
}
break;
case ScrollDirection.idle:
// 스크롤 멈춤: 아무것도 안 함
break;
}
}
return false; // 다른 리스너도 알림을 받을 수 있도록 false 반환
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Perfect Scroll-Aware Bar'),
),
body: NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: ListView.builder(
// 스크롤 위치를 추후에 참조하기 위해 컨트롤러를 연결해둘 수 있습니다. (예외처리용)
// controller: _scrollController,
itemCount: 100,
itemBuilder: (context, index) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
leading: CircleAvatar(child: Text('$index')),
title: Text('List Item $index'),
subtitle: const Text('Scroll up and down to see the magic!'),
),
);
},
),
),
// SizeTransition을 사용하여 높이를 조절하는 애니메이션을 구현
bottomNavigationBar: SizeTransition(
sizeFactor: _hideBottomBarAnimationController,
axisAlignment: -1.0, // 바가 사라질 때 아래로 정렬되도록 함
child: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.search),
label: 'Search',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: 'Profile',
),
],
currentIndex: _selectedIndex,
onTap: (index) {
setState(() {
_selectedIndex = index;
});
},
selectedItemColor: Colors.indigo,
unselectedItemColor: Colors.grey,
),
),
);
}
}
이전 예제에서 SlideTransition
을 사용했지만, SizeTransition
을 사용하는 것이 더 일반적이고 자연스러운 효과를 줍니다. SizeTransition
은 자식 위젯의 높이(또는 너비)를 sizeFactor
값(0.0 ~ 1.0)에 따라 조절합니다. 애니메이션 컨트롤러의 값을 직접 sizeFactor
에 연결하면, 컨트롤러 값이 1.0일 때 원래 높이를, 0.0일 때 높이 0을 갖게 되어 자연스럽게 사라지는 효과를 냅니다. axisAlignment: -1.0
은 높이가 줄어들 때 위젯이 아래쪽 경계를 기준으로 축소되도록 하여, 마치 아래로 사라지는 것처럼 보이게 하는 중요한 속성입니다.
4. 심화 과정: 예외 처리 및 고급 기법
기본적인 기능은 완성되었습니다. 하지만 실제 프로덕션 환경에서는 다양한 예외 상황이 발생할 수 있습니다. 완성도를 높이기 위한 몇 가지 고급 기법들을 알아봅시다.
4.1. 스크롤 끝(Edge)에 도달했을 때 처리
사용자가 스크롤을 아주 빠르게 '휙' 던졌다가(Fling) 리스트의 맨 위나 맨 아래에 도달했을 때, 마지막 스크롤 방향이 reverse
였다면 바가 숨겨진 채로 멈출 수 있습니다. 일반적으로 리스트의 끝에 도달했을 때는 탐색 바가 항상 보이는 것이 사용자 경험에 좋습니다.
이 문제를 해결하려면 ScrollController
를 함께 사용해야 합니다. 컨트롤러를 ListView
에 연결하고, 스크롤 알림 콜백에서 현재 스크롤 위치를 확인합니다.
// _HomePageState에 ScrollController 추가
final ScrollController _scrollController = ScrollController();
// initState에 리스너 추가 (또는 NotificationListener 내에서 확인)
@override
void initState() {
super.initState();
// ... 기존 코드
_scrollController.addListener(_scrollListener);
}
void _scrollListener() {
// 스크롤이 맨 위에 도달했을 때
if (_scrollController.position.atEdge && _scrollController.position.pixels == 0) {
if (!_isBottomBarVisible) {
setState(() {
_isBottomBarVisible = true;
_hideBottomBarAnimationController.forward();
});
}
}
}
// ListView에 controller 연결
// ...
child: ListView.builder(
controller: _scrollController,
// ...
위 코드는 ScrollController
의 리스너를 통해 스크롤 위치를 계속 감시합니다. position.atEdge
가 true이고 position.pixels
가 0이면 스크롤이 맨 위에 도달했음을 의미합니다. 이때 BottomNavigationBar를 강제로 보이게 합니다. NotificationListener
와 ScrollController.addListener
를 조합하여 더 정교한 제어가 가능해집니다.
4.2. 상태 관리 라이브러리(Provider)와 함께 사용하기
앱의 규모가 커지면 UI와 비즈니스 로직을 분리하는 것이 중요합니다. Provider나 Riverpod 같은 상태 관리 라이브러리를 사용하면 코드를 더 깔끔하게 구조화할 수 있습니다. BottomNavigationBar의 가시성 상태를 ChangeNotifier
로 분리해 보겠습니다.
4.2.1. BottomBarVisibilityNotifier
생성
import 'package:flutter/material.dart';
class BottomBarVisibilityNotifier with ChangeNotifier {
bool _isVisible = true;
bool get isVisible => _isVisible;
void show() {
if (!_isVisible) {
_isVisible = true;
notifyListeners();
}
}
void hide() {
if (_isVisible) {
_isVisible = false;
notifyListeners();
}
}
}
4.2.2. Provider 설정 및 UI 연동
main.dart
에서 ChangeNotifierProvider
를 설정하고, UI에서는 Consumer
또는 context.watch
를 사용하여 상태를 구독합니다.
// main.dart
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => BottomBarVisibilityNotifier(),
child: const MyApp(),
),
);
}
// HomePage.dart
// _handleScrollNotification 함수 내에서 setState 대신 Notifier 호출
// ...
if (userScroll.direction == ScrollDirection.forward) {
context.read<BottomBarVisibilityNotifier>().show();
} else if (userScroll.direction == ScrollDirection.reverse) {
context.read<BottomBarVisibilityNotifier>().hide();
}
// ...
// build 메소드 내에서
@override
Widget build(BuildContext context) {
final isVisible = context.watch<BottomBarVisibilityNotifier>().isVisible;
// isVisible 상태가 변경될 때마다 애니메이션 컨트롤러를 직접 제어
// 이 로직은 build 메소드 내에 위치하면 안되므로, Consumer나 다른 방식을 사용해야 합니다.
// 더 나은 방법은 Notifier 자체에 AnimationController를 포함시키는 것입니다.
// ... 올바른 구현을 위해 Notifier가 AnimationController를 갖도록 리팩토링 ...
}
더 발전된 구조는 BottomBarVisibilityNotifier
가 AnimationController
를 직접 소유하고 관리하는 것입니다. 이렇게 하면 UI는 단순히 Notifier의 상태만 구독하고, 애니메이션 로직은 Notifier 내부에 완전히 캡슐화되어 재사용성이 극대화됩니다.
4.3. CustomScrollView
와 Sliver
위젯과의 호환성
우리가 사용한 NotificationListener
방식의 가장 큰 장점은 특정 스크롤 위젯에 종속되지 않는다는 것입니다. ListView
대신 CustomScrollView
와 SliverAppBar
, SliverList
등을 사용하는 복잡한 화면에서도 동일한 코드가 문제없이 작동합니다.
// body 부분을 CustomScrollView로 교체해도 동일하게 작동
body: NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: CustomScrollView(
slivers: [
SliverAppBar(
title: Text('Complex Scroll'),
floating: true,
pinned: false,
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => Card(
// ...
),
childCount: 100,
),
),
],
),
),
CustomScrollView
가 발생시키는 스크롤 알림 또한 NotificationListener
가 감지할 수 있으므로, 어떤 종류의 스크롤 뷰를 사용하든 하단 바 숨김/표시 기능은 일관되게 동작합니다. 이것이 ScrollController
에만 의존하는 방식보다 NotificationListener
가 더 유연하고 강력한 이유입니다.
결론: 사용자 경험을 한 단계 끌어올리는 디테일
지금까지 Flutter에서 스크롤 방향에 따라 BottomNavigationBar를 동적으로 숨기고 표시하는 방법을 깊이 있게 탐구했습니다. 단순히 기능을 구현하는 것을 넘어, NotificationListener
를 활용한 유연한 구조, AnimationController
와 SizeTransition
을 이용한 부드러운 애니메이션, 그리고 스크롤 끝에 도달하는 예외 상황 처리까지 다루었습니다.
이러한 동적 UI는 단순히 '있으면 좋은' 기능이 아니라, 사용자가 앱의 콘텐츠에 더 깊이 몰입하게 하고, 제한된 모바일 화면을 최대한 효율적으로 사용할 수 있게 해주는 핵심적인 UX 요소입니다. 오늘 배운 기법들을 여러분의 프로젝트에 적용하여, 사용자에게 더욱 쾌적하고 전문적인 인상을 주는 앱을 만들어 보시길 바랍니다.
핵심을 요약하자면 다음과 같습니다.
- 스크롤 감지:
NotificationListener<UserScrollNotification>
을 사용하여 사용자의 명시적인 스크롤 의도를 파악합니다. - 상태 관리:
bool
변수나ChangeNotifier
를 통해 바의 가시성 상태를 관리합니다. - 애니메이션:
AnimationController
를 상태에 따라 제어하고,SizeTransition
또는SlideTransition
을 사용하여 UI를 부드럽게 변경합니다. - 예외 처리:
ScrollController
를 보조적으로 사용하여 스크롤의 끝(edge)에 도달하는 등의 특수 상황에 대응하여 완성도를 높입니다.
이제 여러분은 Flutter에서 어떤 스크롤 뷰와도 완벽하게 연동되는 동적 BottomNavigationBar를 자신 있게 구현할 수 있을 것입니다. 코드를 직접 실행해보고, 애니메이션 속도나 커브를 변경해보며 자신만의 스타일을 찾아보는 것을 추천합니다.
0 개의 댓글:
Post a Comment