2. 온보딩 (Onboarding) - 관심사 선택과 튜토리얼

Interests Screen - 관심사 선택 화면

🎯 이번 단계에서 배울 것

  • 온보딩 기능 모듈과 인증 모듈 연결하기
  • Wrap 위젯으로 동적 레이아웃 만들기
  • SingleChildScrollView로 스크롤 가능한 화면 구성
  • BottomAppBar 사용법
  • 모듈 간 의존성 설정 방법

📂 파일 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
features/
├── authentication/
│ ├── lib/screens/
│ │ ├── birthday_screen.dart (수정됨)
│ │ └── login_form_screen.dart (수정됨)
│ └── pubspec.yaml (수정됨)

└── onboarding/
└── lib/screens/
└── interests_screen.dart (수정됨)

lib/
└── main.dart (수정됨)

📝 1단계: 모듈 간 의존성 설정

authentication/pubspec.yaml:

1
2
3
4
5
6
7
8
9
10
11
dependencies:
flutter:
sdk: flutter

font_awesome_flutter: 10.9.1

design_system:
path: ../../common/design_system

feature_onboarding: # 새로 추가
path: ../onboarding

🔍 모듈 간 의존성이란?

의존성(Dependency) 은 한 모듈이 다른 모듈의 코드를 사용하기 위해 필요한 연결입니다.

왜 필요한가?

  • Authentication 모듈에서 Onboarding 화면으로 이동하려면
  • Onboarding 모듈의 InterestsScreen을 import 해야 합니다
  • 그러려면 pubspec.yaml에 의존성을 추가해야 합니다

작동 방식:

1
2
3
4
5
authentication 모듈
↓ (의존성 추가)
onboarding 모듈
↓ (사용 가능)
InterestsScreen import

📝 2단계: Interests Screen 구현

전체 코드 (features/onboarding/lib/screens/interests_screen.dart):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';

const interests = [
"Daily Life",
"Comedy",
"Entertainment",
"Animals",
"Food",
"Beauty & Style",
"Drama",
"Learning",
"Talent",
"Sports",
"Auto",
"Family",
"Fitness & Health",
"DIY & Life Hacks",
"Arts & Crafts",
"Dance",
"Outdoors",
"Oddly Satisfying",
"Home & Garden",
"Daily Life",
"Comedy",
"Entertainment",
"Animals",
"Food",
"Beauty & Style",
"Drama",
"Learning",
"Talent",
"Sports",
"Auto",
"Family",
"Fitness & Health",
"DIY & Life Hacks",
"Arts & Crafts",
"Dance",
"Outdoors",
"Oddly Satisfying",
"Home & Garden",
];

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

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Choose your interests"),
),
body: SingleChildScrollView(
child: Padding(
padding: EdgeInsets.only(
left: Sizes.size24,
right: Sizes.size24,
bottom: Sizes.size16,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gaps.v32,
Text(
'Choose your interests',
style: TextStyle(
fontSize: Sizes.size40,
fontWeight: FontWeight.bold,
),
),
Gaps.v24,
Text(
'Get better video recommendations',
style: TextStyle(
fontSize: Sizes.size20,
),
),
Gaps.v64,
Wrap(
spacing: Sizes.size16 - Sizes.size1,
runSpacing: Sizes.size16 - Sizes.size1,
children: [
for (var interest in interests)
_buildInterestCard(interest: interest)
],
)
],
),
),
),
bottomNavigationBar: BottomAppBar(
elevation: 2,
child: Padding(
padding: EdgeInsets.only(
top: Sizes.size16,
left: Sizes.size24,
right: Sizes.size24,
),
child: Container(
padding: EdgeInsets.symmetric(vertical: Sizes.size20),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
),
child: Text(
'Next',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: Sizes.size16
),
),
),
),
),
);
}

Widget _buildInterestCard({required String interest}) {
return Container(
padding: EdgeInsets.symmetric(
vertical: Sizes.size16,
horizontal: Sizes.size24,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(Sizes.size32),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
spreadRadius: 5,
)
],
border: BoxBorder.all(color: Colors.black.withOpacity(0.1)),
),
child: Text(
interest,
style: TextStyle(fontWeight: FontWeight.bold),
),
);
}
}

🔍 코드 상세 설명

1. const 리스트란?

1
2
3
4
5
const interests = [
"Daily Life",
"Comedy",
// ...
];
  • const: 컴파일 타임에 값이 결정되는 상수
  • 앱 실행 중 절대 변하지 않는 데이터
  • 메모리를 효율적으로 사용

왜 사용하는가?

  • 관심사 목록은 앱 실행 중 변하지 않음
  • 메모리에 한 번만 생성되어 재사용됨

2. SingleChildScrollView란?

1
2
3
4
5
SingleChildScrollView(
child: Padding(
// ...
),
)
  • 화면에 콘텐츠가 많을 때 스크롤 가능하게 만드는 위젯
  • 자식 위젯이 화면을 벗어나면 자동으로 스크롤 활성화

작동 방식:

1
2
3
4
화면 높이: 800px
콘텐츠 높이: 1200px

자동으로 스크롤 활성화

3. Wrap 위젯이란?

1
2
3
4
5
6
7
8
Wrap(
spacing: Sizes.size16 - Sizes.size1,
runSpacing: Sizes.size16 - Sizes.size1,
children: [
for (var interest in interests)
_buildInterestCard(interest: interest)
],
)
  • 자식 위젯들을 자동으로 줄바꿈하는 레이아웃 위젯
  • 화면 너비를 넘으면 다음 줄로 이동

시각화:

1
2
3
4
5
┌─────────────────────────────────┐
│ [Daily Life] [Comedy] [Animals] │ ← 한 줄
│ [Food] [Beauty] [Drama] │ ← 자동 줄바꿈
│ [Learning] [Talent] [Sports] │ ← 자동 줄바꿈
└─────────────────────────────────┘

spacing vs runSpacing:

1
2
spacing: Sizes.size16      // 가로 간격 (같은 줄 내)
runSpacing: Sizes.size16 // 세로 간격 (줄 사이)

비교 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Row를 사용하면?
Row(
children: [
// 화면 밖으로 넘어가면 에러 발생!
// overflow 발생
],
)

// Wrap을 사용하면?
Wrap(
children: [
// 자동으로 다음 줄로 이동
// 문제 없음
],
)

4. for-in 반복문 (Collection for)

1
2
3
4
children: [
for (var interest in interests)
_buildInterestCard(interest: interest)
],
  • 리스트 안에서 직접 반복문 사용
  • interests 리스트의 각 항목마다 카드 생성

기존 방식과 비교:

1
2
3
4
5
6
7
8
9
10
// Before (map 사용)
children: interests.map(
(interest) => _buildInterestCard(interest: interest)
).toList(),

// After (collection for 사용)
children: [
for (var interest in interests)
_buildInterestCard(interest: interest)
],

5. BottomAppBar란?

1
2
3
4
bottomNavigationBar: BottomAppBar(
elevation: 2,
child: // ...
),
  • 화면 하단에 고정되는 바
  • 스크롤해도 항상 하단에 표시됨
  • elevation으로 그림자 효과 추가

6. BoxDecoration 상세

1
2
3
4
5
6
7
8
9
10
11
12
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(Sizes.size32),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
spreadRadius: 5,
)
],
border: BoxBorder.all(color: Colors.black.withOpacity(0.1)),
),

각 속성 설명:

  • color: 배경색
  • borderRadius: 모서리 둥글게
  • boxShadow: 그림자 효과
    • color: 그림자 색상 (투명도 5%)
    • blurRadius: 흐림 정도
    • spreadRadius: 퍼지는 정도
  • border: 테두리

📝 3단계: 네비게이션 연결

birthday_screen.dart:

1
2
3
4
5
6
7
8
9
10
import 'package:feature_onboarding/screens/interests_screen.dart';

void _onNextTap() {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const InterestsScreen()
),
);
}

login_form_screen.dart:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import 'package:feature_onboarding/screens/interests_screen.dart';

void _onSubmitTap() {
if (_formKey.currentState != null &&
!_formKey.currentState!.validate()) {
return;
}

if (isValid) {
_formKey.currentState?.save();

Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const InterestsScreen()
),
);
}
}

🔍 코드 상세 설명

1. import 경로

1
import 'package:feature_onboarding/screens/interests_screen.dart';
  • package:feature_onboarding: pubspec.yaml에 추가한 의존성 이름
  • /screens/interests_screen.dart: 파일 경로

작동 방식:

1
2
3
4
5
6
7
pubspec.yaml에 정의된 이름

feature_onboarding

features/onboarding/lib 폴더

screens/interests_screen.dart

📝 4단계: 개발용 임시 홈 화면 설정

lib/main.dart:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import 'package:feature_onboarding/screens/interests_screen.dart';

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

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'TikTok Clone',
theme: ThemeData(
scaffoldBackgroundColor: Colors.white,
primaryColor: const Color(0xFFE9435A),
appBarTheme: AppBarTheme(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0,
titleTextStyle: TextStyle(
color: Colors.black,
fontSize: Sizes.size16 + Sizes.size2,
fontWeight: FontWeight.w600,
),
),
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFFE9435A)
),
useMaterial3: false,
),
// home: const SplashScreen(),
home: const InterestsScreen(), // 개발용 임시 홈 화면
);
}
}

🔍 왜 임시 홈 화면을 설정하나?

개발 효율성:

  • 온보딩 화면을 개발할 때
  • 매번 로그인/회원가입 과정을 거치지 않기 위해
  • 바로 작업 중인 화면으로 이동

주의사항:

  • 개발 완료 후 다시 원래 홈 화면으로 변경해야 함
  • 주석으로 원래 코드를 남겨두면 좋음

🎨 화면 미리보기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌─────────────────────────────────┐
│ ← Choose your interests │ AppBar
├─────────────────────────────────┤
│ │
│ Choose your interests │ 제목 (큰 글씨)
│ │
│ Get better video │ 설명
│ recommendations │
│ │
│ [Daily Life] [Comedy] [Animals]│
│ [Food] [Beauty] [Drama] │ Wrap으로
│ [Learning] [Talent] [Sports] │ 자동 정렬
│ [Auto] [Family] [Fitness] │
│ ... │
│ │
├─────────────────────────────────┤
│ [ Next ] │ BottomAppBar
└─────────────────────────────────┘

📊 동작 흐름

1
2
3
4
5
6
7
8
9
10
11
1. 사용자가 생일 입력 완료 (BirthdayScreen)

2. Next 버튼 탭

3. Navigator.push()

4. InterestsScreen으로 이동

5. 관심사 목록 표시 (Wrap 레이아웃)

6. 스크롤 가능 (SingleChildScrollView)

✅ 체크리스트

  • authentication 모듈의 pubspec.yaml에 feature_onboarding 의존성 추가
  • interests_screen.dart에서 관심사 리스트 정의
  • Wrap 위젯으로 관심사 카드 레이아웃 구성
  • SingleChildScrollView로 스크롤 가능하게 만들기
  • BottomAppBar로 하단 Next 버튼 구현
  • birthday_screen.dart에서 InterestsScreen으로 네비게이션 추가
  • login_form_screen.dart에서 InterestsScreen으로 네비게이션 추가
  • 앱 실행 시 InterestsScreen이 표시되는지 확인

💡 연습 과제

  1. 기본: interests 리스트에 새로운 관심사 3개 추가하기
  2. 기본: _buildInterestCard의 borderRadius 값을 변경해보기
  3. 중급: Wrap의 spacing과 runSpacing 값을 조정해서 간격 변경하기
  4. 중급: 관심사 카드의 색상을 다른 색으로 변경하기
  5. 고급: Column을 사용해서 Wrap과 같은 레이아웃 만들어보기 (힌트: 줄바꿈이 어려움)

Scroll Animations - 스크롤 애니메이션

🎯 이번 단계에서 배울 것

  • ScrollController로 스크롤 위치 감지하기
  • StatefulWidget으로 상태 관리하기
  • AnimatedOpacity로 부드러운 애니메이션 구현
  • 스크롤 이벤트 리스너 사용법
  • 위젯 분리와 재사용 패턴

📂 파일 구조

1
2
3
4
5
6
features/onboarding/
├── lib/
│ ├── screens/
│ │ └── interests_screen.dart (수정됨)
│ └── widget/
│ └── interest_button.dart (새로 생성)

📝 1단계: StatelessWidget → StatefulWidget 변환

전체 코드 (interests_screen.dart 일부):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class InterestsScreen extends StatefulWidget {
const InterestsScreen({super.key});

@override
State<InterestsScreen> createState() => _InterestsScreenState();
}

class _InterestsScreenState extends State<InterestsScreen> {
final _scrollController = ScrollController();
bool _showTitle = false;

@override
void initState() {
super.initState();
_scrollController.addListener(() {
if (_scrollController.offset > 200) {
if (_showTitle) return;
setState(() {
_showTitle = true;
});
} else {
if (!_showTitle) return;
setState(() {
_showTitle = false;
});
}
});
}

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

// build 메서드는 다음 단계에서...
}

🔍 코드 상세 설명

1. StatefulWidget vs StatelessWidget

1
2
3
4
5
6
7
8
9
// Before
class InterestsScreen extends StatelessWidget {
// 상태가 없음, 변하지 않음
}

// After
class InterestsScreen extends StatefulWidget {
// 상태를 가질 수 있음, 변할 수 있음
}

왜 변경하는가?

  • 스크롤 위치에 따라 AppBar 제목을 보이거나 숨기려면
  • 변화하는 상태(_showTitle)가 필요
  • StatefulWidget만 setState()로 상태 변경 가능

2. ScrollController란?

1
final _scrollController = ScrollController();
  • 스크롤 위치를 감지하고 제어하는 컨트롤러
  • 현재 스크롤 위치(offset)를 알 수 있음
  • 프로그램으로 스크롤 위치를 변경할 수도 있음

작동 방식:

1
2
3
4
5
6
7
사용자가 스크롤

ScrollController가 감지

offset 값 업데이트

리스너 함수 실행

3. initState()란?

1
2
3
4
5
@override
void initState() {
super.initState();
// 초기화 코드
}
  • 위젯이 처음 생성될 때 한 번만 실행되는 메서드
  • 리스너 등록, 컨트롤러 설정 등 초기화 작업 수행

생명주기:

1
2
3
4
5
6
7
위젯 생성

initState() 실행 (한 번만)

build() 실행

setState() 호출 시 build() 재실행

4. addListener()란?

1
2
3
_scrollController.addListener(() {
// 스크롤할 때마다 실행되는 코드
});
  • 스크롤이 발생할 때마다 실행될 함수 등록
  • 스크롤 위치 변화를 감지하는 핵심 메커니즘

5. offset 값 활용

1
2
3
if (_scrollController.offset > 200) {
// 200px 이상 스크롤했을 때
}
  • offset: 현재 스크롤 위치 (픽셀 단위)
  • 값이 클수록 더 아래로 스크롤한 것

시각화:

1
2
3
4
5
6
7
화면 최상단 (offset: 0)
↓ 스크롤
offset: 100
↓ 스크롤
offset: 200 ← 제목 표시 시작
↓ 스크롤
offset: 300

6. 중복 setState() 방지

1
2
3
4
5
6
if (_scrollController.offset > 200) {
if (_showTitle) return; // 이미 true면 종료
setState(() {
_showTitle = true;
});
}

왜 필요한가?

  • 스크롤할 때마다 리스너가 계속 호출됨
  • 같은 상태로 setState()를 반복 호출하면 비효율적
  • 이미 같은 상태면 조기에 return으로 종료

비교:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Bad (비효율적)
_scrollController.addListener(() {
setState(() {
_showTitle = _scrollController.offset > 200;
});
// 스크롤할 때마다 계속 setState() 호출
});

// Good (효율적)
if (_showTitle) return; // 상태가 같으면 종료
setState(() {
_showTitle = true;
});
// 상태가 변할 때만 setState() 호출

7. dispose()란?

1
2
3
4
5
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
  • 위젯이 제거될 때 실행되는 메서드
  • 메모리 누수 방지를 위해 리소스 정리

왜 필요한가?

  • Controller는 메모리를 사용하는 객체
  • 위젯이 사라져도 Controller가 남아있으면 메모리 낭비
  • dispose()로 명시적으로 해제해야 함

메모리 관리:

1
2
3
4
5
위젯 생성 → Controller 생성 (메모리 사용)

위젯 사용 중

위젯 제거 → dispose() 호출 → Controller 해제 (메모리 반환)

📝 2단계: AnimatedOpacity 적용

전체 코드 (build 메서드 일부):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: AnimatedOpacity(
opacity: _showTitle ? 1 : 0,
duration: const Duration(milliseconds: 300),
child: const Text("Choose your interests"),
),
),
body: Scrollbar(
controller: _scrollController,
child: SingleChildScrollView(
controller: _scrollController,
child: Padding(
// ... (이전과 동일)
),
),
),
// ... (bottomNavigationBar)
);
}

🔍 코드 상세 설명

1. AnimatedOpacity란?

1
2
3
4
5
AnimatedOpacity(
opacity: _showTitle ? 1 : 0,
duration: const Duration(milliseconds: 300),
child: const Text("Choose your interests"),
)
  • 투명도를 부드럽게 변화시키는 애니메이션 위젯
  • opacity: 투명도 (0 = 완전 투명, 1 = 완전 불투명)
  • duration: 애니메이션 지속 시간

작동 방식:

1
2
3
4
5
6
7
8
9
10
11
_showTitle = false

opacity: 0 (안 보임)

사용자 스크롤 (offset > 200)

_showTitle = true

300ms 동안 0 → 1로 변화

opacity: 1 (보임)

비교 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Without Animation (갑작스러움)
Opacity(
opacity: _showTitle ? 1 : 0,
child: Text("Title"),
)
// 0 → 1로 즉시 변경

// With Animation (부드러움)
AnimatedOpacity(
opacity: _showTitle ? 1 : 0,
duration: Duration(milliseconds: 300),
child: Text("Title"),
)
// 0 → 1로 300ms 동안 부드럽게 변경

2. Scrollbar 위젯

1
2
3
4
5
6
7
Scrollbar(
controller: _scrollController,
child: SingleChildScrollView(
controller: _scrollController,
// ...
),
)
  • 스크롤바를 표시하는 위젯
  • 같은 controller를 공유해야 함

왜 필요한가?

  • 사용자에게 스크롤 가능함을 시각적으로 알림
  • 현재 스크롤 위치를 알 수 있음

3. controller 연결

1
2
3
4
5
6
7
final _scrollController = ScrollController();

// Scrollbar에 연결
Scrollbar(controller: _scrollController, ...)

// SingleChildScrollView에 연결
SingleChildScrollView(controller: _scrollController, ...)
  • 같은 ScrollController를 여러 위젯이 공유
  • Scrollbar와 ScrollView가 동기화됨

📝 3단계: InterestButton 위젯 분리

전체 코드 (interest_button.dart):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';

class InterestButton extends StatefulWidget {
const InterestButton({
super.key,
required this.interest,
});

final String interest;

@override
State<InterestButton> createState() => _InterestButtonState();
}

class _InterestButtonState extends State<InterestButton> {
bool _isSelected = false;

void _onTap() {
setState(() {
_isSelected = !_isSelected;
});
}

@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: EdgeInsets.symmetric(
vertical: Sizes.size16,
horizontal: Sizes.size24,
),
decoration: BoxDecoration(
color: _isSelected
? Theme.of(context).primaryColor
: Colors.white,
borderRadius: BorderRadius.circular(Sizes.size32),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
spreadRadius: 5,
)
],
border: BoxBorder.all(
color: Colors.black.withOpacity(0.1)
),
),
child: Text(
widget.interest,
style: TextStyle(
fontWeight: FontWeight.bold,
color: _isSelected ? Colors.white : Colors.black87,
),
),
),
);
}
}

🔍 코드 상세 설명

1. 위젯 분리 이유

1
2
3
4
5
6
7
8
9
// Before (interests_screen.dart)
Widget _buildInterestCard({required String interest}) {
return Container(...);
}

// After (interest_button.dart)
class InterestButton extends StatefulWidget {
// ...
}

왜 분리하는가?

  • 각 버튼이 독립적인 선택 상태를 가져야 함
  • 한 버튼만 다시 그리기 위해 (성능 향상)
  • 코드 재사용성과 가독성 향상

2. widget.interest 접근

1
2
3
4
5
6
7
8
9
10
11
12
class InterestButton extends StatefulWidget {
final String interest; // StatefulWidget 속성

// ...
}

class _InterestButtonState extends State<InterestButton> {
@override
Widget build(BuildContext context) {
return Text(widget.interest); // widget.으로 접근
}
}
  • StatefulWidget의 속성은 widget.으로 접근
  • State 클래스에서 부모 위젯의 속성에 접근하는 방법

3. GestureDetector

1
2
3
4
GestureDetector(
onTap: _onTap,
child: // ...
)
  • 제스처(탭, 드래그 등)를 감지하는 위젯
  • onTap: 탭했을 때 실행할 함수

4. AnimatedContainer

1
2
3
4
5
AnimatedContainer(
duration: const Duration(milliseconds: 300),
color: _isSelected ? primaryColor : Colors.white,
// ...
)
  • 속성 변화를 자동으로 애니메이션하는 Container
  • color, padding, decoration 등의 변화가 부드럽게 전환됨

작동 방식:

1
2
3
4
5
6
7
_isSelected = false (흰색)

사용자 탭

_isSelected = true

300ms 동안 흰색 → 핑크색으로 변화

비교 예시:

1
2
3
4
5
6
7
8
9
10
11
12
// Container (애니메이션 없음)
Container(
color: _isSelected ? Colors.pink : Colors.white,
)
// 색상이 즉시 변경

// AnimatedContainer (애니메이션 있음)
AnimatedContainer(
duration: Duration(milliseconds: 300),
color: _isSelected ? Colors.pink : Colors.white,
)
// 색상이 부드럽게 변경

5. 토글 로직

1
2
3
4
5
void _onTap() {
setState(() {
_isSelected = !_isSelected; // true ↔ false 전환
});
}
  • ! 연산자: 논리 부정 (NOT)
  • !true = false
  • !false = true

📝 4단계: interests_screen.dart 업데이트

변경된 부분:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import '../widget/interest_button.dart';

class _InterestsScreenState extends State<InterestsScreen> {
// ...

@override
Widget build(BuildContext context) {
return Scaffold(
// ...
body: Scrollbar(
controller: _scrollController,
child: SingleChildScrollView(
controller: _scrollController,
child: Padding(
// ...
child: Column(
// ...
children: [
// ...
Wrap(
spacing: Sizes.size16 - Sizes.size1,
runSpacing: Sizes.size16 - Sizes.size1,
children: [
for (var interest in interests)
InterestButton(interest: interest)
],
)
],
),
),
),
),
// ...
);
}
}

변경 사항:

  • _buildInterestCard() 제거
  • InterestButton 위젯으로 교체
  • import 추가

🎨 화면 미리보기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌─────────────────────────────────┐
│ ← Choose your interests │ AppBar (애니메이션)
├─────────────────────────────────┤ opacity: 0 → 1
│ │
│ Choose your interests │ ← 스크롤 전
│ │
│ Get better video │
│ recommendations │
│ │
│ [Daily Life] [Comedy] [Animals]│ ← 탭하면
│ [Food] [Beauty] [Drama] │ 배경색 변경
│ [Learning] [Talent] [Sports] │ (애니메이션)
│ ... │
│ │ ← 스크롤 후
└─────────────────────────────────┘ AppBar 제목 표시

📊 동작 흐름

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1. 화면 로드

2. ScrollController 생성 및 리스너 등록

3. 사용자 스크롤

4. offset > 200?
├─ Yes → _showTitle = true → AppBar 제목 표시
└─ No → _showTitle = false → AppBar 제목 숨김

5. 사용자가 관심사 버튼 탭

6. 해당 버튼만 _isSelected 토글

7. AnimatedContainer로 색상 변경 (300ms)

✅ 체크리스트

  • InterestsScreen을 StatefulWidget으로 변경
  • ScrollController 생성 및 리스너 등록
  • initState()와 dispose() 구현
  • AnimatedOpacity로 AppBar 제목 애니메이션 추가
  • Scrollbar 위젯 추가
  • InterestButton 위젯 파일 생성
  • GestureDetector와 AnimatedContainer 구현
  • 버튼 탭 시 선택 상태 토글 기능 구현
  • interests_screen.dart에서 InterestButton import 및 사용

💡 연습 과제

  1. 기본: 스크롤 임계값을 200에서 100으로 변경하기
  2. 기본: AnimatedOpacity의 duration을 500ms로 변경해보기
  3. 중급: 선택된 관심사가 3개 이상이면 다른 버튼을 선택 못하게 만들기
  4. 중급: 선택된 관심사 개수를 화면에 표시하기
  5. 고급: 스크롤 방향(위/아래)을 감지해서 다른 애니메이션 적용하기

Tutorial Screen - 튜토리얼 화면

🎯 이번 단계에서 배울 것

  • DefaultTabController로 페이지 전환 구현
  • TabBarView로 여러 페이지 관리
  • TabPageSelector로 페이지 인디케이터 추가
  • SafeArea로 안전 영역 설정
  • 튜토리얼 UI 패턴 구현

📂 파일 구조

1
2
3
4
5
features/onboarding/
└── lib/
└── screens/
├── interests_screen.dart (수정됨)
└── tutorial_screen.dart (새로 생성)

📝 1단계: Interests Screen에 네비게이션 추가

interests_screen.dart 변경 부분:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import 'tutorial_screen.dart';

class _InterestsScreenState extends State<InterestsScreen> {
// ...

void _onNextTap() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const TutorialScreen()
)
);
}

@override
Widget build(BuildContext context) {
return Scaffold(
// ...
bottomNavigationBar: BottomAppBar(
elevation: 2,
child: Padding(
padding: EdgeInsets.only(
top: Sizes.size16,
left: Sizes.size24,
right: Sizes.size24,
),
child: GestureDetector( // Container를 GestureDetector로 감싸기
onTap: _onNextTap,
child: Container(
padding: EdgeInsets.symmetric(vertical: Sizes.size20),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(Sizes.size8),
),
child: Text(
'Next',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: Sizes.size16
),
),
),
),
),
),
);
}
}

🔍 코드 상세 설명

1. Navigator.of(context).push()

1
2
3
4
5
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const TutorialScreen()
)
);

Navigator.push() vs Navigator.of(context).push():

  • Navigator.push(context, route): 축약 형태
  • Navigator.of(context).push(route): 명시적 형태
  • 기능은 동일하지만, of(context)가 더 명확함

2. GestureDetector 적용

1
2
3
4
GestureDetector(
onTap: _onNextTap,
child: Container(...),
)
  • Container는 기본적으로 탭 이벤트를 받지 못함
  • GestureDetector로 감싸서 탭 가능하게 만듦

📝 2단계: Tutorial Screen 생성

전체 코드 (tutorial_screen.dart):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';

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

@override
State<TutorialScreen> createState() => _TutorialScreenState();
}

class _TutorialScreenState extends State<TutorialScreen> {
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
body: SafeArea(
child: TabBarView(
children: [
_buildFirstPage(),
_buildSecondPage(),
_buildThirdPage(),
],
),
),
bottomNavigationBar: BottomAppBar(
child: Container(
padding: EdgeInsets.symmetric(vertical: Sizes.size24),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TabPageSelector(
color: Colors.white,
selectedColor: Colors.black38,
)
],
),
),
),
),
);
}

Widget _buildFirstPage() {
return Padding(
padding: EdgeInsets.symmetric(horizontal: Sizes.size24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gaps.v52,
Text(
'Watch cool videos!',
style: TextStyle(
fontSize: Sizes.size40,
fontWeight: FontWeight.bold,
),
),
Gaps.v16,
Text(
'Videos are personalized for you based on what you watch, like, and share.',
style: TextStyle(
fontSize: Sizes.size20,
),
),
],
),
);
}

Widget _buildSecondPage() {
return Padding(
padding: EdgeInsets.symmetric(horizontal: Sizes.size24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gaps.v52,
Text(
'Follow the rules!',
style: TextStyle(
fontSize: Sizes.size40,
fontWeight: FontWeight.bold,
),
),
Gaps.v16,
Text(
'Videos are personalized for you based on what you watch, like, and share.',
style: TextStyle(
fontSize: Sizes.size20,
),
),
],
),
);
}

Widget _buildThirdPage() {
return Padding(
padding: EdgeInsets.symmetric(horizontal: Sizes.size24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gaps.v52,
Text(
'Enjoy the ride',
style: TextStyle(
fontSize: Sizes.size40,
fontWeight: FontWeight.bold,
),
),
Gaps.v16,
Text(
'Videos are personalized for you based on what you watch, like, and share.',
style: TextStyle(
fontSize: Sizes.size20,
),
),
],
),
);
}
}

🔍 코드 상세 설명

1. DefaultTabController란?

1
2
3
4
5
6
DefaultTabController(
length: 3, // 탭(페이지) 개수
child: Scaffold(
// ...
),
)
  • 탭 기반 네비게이션을 제공하는 컨트롤러
  • TabBar, TabBarView, TabPageSelector를 자동으로 연결
  • 별도의 TabController 생성 없이 사용 가능

작동 방식:

1
2
3
4
5
6
7
DefaultTabController

TabBarView (페이지 내용)

TabPageSelector (인디케이터)

자동으로 동기화됨

2. length 속성

1
length: 3  // 총 페이지 수
  • TabBarView의 children 개수와 일치해야 함
  • 인디케이터 점의 개수 결정

3. SafeArea란?

1
2
3
SafeArea(
child: TabBarView(...),
)
  • 기기의 노치, 상태바 등을 피해서 콘텐츠 배치
  • 안전한 화면 영역에만 콘텐츠 표시

시각화:

1
2
3
4
5
6
7
8
9
10
┌─────────────────────────────────┐
│ 📱 노치/카메라 영역 │ ← SafeArea가 피하는 영역
├─────────────────────────────────┤
│ │
│ 여기부터 콘텐츠 표시 │ ← SafeArea 영역
│ │
│ │
├─────────────────────────────────┤
│ 🏠 홈 인디케이터 영역 │ ← SafeArea가 피하는 영역
└─────────────────────────────────┘

비교 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Without SafeArea
Column(
children: [
Text('Title'), // 노치에 가려질 수 있음
// ...
],
)

// With SafeArea
SafeArea(
child: Column(
children: [
Text('Title'), // 노치를 피해서 표시됨
// ...
],
),
)

4. TabBarView란?

1
2
3
4
5
6
7
TabBarView(
children: [
_buildFirstPage(),
_buildSecondPage(),
_buildThirdPage(),
],
)
  • 스와이프로 전환 가능한 페이지 뷰
  • DefaultTabController와 자동으로 연결됨
  • 좌우 스와이프로 페이지 이동

작동 방식:

1
2
3
4
5
사용자 스와이프 →

TabBarView가 다음 페이지로 이동

TabPageSelector의 인디케이터 자동 업데이트

5. TabPageSelector란?

1
2
3
4
TabPageSelector(
color: Colors.white, // 비선택 점 색상
selectedColor: Colors.black38, // 선택된 점 색상
)
  • 현재 페이지를 나타내는 점(dot) 인디케이터
  • DefaultTabController와 자동으로 동기화
  • PageView의 현재 위치를 시각적으로 표시

시각화:

1
2
3
페이지 1: ● ○ ○
페이지 2: ○ ● ○
페이지 3: ○ ○ ●

6. 페이지 빌더 메서드

1
2
3
4
5
6
7
8
9
10
11
Widget _buildFirstPage() {
return Padding(
padding: EdgeInsets.symmetric(horizontal: Sizes.size24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ...
],
),
);
}
  • 각 페이지의 UI를 별도 메서드로 분리
  • 코드 가독성 향상
  • 재사용 가능한 구조

7. crossAxisAlignment.start

1
2
3
4
5
6
7
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Title'),
Text('Description'),
],
)
  • Column의 자식들을 왼쪽 정렬
  • start: 왼쪽 (LTR 언어) / 오른쪽 (RTL 언어)

비교:

1
2
3
4
5
6
7
8
9
10
11
// crossAxisAlignment.center (기본값)
┌─────────────────────────────────┐
│ Watch cool videos! │ ← 가운데 정렬
│ Videos are personalized... │
└─────────────────────────────────┘

// crossAxisAlignment.start
┌─────────────────────────────────┐
│ Watch cool videos! │ ← 왼쪽 정렬
│ Videos are personalized... │
└─────────────────────────────────┘

🎨 화면 미리보기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
페이지 1:
┌─────────────────────────────────┐
│ │
│ Watch cool videos! │ ← 큰 제목
│ │
│ Videos are personalized for │ ← 설명
│ you based on what you watch, │
│ like, and share. │
│ │
│ │
│ │
├─────────────────────────────────┤
│ ● ○ ○ │ ← 페이지 인디케이터
└─────────────────────────────────┘
스와이프 →

페이지 2:
┌─────────────────────────────────┐
│ │
│ Follow the rules! │
│ │
│ Videos are personalized for │
│ you based on what you watch, │
│ like, and share. │
│ │
│ │
│ │
├─────────────────────────────────┤
│ ○ ● ○ │
└─────────────────────────────────┘
스와이프 →

페이지 3:
┌─────────────────────────────────┐
│ │
│ Enjoy the ride │
│ │
│ Videos are personalized for │
│ you based on what you watch, │
│ like, and share. │
│ │
│ │
│ │
├─────────────────────────────────┤
│ ○ ○ ● │
└─────────────────────────────────┘

📊 동작 흐름

1
2
3
4
5
6
7
8
9
10
11
12
13
1. Interests Screen에서 Next 버튼 탭

2. Navigator.push() → TutorialScreen으로 이동

3. DefaultTabController 초기화 (length: 3)

4. TabBarView로 첫 번째 페이지 표시

5. 사용자가 좌우 스와이프

6. TabBarView가 페이지 전환

7. TabPageSelector 자동 업데이트 (● 위치 변경)

✅ 체크리스트

  • tutorial_screen.dart 파일 생성
  • DefaultTabController 설정 (length: 3)
  • SafeArea로 안전 영역 설정
  • TabBarView로 3개 페이지 구현
  • TabPageSelector로 인디케이터 추가
  • 각 페이지 빌더 메서드 구현
  • interests_screen.dart에 네비게이션 추가
  • Next 버튼에 GestureDetector 적용

💡 연습 과제

  1. 기본: 페이지를 4개로 늘려보기 (length 및 children 수정)
  2. 기본: TabPageSelector의 색상을 다른 색으로 변경하기
  3. 중급: 각 페이지의 제목과 설명 텍스트를 다르게 만들기
  4. 중급: 페이지마다 배경색을 다르게 설정하기
  5. 고급: TabController를 직접 생성해서 프로그램으로 페이지 이동하기

AnimatedCrossFade - 제스처 기반 페이지 전환

🎯 이번 단계에서 배울 것

  • TabBarView를 AnimatedCrossFade로 대체하기
  • GestureDetector로 드래그 제스처 감지
  • Enum으로 상태 관리하기
  • CupertinoButton 사용법
  • AnimatedOpacity로 조건부 위젯 표시

📂 파일 구조

1
2
3
4
features/onboarding/
└── lib/
└── screens/
└── tutorial_screen.dart (수정됨)

📝 1단계: Enum 정의

tutorial_screen.dart 상단에 추가:

1
2
3
4
5
6
7
8
9
10
11
import 'package:design_system/design_system.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

enum Direction { left, right }

enum Page { first, second }

class TutorialScreen extends StatefulWidget {
// ...
}

🔍 코드 상세 설명

1. Enum이란?

1
2
enum Direction { left, right }
enum Page { first, second }
  • 열거형(Enumeration): 미리 정의된 값들의 집합
  • 제한된 선택지를 명확하게 표현
  • 오타나 잘못된 값 사용 방지

왜 사용하는가?

1
2
3
4
5
6
7
8
9
10
11
// Without Enum (문자열 사용)
String direction = "left"; // 오타 가능: "leeft", "Left"
if (direction == "left") { // 매번 문자열 비교
// ...
}

// With Enum (타입 안정성)
Direction direction = Direction.left; // 오타 불가능
if (direction == Direction.left) { // 명확한 비교
// ...
}

2. Direction Enum

1
enum Direction { left, right }
  • 스와이프 방향을 나타냄
  • left: 왼쪽으로 스와이프
  • right: 오른쪽으로 스와이프

3. Page Enum

1
enum Page { first, second }
  • 현재 표시 중인 페이지
  • first: 첫 번째 페이지
  • second: 두 번째 페이지

📝 2단계: State 클래스 수정

_TutorialScreenState 변경:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class _TutorialScreenState extends State<TutorialScreen> {
Direction _direction = Direction.right;
Page _showingPage = Page.first;

void _onPanUpdate(DragUpdateDetails details) {
if (details.delta.dx > 0) {
// to the right
setState(() {
_direction = Direction.right;
});
} else {
// to the left
setState(() {
_direction = Direction.left;
});
}
}

void _onPanEnd(DragEndDetails details) {
if (_direction == Direction.left) {
setState(() {
_showingPage = Page.second;
});
} else {
setState(() {
_showingPage = Page.first;
});
}
}

// build 메서드는 다음 단계에서...
}

🔍 코드 상세 설명

1. 상태 변수

1
2
Direction _direction = Direction.right;
Page _showingPage = Page.first;
  • _direction: 현재 드래그 방향
  • _showingPage: 현재 표시 중인 페이지

2. onPanUpdate란?

1
2
3
void _onPanUpdate(DragUpdateDetails details) {
// 드래그 중일 때 계속 호출됨
}
  • 사용자가 화면을 드래그하는 동안 계속 호출되는 콜백
  • details: 드래그에 대한 상세 정보

작동 방식:

1
2
3
4
5
6
7
사용자가 화면 터치

손가락을 움직임 (드래그)

onPanUpdate 계속 호출 (프레임마다)

details.delta.dx로 방향 파악

3. DragUpdateDetails란?

1
DragUpdateDetails details
  • 드래그 이벤트의 상세 정보를 담은 객체
  • 주요 속성:
    • delta: 이전 프레임에서 이동한 거리
    • globalPosition: 화면에서의 절대 위치
    • localPosition: 위젯 내에서의 상대 위치

4. details.delta.dx

1
2
3
4
5
if (details.delta.dx > 0) {
// 오른쪽으로 드래그
} else {
// 왼쪽으로 드래그
}
  • delta: 이전 위치에서 변화량
  • dx: x축 변화량 (가로 방향)
  • dx > 0: 오른쪽으로 이동
  • dx < 0: 왼쪽으로 이동

시각화:

1
2
3
◀──────────────────────▶
dx < 0 dx > 0
(왼쪽) (오른쪽)

5. onPanEnd란?

1
2
3
void _onPanEnd(DragEndDetails details) {
// 드래그가 끝났을 때 한 번 호출됨
}
  • 사용자가 손가락을 떼었을 때 호출되는 콜백
  • 드래그 완료 후 최종 처리

작동 방식:

1
2
3
4
5
6
7
onPanUpdate (계속 호출)

사용자가 손가락을 뗌

onPanEnd (한 번만 호출)

최종 방향에 따라 페이지 전환

6. 페이지 전환 로직

1
2
3
4
5
6
7
8
9
if (_direction == Direction.left) {
setState(() {
_showingPage = Page.second; // 두 번째 페이지로
});
} else {
setState(() {
_showingPage = Page.first; // 첫 번째 페이지로
});
}

흐름:

1
2
3
4
5
6
7
8
드래그 방향 확인

왼쪽으로 드래그? → second 페이지
오른쪽으로 드래그? → first 페이지

setState()로 상태 변경

UI 업데이트

📝 3단계: Build 메서드 구현

전체 코드:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@override
Widget build(BuildContext context) {
return GestureDetector(
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
child: Scaffold(
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: Sizes.size24),
child: SafeArea(
child: AnimatedCrossFade(
firstChild: _buildFirstPage(),
secondChild: _buildSecondPage(),
crossFadeState: _showingPage == Page.first
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 300),
),
),
),
bottomNavigationBar: BottomAppBar(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: Sizes.size24,
horizontal: Sizes.size24,
),
child: AnimatedOpacity(
opacity: _showingPage == Page.first ? 0 : 1,
duration: const Duration(milliseconds: 300),
child: CupertinoButton(
color: Theme.of(context).primaryColor,
onPressed: () {},
child: const Text(
'Enter the app!',
style: TextStyle(color: Colors.white),
),
),
),
),
),
),
);
}

🔍 코드 상세 설명

1. GestureDetector를 Scaffold 밖에 배치

1
2
3
4
5
GestureDetector(
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
child: Scaffold(...),
)

왜 최상위에 배치하는가?

  • 전체 화면에서 제스처 감지 가능
  • Scaffold 내부에 있으면 일부 영역에서만 감지됨

2. AnimatedCrossFade란?

1
2
3
4
5
6
7
8
AnimatedCrossFade(
firstChild: _buildFirstPage(),
secondChild: _buildSecondPage(),
crossFadeState: _showingPage == Page.first
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 300),
)
  • 두 위젯 사이를 부드럽게 전환하는 애니메이션 위젯
  • 한 위젯이 사라지면서(fade out) 다른 위젯이 나타남(fade in)

작동 방식:

1
2
3
4
5
6
7
8
firstChild (opacity: 1)

crossFadeState 변경

300ms 동안 전환 애니메이션

firstChild (opacity: 0) ← 사라짐
secondChild (opacity: 1) ← 나타남

비교 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Without Animation (즉시 전환)
_showingPage == Page.first
? _buildFirstPage()
: _buildSecondPage()

// With AnimatedCrossFade (부드러운 전환)
AnimatedCrossFade(
firstChild: _buildFirstPage(),
secondChild: _buildSecondPage(),
crossFadeState: _showingPage == Page.first
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: Duration(milliseconds: 300),
)

3. CrossFadeState

1
2
3
crossFadeState: _showingPage == Page.first
? CrossFadeState.showFirst
: CrossFadeState.showSecond
  • CrossFadeState.showFirst: 첫 번째 자식 표시
  • CrossFadeState.showSecond: 두 번째 자식 표시

4. CupertinoButton이란?

1
2
3
4
5
6
7
8
CupertinoButton(
color: Theme.of(context).primaryColor,
onPressed: () {},
child: const Text(
'Enter the app!',
style: TextStyle(color: Colors.white),
),
)
  • iOS 스타일 버튼 위젯
  • Material 버튼보다 둥근 모서리와 부드러운 느낌

비교:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Material Button
ElevatedButton(
onPressed: () {},
child: Text('Button'),
)
// ┌─────────┐
// │ Button │ ← 각진 모서리
// └─────────┘

// Cupertino Button
CupertinoButton(
color: Colors.blue,
onPressed: () {},
child: Text('Button'),
)
// ╭─────────╮
// │ Button │ ← 둥근 모서리
// ╰─────────╯

5. AnimatedOpacity로 조건부 표시

1
2
3
4
5
AnimatedOpacity(
opacity: _showingPage == Page.first ? 0 : 1,
duration: const Duration(milliseconds: 300),
child: CupertinoButton(...),
)
  • 첫 번째 페이지에서는 버튼 숨김 (opacity: 0)
  • 두 번째 페이지에서는 버튼 표시 (opacity: 1)

작동 방식:

1
2
3
4
5
Page.first → opacity: 0 (안 보임, 클릭 안 됨)

스와이프

Page.second → opacity: 1 (보임, 클릭 가능)

📝 4단계: 페이지 빌더 수정

변경된 부분:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
Widget _buildFirstPage() {
return Padding(
padding: EdgeInsets.symmetric(horizontal: Sizes.size24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gaps.v80, // v52 → v80으로 변경
Text(
'Watch cool videos!',
style: TextStyle(
fontSize: Sizes.size40,
fontWeight: FontWeight.bold,
),
),
Gaps.v16,
Text(
'Videos are personalized for you based on what you watch, like, and share.',
style: TextStyle(
fontSize: Sizes.size20,
),
),
],
),
);
}

Widget _buildSecondPage() {
return Padding(
padding: EdgeInsets.symmetric(horizontal: Sizes.size24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gaps.v80, // v52 → v80으로 변경
Text(
'Follow the rules!',
style: TextStyle(
fontSize: Sizes.size40,
fontWeight: FontWeight.bold,
),
),
Gaps.v16,
Text(
'Take care of one another! Plis!', // 텍스트 변경
style: TextStyle(
fontSize: Sizes.size20,
),
),
],
),
);
}

// _buildThirdPage()는 제거됨 (2개 페이지만 사용)

🔍 변경 사항

1. Gaps.v52 → Gaps.v80

  • 상단 여백을 더 크게 조정
  • SafeArea 내에서 더 나은 시각적 균형

2. 페이지 개수 3개 → 2개

  • TabBarView (3페이지) → AnimatedCrossFade (2페이지)
  • 더 간단한 튜토리얼 플로우

3. 설명 텍스트 변경

  • 두 번째 페이지 텍스트를 구체적으로 변경
  • 각 페이지의 목적을 더 명확하게 표현

🎨 화면 미리보기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
페이지 1 (오른쪽으로 스와이프):
┌─────────────────────────────────┐
│ │
│ Watch cool videos! │
│ │
│ Videos are personalized for │
│ you based on what you watch, │
│ like, and share. │
│ │
│ │
│ │
├─────────────────────────────────┤
│ │ ← 버튼 숨김
└─────────────────────────────────┘
◀── 드래그

페이지 2 (왼쪽으로 스와이프):
┌─────────────────────────────────┐
│ │
│ Follow the rules! │
│ │
│ Take care of one another! │
│ Plis! │
│ │
│ │
│ │
├─────────────────────────────────┤
│ [ Enter the app! ] │ ← 버튼 표시
└─────────────────────────────────┘

📊 동작 흐름

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
1. 첫 번째 페이지 표시
_showingPage = Page.first
버튼 opacity = 0 (안 보임)

2. 사용자가 왼쪽으로 드래그 시작

3. onPanUpdate 계속 호출
details.delta.dx < 0
_direction = Direction.left

4. 사용자가 손가락을 뗌

5. onPanEnd 호출
_direction == Direction.left
_showingPage = Page.second

6. AnimatedCrossFade 작동 (300ms)
firstChild fade out
secondChild fade in

7. 버튼 AnimatedOpacity 작동 (300ms)
opacity: 0 → 1

8. 두 번째 페이지 완전히 표시
버튼 클릭 가능

✅ 체크리스트

  • Direction과 Page enum 정의
  • State 클래스에 _direction, _showingPage 상태 변수 추가
  • _onPanUpdate 메서드 구현 (드래그 방향 감지)
  • _onPanEnd 메서드 구현 (페이지 전환)
  • GestureDetector를 Scaffold 밖에 배치
  • DefaultTabController 제거
  • TabBarView를 AnimatedCrossFade로 교체
  • TabPageSelector 제거
  • CupertinoButton 추가
  • AnimatedOpacity로 버튼 조건부 표시
  • _buildThirdPage() 제거
  • Gaps.v52를 v80으로 변경
  • 두 번째 페이지 텍스트 변경

💡 연습 과제

  1. 기본: AnimatedCrossFade의 duration을 500ms로 변경해보기
  2. 기본: 버튼 색상을 다른 색으로 변경하기
  3. 중급: 3개 페이지를 지원하도록 확장하기 (Page enum에 third 추가)
  4. 중급: 드래그 속도(velocity)를 고려해서 페이지 전환하기
  5. 고급: AnimatedCrossFade 대신 PageView.builder로 무한 페이지 만들기

전체 플로우 정리

완성된 기능

1
2
3
4
5
6
7
8
9
10
11
1. Interests Screen (관심사 선택)
- Wrap 레이아웃으로 동적 배치
- ScrollController로 스크롤 애니메이션
- InterestButton으로 선택 상태 관리

2. Tutorial Screen (튜토리얼)
- AnimatedCrossFade로 페이지 전환
- GestureDetector로 드래그 감지
- 조건부 버튼 표시

3. 앱 진입 준비 완료

최종 파일 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
features/
├── authentication/
│ ├── lib/screens/
│ │ ├── birthday_screen.dart
│ │ └── login_form_screen.dart
│ └── pubspec.yaml

└── onboarding/
└── lib/
├── screens/
│ ├── interests_screen.dart
│ └── tutorial_screen.dart
└── widget/
└── interest_button.dart

lib/
└── main.dart

배운 핵심 개념

1. 레이아웃

  • Wrap: 자동 줄바꿈 레이아웃
  • SingleChildScrollView: 스크롤 가능한 화면
  • SafeArea: 안전 영역 설정

2. 애니메이션

  • AnimatedOpacity: 투명도 애니메이션
  • AnimatedContainer: 속성 변화 애니메이션
  • AnimatedCrossFade: 위젯 전환 애니메이션

3. 상태 관리

  • StatefulWidget: 변화하는 상태 관리
  • setState(): UI 업데이트
  • Controller 패턴: ScrollController

4. 제스처

  • GestureDetector: 제스처 감지
  • onPanUpdate: 드래그 중
  • onPanEnd: 드래그 완료

5. 위젯

  • BottomAppBar: 하단 고정 바
  • TabBarView: 페이지 뷰
  • CupertinoButton: iOS 스타일 버튼